feat: dzgui 7 alpha
No files matched your search
@@ -0,0 +1,40 @@
|
||||
import logging
|
||||
import requests
|
||||
from pathlib import Path
|
||||
|
||||
from dzgui.api.servers import Record
|
||||
from dzgui.config.query import lookup
|
||||
from dzgui.const.endpoints import BM_SERVERS
|
||||
from dzgui.const.enum import Preferences
|
||||
|
||||
from typing import Union
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def get_attributes(config: Path, uid: int) -> str:
|
||||
# TODO: handle if key is not set
|
||||
# TODO: tests for malformed IDs/values
|
||||
key = lookup(config, Preferences.BM)
|
||||
|
||||
hdr = {"Authorization": "Bearer " + key}
|
||||
payload: dict[str, str] = {
|
||||
"filter[game]": "dayz",
|
||||
"sort": "-players",
|
||||
"filter[ids][whitelist]": str(uid)
|
||||
}
|
||||
res = requests.get(BM_SERVERS, params=payload, headers=hdr)
|
||||
res.raise_for_status()
|
||||
j = res.json()["data"][0]["attributes"]
|
||||
return j
|
||||
|
||||
|
||||
def map_id_to_record(config: Path, uid: int) -> Record | None:
|
||||
try:
|
||||
record = get_attributes(config, uid)
|
||||
ip = record["ip"]
|
||||
port = record["port"]
|
||||
qport = record["portQuery"]
|
||||
return Record(ip, port, qport)
|
||||
except Exception as e:
|
||||
logger.warn(e)
|
||||
return None
|
||||
@@ -0,0 +1,170 @@
|
||||
import dayzquery
|
||||
import hashlib
|
||||
import logging
|
||||
import re
|
||||
import shlex
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import dzgui.api.pefile as PeFile
|
||||
from dzgui.api.servers import Record, get_rules
|
||||
from dzgui.const.constants import (
|
||||
APPID_DAYZ,
|
||||
APPID_DAYZ_EXP,
|
||||
LIBRARYFOLDERS_PATH,
|
||||
WORKSHOP_PATH,
|
||||
)
|
||||
|
||||
from dzgui.util.strings import checkmark
|
||||
from dzgui.config.query import enum_to_key, lookup
|
||||
from dzgui.const.enum import Preferences
|
||||
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@dataclass
|
||||
class ModMeta:
|
||||
protocol: str
|
||||
published_id: str
|
||||
name: str
|
||||
timestamp: str
|
||||
|
||||
|
||||
def get_local_mod_ids(steam_path: Path) -> list[int]:
|
||||
workshop_path = get_local_mod_path(steam_path)
|
||||
mods = get_local_mods(workshop_path)
|
||||
return [int(mod.name) for mod in mods]
|
||||
|
||||
|
||||
def get_local_mod_path(steam_path: Path) -> Path:
|
||||
p = PeFile.get_app_path(steam_path / LIBRARYFOLDERS_PATH, APPID_DAYZ)
|
||||
workshop_path = p / WORKSHOP_PATH
|
||||
return workshop_path
|
||||
|
||||
def get_local_mods(workshop_path: Path) -> list[Path]:
|
||||
mods = [file for file in workshop_path.iterdir() if file.is_dir()]
|
||||
return mods
|
||||
|
||||
|
||||
# TODO: TEST: mock bad meta files with fixtures and remove them
|
||||
def parse_meta(file: Path) -> ModMeta:
|
||||
mod = file / "meta.cpp"
|
||||
if mod.exists() is False:
|
||||
return None
|
||||
with open(file / "meta.cpp", "r") as f:
|
||||
st = f.read()
|
||||
lex = shlex.shlex(st)
|
||||
lex.whitespace += "=;"
|
||||
v = []
|
||||
while True:
|
||||
tok = lex.get_token()
|
||||
if not tok:
|
||||
break
|
||||
if tok == "protocol" or tok == "publishedid":
|
||||
ntok = lex.get_token()
|
||||
elif tok == "timestamp":
|
||||
# some malformed .NET tick conversions result in numbers < 0
|
||||
ntok = lex.get_token()
|
||||
if ntok == "-":
|
||||
ntok += str(lex.get_token())
|
||||
elif tok == "name":
|
||||
ntok = lex.get_token().split('"')[1]
|
||||
v.append(ntok)
|
||||
meta = ModMeta(*v)
|
||||
return meta
|
||||
|
||||
|
||||
def get_mod_size(path: Path) -> float:
|
||||
s = 0
|
||||
for f in path.rglob("*"):
|
||||
s += f.stat().st_size
|
||||
size = round(s / (1024 * 1024), 3)
|
||||
return size
|
||||
|
||||
|
||||
def get_delimited_mods(steam_path: Path) -> list[Any]:
|
||||
workshop_path = get_local_mod_path(steam_path)
|
||||
mods = get_local_mods(workshop_path)
|
||||
clean = []
|
||||
for mod in mods:
|
||||
mod_dir = mod.name
|
||||
symlink = _hash(mod_dir)
|
||||
# NOTE: malformed .cpp files could break this
|
||||
# mention that mods may be downloading
|
||||
meta = parse_meta(mod)
|
||||
if meta is None:
|
||||
continue
|
||||
size = get_mod_size(mod)
|
||||
clean.append([meta.name, symlink, mod_dir, size])
|
||||
clean.sort(key=lambda row: row[0])
|
||||
return clean
|
||||
|
||||
|
||||
def get_missing_mods(local: list, remote: list) -> list:
|
||||
return [mod for mod in remote if mod not in local]
|
||||
|
||||
|
||||
def get_server_modlist(server: Record, steam: Path) -> list:
|
||||
try:
|
||||
rules = dayzquery.dayz_rules((server.ip, server.qport))
|
||||
except Exception as e:
|
||||
raise e
|
||||
remote_mods = [[mod.name, mod.workshop_id] for mod in rules.mods]
|
||||
remote_mods.sort(key=lambda row: row[0])
|
||||
local_mods = get_local_mod_ids(steam)
|
||||
for mod in remote_mods:
|
||||
if mod[1] in local_mods:
|
||||
mod.append(checkmark)
|
||||
else:
|
||||
mod.append("")
|
||||
return remote_mods
|
||||
|
||||
|
||||
def _hash(uid: str) -> str:
|
||||
md5 = hashlib.md5()
|
||||
md5.update(uid.encode("ascii"))
|
||||
return "@" + md5.hexdigest()[:8]
|
||||
|
||||
|
||||
def remove_stale_signatures(config: Path, versions: Path) -> None:
|
||||
if versions.is_file() is False:
|
||||
logger.warning("No mod signatures file found")
|
||||
return
|
||||
path = lookup(config, Preferences.DEFAULT)
|
||||
steam_path = Path(path)
|
||||
ids = get_local_mod_ids(steam_path)
|
||||
with open(versions, "r") as f:
|
||||
lines = f.readlines()
|
||||
for line in lines:
|
||||
uid = int(line.split(",")[0])
|
||||
if uid not in ids:
|
||||
lines.remove(line)
|
||||
with open(versions, "w") as f:
|
||||
for line in lines:
|
||||
f.write(line)
|
||||
|
||||
|
||||
def find_stale_mods(config: Path) -> list[int]:
|
||||
steam = lookup(config, Preferences.DEFAULT)
|
||||
steam_path = Path(steam)
|
||||
|
||||
local = get_local_mod_ids(steam_path)
|
||||
servers = lookup(config, Preferences.IP_LIST)
|
||||
|
||||
all_mods = []
|
||||
for server in servers:
|
||||
split = server.split(":")
|
||||
ip = split[0]
|
||||
gport = split[1]
|
||||
qport = split[2]
|
||||
|
||||
mods = get_rules(ip, qport)
|
||||
all_mods += mods
|
||||
|
||||
stale = []
|
||||
for mod in local:
|
||||
if mod not in all_mods:
|
||||
stale.append(mod)
|
||||
return stale
|
||||
@@ -0,0 +1,465 @@
|
||||
import json
|
||||
import struct
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from shlex import shlex
|
||||
from typing import BinaryIO, Union
|
||||
|
||||
from packaging.version import Version
|
||||
|
||||
from dzgui.const.constants import (
|
||||
APPID_DAYZ,
|
||||
APPID_DAYZ_EXP,
|
||||
APPNAME_DAYZ,
|
||||
APPNAME_DAYZ_EXP,
|
||||
DAYZ_BINARY,
|
||||
LIBRARYFOLDERS_PATH,
|
||||
)
|
||||
from dzgui.api.steam import vdf2json
|
||||
|
||||
# https://learn.microsoft.com/en-us/windows/win32/debug/pe-format
|
||||
endian = "<"
|
||||
IMAGE_DIRECTORY_ENTRY = 2
|
||||
VERSION_RESOURCE = 16
|
||||
RESOURCE_NODE = ".rsrc"
|
||||
PE32_x86 = "0x10b"
|
||||
PE32_x64 = "0x20b"
|
||||
VS_VERSION_INFO_MAGIC = "0xfeef04bd0000"
|
||||
VS_VERSION_INFO_ID = "VS_VERSION_INFO"
|
||||
|
||||
|
||||
class VersionMatch(Enum):
|
||||
LOCAL_OLDER = 1
|
||||
LOCAL_NEWER = 2
|
||||
SAME_VERSION = 3
|
||||
|
||||
|
||||
class u8:
|
||||
fmt = "B"
|
||||
|
||||
|
||||
class u16:
|
||||
fmt = "H"
|
||||
|
||||
|
||||
class u32:
|
||||
fmt = "L"
|
||||
|
||||
|
||||
class u64:
|
||||
fmt = "Q"
|
||||
|
||||
|
||||
class i8:
|
||||
fmt = "b"
|
||||
|
||||
|
||||
class i16:
|
||||
fmt = "h"
|
||||
|
||||
|
||||
class i32:
|
||||
fmt = "l"
|
||||
|
||||
|
||||
class i64:
|
||||
fmt = "q"
|
||||
|
||||
|
||||
class PackedData:
|
||||
@classmethod
|
||||
def unpack(cls, data: BinaryIO):
|
||||
r = []
|
||||
for key, value in cls.__annotations__.items():
|
||||
if value == str:
|
||||
f = data.read(8).rstrip(b"\x00\x00").decode()
|
||||
else:
|
||||
fmt = endian + (value.fmt)
|
||||
size = struct.calcsize(fmt)
|
||||
f = struct.unpack(fmt, data.read(size))[0]
|
||||
r.append(f)
|
||||
return cls(*r)
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class COFF_FILE_HDR(PackedData):
|
||||
machine_type: u16
|
||||
number_of_sections: u16
|
||||
timestamp: u32
|
||||
pointer_to_symbol_table: u32
|
||||
number_of_symbols: u32
|
||||
size_of_optional_header: u16
|
||||
characteristics: u16
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class OPTIONAL_HDR_X86(PackedData):
|
||||
magic: u16
|
||||
major_linker_ver: u8
|
||||
minor_linker_ver: u8
|
||||
size_of_code: u32
|
||||
size_of_initialized_data: u32
|
||||
size_of_uninitialized_data: u32
|
||||
address_of_entry_point: u32
|
||||
base_of_code: u32
|
||||
base_of_data: u32
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class OPTIONAL_HDR_X64(PackedData):
|
||||
magic: u16
|
||||
major_linker_ver: u8
|
||||
minor_linker_ver: u8
|
||||
size_of_code: u32
|
||||
size_of_initialized_data: u32
|
||||
size_of_uninitialized_data: u32
|
||||
address_of_entry_point: u32
|
||||
base_of_code: u32
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class OPTIONAL_HDR_WIN_X86(PackedData):
|
||||
image_base: u32
|
||||
section_alignment: u32
|
||||
file_alignment: u32
|
||||
major_operating_system_version: u16
|
||||
minor_operating_system_version: u16
|
||||
major_image_version: u16
|
||||
minor_image_version: u16
|
||||
major_subsystem_version: u16
|
||||
minor_subsystem_version: u16
|
||||
win32_version_value: u32
|
||||
size_of_image: u32
|
||||
size_of_headers: u32
|
||||
checksum: u32
|
||||
subsystem: u16
|
||||
dll_characteristics: u16
|
||||
size_of_stack_reserve: u32
|
||||
size_of_stack_commit: u32
|
||||
size_of_heap_reserve: u32
|
||||
size_of_heap_commit: u32
|
||||
loader_flags: u32
|
||||
number_of_rva_and_sizes: u32
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class OPTIONAL_HDR_WIN_X64(PackedData):
|
||||
image_base: u64
|
||||
section_alignment: u32
|
||||
file_alignment: u32
|
||||
major_operating_system_version: u16
|
||||
minor_operating_system_version: u16
|
||||
major_image_version: u16
|
||||
minor_image_version: u16
|
||||
major_subsystem_version: u16
|
||||
minor_subsystem_version: u16
|
||||
win32_version_value: u32
|
||||
size_of_image: u32
|
||||
size_of_headers: u32
|
||||
checksum: u32
|
||||
subsystem: u16
|
||||
dll_characteristics: u16
|
||||
size_of_stack_reserve: u64
|
||||
size_of_stack_commit: u64
|
||||
size_of_heap_reserve: u64
|
||||
size_of_heap_commit: u64
|
||||
loader_flags: u32
|
||||
number_of_rva_and_sizes: u32
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class DATA_DIR(PackedData):
|
||||
virtual_address: u32
|
||||
size: u32
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class SECTION_HDR(PackedData):
|
||||
name: str
|
||||
virtual_size: u32
|
||||
virtual_address: u32
|
||||
size_of_raw_data: u32
|
||||
pointer_to_raw_data: u32
|
||||
pointer_to_relocations: u32
|
||||
pointer_to_line_numbers: u32
|
||||
number_of_relocations: u16
|
||||
number_of_line_numbers: u16
|
||||
characteristics: u32
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class RESOURCE_DIRECTORY_TABLE(PackedData):
|
||||
characteristics: u32
|
||||
timestamp: u32
|
||||
major_version: u16
|
||||
minor_version: u16
|
||||
number_of_name_entries: u16
|
||||
number_of_id_entries: u16
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class RESOURCE_DIRECTORY_ENTRY(PackedData):
|
||||
"""
|
||||
This field is either a string identifying a data leaf
|
||||
(if the high bit is set) or an ID to another nested directory
|
||||
(if the high bit is clear). The outermost level is always a
|
||||
directory. If it is a name, the lower 31 bits are the offset from the
|
||||
beginning of the resource section's raw data to the name
|
||||
(the name consists of 16 bits length and trailing wide characters,
|
||||
in Unicode, not 0-terminated).
|
||||
"""
|
||||
|
||||
name_or_id: u32
|
||||
data_or_subdir: u32
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class RESOURCE_DATA_ENTRY(PackedData):
|
||||
data_rva: u32
|
||||
size: u32
|
||||
codepage: u32
|
||||
reserved: u32
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class VS_VERSION_INFO_HDR(PackedData):
|
||||
size: u16
|
||||
value_length: u16
|
||||
value_type: u16
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class DayZVersion:
|
||||
major: int
|
||||
minor: int
|
||||
patch: int
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class FileVersion:
|
||||
major: int
|
||||
minor: int
|
||||
build: int
|
||||
revision: int
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class Result:
|
||||
local: Union[str, None]
|
||||
remote: str
|
||||
build: str
|
||||
path: Union[Path, None]
|
||||
match: VersionMatch
|
||||
error: Union[Exception, None]
|
||||
|
||||
|
||||
class PeFileError(Exception):
|
||||
"""Expected contents missing from headers or resource nodes"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class AppNotInstalledError(Exception):
|
||||
"""App not present in user's libraryfolders"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class AppMovedError(Exception):
|
||||
"""VDF points to a nonexistent location on disk"""
|
||||
pass
|
||||
|
||||
|
||||
class VDFLoadError(Exception):
|
||||
"""Malformed VDF or JSON conversion"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
def parse_version_number(data: BinaryIO) -> FileVersion:
|
||||
# https://learn.microsoft.com/en-us/windows/win32/api/verrsrc/ns-verrsrc-vs_fixedfileinfo
|
||||
minor = struct.unpack("<L", data.read(4))[0] >> 16 & 0xFFFF
|
||||
major = struct.unpack("<L", data.read(4))[0] >> 0 & 0xFFFF
|
||||
build = struct.unpack("<L", data.read(4))[0] >> 0 & 0xFFFF
|
||||
revision = struct.unpack("<L", data.read(4))[0] >> 16 & 0xFFFF
|
||||
return FileVersion(major, minor, build, revision)
|
||||
|
||||
|
||||
def seek_to_hex(address: str, data: BinaryIO) -> None:
|
||||
data.seek(int(address, base=16))
|
||||
|
||||
|
||||
def seek_to_pe_stub(data: BinaryIO) -> None:
|
||||
MAGIC = "0x3c"
|
||||
seek_to_hex(MAGIC, data)
|
||||
e_lfanew = hex(struct.unpack("<h", data.read(2))[0])
|
||||
seek_to_hex(e_lfanew, data)
|
||||
pe_stub = data.read(4).rstrip(b"\x00\x00\x00\x00").decode()
|
||||
if pe_stub != "PE":
|
||||
raise PeFileError("missing PE header data")
|
||||
|
||||
|
||||
def get_dayz_version(file: Path) -> DayZVersion:
|
||||
try:
|
||||
version = get_version(file)
|
||||
except Exception as e:
|
||||
# TODO: log the exception
|
||||
raise e
|
||||
patch = str(version.build) + str(version.revision)
|
||||
dz_vers = DayZVersion(version.major, version.minor, int(patch))
|
||||
return dz_vers
|
||||
|
||||
|
||||
def dayz_version_to_str(v: DayZVersion) -> str:
|
||||
return ".".join(str(el) for el in [v.major, v.minor, v.patch])
|
||||
|
||||
|
||||
def dayz_version_from_str(v: str) -> DayZVersion:
|
||||
vers = v.split(".")
|
||||
assert len(vers) == 3
|
||||
return DayZVersion(*[int(el) for el in vers])
|
||||
|
||||
|
||||
def get_version(file: Path) -> FileVersion:
|
||||
with open(file, "rb") as f:
|
||||
seek_to_pe_stub(f)
|
||||
COFF = COFF_FILE_HDR.unpack(f)
|
||||
|
||||
pos = f.tell()
|
||||
blob = f.read()
|
||||
magic = hex(struct.unpack("<H", (blob[0:2]))[0])
|
||||
f.seek(pos)
|
||||
|
||||
if magic == PE32_x86:
|
||||
OPTIONAL_HDR_X86.unpack(f)
|
||||
OBJW = OPTIONAL_HDR_WIN_X86.unpack(f)
|
||||
else:
|
||||
OPTIONAL_HDR_X64.unpack(f)
|
||||
OBJW = OPTIONAL_HDR_WIN_X64.unpack(f)
|
||||
|
||||
if OBJW.number_of_rva_and_sizes < 1:
|
||||
raise PeFileError("no data resource directory")
|
||||
|
||||
data_dirs = []
|
||||
for rva in range(OBJW.number_of_rva_and_sizes):
|
||||
data_dir = DATA_DIR.unpack(f)
|
||||
data_dirs.append(data_dir)
|
||||
|
||||
res_dir = data_dirs[IMAGE_DIRECTORY_ENTRY]
|
||||
dir_va = res_dir.virtual_address
|
||||
|
||||
for section in range(COFF.number_of_sections):
|
||||
hdr = SECTION_HDR.unpack(f)
|
||||
if hdr.name == RESOURCE_NODE:
|
||||
va = hdr.virtual_address
|
||||
ptr = hdr.pointer_to_raw_data
|
||||
offset = dir_va - va + ptr
|
||||
seek_to_hex(hex(offset), f)
|
||||
break
|
||||
|
||||
if hdr.name != RESOURCE_NODE:
|
||||
raise PeFileError("no root resource node found")
|
||||
|
||||
table = RESOURCE_DIRECTORY_TABLE.unpack(f)
|
||||
total = table.number_of_name_entries + table.number_of_id_entries
|
||||
|
||||
for entry in range(total):
|
||||
entry = RESOURCE_DIRECTORY_ENTRY.unpack(f)
|
||||
if entry.name_or_id == VERSION_RESOURCE:
|
||||
while entry.data_or_subdir & (1 << 31):
|
||||
shift = entry.data_or_subdir & ~(1 << 31)
|
||||
seek_to_hex(hex(offset + shift), f)
|
||||
table = RESOURCE_DIRECTORY_TABLE.unpack(f)
|
||||
total = (
|
||||
table.number_of_name_entries
|
||||
+ table.number_of_id_entries
|
||||
)
|
||||
for entry in range(total):
|
||||
entry = RESOURCE_DIRECTORY_ENTRY.unpack(f)
|
||||
break
|
||||
if entry.name_or_id > VERSION_RESOURCE:
|
||||
raise PeFileError("no version info node found")
|
||||
seek_to_hex(hex(offset + entry.data_or_subdir), f)
|
||||
|
||||
data = RESOURCE_DATA_ENTRY.unpack(f)
|
||||
# https://stackoverflow.com/questions/2170843/va-virtual-address-rva-relative-virtual-address
|
||||
offset = data.data_rva - hdr.virtual_address + hdr.pointer_to_raw_data
|
||||
seek_to_hex(hex(offset), f)
|
||||
|
||||
hdr = VS_VERSION_INFO_HDR.unpack(f)
|
||||
# https://learn.microsoft.com/en-us/windows/win32/menurc/vs-versioninfo
|
||||
byte_len = len(VS_VERSION_INFO_ID.encode("utf-16le"))
|
||||
label = f.read(byte_len).decode("utf-16le")
|
||||
if label != VS_VERSION_INFO_ID:
|
||||
raise PeFileError(f"header identifier != '{VS_VERSION_INFO_ID}'")
|
||||
f.read(32 - byte_len)
|
||||
|
||||
identifier = hex(struct.unpack("<Q", f.read(8))[0])
|
||||
if identifier != VS_VERSION_INFO_MAGIC:
|
||||
raise PeFileError(
|
||||
f"{VS_VERSION_INFO_ID} address != '{VS_VERSION_INFO_MAGIC}'"
|
||||
)
|
||||
|
||||
try:
|
||||
version = parse_version_number(f)
|
||||
except Exception as e:
|
||||
raise PeFileError(e)
|
||||
|
||||
return version
|
||||
|
||||
|
||||
def get_nested_app_path(steam_path: Path, appid: int) -> Path:
|
||||
return get_pefile_path(steam_path, appid).parent
|
||||
|
||||
|
||||
def get_pefile_path(steam_path: Path, appid: int) -> Path:
|
||||
identifier = {APPID_DAYZ: APPNAME_DAYZ, APPID_DAYZ_EXP: APPNAME_DAYZ_EXP}
|
||||
name = identifier[appid]
|
||||
binary = DAYZ_BINARY
|
||||
|
||||
app_path = get_app_path(steam_path / LIBRARYFOLDERS_PATH, appid)
|
||||
pe_path = app_path / f"steamapps/common/{name}/{binary}"
|
||||
return pe_path
|
||||
|
||||
def get_app_path(folders_path: Path, appid: int) -> Path:
|
||||
app_path = None
|
||||
|
||||
try:
|
||||
j = json.loads(vdf2json(folders_path))
|
||||
except Exception:
|
||||
raise VDFLoadError("Failed to parse libraryfolders")
|
||||
|
||||
for obj in j["libraryfolders"]:
|
||||
if str(appid) in j["libraryfolders"][obj]["apps"]:
|
||||
app_path = j["libraryfolders"][obj]["path"]
|
||||
break
|
||||
|
||||
if app_path is None:
|
||||
raise AppNotInstalledError(
|
||||
f"Failed to find a libraryfolder for the appid {appid}"
|
||||
)
|
||||
|
||||
app_path = Path(app_path)
|
||||
if app_path.exists() is False:
|
||||
raise AppMovedError(
|
||||
f"Path '{app_path}' specified in libraryfolders does not exist"
|
||||
)
|
||||
|
||||
return app_path
|
||||
|
||||
|
||||
# TODO: tests
|
||||
def compare_versions(local: DayZVersion, remote: DayZVersion) -> VersionMatch:
|
||||
local_str = Version(dayz_version_to_str(local))
|
||||
remote_str = Version(dayz_version_to_str(remote))
|
||||
|
||||
if local_str == remote_str:
|
||||
return VersionMatch.SAME_VERSION
|
||||
|
||||
if local_str < remote_str:
|
||||
return VersionMatch.LOCAL_OLDER
|
||||
|
||||
if local_str > remote_str:
|
||||
return VersionMatch.LOCAL_NEWER
|
||||
@@ -0,0 +1,62 @@
|
||||
import logging
|
||||
import requests
|
||||
|
||||
from typing import Union, TYPE_CHECKING
|
||||
|
||||
from dzgui.const.constants import APPID_DAYZ, REQUEST_TIMEOUT
|
||||
from dzgui.const import endpoints
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from requests import Response
|
||||
|
||||
def test_steam_api(key: str) -> bool:
|
||||
appid = APPID_DAYZ
|
||||
payload: dict[str, Union[int, str]] = {
|
||||
"filter": r"\appid" + fr"\{appid}",
|
||||
"limit": 10,
|
||||
"key": key,
|
||||
}
|
||||
try:
|
||||
res = requests.get(endpoints.STEAM_SERVERS, params=payload, timeout=REQUEST_TIMEOUT)
|
||||
return is_remote_up(res)
|
||||
except Exception as e:
|
||||
logger.critical(e)
|
||||
# TODO: pass exception text into dialog
|
||||
return False
|
||||
|
||||
|
||||
# TODO: consolidate is_remote_up and exception logic into one function
|
||||
def test_ipdb() -> bool:
|
||||
try:
|
||||
res = requests.get(endpoints.DB_IP, timeout=REQUEST_TIMEOUT)
|
||||
return is_remote_up(res)
|
||||
except Exception as e:
|
||||
logger.critical(e)
|
||||
return False
|
||||
|
||||
|
||||
def test_bm_api(key: str) -> bool:
|
||||
payload: dict[str, str] = {
|
||||
"filter[game]": "dayz",
|
||||
}
|
||||
hdr = {"Authorization": "Bearer " + key}
|
||||
try:
|
||||
res = requests.get(
|
||||
endpoints.BM_SERVERS,
|
||||
params=payload,
|
||||
headers=hdr,
|
||||
timeout=REQUEST_TIMEOUT
|
||||
)
|
||||
return is_remote_up(res)
|
||||
except Exception as e:
|
||||
logger.critical(e)
|
||||
return False
|
||||
|
||||
|
||||
def is_remote_up(res: "Response") -> bool:
|
||||
if res.status_code == 200:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
@@ -0,0 +1,444 @@
|
||||
import ipaddress
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import requests
|
||||
import socket
|
||||
import subprocess
|
||||
import typing # noqa
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Union
|
||||
|
||||
from dzgui.const.constants import REQUEST_TIMEOUT
|
||||
from dzgui.const.endpoints import STEAM_SERVERS
|
||||
from dzgui.util import strings
|
||||
|
||||
import a2s
|
||||
import dayzquery
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# TODO: confirm that patches from testing are incorporated here
|
||||
|
||||
params = [
|
||||
r"\nor\1\map\chernarusplus\nor\1\map\sakhal\nor\1\map\enoch\empty\1\nor\1\map\namalsk", # noqa
|
||||
r"\map\namalsk\empty\1",
|
||||
r"\map\namalsk\noplayers\1",
|
||||
r"\map\chernarusplus\empty\1",
|
||||
r"\map\chernarusplus\noplayers\1",
|
||||
r"\map\sakhal\empty\1",
|
||||
r"\map\sakhal\noplayers\1",
|
||||
r"\map\enoch\empty\1",
|
||||
r"\map\enoch\noplayers\1",
|
||||
]
|
||||
|
||||
|
||||
class BmAPIError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class BmIdError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class InvalidIpError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def get_netmask() -> str:
|
||||
hostname = os.uname()[1]
|
||||
i = socket.gethostbyname(hostname)
|
||||
netmask = i.rsplit(".", 1)[0]
|
||||
return netmask
|
||||
|
||||
|
||||
def test_ip(suffix: int, port: int) -> dict | None:
|
||||
netmask = get_netmask()
|
||||
hostname = f"{netmask}.{str(suffix)}"
|
||||
ping = ["ping", "-c1", "-i", "0.1", "-w", "1"]
|
||||
output = subprocess.run(ping + [hostname], capture_output=True)
|
||||
if output.returncode == 0:
|
||||
return query_direct(hostname, port)
|
||||
return None
|
||||
|
||||
|
||||
def sanitize(name: str) -> str:
|
||||
name = re.sub(r"\r", r"", name)
|
||||
name = re.sub(r"\n", r"", name)
|
||||
name = re.sub(r"\x01", r"", name)
|
||||
name = re.sub(r"\ufeff", r"", name)
|
||||
name = re.sub(r"(^!\s*)", r"-", name)
|
||||
name = re.sub(r"(^-\s*)", r"", name)
|
||||
name = re.sub(r"(^-)", r"", name)
|
||||
name = re.sub(r"(^\s*)", r"", name)
|
||||
name = re.sub(r"\t", "", name)
|
||||
return name
|
||||
|
||||
|
||||
def parse_json(json: list) -> list:
|
||||
"""
|
||||
Server metadata is underspecified and server operators
|
||||
tend to insert random garbage in the headers. In case
|
||||
sanitization failed, discard malformed rows rather than
|
||||
aborting outright.
|
||||
"""
|
||||
rows = []
|
||||
for row in json:
|
||||
try:
|
||||
name = sanitize(row["name"])
|
||||
if name == "":
|
||||
continue
|
||||
except KeyError:
|
||||
continue
|
||||
|
||||
malformed = False
|
||||
for key in [
|
||||
"map",
|
||||
"gametype",
|
||||
"players",
|
||||
"max_players",
|
||||
"addr",
|
||||
"gameport",
|
||||
]:
|
||||
try:
|
||||
row[key]
|
||||
except KeyError:
|
||||
malformed = True
|
||||
break
|
||||
|
||||
if malformed is True:
|
||||
continue
|
||||
|
||||
try:
|
||||
r = row["gametype"].split(",")
|
||||
except KeyError:
|
||||
continue
|
||||
|
||||
if "no3rd" in r:
|
||||
view = strings.filter_1pp
|
||||
else:
|
||||
view = strings.filter_3pp
|
||||
|
||||
if "external" in r:
|
||||
provider = strings.filter_unoffiical
|
||||
else:
|
||||
provider = strings.filter_official
|
||||
|
||||
if "mod" in r:
|
||||
modded = True
|
||||
else:
|
||||
modded = False
|
||||
|
||||
try:
|
||||
r = row["gametype"].split("lqs")
|
||||
queue = r[1].split(",")[0]
|
||||
except IndexError:
|
||||
queue = 0
|
||||
|
||||
try:
|
||||
test_time = re.search(r"[0-9]{2}:[0-9]{2}", row["gametype"])
|
||||
if test_time:
|
||||
time = test_time.group(0)
|
||||
else:
|
||||
time = strings.unknown
|
||||
except AttributeError:
|
||||
time = strings.unknown
|
||||
|
||||
try:
|
||||
ip = row["addr"].split(":")[0] + ":" + str(row["gameport"])
|
||||
qport = row["addr"].split(":")[1]
|
||||
except IndexError:
|
||||
continue
|
||||
|
||||
try:
|
||||
ping = row["ping"]
|
||||
except KeyError:
|
||||
ping = 9999
|
||||
|
||||
mapname = row["map"].lower()
|
||||
players = row["players"]
|
||||
max_players = row["max_players"]
|
||||
raw = [
|
||||
name,
|
||||
mapname,
|
||||
view,
|
||||
time,
|
||||
int(players),
|
||||
int(max_players),
|
||||
int(queue),
|
||||
ip,
|
||||
int(qport),
|
||||
ping,
|
||||
provider,
|
||||
modded,
|
||||
]
|
||||
rows.append(raw)
|
||||
return rows
|
||||
|
||||
|
||||
def query_direct(ip: str, qport: int, TIMEOUT: float=3.0) -> dict | None:
|
||||
try:
|
||||
info = a2s.info((ip, qport), TIMEOUT)
|
||||
|
||||
name = info.server_name
|
||||
mapname = info.map_name
|
||||
address = ip + ":" + str(qport)
|
||||
gameport = str(info.port)
|
||||
players = info.player_count
|
||||
max_players = info.max_players
|
||||
keywords = info.keywords
|
||||
|
||||
try:
|
||||
ping = info.ping
|
||||
ping = math.floor(info.ping * 1000)
|
||||
except AttributeError:
|
||||
ping = 9999
|
||||
|
||||
res = {}
|
||||
res["name"] = name
|
||||
res["map"] = mapname
|
||||
res["gametype"] = keywords
|
||||
res["players"] = players
|
||||
res["max_players"] = max_players
|
||||
res["addr"] = address
|
||||
res["gameport"] = gameport
|
||||
res["ping"] = ping
|
||||
return res
|
||||
except TimeoutError:
|
||||
return None
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class Res:
|
||||
status: int
|
||||
parsed: bool
|
||||
json: Union[str, None]
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class Ping:
|
||||
addr: str
|
||||
iteration: int
|
||||
ping: int
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class Details:
|
||||
data: Union[list, None]
|
||||
description: str
|
||||
success: bool
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class Prereqs:
|
||||
password: bool
|
||||
gameport: int
|
||||
appid: Union[int, None]
|
||||
version: Union[str, None]
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Record:
|
||||
"""
|
||||
The gameport field is manipulated by the RowType.CONN_BY_IP method
|
||||
"""
|
||||
ip: str
|
||||
gameport: int
|
||||
qport: int
|
||||
|
||||
|
||||
# TODO: refactor this
|
||||
def get_prereqs(ip: str, qport: int) -> Prereqs:
|
||||
try:
|
||||
info = a2s.info((ip, qport))
|
||||
except TimeoutError:
|
||||
return Prereqs(False, 0, None, None)
|
||||
|
||||
gameport = getattr(info, "port", 0)
|
||||
is_password = getattr(info, "password_protected", False)
|
||||
appid = getattr(info, "game_id", None)
|
||||
version = getattr(info, "version", None)
|
||||
|
||||
return Prereqs(is_password, gameport, appid, version)
|
||||
|
||||
|
||||
def details(ip: str, qport: int) -> Details:
|
||||
default_str = strings.none_provided
|
||||
|
||||
try:
|
||||
info = a2s.info((ip, qport))
|
||||
except TimeoutError:
|
||||
return Details(None, default_str, False)
|
||||
try:
|
||||
rules = dayzquery.dayz_rules((ip, int(qport)))
|
||||
except TimeoutError:
|
||||
return Details(None, default_str, False)
|
||||
|
||||
try:
|
||||
keywords = info.keywords.split(",")
|
||||
except AttributeError:
|
||||
return Details(None, default_str, False)
|
||||
|
||||
battleye = strings.disabled
|
||||
if "battleye" in keywords:
|
||||
battleye = strings.enabled
|
||||
|
||||
day_accel = 0.0
|
||||
night_accel = 0.0
|
||||
for keyword in keywords:
|
||||
if "etm" in keyword:
|
||||
day_accel = float(keyword.lstrip("etm"))
|
||||
day_accel = float(f"{day_accel:g}")
|
||||
if "entm" in keywords:
|
||||
night_accel = float(keyword.lstrip("entm"))
|
||||
night_accel = float(f"{night_accel:g}")
|
||||
|
||||
try:
|
||||
password = info.password_protected
|
||||
if password is False:
|
||||
password = strings.disabled
|
||||
else:
|
||||
password = strings.enabled
|
||||
except AttributeError:
|
||||
password = "-"
|
||||
|
||||
try:
|
||||
vac = info.vac_enabled
|
||||
if vac is False:
|
||||
vac = strings.disabled
|
||||
else:
|
||||
vac = strings.enabled
|
||||
except AttributeError:
|
||||
vac = strings.null
|
||||
|
||||
try:
|
||||
version = info.version
|
||||
except AttributeError:
|
||||
version = strings.null
|
||||
|
||||
try:
|
||||
dlc = rules.dlc_flags
|
||||
if dlc == 0:
|
||||
dlc = strings.none
|
||||
if dlc == 2:
|
||||
dlc = strings.dlc_frostline
|
||||
except AttributeError:
|
||||
dlc = strings.unspecified
|
||||
|
||||
try:
|
||||
platform = rules.platform
|
||||
if platform == "win":
|
||||
platform = strings.windows
|
||||
if platform == "?":
|
||||
platform = strings.linux
|
||||
except AttributeError:
|
||||
platform = strings.unspecified
|
||||
|
||||
try:
|
||||
description = rules.description.strip()
|
||||
if description == "":
|
||||
description = default_str
|
||||
except AttributeError:
|
||||
description = default_str
|
||||
|
||||
rows = [
|
||||
["Battleye", battleye],
|
||||
["Daytime acceleration", f"{day_accel}x"],
|
||||
["DLC", dlc],
|
||||
["Night-time acceleration", f"{night_accel}x"],
|
||||
["Password", password],
|
||||
["Platform", platform],
|
||||
["Valve Anti-Cheat", vac],
|
||||
["Version", version],
|
||||
]
|
||||
|
||||
return Details(rows, description, True)
|
||||
|
||||
|
||||
def ping(iteration: int, row: list) -> Ping:
|
||||
addr = row[7]
|
||||
qport = row[8]
|
||||
|
||||
res = None
|
||||
|
||||
if row[9] != 9999:
|
||||
return Ping(addr, iteration, row[9])
|
||||
|
||||
try:
|
||||
ip = addr.split(":")[0]
|
||||
except IndexError:
|
||||
ping = 9999
|
||||
|
||||
try:
|
||||
res = query_direct(ip, qport, 0.5)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if res is None:
|
||||
ping = 9999
|
||||
else:
|
||||
ping = res["ping"]
|
||||
|
||||
return Ping(addr, iteration, ping)
|
||||
|
||||
|
||||
def query_api(key: str, appid: int, param: str) -> Res:
|
||||
LIMIT = 10000
|
||||
payload: dict[str, Union[int, str]] = {
|
||||
"filter": r"\appid" + fr"\{appid}" + param,
|
||||
"limit": LIMIT,
|
||||
"key": key,
|
||||
}
|
||||
|
||||
try:
|
||||
res = requests.get(STEAM_SERVERS, params=payload, timeout=REQUEST_TIMEOUT)
|
||||
res.raise_for_status()
|
||||
parsed = True
|
||||
data = res.json()
|
||||
except Exception as e:
|
||||
status = res.status_code
|
||||
parsed = False
|
||||
data = None
|
||||
finally:
|
||||
return Res(status, parsed, data)
|
||||
|
||||
|
||||
def validate_ip(addr: str) -> Record:
|
||||
fields = addr.split(":")
|
||||
if len(fields) != 2:
|
||||
raise InvalidIpError("Address must be formatted as IP:Queryport")
|
||||
|
||||
ip = fields[0]
|
||||
port = fields[1]
|
||||
|
||||
try:
|
||||
int(port)
|
||||
except ValueError:
|
||||
raise InvalidIpError(f"'{port}' is not a valid port")
|
||||
|
||||
if int(port) > 65535 or int(port) < 0:
|
||||
raise InvalidIpError(f"'{port}' is not a valid port")
|
||||
|
||||
try:
|
||||
ipaddress.ip_address(ip)
|
||||
except ValueError:
|
||||
raise InvalidIpError(f"'{ip}' is not a valid IP")
|
||||
|
||||
ip = addr.split(":")[0]
|
||||
qport = int(addr.split(":")[1])
|
||||
record = Record(ip, 0, qport)
|
||||
return record
|
||||
|
||||
def get_rules(ip: str, qport: int) -> list[int]:
|
||||
try:
|
||||
mods = dayzquery.dayz_rules((ip, int(qport))).mods
|
||||
return [mod for mod in mods]
|
||||
except Exception as e:
|
||||
logger.critical(e)
|
||||
return []
|
||||
@@ -0,0 +1,137 @@
|
||||
import json
|
||||
import logging
|
||||
import subprocess
|
||||
|
||||
from shlex import shlex
|
||||
from pathlib import Path
|
||||
|
||||
from dzgui.util.bash import concat_bash_args
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def query_defunct():
|
||||
pass
|
||||
# TODO: unimplemented
|
||||
#cf.
|
||||
#!/usr/bin/env bash
|
||||
#query_defunct(){
|
||||
# readarray -t modlist <<< "$@"
|
||||
# local max=${#modlist[@]}
|
||||
# concat(){
|
||||
# for ((i=0;i<$max;i++)); do
|
||||
# echo "publishedfileids[$i]=${modlist[$i]}&"
|
||||
# done | awk '{print}' ORS=''
|
||||
# }
|
||||
# payload(){
|
||||
# echo -e "itemcount=${max}&$(concat)"
|
||||
# }
|
||||
# post(){
|
||||
# curl -s \
|
||||
# -X POST \
|
||||
# -H "Content-Type:application/x-www-form-urlencoded"\
|
||||
# -d "$(payload)" 'https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/v1/?format=json'
|
||||
# }
|
||||
# post | jq
|
||||
# return
|
||||
# local result=$(post | jq -r '
|
||||
# .[].publishedfiledetails[]
|
||||
# | select(.result==1)
|
||||
# | select(.filename|contains("screenshot")|not)
|
||||
# | "\(.file_size) \(.publishedfileid)"')
|
||||
# <<< "$result" awk '{print $2}'
|
||||
#}
|
||||
#
|
||||
#query_defunct "3576065083"
|
||||
|
||||
|
||||
def concat_mods(mods: list[str]) -> str:
|
||||
for mod in mods:
|
||||
mods[mod] = "@" + mod
|
||||
return ";".join(mods)
|
||||
|
||||
# TEST: set config to name=user, use official server and no mods,
|
||||
# ensure that formatted string is identical to fixture
|
||||
def connect(addr: str, appid: int, name: str, mods: list):
|
||||
# TODO: get name from configs
|
||||
# TODO: concat_mods(mods):
|
||||
# @<mod>;@<mod>;
|
||||
params = [
|
||||
f"-connect={addr}",
|
||||
"-nolauncher",
|
||||
"-nosplash",
|
||||
"-skipintro",
|
||||
f"-name={name}",
|
||||
f"-mod={concat}"
|
||||
]
|
||||
# TODO: get steam launch command from configs
|
||||
# args = concat_bash_args()
|
||||
#
|
||||
proc = subprocess.Popen([*args, "-applaunch", appid, *params])
|
||||
# check proc.returncode
|
||||
|
||||
|
||||
def find_user_id(path: Path) -> str | None:
|
||||
resolved_path = path / "config" / "loginusers.vdf"
|
||||
try:
|
||||
vdf = vdf2json(resolved_path)
|
||||
j = json.loads(vdf)
|
||||
for user in j["users"]:
|
||||
if j["users"][user]["MostRecent"] == "1":
|
||||
return user
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warn(e)
|
||||
return None
|
||||
|
||||
|
||||
def vdf2json(path: Path) -> str:
|
||||
def _istr(indent, string):
|
||||
return (indent * " ") + string
|
||||
|
||||
jbuf = "{\n"
|
||||
indent = 1
|
||||
|
||||
with open(path, "r") as f:
|
||||
st = f.read()
|
||||
lex = shlex(st)
|
||||
|
||||
while True:
|
||||
tok = lex.get_token()
|
||||
if not tok:
|
||||
return jbuf + "}\n"
|
||||
if tok == "}":
|
||||
indent -= 1
|
||||
jbuf += _istr(indent, "}")
|
||||
ntok = lex.get_token()
|
||||
lex.push_token(ntok)
|
||||
if ntok and ntok != "}":
|
||||
jbuf += ","
|
||||
jbuf += "\n"
|
||||
else:
|
||||
ntok = lex.get_token()
|
||||
if ntok == "{":
|
||||
jbuf += _istr(indent, tok + ": {\n")
|
||||
indent += 1
|
||||
else:
|
||||
jbuf += _istr(indent, tok + ": " + ntok)
|
||||
ntok = lex.get_token()
|
||||
lex.push_token(ntok)
|
||||
if ntok != "}":
|
||||
jbuf += ","
|
||||
jbuf += "\n"
|
||||
|
||||
|
||||
def gen_shortcut() -> None:
|
||||
# TODO:
|
||||
"""
|
||||
during setup, prompt user to select matching user id from loginusers
|
||||
show user account name, select outer steam id
|
||||
steam/userdata/<steamid_32>/config/shortcuts.vdf
|
||||
"""
|
||||
#STEAMID_MAGIC = 76561197960265728
|
||||
# STEAMID_64 - STEAMID_MAGIC = STEAMID32
|
||||
# or get right-most 32 bits
|
||||
#STEAMID_64 & 0xFFFFFFFF
|
||||
pass
|
||||
@@ -0,0 +1,72 @@
|
||||
import json
|
||||
import shlex
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
"""
|
||||
Convert legacy dztuirc to config.json
|
||||
"""
|
||||
|
||||
class UnsupportedKey(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def str2bool(string: str) -> bool:
|
||||
s = string.lower()
|
||||
match s:
|
||||
case "":
|
||||
return False
|
||||
case "0":
|
||||
return False
|
||||
case "1" | "2":
|
||||
return True
|
||||
case "false":
|
||||
return False
|
||||
case "true":
|
||||
return True
|
||||
case _:
|
||||
raise UnsupportedKey(f"The key {s} is not a valid config key.")
|
||||
|
||||
|
||||
def rc2json(file: Path) -> str:
|
||||
with open(file, "r") as f:
|
||||
lex = shlex.shlex(f.read())
|
||||
lex.whitespace += "="
|
||||
|
||||
d = {}
|
||||
ips: list[str] = []
|
||||
|
||||
toggles = ["auto_install", "debug", "fullscreen"]
|
||||
deprecated = ["staging_dir", "src_path", "steam_path"]
|
||||
|
||||
while True:
|
||||
tok = lex.get_token()
|
||||
ntok = lex.get_token()
|
||||
|
||||
if ntok is not None:
|
||||
ntok = ntok.strip('""')
|
||||
|
||||
if tok in deprecated:
|
||||
continue
|
||||
elif tok in toggles:
|
||||
ntok = str2bool(ntok)
|
||||
elif tok == "preferred_client":
|
||||
tok = "client"
|
||||
elif tok == "api_key":
|
||||
tok = "bm_api"
|
||||
elif tok == "ip_list":
|
||||
while True:
|
||||
ntok = lex.get_token().strip('""')
|
||||
if ntok == ")":
|
||||
break
|
||||
ips.append(ntok)
|
||||
continue
|
||||
|
||||
if not tok:
|
||||
break
|
||||
|
||||
d[tok] = ntok
|
||||
|
||||
d["ip_list"] = ips
|
||||
d["use_miles"] = False
|
||||
return json.dumps(d, indent=2)
|
||||
@@ -0,0 +1,42 @@
|
||||
import shutil
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def write_desktop_file(share_path: Path) -> None:
|
||||
template = f"""\
|
||||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Type=Application
|
||||
Terminal=false
|
||||
Exec={share_path}/launch.sh
|
||||
Name=DZGUI
|
||||
Comment=dzgui
|
||||
Icon={share_path}/dzgui
|
||||
Categories=Game"""
|
||||
|
||||
file = share_path / "dzgui.desktop"
|
||||
file.write_text(textwrap.dedent(template))
|
||||
|
||||
|
||||
def write_shim_file(share_path: Path) -> None:
|
||||
# FIXME: expects sys.prefix to be a venv
|
||||
file = share_path / "launch.sh"
|
||||
template=f"""\
|
||||
source {venv}/bin/activate
|
||||
python3.13 dzgui
|
||||
"""
|
||||
try:
|
||||
file.write_text(textwrap.dedent(template))
|
||||
f.chmod(f.stat().st_mode | stat.S_IEXEC)
|
||||
except OSError:
|
||||
logger.critical(e)
|
||||
return
|
||||
clear_old_path()
|
||||
write_desktop_file(share_path)
|
||||
|
||||
|
||||
def clear_old_path() -> None:
|
||||
home = Path.home()
|
||||
path = home / ".local/share/dzgui"
|
||||
shutil.rmtree(path)
|
||||
@@ -0,0 +1,97 @@
|
||||
import gzip
|
||||
import logging
|
||||
import re
|
||||
import requests
|
||||
import shutil
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from dzgui.const.constants import REQUEST_TIMEOUT
|
||||
from dzgui.const.endpoints import DB_IP
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def find_download_url(url: str) -> str | None:
|
||||
res = requests.get(url, timeout=REQUEST_TIMEOUT)
|
||||
res.raise_for_status()
|
||||
reg = r'ref=[\'"]([^\'">]+.csv.gz)'
|
||||
urls = re.findall(reg, res.text)
|
||||
if len(urls) < 1:
|
||||
return None
|
||||
return str(urls[0])
|
||||
|
||||
def get_ipdb(ips_path: Path) -> None:
|
||||
url = find_download_url(DB_IP)
|
||||
if url is None:
|
||||
return
|
||||
|
||||
date = find_date(url)
|
||||
month_file = ips_path.parent / ".month"
|
||||
if month_file.exists() is True:
|
||||
old_date = month_file.read_text().rstrip("\n")
|
||||
if old_date == date:
|
||||
logger.info(f"IP DB date matches: {date}")
|
||||
return
|
||||
|
||||
# TODO: log additional output
|
||||
logger.info(f"Fetching IPDB for {date} from {url}")
|
||||
try:
|
||||
tmp = serialize(url)
|
||||
except Exception as e:
|
||||
logger.critical(e)
|
||||
return
|
||||
|
||||
logger.info(f"Extracting {tmp}")
|
||||
unzip(tmp, ips_path)
|
||||
|
||||
logger.info("Stripping IPv6 records")
|
||||
try:
|
||||
strip_ipv6(ips_path)
|
||||
except Exception as e:
|
||||
logger.critical(e)
|
||||
return
|
||||
|
||||
with open(month_file, "w") as f:
|
||||
f.write(date)
|
||||
logger.info(f"Wrote {date} to {month_file}")
|
||||
|
||||
|
||||
def find_date(url: str) -> str:
|
||||
date = re.sub(r"(dbip-city-lite-)(.*)(.csv.gz)", r"\2", url)
|
||||
date = date.split("/")[-1]
|
||||
return date
|
||||
|
||||
|
||||
def unzip(tmp: Path, ips_path: Path) -> None:
|
||||
with gzip.open(tmp, "rb") as f:
|
||||
with open(ips_path, "wb") as outfile:
|
||||
shutil.copyfileobj(f, outfile)
|
||||
tmp.unlink()
|
||||
|
||||
|
||||
def serialize(url: str) -> Path:
|
||||
with requests.get(url, stream=True) as r:
|
||||
r.raise_for_status()
|
||||
filename = "ips.csv.gz"
|
||||
tmp = Path("/tmp") / filename
|
||||
with open(tmp, "wb") as f:
|
||||
shutil.copyfileobj(r.raw, f)
|
||||
return tmp
|
||||
|
||||
|
||||
# TODO: optimize this function
|
||||
# consider using grep
|
||||
# cf. grep -vE "^[a-z0-9]{4}:" | grep -v "::" > "$ip_file"
|
||||
def strip_ipv6(path: Path) -> None:
|
||||
ips = []
|
||||
with open(path, "r") as f:
|
||||
s = f.read()
|
||||
|
||||
reg = r"^\d{1,3}\..*"
|
||||
ips = re.findall(reg, s, re.MULTILINE)
|
||||
assert ips[0].split(",")[0] == "0.0.0.0"
|
||||
assert ips[-1].split(",")[0] == "224.0.0.0"
|
||||
|
||||
with open(path, "w") as f:
|
||||
for ip in ips:
|
||||
f.write(ip + "\n")
|
||||
@@ -0,0 +1,60 @@
|
||||
from typing import Any
|
||||
from pathlib import Path
|
||||
|
||||
from dzgui.util._json import read_json
|
||||
|
||||
from dzgui.const.enum import Preferences
|
||||
from dzgui.const.constants import STEAM_CMD, FLATPAK_RUN_CMD, FLATPAK_SANDBOX
|
||||
|
||||
|
||||
class ConfigFileError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def lookup(path: Path, enum: Preferences) -> Any:
|
||||
if path.is_file() is False:
|
||||
raise ConfigFileError("Not a valid file")
|
||||
|
||||
key = enum_to_key(enum)
|
||||
config = get_config(path)
|
||||
try:
|
||||
return config[key]
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
|
||||
def get_config(path: Path) -> dict:
|
||||
# TODO: is this being called twice?
|
||||
try:
|
||||
json = read_json(path)
|
||||
except Exception as e:
|
||||
raise e
|
||||
return json
|
||||
|
||||
|
||||
def get_favorites(path: Path) -> list[str]:
|
||||
try:
|
||||
conf = get_config(path)
|
||||
except Exception as e:
|
||||
pass
|
||||
return conf["ip_list"]
|
||||
|
||||
|
||||
def is_in_favs(record: str, path: Path) -> bool:
|
||||
favs = get_favorites(path)
|
||||
if record in favs:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def enum_to_key(enum: Preferences) -> str:
|
||||
return enum.dict["key"]
|
||||
|
||||
|
||||
def get_client_index(client: str) -> int:
|
||||
if client == STEAM_CMD:
|
||||
return 0
|
||||
elif client == FLATPAK_RUN_CMD:
|
||||
return 1
|
||||
else:
|
||||
return 2
|
||||
@@ -0,0 +1,10 @@
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
def uninstall(path: Path) -> None
|
||||
# TODO: use xdg object?
|
||||
# partial/full (keep config?)
|
||||
# -u removes state, log
|
||||
# XDG_SHARE_HOME/dzgui
|
||||
# XDG_STATE_HOME/dzgui
|
||||
# XDG_DATA_HOME/dzgui
|
||||
@@ -0,0 +1,25 @@
|
||||
from pathlib import Path
|
||||
|
||||
from dzgui.const.enum import Preferences
|
||||
from dzgui.config.query import get_config, enum_to_key
|
||||
from dzgui.util._json import write_json
|
||||
|
||||
# TEST: parametrize writing config values and checking config output in test
|
||||
def toggle_config(path: Path, key: Preferences) -> None:
|
||||
real_key = enum_to_key(key)
|
||||
try:
|
||||
conf = get_config(path)
|
||||
cur_val = conf[real_key]
|
||||
conf[real_key] = not cur_val
|
||||
write_json(conf, path)
|
||||
except Exception as e:
|
||||
raise e
|
||||
|
||||
def write_config(path: Path, key: Preferences, value: str) -> None:
|
||||
real_key = enum_to_key(key)
|
||||
try:
|
||||
conf = get_config(path)
|
||||
conf[real_key] = value
|
||||
write_json(conf, path)
|
||||
except Exception as e:
|
||||
raise e
|
||||
@@ -0,0 +1,12 @@
|
||||
from dataclasses import dataclass
|
||||
from dzgui.config.xdg import Xdg
|
||||
from dzgui.util.ip import Coords
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class UserPrefs:
|
||||
is_steam_deck: bool
|
||||
is_game_mode: bool
|
||||
coords: Coords | None
|
||||
version: str
|
||||
allow_updates: bool
|
||||
paths: Xdg
|
||||
@@ -0,0 +1,90 @@
|
||||
import os
|
||||
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass
|
||||
|
||||
from dzgui.const.constants import APP_NAME_LOWER, DEBUG_LOG, SYSTEM_LOG
|
||||
|
||||
@dataclass
|
||||
class Xdg:
|
||||
config: Path
|
||||
columns: Path
|
||||
notes: Path
|
||||
resolution: Path
|
||||
history: Path
|
||||
version: Path
|
||||
system: Path
|
||||
debug: Path
|
||||
ips: Path
|
||||
|
||||
|
||||
def is_writeable(path_str: str) -> bool:
|
||||
if path_str is None:
|
||||
return False
|
||||
|
||||
path = Path(path_str)
|
||||
|
||||
if not path.exists():
|
||||
try:
|
||||
path.mkdir(parents=True)
|
||||
except OSError:
|
||||
return False
|
||||
path.unlink()
|
||||
return True
|
||||
|
||||
try:
|
||||
file = Path(path / "temp")
|
||||
with open(file, "w") as f:
|
||||
f.write("")
|
||||
except Exception:
|
||||
return False
|
||||
file.unlink()
|
||||
return True
|
||||
|
||||
|
||||
def get_xdg_paths() -> dict:
|
||||
home = Path.home()
|
||||
xdg_paths = {
|
||||
"XDG_CONFIG_HOME": home / ".config",
|
||||
"XDG_STATE_HOME": home / ".local" / "state",
|
||||
"XDG_DATA_HOME": home / ".local" / "share",
|
||||
"XDG_CACHE_HOME": home / ".cache",
|
||||
}
|
||||
|
||||
resolved_paths = {}
|
||||
for path in xdg_paths:
|
||||
rp = os.environ.get(path)
|
||||
resolved_paths[path] = Path(rp) / APP_NAME_LOWER if is_writeable(rp) else xdg_paths[path] / APP_NAME_LOWER
|
||||
return resolved_paths
|
||||
|
||||
|
||||
def parse_filepaths(xdg: dict) -> Xdg:
|
||||
# cache = xdg["XDG_CACHE_HOME"]
|
||||
# data = xdg["XDG_DATA_HOME"]
|
||||
|
||||
config = xdg["XDG_CONFIG_HOME"]
|
||||
state = xdg["XDG_STATE_HOME"]
|
||||
|
||||
config = config / "config.json"
|
||||
|
||||
system = state / "logs" / SYSTEM_LOG
|
||||
debug = state / "logs" / DEBUG_LOG
|
||||
|
||||
columns = state / "dzg.cols.json"
|
||||
notes = state / "dzg.notes.json"
|
||||
resolution = state / "dzg.res.json"
|
||||
history = state / "dzg.history"
|
||||
versions = state / "dzg.versions"
|
||||
ips = state / "ips.csv"
|
||||
|
||||
return Xdg(
|
||||
config,
|
||||
columns,
|
||||
notes,
|
||||
resolution,
|
||||
history,
|
||||
versions,
|
||||
system,
|
||||
debug,
|
||||
ips,
|
||||
)
|
||||
@@ -0,0 +1,53 @@
|
||||
APPID_DAYZ = 221100
|
||||
APPID_DAYZ_EXP = 1024020
|
||||
UDP_PORT = 27016
|
||||
VM_FILE = "/proc/sys/vm/max_map_count"
|
||||
MIN_COUNT = 1048576
|
||||
|
||||
REQUEST_TIMEOUT = 3
|
||||
|
||||
APPNAME_DAYZ = "DayZ"
|
||||
APPNAME_DAYZ_EXP = "DayZ Experimental"
|
||||
DAYZ_BINARY = "DayZ_x64.exe"
|
||||
|
||||
LIBRARYFOLDERS_PATH = "steamapps/libraryfolders.vdf"
|
||||
WORKSHOP_PATH = "steamapps/workshop/content/" + str(APPID_DAYZ)
|
||||
|
||||
STEAM_CMD = "steam"
|
||||
FLATPAK_CMD = "flatpak"
|
||||
FLATPAK_APPID = "com.valvesoftware.Steam"
|
||||
FLATPAK_RUN_CMD = "flatpak run " + FLATPAK_APPID
|
||||
FLATPAK_SANDBOX = "flatpak-spawn --host flatpak run " + FLATPAK_APPID
|
||||
|
||||
AUTHOR = "aclist"
|
||||
STABLE_REPO = "stable"
|
||||
BETA_REPO = "testing"
|
||||
|
||||
APP_NAME = "DZGUI"
|
||||
APP_NAME_LOWER = "dzgui"
|
||||
APP_NAME_ABBR = "dzg"
|
||||
|
||||
HEX_RED = "#FF0000"
|
||||
HEX_ORANGE = "#FFAC1C"
|
||||
|
||||
VIEW_CONCEAL = "view-conceal-symbolic"
|
||||
VIEW_REVEAL = "view-reveal-symbolic"
|
||||
INPUT_KEYBOARD = "input-keyboard-symbolic"
|
||||
HELP_BUBBLE = "help-about-symbolic"
|
||||
WEB_BROWSER = "web-browser-symbolic"
|
||||
|
||||
SEPARATOR = "SEPARATOR"
|
||||
|
||||
NO_EXPAND = False
|
||||
NO_FILL = False
|
||||
EXPAND = True
|
||||
FILL = True
|
||||
|
||||
SCROLL_INCREMENT = 50
|
||||
WINDOW_DEFAULT_X = 1400
|
||||
WINDOW_DEFAULT_Y = 800
|
||||
|
||||
LEGACY_CONFIG_PATH = ".config/dztui/dztuirc"
|
||||
|
||||
DEBUG_LOG = f"{APP_NAME}_DEBUG.LOG"
|
||||
SYSTEM_LOG = f"{APP_NAME}_SYSTEM.LOG"
|
||||
@@ -0,0 +1,23 @@
|
||||
# internal
|
||||
STEAM_SERVERS = "https://api.steampowered.com/IGameServersService/GetServerList/v1/?"
|
||||
BM_SERVERS = "https://api.battlemetrics.com/servers?"
|
||||
GITHUB = "https://github.com/aclist"
|
||||
GITHUB_RELEASES = "https://api.github.com/repos/aclist/dztui/releases/latest"
|
||||
CODEBERG_RELEASES = "https://codeberg.org/api/v1/repos/aclist/dzgui/releases/latest"
|
||||
DB_IP = "https://db-ip.com/db/download/ip-to-city-lite"
|
||||
IP_ECHO = "https://ipecho.net/plain"
|
||||
COORDS_API = "http://ip-api.com/json"
|
||||
|
||||
# steam URIs
|
||||
WORKSHOP = "steam://url/CommunityFilePage/"
|
||||
OPEN_URL = "steam://openurl/"
|
||||
|
||||
# user facing
|
||||
GITHUB_PAGES = "https://aclist.github.io/dzgui/index"
|
||||
CODEBERG_PAGES = "https://aclist.codeberg.page"
|
||||
GITHUB_ISSUES = "https://github.com/aclist/dzgui/issues"
|
||||
FORUM = "https://old.reddit.com/r/dzgui"
|
||||
SPONSORS = "https://github.com/sponsors/aclist"
|
||||
STEAM_API_SETUP = "https://steamcommunity.com/dev/apikey"
|
||||
BM_API_SETUP = "https://www.battlemetrics.com/developers"
|
||||
BM_BROWSE = "https://www.battlemetrics.com/servers/dayz"
|
||||
@@ -0,0 +1,525 @@
|
||||
from enum import Enum
|
||||
from typing import Self
|
||||
|
||||
from dzgui.util import strings
|
||||
|
||||
|
||||
class Port(Enum):
|
||||
DEFAULT = 1
|
||||
CUSTOM = 2
|
||||
|
||||
|
||||
class Popup(Enum):
|
||||
WAIT = 1
|
||||
NOTIFY = 2
|
||||
CONFIRM = 3
|
||||
ENTRY = 4
|
||||
RETURN = 5
|
||||
MODLIST = 6
|
||||
DETAILS = 7
|
||||
QUIT = 8
|
||||
|
||||
|
||||
class Command(Enum):
|
||||
INTERACTIVE = 1
|
||||
ONESHOT = 2
|
||||
HELP = 3
|
||||
TOGGLE = 4
|
||||
THANKS = 5
|
||||
|
||||
|
||||
class VAdjustment(Enum):
|
||||
UP = 1
|
||||
DOWN = 2
|
||||
TOP = 3
|
||||
BOTTOM = 4
|
||||
|
||||
|
||||
class CursorPosition(Enum):
|
||||
UP = 1
|
||||
DOWN = 2
|
||||
TOP = 3
|
||||
BOTTOM = 4
|
||||
|
||||
|
||||
class FilterMode(Enum):
|
||||
KEYWORD = 1
|
||||
MAP = 2
|
||||
INITIAL = 3
|
||||
TOGGLE_OFF = 4
|
||||
TOGGLE_ON = 5
|
||||
|
||||
|
||||
class EnumWithAttrs(Enum):
|
||||
def __new__(cls, *args, **kwargs) -> Self:
|
||||
value = len(cls.__members__) + 1
|
||||
obj = object.__new__(cls)
|
||||
obj._value_ = value
|
||||
return obj
|
||||
|
||||
def __init__(self, d: dict):
|
||||
self.dict = d
|
||||
|
||||
class Preferences(EnumWithAttrs):
|
||||
STEAM = {"key": "steam_api",}
|
||||
BM = {"key": "bm_api",}
|
||||
CLIENT = {"key": "client",}
|
||||
WINDOW = {"key": "fullscreen",}
|
||||
DIST = {"key": "use_miles",}
|
||||
NAME = {"key": "name",}
|
||||
INSTALL = {"key": "auto_install",}
|
||||
DEBUG = {"key": "debug",}
|
||||
DEFAULT = {"key": "default_steam_path",}
|
||||
FAV_LBL = {"key": "fav_label",}
|
||||
FAV_SRV = {"key": "fav_server",}
|
||||
IP_LIST = {"key": "ip_list",}
|
||||
BRANCH = {"key": "branch",}
|
||||
|
||||
|
||||
class NotebookPage(EnumWithAttrs):
|
||||
CHANGELOG = {"crumbs": strings.crumbs.changelog,}
|
||||
HISTORY = {"crumbs": strings.crumbs.history,}
|
||||
KEYS = {"crumbs": strings.crumbs.keys,}
|
||||
LAN = {"crumbs": strings.crumbs.lan,}
|
||||
LOG = {"crumbs": strings.crumbs.log,}
|
||||
MAIN = {"crumbs": strings.crumbs.main,}
|
||||
MODS = {"crumbs": strings.crumbs.mods,}
|
||||
MY_SERVERS = {"crumbs": strings.crumbs.my_servers,}
|
||||
OPTIONS = {"crumbs": strings.crumbs.options,}
|
||||
SERVERS = {"crumbs": strings.crumbs.servers,}
|
||||
THANKS = {"crumbs": strings.crumbs.thanks,}
|
||||
|
||||
|
||||
class RowType(EnumWithAttrs):
|
||||
@classmethod
|
||||
def str2rowtype(cls, string: str) -> "RowType":
|
||||
for member in cls:
|
||||
if string == member.dict["label"]:
|
||||
return member
|
||||
return RowType.DYNAMIC
|
||||
|
||||
# specialized behavior
|
||||
# TODO: deprecated
|
||||
DYNAMIC = {
|
||||
"label": None,
|
||||
"tooltip": None,
|
||||
}
|
||||
RESOLVE_IP = {
|
||||
"label": "Resolve IP",
|
||||
"tooltip": None,
|
||||
"wait_msg": "Resolving remote IP",
|
||||
}
|
||||
HIGHLIGHT = {
|
||||
"label": "Highlight stale",
|
||||
"tooltip": None,
|
||||
"wait_msg": "Looking for stale mods",
|
||||
}
|
||||
|
||||
# pages
|
||||
SERVER_BROWSER = {
|
||||
"label": "Server browser",
|
||||
"tooltip": "Used to browse the global server list",
|
||||
"type": "server",
|
||||
}
|
||||
SAVED_SERVERS = {
|
||||
"label": "My saved servers",
|
||||
"tooltip": "Browse your saved servers. Unreachable servers will be excluded",
|
||||
"type": "server",
|
||||
}
|
||||
RECENT_SERVERS = {
|
||||
"label": "Recent servers",
|
||||
"tooltip": "Shows the last 10 servers you connected to (includes attempts)",
|
||||
"type": "server",
|
||||
}
|
||||
SCAN_LAN = {
|
||||
"label": "Scan LAN servers",
|
||||
"tooltip": "Search for servers on your local network",
|
||||
"type": "server",
|
||||
}
|
||||
LIST_MODS = {
|
||||
"label": "Mods",
|
||||
"tooltip": "Browse a list of locally-installed mods",
|
||||
"quad_label": "Mods",
|
||||
"type": "mods",
|
||||
}
|
||||
CHANGELOG = {
|
||||
"label": "View changelog",
|
||||
"tooltip": "Opens the DZGUI changelog",
|
||||
}
|
||||
OPTIONS = {"label": "Options", "tooltip": None}
|
||||
KEYBINDINGS = {"label": "Keybindings", "tooltip": None}
|
||||
SHOW_LOG = {
|
||||
"label": "Show debug log",
|
||||
"tooltip": "Read the DZGUI log generated since startup",
|
||||
"quad_label": "Debug log",
|
||||
}
|
||||
|
||||
# interactive dialogs
|
||||
CONN_BY_IP = {
|
||||
"label": "Connect by IP",
|
||||
"tooltip": "Connect to a server by IP",
|
||||
"prompt": "Enter IP in IP:Queryport format (e.g. 192.168.1.1:27016)",
|
||||
"link_label": None,
|
||||
"type": Command.INTERACTIVE,
|
||||
}
|
||||
CONN_BY_ID = {
|
||||
"label": "Connect by ID",
|
||||
"tooltip": "Connect to a server by Battlemetrics ID",
|
||||
"prompt": "Enter server ID",
|
||||
"link_label": "Open Battlemetrics",
|
||||
"type": Command.INTERACTIVE,
|
||||
}
|
||||
SEPARATOR = {
|
||||
"label" : "SEPARATOR",
|
||||
"tooltip": "",
|
||||
}
|
||||
ADD_BY_IP = {
|
||||
"label": "Add server by IP",
|
||||
"tooltip": "Add a server by IP",
|
||||
"prompt": "Enter IP in IP:Queryport format (e.g. 192.168.1.1:27016)",
|
||||
"link_label": None,
|
||||
"type": Command.INTERACTIVE,
|
||||
}
|
||||
ADD_BY_ID = {
|
||||
"label": "Add server by ID",
|
||||
"tooltip": "Add a server by Battlemetrics ID",
|
||||
"prompt": "Enter server ID",
|
||||
"link_label": "Open Battlemetrics",
|
||||
"type": Command.INTERACTIVE,
|
||||
}
|
||||
CHNG_FAV = {
|
||||
"label": "Change favorite server",
|
||||
"tooltip": "Update your quick-connect server",
|
||||
"prompt": "Enter IP in IP:Queryport format (e.g. 192.168.1.1:27016)",
|
||||
"link_label": None,
|
||||
"alt": None,
|
||||
"default": "unset",
|
||||
"val": "fav_label",
|
||||
"type": Command.INTERACTIVE,
|
||||
}
|
||||
CHNG_PLAYER = {
|
||||
"label": "Change player name",
|
||||
"tooltip": "Update your in-game name (required by some servers)",
|
||||
"prompt": "Enter new nickname",
|
||||
"link_label": None,
|
||||
"alt": None,
|
||||
"default": None,
|
||||
"val": "name",
|
||||
"type": Command.INTERACTIVE,
|
||||
}
|
||||
CHNG_STEAM_API = {
|
||||
"label": "Change Steam API key",
|
||||
"tooltip": "Can be used if you revoked an old API key",
|
||||
"prompt": "Enter new API key",
|
||||
"link_label": "Open Steam API page",
|
||||
"type": Command.INTERACTIVE,
|
||||
}
|
||||
CHNG_BM_API = {
|
||||
"label": "Change Battlemetrics API key",
|
||||
"tooltip": "Can be used if you revoked an old API key",
|
||||
"link_label": "Open Battlemetrics API page",
|
||||
"prompt": "Enter new API key",
|
||||
"type": Command.INTERACTIVE,
|
||||
}
|
||||
|
||||
# oneshot commands
|
||||
QUICK_CONNECT = {
|
||||
"label": "Quick-connect to favorite server",
|
||||
"tooltip": "Connect to your favorite server",
|
||||
"wait_msg": "Working",
|
||||
"default": "unset",
|
||||
"alt": None,
|
||||
"val": "fav_label",
|
||||
"type": Command.ONESHOT,
|
||||
}
|
||||
FORCE_UPDATE = {
|
||||
"label": "Force update local mods",
|
||||
"tooltip": "Synchronize local mods with remote versions (experimental)",
|
||||
"wait_msg": "Updating mods",
|
||||
"type": Command.ONESHOT,
|
||||
}
|
||||
DUMP_LOG = {
|
||||
"label": "Output system info to log file",
|
||||
"tooltip": "Dump diagnostic data for troubleshooting",
|
||||
"wait_msg": "Generating log",
|
||||
"type": Command.ONESHOT,
|
||||
}
|
||||
HANDSHAKE = {
|
||||
"label": "Handshake",
|
||||
"tooltip": None,
|
||||
"wait_msg": "Waiting for DayZ",
|
||||
"type": Command.ONESHOT,
|
||||
}
|
||||
HANDSHAKE_EXP = {
|
||||
"label": "Handshake_EXP",
|
||||
"tooltip": None,
|
||||
"wait_msg": "Waiting for DayZ",
|
||||
"type": Command.ONESHOT,
|
||||
}
|
||||
DELETE_SELECTED = {
|
||||
"label": "Delete selected mods",
|
||||
"tooltip": None,
|
||||
"wait_msg": "Deleting mods",
|
||||
"type": Command.ONESHOT,
|
||||
}
|
||||
|
||||
# help pages
|
||||
DOCS = {
|
||||
"label": "Documentation/help files (GitHub) ⧉",
|
||||
"tooltip": "Open the DZGUI documentation in a browser",
|
||||
"type": Command.HELP,
|
||||
}
|
||||
DOCS_FALLBACK = {
|
||||
"label": "Documentation/help files (Codeberg mirror) ⧉",
|
||||
"tooltip": "Open the DZGUI documentation in a browser",
|
||||
"type": Command.HELP,
|
||||
}
|
||||
BUGS = {
|
||||
"label": "Report a bug (GitHub) ⧉",
|
||||
"tooltip": "Open the DZGUI issue tracker in a browser",
|
||||
"type": Command.HELP,
|
||||
}
|
||||
FORUM = {
|
||||
"label": "DZGUI Subreddit ⧉",
|
||||
"tooltip": "Open the DZGUI discussion forum in a browser",
|
||||
"type": Command.HELP,
|
||||
}
|
||||
SPONSOR = {
|
||||
"label": "Sponsor (GitHub) ⧉",
|
||||
"tooltip": "Sponsor development of the DZGUI project",
|
||||
"type": Command.HELP,
|
||||
}
|
||||
THANKS = {
|
||||
"label": "Special thanks",
|
||||
"tooltip": "A list of contributors, testers, and sponsors",
|
||||
"type": Command.THANKS,
|
||||
}
|
||||
|
||||
|
||||
# TODO: rename to ContextItem
|
||||
class ContextMenu(EnumWithAttrs):
|
||||
ADD_SERVER = {"label": strings.add}
|
||||
REMOVE_SERVER = {"label": strings.remove}
|
||||
COPY_NAME = {"label": strings.copy_name}
|
||||
COPY_CLIPBOARD = {"label": strings.copy_ip}
|
||||
ADD_NOTE = {"label": strings.add_note}
|
||||
SHOW_MODS = {"label": strings.show_mods}
|
||||
SHOW_DETAILS = {"label": strings.show_details}
|
||||
REFRESH_PLAYERS = {"label": strings.refresh_players}
|
||||
REMOVE_HISTORY = {"label": strings.remove_history}
|
||||
OPEN_WORKSHOP = {"label": strings.open_workshop}
|
||||
DELETE_MOD = {"label": strings.delete_mod}
|
||||
COPY_LOG_CLIPBOARD = {"label": strings.copy_log}
|
||||
|
||||
|
||||
class ContextMenuGroup(Enum):
|
||||
"""
|
||||
Groupings of context menu items
|
||||
"""
|
||||
MOD = (
|
||||
ContextMenu.OPEN_WORKSHOP, ContextMenu.DELETE_MOD
|
||||
)
|
||||
LOG = (
|
||||
ContextMenu.COPY_LOG_CLIPBOARD,
|
||||
)
|
||||
SERVER_BROWSER = (
|
||||
ContextMenu.ADD_SERVER,
|
||||
ContextMenu.COPY_NAME,
|
||||
ContextMenu.COPY_CLIPBOARD,
|
||||
ContextMenu.ADD_NOTE,
|
||||
ContextMenu.SHOW_MODS,
|
||||
ContextMenu.SHOW_DETAILS,
|
||||
ContextMenu.REFRESH_PLAYERS,
|
||||
)
|
||||
SCAN_LAN = (
|
||||
ContextMenu.COPY_NAME,
|
||||
ContextMenu.COPY_CLIPBOARD,
|
||||
ContextMenu.ADD_NOTE,
|
||||
ContextMenu.SHOW_MODS,
|
||||
ContextMenu.SHOW_DETAILS,
|
||||
ContextMenu.REFRESH_PLAYERS,
|
||||
)
|
||||
SAVED = (
|
||||
ContextMenu.REMOVE_SERVER,
|
||||
ContextMenu.COPY_NAME,
|
||||
ContextMenu.COPY_CLIPBOARD,
|
||||
ContextMenu.ADD_NOTE,
|
||||
ContextMenu.SHOW_MODS,
|
||||
ContextMenu.SHOW_DETAILS,
|
||||
ContextMenu.REFRESH_PLAYERS,
|
||||
)
|
||||
RECENT = (
|
||||
ContextMenu.ADD_SERVER,
|
||||
ContextMenu.REMOVE_HISTORY,
|
||||
ContextMenu.COPY_NAME,
|
||||
ContextMenu.COPY_CLIPBOARD,
|
||||
ContextMenu.ADD_NOTE,
|
||||
ContextMenu.SHOW_MODS,
|
||||
ContextMenu.SHOW_DETAILS,
|
||||
ContextMenu.REFRESH_PLAYERS,
|
||||
)
|
||||
|
||||
|
||||
MAIN_MENU_ROWS = (
|
||||
RowType.SERVER_BROWSER,
|
||||
RowType.SAVED_SERVERS,
|
||||
RowType.RECENT_SERVERS,
|
||||
RowType.SCAN_LAN,
|
||||
RowType.SEPARATOR,
|
||||
RowType.QUICK_CONNECT,
|
||||
RowType.CONN_BY_IP,
|
||||
RowType.CONN_BY_ID,
|
||||
RowType.SEPARATOR,
|
||||
RowType.ADD_BY_IP,
|
||||
RowType.ADD_BY_ID,
|
||||
RowType.CHNG_FAV,
|
||||
)
|
||||
HELP_MENU_ROWS = (
|
||||
RowType.CHANGELOG,
|
||||
RowType.SHOW_LOG,
|
||||
RowType.DUMP_LOG,
|
||||
RowType.SEPARATOR,
|
||||
RowType.DOCS,
|
||||
RowType.DOCS_FALLBACK,
|
||||
RowType.BUGS,
|
||||
RowType.FORUM,
|
||||
RowType.SPONSOR,
|
||||
RowType.SEPARATOR,
|
||||
RowType.THANKS,
|
||||
)
|
||||
|
||||
#class WindowContext(EnumWithAttrs):
|
||||
# # TODO: deprecated
|
||||
# @classmethod
|
||||
# def row2con(cls, row: RowType) -> "WindowContext":
|
||||
# m = WindowContext.MAIN_MENU
|
||||
# for member in cls:
|
||||
# if row in member.dict["rows"]:
|
||||
# m = member
|
||||
# elif row in member.dict["called_by"]:
|
||||
# m = member
|
||||
# else:
|
||||
# continue
|
||||
# return m
|
||||
#
|
||||
# # outer menu pages
|
||||
# # TODO: some deprecated keys in here
|
||||
# #MAIN_MENU = {
|
||||
# # RowType.SERVER_BROWSER,
|
||||
# # RowType.SAVED_SERVERS,
|
||||
# # RowType.RECENT_SERVERS,
|
||||
# # RowType.SCAN_LAN,
|
||||
# # RowType.SEPARATOR,
|
||||
# # RowType.QUICK_CONNECT,
|
||||
# # RowType.CONN_BY_IP,
|
||||
# # RowType.CONN_BY_ID,
|
||||
# # RowType.SEPARATOR,
|
||||
# # RowType.ADD_BY_IP,
|
||||
# # RowType.ADD_BY_ID,
|
||||
# # RowType.CHNG_FAV,
|
||||
# # }
|
||||
# #MODS = {
|
||||
# # "label": "Mods",
|
||||
# # "rows": [],
|
||||
# # "called_by": [],
|
||||
# #}
|
||||
# HELP = {
|
||||
# "label": "Help",
|
||||
# "rows": [
|
||||
# RowType.CHANGELOG,
|
||||
# RowType.SHOW_LOG,
|
||||
# RowType.DUMP_LOG,
|
||||
# RowType.SEPARATOR,
|
||||
# RowType.DOCS,
|
||||
# RowType.DOCS_FALLBACK,
|
||||
# RowType.BUGS,
|
||||
# RowType.FORUM,
|
||||
# RowType.SPONSOR,
|
||||
# RowType.SEPARATOR,
|
||||
# RowType.THANKS,
|
||||
# ],
|
||||
# "called_by": [],
|
||||
# }
|
||||
#
|
||||
# ## inner server contexts
|
||||
# #TABLE_API = {
|
||||
# # "label": "",
|
||||
# # "rows": [],
|
||||
# # "called_by": [RowType.SERVER_BROWSER],
|
||||
# #}
|
||||
# #TABLE_SERVER = {
|
||||
# # "label": "",
|
||||
# # "rows": [],
|
||||
# # "called_by": [
|
||||
# # RowType.SAVED_SERVERS,
|
||||
# # RowType.RECENT_SERVERS,
|
||||
# # RowType.SCAN_LAN,
|
||||
# # ],
|
||||
# #}
|
||||
# #TABLE_MODS = {
|
||||
# # "label": "",
|
||||
# # "rows": [],
|
||||
# # "called_by": [
|
||||
# # RowType.LIST_MODS,
|
||||
# # ],
|
||||
# #}
|
||||
# #TABLE_LOG = {
|
||||
# # "label": "",
|
||||
# # "rows": [],
|
||||
# # "called_by": [RowType.SHOW_LOG],
|
||||
# #}
|
||||
|
||||
|
||||
class ModButton(EnumWithAttrs):
|
||||
SELECT_ALL = {
|
||||
"label": strings.mod_panel.select_all,
|
||||
"tooltip": strings.mod_panel.select_all_tooltip,
|
||||
}
|
||||
UNSELECT_ALL = {
|
||||
"label": strings.mod_panel.unselect_all,
|
||||
"tooltip": strings.mod_panel.unselect_all_tooltip
|
||||
}
|
||||
HIGHLIGHT_STALE = {
|
||||
"label": strings.mod_panel.highlight_stale,
|
||||
"tooltip": strings.mod_panel.highlight_stale_tooltip,
|
||||
}
|
||||
UNHIGHLIGHT_STALE = {
|
||||
"label": strings.mod_panel.unhighlight_stale,
|
||||
"tooltip": strings.mod_panel.unhighlight_stale_tooltip,
|
||||
}
|
||||
DELETE_SELECTED = {
|
||||
"label": strings.mod_panel.delete_selected,
|
||||
"tooltip": strings.mod_panel.delete_selected_tooltip,
|
||||
}
|
||||
SELECT_STALE = {
|
||||
"label": strings.mod_panel.select_stale,
|
||||
"tooltip": strings.mod_panel.select_stale_tooltip,
|
||||
}
|
||||
|
||||
|
||||
class ButtonType(EnumWithAttrs):
|
||||
MAIN_MENU = {
|
||||
"label": strings.buttons.main_label,
|
||||
"tooltip": strings.buttons.main_tooltip,
|
||||
"opens": NotebookPage.MAIN,
|
||||
}
|
||||
MODS = {
|
||||
"label": strings.buttons.mods_label,
|
||||
"tooltip": strings.buttons.mods_tooltip,
|
||||
"opens": NotebookPage.MODS,
|
||||
}
|
||||
OPTIONS = {
|
||||
"label": strings.buttons.options_label,
|
||||
"tooltip": strings.buttons.options_tooltip,
|
||||
"opens": NotebookPage.OPTIONS
|
||||
}
|
||||
HELP = {
|
||||
"label": strings.buttons.help_label,
|
||||
"tooltip": strings.buttons.help_tooltip,
|
||||
"opens": NotebookPage.MAIN,
|
||||
}
|
||||
EXIT = {
|
||||
"label": strings.buttons.exit_label,
|
||||
"tooltip": strings.buttons.exit_tooltip,
|
||||
"opens": None,
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
"""
|
||||
This file is intended to be patched by package maintainers
|
||||
repackaging DZGUI for use in different distributions.
|
||||
|
||||
If DZGUI is going to be installed globally via a package manager
|
||||
or other means and will reside in an immutable location, or needs to be
|
||||
explicitly bound to a certain version, set the flag below to False.
|
||||
|
||||
This will disable the following:
|
||||
|
||||
- Version update checks at startup
|
||||
- Ability to toggle between Stable/Testing branches in the Options menu
|
||||
"""
|
||||
|
||||
ALLOW_UPDATES = True
|
||||
@@ -0,0 +1,545 @@
|
||||
import logging
|
||||
import os
|
||||
import locale
|
||||
import shutil
|
||||
import threading
|
||||
import textwrap
|
||||
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, TYPE_CHECKING
|
||||
|
||||
import gi # noqa E402
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gtk, Gdk, GLib, GObject
|
||||
|
||||
import dzgui.api.pefile as PeFile
|
||||
from dzgui.api.probe import test_steam_api, test_bm_api
|
||||
|
||||
from dzgui.api.mods import (
|
||||
get_delimited_mods,
|
||||
get_local_mod_path,
|
||||
find_stale_mods,
|
||||
_hash,
|
||||
remove_stale_signatures
|
||||
)
|
||||
from dzgui.const.enum import (
|
||||
Preferences,
|
||||
Popup,
|
||||
MAIN_MENU_ROWS,
|
||||
HELP_MENU_ROWS,
|
||||
RowType,
|
||||
NotebookPage,
|
||||
ButtonType,
|
||||
ContextMenu,
|
||||
)
|
||||
|
||||
from dzgui.const.constants import LIBRARYFOLDERS_PATH, APPID_DAYZ, APPID_DAYZ_EXP, HEX_RED
|
||||
from dzgui.controllers.model import ModelManager
|
||||
|
||||
from dzgui.config import update
|
||||
from dzgui.config.query import lookup
|
||||
from dzgui.config.userprefs import UserPrefs
|
||||
|
||||
from dzgui.util.diag import write_diagnostic
|
||||
from dzgui.util import localize, cooldown, strings
|
||||
from dzgui.util._json import read_json, write_json
|
||||
from dzgui.util.open_links import open_workshop_page
|
||||
from dzgui.util.format import pluralize, format_mods
|
||||
|
||||
from dzgui.views.dialogs.filepicker import FilePicker
|
||||
from dzgui.views.dialogs.generic import GenericDialog
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dzgui.views.components.buttonbox import ContextualButton
|
||||
from dzgui.views.base import AppNavigation
|
||||
from dzgui.const.enum import ContextMenu
|
||||
from dzgui.views.base import OuterWindow
|
||||
|
||||
class Controller:
|
||||
def __init__(self) -> None:
|
||||
# TODO: main controller delegates to model manager
|
||||
self.map_store = Gtk.ListStore(str)
|
||||
self.row_store = Gtk.ListStore(str, GObject.TYPE_PYOBJECT)
|
||||
self.help_store = Gtk.ListStore(str, GObject.TYPE_PYOBJECT)
|
||||
|
||||
# NOTE: long timestamp numbers
|
||||
self.modlist_store = Gtk.ListStore(str, GObject.TYPE_INT64, str)
|
||||
self.log_store = Gtk.ListStore(str, str, str, str)
|
||||
|
||||
self.notes_cache: dict[str, str] = {}
|
||||
|
||||
self.mediator: AppNavigation
|
||||
self.prefs: UserPrefs
|
||||
self.cooldown = 0
|
||||
|
||||
for row in MAIN_MENU_ROWS:
|
||||
label = row.dict["label"]
|
||||
self.row_store.append([label, row])
|
||||
|
||||
for row in HELP_MENU_ROWS:
|
||||
label = row.dict["label"]
|
||||
self.help_store.append([label, row])
|
||||
|
||||
self.model_manager = ModelManager()
|
||||
|
||||
def set_crumbs(self, crumbs: str) -> None:
|
||||
self.mediator.grid.set_breadcrumbs(crumbs)
|
||||
|
||||
def get_crumbs(self) -> str:
|
||||
return self.mediator.grid.get_breadcrumbs()
|
||||
|
||||
def get_help_store(self) -> Gtk.ListStore:
|
||||
return self.help_store
|
||||
|
||||
def get_map_store(self) -> Gtk.ListStore:
|
||||
return self.map_store
|
||||
|
||||
def get_row_store(self) -> Gtk.ListStore:
|
||||
return self.row_store
|
||||
|
||||
def get_modlist_store(self) -> Gtk.ListStore:
|
||||
return self.modlist_store
|
||||
|
||||
def get_mod_store(self) -> Gtk.ListStore:
|
||||
return self.model_manager.get_mod_store()
|
||||
|
||||
def get_log_store(self) -> Gtk.ListStore:
|
||||
return self.log_store
|
||||
|
||||
def terminate_process(self) -> None:
|
||||
# TODO: only used by server table multiprocessing queue
|
||||
self.mediator.notebook.servers.terminate_process()
|
||||
|
||||
def get_prefs(self) -> UserPrefs:
|
||||
return self.prefs
|
||||
|
||||
def set_prefs(self, prefs: UserPrefs) -> None:
|
||||
self.prefs = prefs
|
||||
|
||||
def query_config(self, key: Preferences) -> str | bool | list:
|
||||
config = self.prefs.paths.config
|
||||
return lookup(config, key)
|
||||
|
||||
def is_auto_install(self) -> bool:
|
||||
return self.query_config(Preferences.INSTALL)
|
||||
|
||||
def reinit_map_store(self) -> None:
|
||||
self.map_store.clear()
|
||||
self.map_store.append(["All maps"])
|
||||
|
||||
def append_map(self, map_row: list) -> None:
|
||||
self.map_store.append(map_row)
|
||||
|
||||
def set_mediator(self, mediator: "AppNavigation") -> None:
|
||||
self.mediator = mediator
|
||||
|
||||
def get_mediator(self) -> "AppNavigation":
|
||||
return self.mediator
|
||||
|
||||
def unblock_signals(self) -> None:
|
||||
self.block_signals(False)
|
||||
|
||||
def block_signals(self, state: bool = True) -> None:
|
||||
self.suppress_signal(
|
||||
self.mediator.grid.right_panel.filters_vbox,
|
||||
self.mediator.grid.right_panel.filters_vbox.maps_combo,
|
||||
"_on_map_changed",
|
||||
state,
|
||||
)
|
||||
self.suppress_signal(
|
||||
self.mediator.treeview,
|
||||
self.mediator.treeview.selected_row,
|
||||
"_on_tree_selection_changed",
|
||||
state,
|
||||
)
|
||||
self.suppress_signal(self.mediator.treeview, self.mediator.treeview, "_on_keypress", state)
|
||||
for check in self.mediator.grid.right_panel.filters_vbox.checks:
|
||||
self.suppress_signal(
|
||||
self.mediator.grid.right_panel.filters_vbox,
|
||||
check,
|
||||
"_on_check_toggled",
|
||||
state,
|
||||
)
|
||||
|
||||
def suppress_signal(
|
||||
self, owner: Gtk.Widget, widget: Gtk.Widget, func_name: str, state: bool
|
||||
) -> None:
|
||||
|
||||
func = getattr(owner, func_name)
|
||||
if state:
|
||||
widget.handler_block_by_func(func)
|
||||
else:
|
||||
widget.handler_unblock_by_func(func)
|
||||
self.mediator.treeview.sel_blocked = state
|
||||
|
||||
def toggle_debug_mode(self) -> None:
|
||||
self.toggle_config(Preferences.DEBUG)
|
||||
|
||||
def save_res_and_quit(self, *args: Any) -> None:
|
||||
if self.mediator.window.props.is_maximized:
|
||||
Gtk.main_quit()
|
||||
return
|
||||
|
||||
w, h = self.mediator.window.get_size()
|
||||
data = {"res": {"width": w, "height": h}}
|
||||
|
||||
res_path = self.prefs.paths.resolution
|
||||
try:
|
||||
write_json(data, res_path)
|
||||
except Exception as e:
|
||||
logger.critical(e)
|
||||
|
||||
Gtk.main_quit()
|
||||
|
||||
def set_statusbar(self, text: str) -> None:
|
||||
self.mediator.grid.statusbar.set_text(text)
|
||||
|
||||
def delete_multiple_mods(self) -> None:
|
||||
sel = self.mediator.modtreeview.get_selection()
|
||||
model, pathlist = sel.get_selected_rows()
|
||||
# NOTE: reverse when multiple
|
||||
for path in reversed(pathlist):
|
||||
self.delete_single_mod(path)
|
||||
|
||||
total_mods, total_size = self.calc_mod_size()
|
||||
self.update_mod_statusbar()
|
||||
|
||||
def load_mods(self) -> None:
|
||||
model = self.model_manager.get_mod_store()
|
||||
model.clear()
|
||||
path = self.query_config(Preferences.DEFAULT)
|
||||
mods = get_delimited_mods(Path(path))
|
||||
|
||||
for mod in mods:
|
||||
# NOTE: holds color column
|
||||
mod.append(None)
|
||||
model.append(mod)
|
||||
|
||||
self.update_mod_statusbar()
|
||||
|
||||
def toggle_config(self, context: Preferences) -> None:
|
||||
config = self.prefs.paths.config
|
||||
try:
|
||||
update.toggle_config(config, context)
|
||||
except Exception as e:
|
||||
logger.critical(e)
|
||||
self.spawn_dialog(strings.something_wrong, Popup.NOTIFY)
|
||||
|
||||
def update_config(self, key: Preferences, value: str) -> None:
|
||||
try:
|
||||
update.write_config(self.prefs.paths.config, key, value)
|
||||
except Exception as e:
|
||||
logger.critical(e)
|
||||
self.spawn_dialog(strings.something_wrong, Popup.NOTIFY)
|
||||
# TODO: suppress signals
|
||||
# then reenable (or it spawns twice)
|
||||
self.mediator.grid.notebook.settings.populate_settings()
|
||||
return
|
||||
|
||||
# NOTE: disabled for now
|
||||
def present_toast(self, text: str) -> None:
|
||||
self.mediator.window.toast.set_text_and_fade(text)
|
||||
|
||||
def start_cooldown(self) -> None:
|
||||
self.cooldown = cooldown.get_time()
|
||||
|
||||
def manage_cooldown(self) -> bool:
|
||||
if cooldown.is_elapsed(self.cooldown):
|
||||
self.cooldown = cooldown.get_time()
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
def open_keybindings(self) -> None:
|
||||
notebook = self.mediator.grid.notebook
|
||||
notebook.toggle_keybindings()
|
||||
|
||||
def focus_notebook(self) -> None:
|
||||
notebook = self.mediator.grid.notebook
|
||||
notebook.focus_current()
|
||||
|
||||
def spawn_dialog(self, msg: str, mode: Popup) -> bool:
|
||||
"""
|
||||
Spawns a GenericDialog transient to the OuterWindow
|
||||
"""
|
||||
msg = textwrap.dedent(msg)
|
||||
dialog = GenericDialog(self, msg, mode)
|
||||
response = dialog.run()
|
||||
dialog.destroy()
|
||||
|
||||
match response:
|
||||
case Gtk.ResponseType.OK:
|
||||
return False
|
||||
case Gtk.ResponseType.CANCEL | Gtk.ResponseType.DELETE_EVENT:
|
||||
return True
|
||||
return False
|
||||
|
||||
def set_statusbar_by_row(self, row: RowType) -> None:
|
||||
self.mediator.grid.statusbar.refresh(row)
|
||||
|
||||
def toggle_mod_panel(self, state: bool) -> None:
|
||||
self.mediator.grid.sel_panel.set_visible(state)
|
||||
|
||||
def open_page(self, page: NotebookPage) -> None:
|
||||
self.mediator.grid.notebook.set_page_by_enum(page)
|
||||
|
||||
def open_page_by_button(self, button: "ContextualButton") -> None:
|
||||
match button.context:
|
||||
case ButtonType.EXIT:
|
||||
logger.info("Normal user exit")
|
||||
self.save_res_and_quit()
|
||||
return
|
||||
case ButtonType.OPTIONS:
|
||||
self.mediator.grid.notebook.settings.populate_settings()
|
||||
case ButtonType.MODS:
|
||||
self.load_mods()
|
||||
case ButtonType.HELP:
|
||||
self.mediator.treeview.set_model(self.help_store)
|
||||
case ButtonType.MAIN_MENU:
|
||||
self.mediator.treeview.set_model(self.row_store)
|
||||
|
||||
self.mediator.grid.notebook.set_page_by_enum(button.opens)
|
||||
self.set_crumbs(button.get_label())
|
||||
|
||||
# TODO: deprecated?
|
||||
def open_self_workshop(self, uid: str) -> None:
|
||||
# NOTE: uid may contain leading zeroes, not a real integer
|
||||
client = self.query_config(Preferences.CLIENT)
|
||||
open_workshop_page(uid, client)
|
||||
|
||||
def copy_log(self, paths: list[Gtk.TreePath]) -> str:
|
||||
if len(paths) < 1:
|
||||
return ""
|
||||
final = []
|
||||
for path in paths:
|
||||
record = self.log_store[path]
|
||||
r = [el for el in record]
|
||||
concat = strings.delimiter.join(r)
|
||||
final.append(concat)
|
||||
text = "\n".join(final)
|
||||
return text
|
||||
|
||||
def copy_clipboard(self, text: str) -> None:
|
||||
self.clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD)
|
||||
self.clipboard.set_text(text, -1)
|
||||
|
||||
def open_mod_page(self, path: Gtk.TreePath) -> None:
|
||||
mod, it = self.get_mod_from_tree_path(path)
|
||||
cmd = self.query_config(Preferences.CLIENT)
|
||||
open_workshop_page(mod, cmd)
|
||||
|
||||
# TODO: put in model manager (dedicated manager for mod store)
|
||||
def get_mod_from_tree_path(self, tree_path: Gtk.TreePath) -> tuple[str, Gtk.TreeIter]:
|
||||
model = self.model_manager.get_mod_store()
|
||||
tree_iter = model.get_iter(tree_path)
|
||||
mod = model.get(tree_iter, 2)[0]
|
||||
return mod, tree_iter
|
||||
|
||||
def delete_single_mod(self, tree_path: Gtk.TreePath) -> None:
|
||||
config = self.prefs.paths.config
|
||||
|
||||
mod, it = self.get_mod_from_tree_path(tree_path)
|
||||
|
||||
path = lookup(config, Preferences.DEFAULT)
|
||||
steam_path = Path(path)
|
||||
mods_path = get_local_mod_path(steam_path)
|
||||
app_path = PeFile.get_nested_app_path(steam_path, APPID_DAYZ)
|
||||
|
||||
md5 = _hash(mod)
|
||||
symlink = app_path / md5
|
||||
symlink.unlink()
|
||||
shutil.rmtree(mods_path / mod)
|
||||
|
||||
# NOTE: second pass to unlink DAYZ_EXP mods
|
||||
try:
|
||||
app_path_exp = PeFile.get_nested_app_path(steam_path, APPID_DAYZ_EXP)
|
||||
symlink = app_path_exp / md5
|
||||
symlink.unlink()
|
||||
except PeFile.AppNotInstalledError:
|
||||
pass
|
||||
|
||||
# TODO: belongs in model manager
|
||||
model = self.model_manager.get_mod_store()
|
||||
model.remove(it)
|
||||
|
||||
|
||||
def update_mod_statusbar(self) -> None:
|
||||
total_mods, total_size = self.calc_mod_size()
|
||||
msg = format_mods(total_size, total_mods)
|
||||
self.mediator.grid.statusbar.set_text(msg)
|
||||
|
||||
|
||||
def calc_mod_size(self) -> tuple[int, int]:
|
||||
model = self.model_manager.get_mod_store()
|
||||
total_mods = len(model)
|
||||
total_size = 0
|
||||
for mod in model:
|
||||
total_size += mod[3]
|
||||
return total_mods, total_size
|
||||
|
||||
|
||||
def menu_action(self, action: ContextMenu, path: Gtk.TreePath) -> None:
|
||||
match action:
|
||||
# NOTE: manipulates server store
|
||||
# TODO: unimplemented
|
||||
case ContextMenu.ADD_SERVER:
|
||||
pass
|
||||
case ContextMenu.ADD_NOTE:
|
||||
pass
|
||||
case ContextMenu.COPY_CLIPBOARD:
|
||||
pass
|
||||
case ContextMenu.COPY_NAME:
|
||||
pass
|
||||
case ContextMenu.REFRESH_PLAYERS:
|
||||
pass
|
||||
case ContextMenu.REMOVE_HISTORY:
|
||||
pass
|
||||
case ContextMenu.REMOVE_SERVER:
|
||||
pass
|
||||
case ContextMenu.SHOW_DETAILS:
|
||||
pass
|
||||
case ContextMenu.SHOW_MODS:
|
||||
pass
|
||||
|
||||
# NOTE: manipulates mod store
|
||||
case ContextMenu.DELETE_MOD:
|
||||
self.delete_single_mod(path)
|
||||
self.update_mod_statusbar()
|
||||
remove_stale_signatures(
|
||||
self.prefs.paths.config,
|
||||
self.prefs.paths.version
|
||||
)
|
||||
|
||||
case ContextMenu.OPEN_WORKSHOP:
|
||||
self.open_mod_page(path)
|
||||
|
||||
|
||||
def toggle_mod_selection(self, state: bool) -> None:
|
||||
sel = self.mediator.modtreeview.get_selection()
|
||||
if state:
|
||||
sel.select_all()
|
||||
else:
|
||||
sel.unselect_all()
|
||||
self.mediator.modtreeview.grab_focus()
|
||||
|
||||
def populate_log(self) -> None:
|
||||
log = self.prefs.paths.debug
|
||||
store = self.log_store
|
||||
store.clear()
|
||||
# TODO: pop dialog if log is missing
|
||||
with open(log, "r") as f:
|
||||
lines = [line.split(strings.delimiter) for line in f.read().splitlines()]
|
||||
for record in lines:
|
||||
store.append(record)
|
||||
self.open_page(NotebookPage.LOG)
|
||||
|
||||
def select_colorized(self) -> None:
|
||||
model = self.model_manager.get_mod_store()
|
||||
sel = self.mediator.modtreeview.get_selection()
|
||||
for mod in model:
|
||||
it = mod.iter
|
||||
path = model.get_path(it)
|
||||
if mod[4] == HEX_RED:
|
||||
sel.select_path(path)
|
||||
|
||||
def uncolorize_mods(self) -> None:
|
||||
model = self.model_manager.get_mod_store()
|
||||
for mod in model:
|
||||
it = mod.iter
|
||||
path = model.get_path(it)
|
||||
model[path][4] = None
|
||||
|
||||
def colorize_mods(self) -> None:
|
||||
model = self.model_manager.get_mod_store()
|
||||
stale = find_stale_mods(self.prefs.paths.config)
|
||||
for mod in model:
|
||||
it = mod.iter
|
||||
path = model.get_path(it)
|
||||
# TODO: consider storing in ListStore as int
|
||||
if int(mod[2]) in stale:
|
||||
model[path][4] = HEX_RED
|
||||
|
||||
self.destroy_on_idle()
|
||||
|
||||
def unselect_all_mods(self) -> None:
|
||||
self.mediator.modtreeview.get_selection().unselect_all()
|
||||
|
||||
def highlight_stale(self) -> None:
|
||||
self.call_on_thread(self.colorize_mods)
|
||||
|
||||
def call_on_thread(self, func: Callable, *args) -> None:
|
||||
self.wait_dialog = GenericDialog(self, strings.dialog.fetching, Popup.WAIT)
|
||||
self.wait_dialog.show_all()
|
||||
thread = threading.Thread(target=func, args=args)
|
||||
thread.start()
|
||||
|
||||
def destroy_on_idle(self) -> None:
|
||||
self.wait_dialog.destroy()
|
||||
# TODO: improve upon this
|
||||
func = self.callback["func"]
|
||||
if func is not None:
|
||||
args = self.callback["args"]
|
||||
GLib.idle_add(func, *args)
|
||||
self.set_callback(None, None)
|
||||
|
||||
def set_callback(self, callback: Callable | None, *args) -> None:
|
||||
"""
|
||||
Lets calling widgets register a callback function
|
||||
when spawning a threaded process
|
||||
"""
|
||||
self.callback = { "func": callback, "args": args }
|
||||
|
||||
def dump_diagnostics(self) -> None:
|
||||
picker = FilePicker(self.mediator.window)
|
||||
file = picker.pick_file()
|
||||
if file is not None:
|
||||
try:
|
||||
write_diagnostic(self.prefs.paths.config, file)
|
||||
except Exception as e:
|
||||
self.spawn_dialog(str(e), Popup.NOTIFY)
|
||||
|
||||
def test_api_response(self, text: str, key: Preferences) -> None:
|
||||
if key is Preferences.STEAM:
|
||||
res = test_steam_api(text)
|
||||
else:
|
||||
res = test_bm_api(text)
|
||||
|
||||
if res is True:
|
||||
self.update_config(key, text)
|
||||
self.set_callback(None, None)
|
||||
self.destroy_on_idle()
|
||||
else:
|
||||
self.destroy_on_idle()
|
||||
self.spawn_dialog(strings.api_error, Popup.NOTIFY)
|
||||
|
||||
|
||||
def update_api_key(self, text: str, key: Preferences) -> None:
|
||||
self.call_on_thread(self.test_api_response, text, key)
|
||||
|
||||
def set_resolution(self, window: "OuterWindow") -> None:
|
||||
if self.prefs.is_game_mode:
|
||||
window.fullscreen()
|
||||
return
|
||||
elif self.query_config(Preferences.WINDOW) is True:
|
||||
window.fullscreen()
|
||||
|
||||
try:
|
||||
data = read_json(self.prefs.paths.resolution)
|
||||
valid_json = True
|
||||
except Exception as e:
|
||||
valid_json = False
|
||||
logger.critical(e)
|
||||
|
||||
if valid_json:
|
||||
res = data["res"]
|
||||
w, h = res["width"], res["height"]
|
||||
logger.info(f"Restoring window size to {w},{h}")
|
||||
window.set_default_size(w, h)
|
||||
else:
|
||||
w = WINDOW_DEFAULT_X
|
||||
h = WINDOW_DEFAULT_Y
|
||||
logger.info(f"Using default window size {w},{h}")
|
||||
window.set_default_size(w, h)
|
||||
@@ -0,0 +1,256 @@
|
||||
import gi
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository.Gtk import ListStore
|
||||
|
||||
from dzgui.const.enum import FilterMode
|
||||
|
||||
class ModelManager:
|
||||
"""
|
||||
Manages access to cached ListStore resources and
|
||||
performs filtering on behalf of TreeViews.
|
||||
|
||||
Not thread-safe.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
# NOTE: packed ListStores
|
||||
self.filter_cache = {}
|
||||
self.ping_cache = {}
|
||||
|
||||
self.mod_store = ListStore(str, str, str, float, str)
|
||||
|
||||
# NOTE: stringwise (list) representation of the model
|
||||
self.control_model = None
|
||||
self.filtered = None
|
||||
self.success = True
|
||||
|
||||
def __new__(cls):
|
||||
if not hasattr(cls, "instance"):
|
||||
cls.instance = super(ModelManager, cls).__new__(cls)
|
||||
return cls.instance
|
||||
|
||||
def get_mod_store(self) -> ListStore:
|
||||
return self.mod_store
|
||||
|
||||
def filter(self, mode: FilterMode, *args, **kwargs) -> None:
|
||||
"""
|
||||
Native Gtk.TreeView.refilter() method was not performant enough
|
||||
when running in the main loop with 40k+ records
|
||||
"""
|
||||
filters = AppNav.right_panel.filters_vbox.get_filters()
|
||||
|
||||
if filters in self.filter_cache:
|
||||
cache = self.filter_cache[filters]
|
||||
self.set_store(cache[0])
|
||||
self.set_filtered(cache[1])
|
||||
GLib.idle_add(AppNav.treeview._filter_cleanup)
|
||||
return
|
||||
|
||||
match mode:
|
||||
case FilterMode.INITIAL:
|
||||
rows = self.filter_initial(filters)
|
||||
|
||||
case FilterMode.MAP:
|
||||
panel = AppNav.right_panel.filters_vbox
|
||||
prior_map = panel.get_prior_map()
|
||||
|
||||
if prior_map == "All maps":
|
||||
rows = self.filter_map(filters)
|
||||
else:
|
||||
AppNav.right_panel.ping.set_sensitive(True)
|
||||
rows = self.filter_toggle_on(filters, *args)
|
||||
|
||||
case FilterMode.KEYWORD:
|
||||
AppNav.right_panel.ping.set_sensitive(True)
|
||||
rows = self.filter_toggle_on(filters, *args)
|
||||
|
||||
case FilterMode.TOGGLE_OFF:
|
||||
for f in filters[2:]:
|
||||
self.set_filtered(self.filter_toggle_off(filters, f))
|
||||
rows = self.filtered
|
||||
|
||||
case FilterMode.TOGGLE_ON:
|
||||
AppNav.right_panel.ping.set_sensitive(True)
|
||||
rows = self.filter_toggle_on(filters, *args)
|
||||
|
||||
if mode is not FilterMode.INITIAL:
|
||||
for row in rows:
|
||||
if row[7] in self.ping_cache:
|
||||
row[9] = self.ping_cache[row[7]]
|
||||
|
||||
if len(rows) > 0:
|
||||
clone = ModelMan.new_model()
|
||||
rows = self.sort_rows(rows)
|
||||
for row in rows:
|
||||
clone.append(row)
|
||||
else:
|
||||
clone = None
|
||||
|
||||
self.set_cache(filters, clone, rows)
|
||||
self.set_store(clone)
|
||||
GLib.idle_add(AppNav.treeview._filter_cleanup)
|
||||
|
||||
def sort_rows(self, rows: list) -> list:
|
||||
rows.sort(key=lambda x: re.sub(r"[^A-Za-z0-9]+", "", x[0].lower()))
|
||||
return rows
|
||||
|
||||
def filter_initial(self, filters: tuple) -> list:
|
||||
"""
|
||||
Simply culls the control model of any disabled filters
|
||||
"""
|
||||
self.set_filtered(self.control_model)
|
||||
for f in filters[2:]:
|
||||
self.set_filtered(self.filter_toggle_off(filters, f))
|
||||
return self.filtered
|
||||
|
||||
def filter_map(self, filters: tuple) -> list:
|
||||
"""
|
||||
Multi-filtration for any context starts by narrowing by map
|
||||
"""
|
||||
rows = self.filtered
|
||||
panel = AppNav.right_panel.filters_vbox
|
||||
sel_map = panel.get_selected_map()
|
||||
|
||||
if sel_map == "All maps":
|
||||
return rows
|
||||
|
||||
rows = [row for row in rows if row[1] == sel_map]
|
||||
return rows
|
||||
|
||||
def filter_keyword(self, filters: tuple) -> list:
|
||||
keyword = AppNav.right_panel.filters_vbox.get_keyword_filter()
|
||||
rows = self.filtered
|
||||
|
||||
if keyword == "":
|
||||
return rows
|
||||
|
||||
filtered = [
|
||||
row
|
||||
for row in rows
|
||||
if keyword in row[0].lower()
|
||||
or keyword in row[1].lower()
|
||||
or keyword in row[7].lower()
|
||||
]
|
||||
return filtered
|
||||
|
||||
def filter_toggle_off(self, filters: tuple, filter_type: str) -> list:
|
||||
"""
|
||||
Sub-filtration of the current model
|
||||
"""
|
||||
pairs = {
|
||||
strings.filter_3pp: strings.filter_1pp,
|
||||
strings.filter_day: strings.filter_night,
|
||||
strings.filter_official: strings.filter_unofficial
|
||||
}
|
||||
for k, v in pairs.items():
|
||||
if k in filters and v in filters:
|
||||
self.set_filtered(None)
|
||||
return []
|
||||
|
||||
rows = self.filtered
|
||||
match filter_type:
|
||||
case strings.filter_3pp:
|
||||
rows = [row for row in rows if row[2] != strings.filter_3pp]
|
||||
case strings.filter_1pp:
|
||||
rows = [row for row in rows if row[2] != strings.filter_1pp]
|
||||
case strings.filter_official:
|
||||
rows = [row for row in rows if row[10] != strings.filter_official]
|
||||
case strings.filter_unofficial:
|
||||
rows = [row for row in rows if row[10] != strings.filter_unofficial]
|
||||
case strings.filter_empty:
|
||||
rows = [row for row in rows if row[4] != 0]
|
||||
case strings.filter_full:
|
||||
rows = [row for row in rows if row[4] != row[5]]
|
||||
case strings.filter_duplicate:
|
||||
seen = []
|
||||
final = []
|
||||
for row in rows:
|
||||
if row[0] in seen:
|
||||
continue
|
||||
seen.append(row[0])
|
||||
final.append(row)
|
||||
rows = final
|
||||
case strings.filter_day:
|
||||
reg = r"([0][0-9]|[1][0-6])"
|
||||
rows = [row for row in rows if not re.match(reg, row[3])]
|
||||
case strings.filter_night:
|
||||
reg = r"([0][0-4]|[1][8]|[2][0-3])"
|
||||
rows = [row for row in rows if not re.match(reg, row[3])]
|
||||
case strings.filter_nonascii:
|
||||
rows = [row for row in rows if row[0].isascii()]
|
||||
case strings.filter_lowpop:
|
||||
rows = [row for row in rows if (row[4] / row[5] * 100) > 30]
|
||||
case strings.filter_modded:
|
||||
rows = [row for row in rows if not row[11]]
|
||||
return rows
|
||||
|
||||
def filter_toggle_on(self, filters: tuple, *args: str) -> list:
|
||||
"""Effectively applies all filters"""
|
||||
self.set_filtered(self.control_model)
|
||||
self.set_filtered(self.filter_map(filters))
|
||||
self.set_filtered(self.filter_keyword(filters))
|
||||
|
||||
for f in filters[2:]:
|
||||
self.set_filtered(self.filter_toggle_off(filters, f))
|
||||
return self.filtered
|
||||
|
||||
def set_cache(
|
||||
self, filters: tuple, model: ListStore | None, rows: list
|
||||
) -> None:
|
||||
self.filter_cache[filters] = (model, rows)
|
||||
|
||||
def new_model(self) -> ListStore:
|
||||
return ListStore(
|
||||
str, str, str, str, int, int, int, str, int, int, str, bool
|
||||
)
|
||||
|
||||
def resync_model(self, addr: str, qport: int) -> None:
|
||||
"""
|
||||
Handle in-situ updates to model during
|
||||
row deletion actions. Skipped for ephemeral
|
||||
actions like player count/ping updates
|
||||
"""
|
||||
for row in self.control_model:
|
||||
if row[7] == addr and row[8] == qport:
|
||||
self.control_model.remove(row)
|
||||
|
||||
self.wipe_cache()
|
||||
filters = AppNav.right_panel.filters_vbox.get_filters()
|
||||
refiltered = self.filter_toggle_on(filters)
|
||||
self.set_filtered(refiltered)
|
||||
self.set_success(True)
|
||||
GLib.idle_add(AppNav.treeview._filter_cleanup)
|
||||
|
||||
def convert_model_to_list(self, model: ListStore) -> list:
|
||||
return [[el for el in row] for row in model]
|
||||
|
||||
def set_filtered(self, rows: list | None) -> None:
|
||||
if rows is None:
|
||||
rows = []
|
||||
self.filtered = rows
|
||||
|
||||
def get_filtered(self) -> list:
|
||||
return self.filtered
|
||||
|
||||
def set_store(self, model: ListStore | None) -> None:
|
||||
self.store = model
|
||||
|
||||
def get_store(self) -> ListStore | None:
|
||||
return self.store
|
||||
|
||||
def set_control(self, rows: list) -> None:
|
||||
self.control_model = rows
|
||||
|
||||
def set_success(self, result: bool) -> None:
|
||||
self.success = result
|
||||
|
||||
def get_success(self) -> bool:
|
||||
return self.success
|
||||
|
||||
def wipe_cache(self, full=False) -> None:
|
||||
self.success = True
|
||||
self.filtered = None
|
||||
self.filter_cache = {}
|
||||
self.ping_cache = {}
|
||||
if full:
|
||||
self.control_model = None
|
||||
|
After Width: | Height: | Size: 497 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 459 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 81 B |
@@ -0,0 +1,23 @@
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from dzgui.util.ip import get_local_ip, get_coords, Coords
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_local_coords(path: "Path") -> Coords | None:
|
||||
try:
|
||||
my_ip = get_local_ip()
|
||||
except Exception as e:
|
||||
logger.warn(e)
|
||||
return None
|
||||
|
||||
try:
|
||||
return get_coords(path, my_ip)
|
||||
except Exception as e:
|
||||
logger.warn(e)
|
||||
return None
|
||||
@@ -0,0 +1,22 @@
|
||||
import logging
|
||||
import sys
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from dzgui.config.query import lookup
|
||||
from dzgui.const.constants import LIBRARYFOLDERS_PATH, APPID_DAYZ
|
||||
from dzgui.const.enum import Preferences
|
||||
from dzgui.views.dialogs.early_alert import EarlyAlertDialog
|
||||
|
||||
import dzgui.api.pefile as PeFile
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def is_dayz_installed(config: Path) -> None:
|
||||
try:
|
||||
path = lookup(config, Preferences.DEFAULT)
|
||||
PeFile.get_app_path(Path(path) / LIBRARYFOLDERS_PATH, APPID_DAYZ)
|
||||
except Exception as e:
|
||||
logger.critical(e)
|
||||
EarlyAlertDialog(e)
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,25 @@
|
||||
import fcntl
|
||||
import os
|
||||
import sys
|
||||
|
||||
from pathlib import Path
|
||||
from typing import TextIO
|
||||
|
||||
from dzgui.views.dialogs.early_alert import EarlyAlertDialog
|
||||
from dzgui.util.strings import cannot_acquire_lock
|
||||
|
||||
|
||||
def lock_release(lock: TextIO) -> None:
|
||||
fcntl.flock(lock, fcntl.LOCK_UN)
|
||||
os.close(lock.fileno())
|
||||
|
||||
|
||||
def lock_acquire() -> TextIO:
|
||||
lockfile = Path("/tmp/dzgui.flock")
|
||||
lock = open(lockfile, "w")
|
||||
try:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except OSError:
|
||||
EarlyAlertDialog(cannot_acquire_lock)
|
||||
sys.exit(1)
|
||||
return lock
|
||||
@@ -0,0 +1,37 @@
|
||||
import sys
|
||||
|
||||
from pathlib import Path
|
||||
from dzgui.const.constants import LEGACY_CONFIG_PATH
|
||||
from dzgui.config.convert import rc2json
|
||||
|
||||
|
||||
def test_legacy_conf(config: Path) -> None:
|
||||
if config.exists() is True:
|
||||
return
|
||||
old_conf = Path.home() / LEGACY_CONFIG_PATH
|
||||
if old_conf.is_file():
|
||||
j = rc2json(old_conf)
|
||||
Path(config).parent.mkdir(parents=True, exist_ok=True)
|
||||
config.write_text(j)
|
||||
else:
|
||||
print("Unimplemented. You must have a working dztuirc.")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def move_state_files(state_path: Path) -> None:
|
||||
home = Path.home()
|
||||
legacy = home / ".local/state/dzgui"
|
||||
if state_path == legacy:
|
||||
# TODO: log this
|
||||
return
|
||||
for file in legacy.iterdir():
|
||||
file.rename(state_path / file.name)
|
||||
|
||||
|
||||
def move_ipdb(ips_path: Path) -> None:
|
||||
home = Path.home()
|
||||
name = "ips.csv"
|
||||
legacy = home / ".local/share/helpers/dzgui" / name
|
||||
if ips_path == legacy:
|
||||
return
|
||||
legacy.rename(ips_path)
|
||||
@@ -0,0 +1,23 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
from importlib import metadata
|
||||
from pathlib import Path
|
||||
|
||||
from dzgui.const.constants import APP_NAME_LOWER
|
||||
|
||||
def is_prefix_writeable() -> bool:
|
||||
prefix = sys.prefix
|
||||
path = Path(prefix)
|
||||
file = path / ".is_writeable"
|
||||
try:
|
||||
with open(file, "w") as f:
|
||||
f.write("")
|
||||
os.remove(file)
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def get_version() -> str:
|
||||
return metadata.version(APP_NAME_LOWER)
|
||||
@@ -0,0 +1,28 @@
|
||||
import logging
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
from dzgui.const.constants import STEAM_CMD, FLATPAK_APPID, FLATPAK_CMD
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def has_steam_client() -> bool:
|
||||
if shutil.which(STEAM_CMD):
|
||||
return True
|
||||
if not shutil.which(FLATPAK_CMD):
|
||||
return False
|
||||
try:
|
||||
res = subprocess.run(
|
||||
[FLATPAK_CMD, "list"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True
|
||||
)
|
||||
if FLATPAK_APPID in res.stdout:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.critical(e)
|
||||
return False
|
||||
@@ -0,0 +1,26 @@
|
||||
import psutil
|
||||
import sys
|
||||
|
||||
from dzgui.const.constants import DAYZ_BINARY, STEAM_CMD
|
||||
from dzgui.views.dialogs.early_alert import EarlyAlertDialog
|
||||
from dzgui.util.strings import init
|
||||
|
||||
|
||||
def is_dayz_running() -> None:
|
||||
if is_running(DAYZ_BINARY) is True:
|
||||
EarlyAlertDialog(init.is_dayz_running)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def is_steam_running() -> None:
|
||||
# TODO: check proc name of flatpak steam
|
||||
if is_running(STEAM_CMD) is False:
|
||||
EarlyAlertDialog(init.is_steam_running)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def is_running(proc: str) -> bool:
|
||||
for process in psutil.process_iter(["pid", "name"]):
|
||||
if process.info["name"] == proc:
|
||||
return True
|
||||
return False
|
||||
@@ -0,0 +1,51 @@
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
"""
|
||||
Find possible locations of default steam path
|
||||
"""
|
||||
|
||||
def get_steam_dirs() -> list[Path]:
|
||||
dirs = []
|
||||
# pass 1
|
||||
if shutil.which("locate") is not None:
|
||||
# TODO: config or steamapps path? cf. LIBRARYFOLDERS_PATH
|
||||
proc = subprocess.run(
|
||||
["/usr/bin/locate", "Steam/config/libraryfolders.vdf"],
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
|
||||
if proc.returncode == 0:
|
||||
for d in proc.stdout.splitlines():
|
||||
dirs.append(Path(d))
|
||||
return dirs
|
||||
|
||||
# pass 2
|
||||
for d in Path.home().rglob("Steam/config/libraryfolders.vdf"):
|
||||
dirs.append(d)
|
||||
|
||||
# pass 3
|
||||
# if still nothing, let them select it
|
||||
# prompt what the dir should look like
|
||||
if len(dirs) == 0:
|
||||
print("none found")
|
||||
return dirs
|
||||
|
||||
|
||||
dirs = get_steam_dirs()
|
||||
print(dirs)
|
||||
|
||||
# TODO: present these in a group of radio buttons
|
||||
"""
|
||||
when user toggles radio button, updates text saying
|
||||
"this is the standard steam location on debian" etc.
|
||||
|
||||
if None or if not satisfied, pop a filepicker and choose
|
||||
if selection was custom, it has to have a libraryfolders.vdf
|
||||
keeps next button grayed out until requirements are satisfied
|
||||
|
||||
Cancel Next
|
||||
"""
|
||||
@@ -0,0 +1,50 @@
|
||||
import requests
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from importlib import resources
|
||||
from packaging.version import Version, InvalidVersion
|
||||
from pathlib import Path
|
||||
|
||||
from dzgui.const.constants import APP_NAME_LOWER, REQUEST_TIMEOUT
|
||||
from dzgui.const.endpoints import GITHUB_RELEASES, CODEBERG_RELEASES
|
||||
from dzgui.init.prefix import is_prefix_writeable
|
||||
|
||||
|
||||
def get_latest_release() -> str | None:
|
||||
tag = None
|
||||
for url in [GITHUB_RELEASES, CODEBERG_RELEASES]:
|
||||
res = requests.get(url, timeout=REQUEST_TIMEOUT)
|
||||
if res.status_code == 200:
|
||||
tag = res.json()["tag_name"]
|
||||
break
|
||||
return tag
|
||||
|
||||
def allow_updates(allow: bool) -> bool:
|
||||
if allow is False:
|
||||
return False
|
||||
if allow is True:
|
||||
return is_prefix_writeable()
|
||||
|
||||
def check_updates(version: str) -> None:
|
||||
latest = get_latest_release()
|
||||
prefix = sys.prefix
|
||||
if latest is None:
|
||||
return
|
||||
try:
|
||||
if Version(version) >= Version(latest):
|
||||
return
|
||||
|
||||
# TODO: test update logic
|
||||
print("UNIMPLEMENTED: fetches in-app updates")
|
||||
return
|
||||
|
||||
with resources.path(APP_NAME_LOWER, "scripts/update.sh") as path:
|
||||
proc = subprocess.Popen(["/usr/bin/env", "bash", path, latest, prefix])
|
||||
if proc != 0:
|
||||
# TODO: pop a dialog
|
||||
pass
|
||||
sys.exit(proc)
|
||||
except InvalidVersion:
|
||||
return
|
||||
@@ -0,0 +1,115 @@
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
from packaging.version import Version
|
||||
|
||||
from dzgui.api.mods import remove_stale_signatures
|
||||
|
||||
from dzgui.config.ipdb import get_ipdb
|
||||
from dzgui.config.userprefs import UserPrefs
|
||||
from dzgui.config.xdg import get_xdg_paths, parse_filepaths
|
||||
|
||||
from dzgui.const.constants import APP_NAME_LOWER
|
||||
from dzgui.const.update import ALLOW_UPDATES
|
||||
|
||||
from dzgui.init.coords import get_local_coords
|
||||
from dzgui.init.dayz import is_dayz_installed
|
||||
from dzgui.init.flock import lock_acquire, lock_release
|
||||
from dzgui.init.migrate import test_legacy_conf, move_state_files, move_ipdb
|
||||
from dzgui.init.prefix import get_version
|
||||
from dzgui.init.prereqs import has_steam_client
|
||||
from dzgui.init.proc import is_dayz_running, is_steam_running
|
||||
from dzgui.init.update import allow_updates, check_updates
|
||||
|
||||
from dzgui.util.map_count import get_map_count
|
||||
from dzgui.util.deck import is_steam_deck, is_game_mode
|
||||
from dzgui.util.localize import set_locale
|
||||
from dzgui.util.symlink import rebuild_symlinks
|
||||
from dzgui.util.strings import init, flags
|
||||
|
||||
from dzgui.views.base import App
|
||||
from dzgui.views.dialogs.early_alert import EarlyAlertDialog
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
parser = argparse.ArgumentParser(description=flags.description)
|
||||
|
||||
parser.add_argument("-v", "--version", action="store_true", help=flags.version)
|
||||
parser.add_argument("-u", "--uninstall", action="store_true", help=flags.uninstall)
|
||||
args = parser.parse_args()
|
||||
|
||||
# TODO: profile load time
|
||||
def main() -> None:
|
||||
lock = lock_acquire() # noqa
|
||||
|
||||
if args.version is True:
|
||||
print(get_version())
|
||||
sys.exit(0)
|
||||
|
||||
# TODO: uninstall data files (-u)
|
||||
# -u removes state, log, freedesktop
|
||||
# XDG_SHARE_HOME/dzgui
|
||||
# XDG_STATE_HOME/dzgui
|
||||
# XDG_DATA_HOME/dzgui
|
||||
|
||||
version = get_version()
|
||||
set_locale()
|
||||
|
||||
# NOTE: consider aborting this check if steam deck
|
||||
xdg_paths = get_xdg_paths()
|
||||
XDG = parse_filepaths(xdg_paths)
|
||||
|
||||
# NOTE: one-time operation when upgrading
|
||||
if Version(version) < Version("7.0.0"):
|
||||
test_legacy_conf(XDG.config)
|
||||
move_state_files(xdg_paths["XDG_STATE_HOME"])
|
||||
move_ipdb(XDG.ips)
|
||||
|
||||
_format = "%(asctime)s␞%(levelname)s␞%(filename)s::%(funcName)s::%(lineno)s␞%(message)s"
|
||||
logging.basicConfig(filename=XDG.debug, format=_format, level=logging.DEBUG)
|
||||
with open(XDG.debug, "w") as f:
|
||||
f.truncate(0)
|
||||
|
||||
|
||||
_is_steam_deck = is_steam_deck()
|
||||
_is_game_mode = False
|
||||
if _is_steam_deck:
|
||||
_is_game_mode = is_game_mode()
|
||||
if _is_game_mode:
|
||||
# NOTE: this may no longer be necessary on newer versions of SteamOS
|
||||
del os.environ["GTK_IM_MODULE"]
|
||||
|
||||
# TODO: test spamming timeout
|
||||
allow = False
|
||||
allow = allow_updates(ALLOW_UPDATES)
|
||||
if allow is True:
|
||||
check_updates(version)
|
||||
|
||||
if _is_steam_deck is False:
|
||||
# TODO: sudo escalation dialog
|
||||
count = get_map_count()
|
||||
if has_steam_client() is False:
|
||||
EarlyAlertDialog(init.requires_steam)
|
||||
sys.exit(1)
|
||||
|
||||
is_dayz_installed(XDG.config)
|
||||
is_dayz_running()
|
||||
is_steam_running()
|
||||
|
||||
rebuild_symlinks(XDG.config)
|
||||
remove_stale_signatures(XDG.config, XDG.version)
|
||||
|
||||
get_ipdb(XDG.ips)
|
||||
local_coords = get_local_coords(XDG.ips)
|
||||
|
||||
prefs = UserPrefs(
|
||||
_is_steam_deck,
|
||||
_is_game_mode,
|
||||
local_coords,
|
||||
version,
|
||||
allow,
|
||||
XDG
|
||||
)
|
||||
print("All OK. Loading UI...")
|
||||
App(prefs)
|
||||
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
# TODO: move this into package data dir
|
||||
prefix="dzgui"
|
||||
tag="$1"
|
||||
prefix="$2"
|
||||
dir="/tmp/dzgui-${tag}"
|
||||
|
||||
mkdir -p "$dir"
|
||||
|
||||
git clone -C "$dir" git@github.com:aclist/dzgui.git --branch "${tag}"
|
||||
git -C "$dir" submodule update --recursive --init
|
||||
|
||||
if [[ ! $(which python3.13) ]]; then
|
||||
echo "Missing python3.13"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
python3.13 -m venv "${prefix}"
|
||||
source "$prefix/bin/activate"
|
||||
|
||||
rm -rf "$dir"
|
||||
if [[ $(which uv) ]]; then
|
||||
uv --directory "$dir" pip install .
|
||||
else
|
||||
cd "$dir"
|
||||
pip install -r requirements.txt
|
||||
echo "Setup complete. Re-launch DZGUI via the command 'dzgui'"
|
||||
fi
|
||||
@@ -0,0 +1,27 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
def read_json(path: Path) -> dict:
|
||||
try:
|
||||
with open(path, "r") as infile:
|
||||
try:
|
||||
data = json.load(infile)
|
||||
except json.decoder.JSONDecodeError as e:
|
||||
raise e
|
||||
except OSError as e:
|
||||
raise e
|
||||
return data
|
||||
|
||||
|
||||
def write_json(data: dict, path: Path) -> None:
|
||||
try:
|
||||
j = json.dumps(data, indent=2)
|
||||
except Exception as e:
|
||||
raise e
|
||||
|
||||
# TODO: Path.write_text()
|
||||
try:
|
||||
with open(path, "w") as outfile:
|
||||
outfile.write(j)
|
||||
except OSError as e:
|
||||
raise e
|
||||
@@ -0,0 +1,4 @@
|
||||
import shlex
|
||||
|
||||
def concat_bash_args(command: str) -> list[str]:
|
||||
return shlex.split(command)
|
||||
@@ -0,0 +1,17 @@
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
def get_time() -> int:
|
||||
return int(datetime.now().timestamp())
|
||||
|
||||
# TODO: deprecated
|
||||
#def write_time(path: Path) -> None:
|
||||
# time = get_time()
|
||||
# Path.write_text(str(time))
|
||||
|
||||
def is_elapsed(time_before: int) -> bool:
|
||||
time_now = get_time()
|
||||
if time_now - time_before <= 30:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
@@ -0,0 +1,50 @@
|
||||
import gi # noqa E402
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gtk, Gdk
|
||||
|
||||
|
||||
def add_class(widget: Gtk.Widget, label: str) -> None:
|
||||
"""
|
||||
Sets the classname of a widget, used
|
||||
to apply CSS styling later
|
||||
"""
|
||||
context = widget.get_style_context()
|
||||
context.add_class(label)
|
||||
|
||||
def load_css() -> None:
|
||||
# TODO: consider storing this in a data file
|
||||
css = """
|
||||
.frame {
|
||||
border: 0px;
|
||||
}
|
||||
.toast-label {
|
||||
background-color: black;
|
||||
border: 1px solid white;
|
||||
}
|
||||
.frame > border {
|
||||
border-radius: 5px;
|
||||
padding: 5px;
|
||||
}
|
||||
.page-heading {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 800;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.settings-subheading {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.left-label {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
.details-heading {
|
||||
font-size: 1.2rem
|
||||
}
|
||||
"""
|
||||
prov = Gtk.CssProvider()
|
||||
prov.load_from_data(css.encode("ascii"))
|
||||
screen = Gdk.Screen.get_default()
|
||||
if screen:
|
||||
Gtk.StyleContext.add_provider_for_screen(
|
||||
screen, prov, Gtk.STYLE_PROVIDER_PRIORITY_USER
|
||||
)
|
||||
@@ -0,0 +1,30 @@
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
CPU_FILE = "/proc/cpuinfo"
|
||||
|
||||
def is_steam_deck() -> bool:
|
||||
# TODO: tests
|
||||
r = r"AMD Custom APU [0-9]{4}"
|
||||
with open(CPU_FILE, "r") as f:
|
||||
s = f.read()
|
||||
match = re.search(r, s)
|
||||
if match:
|
||||
if "0932" in match.group() or "0405" in match.group():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def is_game_mode() -> bool:
|
||||
proc = subprocess.run(
|
||||
["/usr/bin/pgrep", "-a", "gamescope"],
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
return False
|
||||
if "generate-drm-mode" in proc.stdout:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
@@ -0,0 +1,88 @@
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import textwrap
|
||||
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from dzgui.api.mods import get_local_mod_ids, get_local_mod_path
|
||||
from dzgui.const.constants import APP_NAME
|
||||
from dzgui.const.enum import Preferences
|
||||
from dzgui.config.query import lookup
|
||||
from dzgui.init.prefix import get_version
|
||||
from dzgui.util.redact import redact
|
||||
|
||||
|
||||
def get_cpu_model() -> str:
|
||||
cpuinfo = Path("/proc/cpuinfo")
|
||||
text = cpuinfo.read_text()
|
||||
lines = text.split("\n")
|
||||
for i, line in enumerate(lines):
|
||||
if line.startswith("model name"):
|
||||
model = line.split(": ")[1]
|
||||
return model
|
||||
return ""
|
||||
|
||||
|
||||
def print_servers(servers: list[str]) -> str:
|
||||
s = ""
|
||||
for server in servers:
|
||||
s+= server + "\n"
|
||||
return s
|
||||
|
||||
|
||||
def print_mods(mods: list[int]) -> str:
|
||||
s = ""
|
||||
for mod in mods:
|
||||
s+= str(mod) + "\n"
|
||||
return s
|
||||
|
||||
def write_diagnostic(config: Path, outfile: Path) -> None:
|
||||
# TODO: test availability on other distros
|
||||
date = datetime.now().isoformat()
|
||||
|
||||
distro = platform.freedesktop_os_release()["ID_LIKE"]
|
||||
kernel = os.uname().release
|
||||
cpu = get_cpu_model()
|
||||
version = get_version()
|
||||
|
||||
branch = lookup(config, Preferences.BRANCH)
|
||||
debug = lookup(config, Preferences.DEBUG)
|
||||
install = lookup(config, Preferences.INSTALL)
|
||||
default = lookup(config, Preferences.DEFAULT)
|
||||
|
||||
steam_path = Path(default)
|
||||
workshop_path = get_local_mod_path(steam_path)
|
||||
|
||||
steam_redacted = redact(default)
|
||||
workshop_redacted = redact(str(workshop_path))
|
||||
|
||||
mods = get_local_mod_ids(steam_path)
|
||||
mods_pretty = print_mods(mods)
|
||||
|
||||
servers = lookup(config, Preferences.IP_LIST)
|
||||
servers_pretty = print_servers(servers)
|
||||
|
||||
# FIXME: extraneous newlines in lists
|
||||
template = f"""\
|
||||
{APP_NAME} version {version} ({branch})
|
||||
Date: {date}
|
||||
===============================
|
||||
Distribution: {distro}
|
||||
Kernel: {kernel}
|
||||
CPU: {cpu}
|
||||
|
||||
Debug: {debug}
|
||||
Auto-install: {install}
|
||||
Steam path: {steam_redacted}
|
||||
Workshop path: {workshop_redacted}
|
||||
|
||||
Mods:
|
||||
{mods_pretty}
|
||||
Servers:
|
||||
{servers_pretty}
|
||||
"""
|
||||
|
||||
output = "\n".join([line.lstrip() for line in template.split('\n')])
|
||||
outfile.write_text(output)
|
||||
@@ -0,0 +1,86 @@
|
||||
import multiprocessing
|
||||
from math import radians, cos, sin, asin, sqrt
|
||||
from typing import Self, TYPE_CHECKING
|
||||
|
||||
import gi
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa E402
|
||||
|
||||
import dzgui.util.ip as ip
|
||||
from dzgui.util.ip import GeolocationError
|
||||
from dzgui.util.localize import number
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dzgui.util.ip import Coords
|
||||
|
||||
class Haversine():
|
||||
def __init__(self, lat1: float, lon1: float, lat2: float, lon2: float) -> None:
|
||||
|
||||
R = 6371 * 1000
|
||||
|
||||
dLat = radians(lat2 - lat1)
|
||||
dLon = radians(lon2 - lon1)
|
||||
lat1 = radians(lat1)
|
||||
lat2 = radians(lat2)
|
||||
|
||||
a = sin(dLat/2) ** 2 + cos(lat1) * cos(lat2) * sin(dLon/2) ** 2
|
||||
c = 2 * asin(sqrt(a))
|
||||
|
||||
self.dist = R * c
|
||||
|
||||
def as_kilometers(self) -> float:
|
||||
return self.dist / 1000
|
||||
|
||||
def as_miles(self) -> float:
|
||||
return self.dist / 1609.344
|
||||
|
||||
def compare(local: "Coords", remote_c: str, fmt: str) -> int | None:
|
||||
if local is None:
|
||||
return None
|
||||
try:
|
||||
# FIXME: expects XDG.ips
|
||||
remote = ip.get_coords(remote_c)
|
||||
except GeolocationError:
|
||||
return None
|
||||
|
||||
haversine = Haversine(local.lat, local.lon, remote.lat, remote.lon)
|
||||
if fmt == "km":
|
||||
dist = haversine.as_kilometers()
|
||||
else:
|
||||
dist = haversine.as_miles()
|
||||
return round(dist)
|
||||
|
||||
class CalcDist(multiprocessing.Process):
|
||||
def __init__(
|
||||
self,
|
||||
widget: Gtk.Widget,
|
||||
addr: str,
|
||||
result_queue: multiprocessing.Queue,
|
||||
cache: dict,
|
||||
):
|
||||
super().__init__()
|
||||
|
||||
self.result_queue = result_queue
|
||||
self.addr = addr
|
||||
self.ip = addr.split(":")[0]
|
||||
|
||||
# TODO: pass controller correctly
|
||||
def run(self) -> None:
|
||||
use_miles = MainController.query_config("use_miles")
|
||||
fmt = "mi" if use_miles else "km"
|
||||
|
||||
# TODO: get cache accordingly
|
||||
if self.addr in cache:
|
||||
if fmt in cache[self.addr]:
|
||||
logger.info(f"Address '{self.addr}' already in cache")
|
||||
self.result_queue.put([self.addr, cache[self.addr]])
|
||||
return
|
||||
|
||||
prefs = MainController.get_prefs()
|
||||
dist = compare(prefs.coords, prefs.paths.ips, self.ip, fmt)
|
||||
if dist is None:
|
||||
dist_pretty = "Unknown"
|
||||
else:
|
||||
d = number(dist)
|
||||
dist_pretty = f"{d} {fmt}"
|
||||
self.result_queue.put([self.addr, dist_pretty])
|
||||
@@ -0,0 +1,44 @@
|
||||
import re
|
||||
|
||||
from dzgui.util.localize import number
|
||||
from dzgui.util.strings import no_mods
|
||||
|
||||
|
||||
def format_pango(text: str) -> str:
|
||||
medium = '<span size="medium"><b>'
|
||||
large = '<span size="large"><b>'
|
||||
xlarge = '<span size="x-large"><b>'
|
||||
text = re.sub("^# ", xlarge, text, flags=re.M)
|
||||
text = re.sub("^## ", large, text, flags=re.M)
|
||||
text = re.sub("^### ", medium, text, flags=re.M)
|
||||
text = re.sub(r"(<span size=.*)", r"\1</b></span>", text)
|
||||
return text
|
||||
|
||||
|
||||
def pluralize(plural: str, count: int) -> str:
|
||||
suffix = plural[-2:]
|
||||
if suffix == "es":
|
||||
base = plural[:-2]
|
||||
return f"{base}{'es'[:2*count ^ 2]}"
|
||||
else:
|
||||
base = plural[:-1]
|
||||
return f"{base}{'s'[:count ^ 1]}"
|
||||
|
||||
|
||||
def format_hyperlinks(text: str) -> str:
|
||||
def href(string: str) -> str:
|
||||
return f'<a href="{string}">{string}</a>'
|
||||
|
||||
reg = r"\s(www\.*?)"
|
||||
text = re.sub(reg, " http://" + r"\1", text)
|
||||
reg2 = r"(http.*?)([ ,\r\n]|$)"
|
||||
text = re.sub(reg2, href(r"\1") + r"\2", text)
|
||||
return text
|
||||
|
||||
|
||||
def format_mods(size: int, mods: int) -> str:
|
||||
if mods == 0:
|
||||
return no_mods
|
||||
l_size = number(size)
|
||||
plural = pluralize("mods", mods)
|
||||
return f"Found {mods:n} {plural} taking up {l_size} MiB"
|
||||
@@ -0,0 +1,137 @@
|
||||
import a2s
|
||||
import gzip
|
||||
import requests
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from dzgui.api.servers import Record
|
||||
from dzgui.const.constants import REQUEST_TIMEOUT
|
||||
from dzgui.const.endpoints import COORDS_API, IP_ECHO
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
@dataclass
|
||||
class Coords:
|
||||
lat: float
|
||||
lon: float
|
||||
|
||||
|
||||
class GeolocationError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class ReservedIPError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def ip2list(ip: str) -> list:
|
||||
return [int(digit) for digit in ip.split(".")]
|
||||
|
||||
|
||||
# TODO: tests
|
||||
def is_reserved(digits: list) -> bool:
|
||||
reserved = [0, 10, 127]
|
||||
|
||||
if digits[0] in reserved:
|
||||
return True
|
||||
if digits[0] == 192 and digits[1] == 168:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def get_coords(ips: "Path", ip: str) -> Coords:
|
||||
split = ip2list(ip)
|
||||
|
||||
if is_reserved(split):
|
||||
raise ReservedIPError(f"{ip} is a reserved IP address.")
|
||||
|
||||
prefix = f"^{split[0]}.{split[1]}."
|
||||
|
||||
proc = subprocess.run(
|
||||
["/usr/bin/grep", "-E", prefix, ips],
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
|
||||
if proc.returncode != 0:
|
||||
raise GeolocationError("Failed to split records")
|
||||
|
||||
ips = proc.stdout.splitlines()
|
||||
|
||||
for address in ips:
|
||||
fields = address.split(",")
|
||||
upper = fields[1]
|
||||
|
||||
upper_digits = ip2list(upper)
|
||||
|
||||
if split[2] > upper_digits[2]:
|
||||
continue
|
||||
|
||||
if split[2] == upper_digits[2]:
|
||||
if split[3] > upper_digits[3]:
|
||||
continue
|
||||
|
||||
if split[2] <= upper_digits[2]:
|
||||
return Coords(float(fields[-2]), float(fields[-1]))
|
||||
raise GeolocationError("No matching records found")
|
||||
|
||||
def get_local_ip() -> str:
|
||||
ip = ""
|
||||
# TODO: use shlex
|
||||
if shutil.which("dig") is not None:
|
||||
proc = subprocess.run(
|
||||
[
|
||||
"/usr/bin/dig",
|
||||
"-4",
|
||||
"+short",
|
||||
"myip.opendns.com",
|
||||
"@resolver1.opendns.com"
|
||||
],
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
|
||||
if proc.returncode == 0:
|
||||
ip = proc.stdout.rstrip("\n")
|
||||
else:
|
||||
res = requests.get(IP_ECHO, timeout=REQUEST_TIMEOUT)
|
||||
res.raise_for_status()
|
||||
ip = res.text
|
||||
return ip
|
||||
|
||||
|
||||
def resolve_ip(address: Record) -> Record:
|
||||
# TODO: unit test for resolved ips
|
||||
"""
|
||||
Multiple game modes may be hosted on the same IP and query port,
|
||||
but resolve to different game ports. The canonical record must
|
||||
contain the real game port. This is merely used when saving a
|
||||
record as a UUID.
|
||||
"""
|
||||
res = a2s.info((address.ip, address.qport))
|
||||
real_gport = res.port
|
||||
return Record(address.ip, real_gport, address.qport)
|
||||
|
||||
|
||||
def is_valid_port(port: str) -> bool:
|
||||
if len(port) < 1:
|
||||
return True
|
||||
if (
|
||||
not port.isdigit()
|
||||
or int(port) == 0
|
||||
or int(port[0]) == 0
|
||||
or int(port) > 65535
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
# TODO: deprecated
|
||||
#def get_local_coords(ip: str):
|
||||
# url = COORDS_API + "/" + ip
|
||||
# #local res=$(curl -Ls "$url" | jq -r '"\(.lat)\n\(.lon)"')
|
||||
# return url
|
||||
@@ -0,0 +1,26 @@
|
||||
import gi # noqa E402
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gdk
|
||||
|
||||
def is_navkey(key: int) -> bool:
|
||||
nav_keys = [
|
||||
Gdk.KEY_Down,
|
||||
Gdk.KEY_Up,
|
||||
Gdk.KEY_Right,
|
||||
Gdk.KEY_Page_Down,
|
||||
Gdk.KEY_Page_Up,
|
||||
Gdk.KEY_j,
|
||||
Gdk.KEY_k,
|
||||
Gdk.KEY_g,
|
||||
Gdk.KEY_G,
|
||||
]
|
||||
if key in nav_keys:
|
||||
return True
|
||||
return False
|
||||
|
||||
def is_ctrl_mask(event: Gdk.EventKey) -> bool:
|
||||
if event.keyval is Gdk.KEY_l \
|
||||
and event.state is Gdk.ModifierType.CONTROL_MASK:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
@@ -0,0 +1,16 @@
|
||||
import os
|
||||
import locale
|
||||
|
||||
def number(num: int | float) -> str:
|
||||
spec = ""
|
||||
if type(num) is int:
|
||||
spec = "%d"
|
||||
if type(num) is float:
|
||||
spec = "%.2f"
|
||||
return locale.format_string(spec, num, grouping=True)
|
||||
|
||||
|
||||
def set_locale():
|
||||
locale.setlocale(locale.LC_ALL, "")
|
||||
user_loc = loc if (loc := os.getenv("LC_CTYPE")) else "en_US.UTF-8"
|
||||
locale.setlocale(locale.LC_NUMERIC, loc)
|
||||
@@ -0,0 +1,28 @@
|
||||
import subprocess
|
||||
|
||||
from pathlib import Path
|
||||
from dzgui.const.constants import VM_FILE, MIN_COUNT
|
||||
|
||||
def get_map_count() -> int | None:
|
||||
path = Path(VM_FILE)
|
||||
if path.is_file() is False:
|
||||
return None
|
||||
count = int(path.read_text())
|
||||
return count
|
||||
|
||||
# TODO: unfinished
|
||||
#if count < MIN_COUNT:
|
||||
# print("needs sudo escalation")
|
||||
# # pop prompt
|
||||
# # get response
|
||||
# set_map_count()
|
||||
#return count
|
||||
|
||||
# TODO: unfinished
|
||||
def set_map_count() -> None:
|
||||
conf = "/etc/sysctl.d/dayz.conf"
|
||||
count = f"vm.max_map_count={MIN_COUNT}"
|
||||
with open(conf, "w") as f:
|
||||
f.write(count)
|
||||
# TODO: use concat_bash_args()
|
||||
subprocess.run(["/usr/bin/sudo", "sysctl", "-p", conf])
|
||||
@@ -0,0 +1,12 @@
|
||||
import gi # noqa E402
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gtk
|
||||
|
||||
def set_surrounding_margins(widget: Gtk.Widget, margin: int) -> None:
|
||||
"""
|
||||
Utility function that sets all margins
|
||||
on a widget to a uniform integer value
|
||||
"""
|
||||
widget.set_margin_top(margin)
|
||||
widget.set_margin_start(margin)
|
||||
widget.set_margin_end(margin)
|
||||
@@ -0,0 +1,61 @@
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
|
||||
from dzgui.const.constants import APPID_DAYZ
|
||||
from dzgui.const.enum import RowType
|
||||
from dzgui.const import endpoints
|
||||
from dzgui.util.bash import concat_bash_args
|
||||
|
||||
links = {
|
||||
RowType.DOCS: endpoints.GITHUB_PAGES,
|
||||
RowType.DOCS_FALLBACK: endpoints.CODEBERG_PAGES,
|
||||
RowType.BUGS: endpoints.GITHUB_ISSUES,
|
||||
RowType.FORUM: endpoints.FORUM,
|
||||
RowType.SPONSOR: endpoints.SPONSORS,
|
||||
RowType.CHNG_STEAM_API: endpoints.STEAM_API_SETUP,
|
||||
RowType.CHNG_BM_API: endpoints.BM_API_SETUP,
|
||||
RowType.CONN_BY_ID: endpoints.BM_BROWSE,
|
||||
RowType.ADD_BY_ID: endpoints.BM_BROWSE,
|
||||
}
|
||||
|
||||
|
||||
def get_browser() -> str:
|
||||
if (browser := os.getenv("BROWSER")) is not None:
|
||||
command = browser
|
||||
else:
|
||||
command = "xdg-open"
|
||||
return command
|
||||
|
||||
|
||||
def open_user_workshop(uid: str, client: str) -> None:
|
||||
base = f"https://steamcommunity.com/profiles/{uid}/myworkshopfiles/?appid={APPID_DAYZ}&browsefilter=mysubscriptions"
|
||||
uri = endpoints.OPEN_URL + base
|
||||
args = concat_bash_args(client)
|
||||
args.append(uri)
|
||||
subprocess.Popen([*args])
|
||||
|
||||
|
||||
def open_link_by_url(url: str) -> None:
|
||||
command = get_browser()
|
||||
args = concat_bash_args(command)
|
||||
args.append(url)
|
||||
subprocess.Popen([*args])
|
||||
|
||||
|
||||
def open_link_by_rowtype(enum: RowType) -> None:
|
||||
command = get_browser()
|
||||
args = concat_bash_args(command)
|
||||
try:
|
||||
url = links[enum]
|
||||
except KeyError:
|
||||
return
|
||||
args.append(url)
|
||||
subprocess.Popen([*args])
|
||||
|
||||
|
||||
def open_workshop_page(uid: str, client: str) -> None:
|
||||
uri = endpoints.WORKSHOP + uid
|
||||
args = concat_bash_args(client)
|
||||
args.append(uri)
|
||||
subprocess.Popen([*args])
|
||||
@@ -0,0 +1,6 @@
|
||||
import re
|
||||
|
||||
def redact(text: str) -> str:
|
||||
r = r"(/home/)([^/])*"
|
||||
cleaned = re.sub(r, r"/home/REDACTED", text)
|
||||
return cleaned
|
||||
@@ -0,0 +1,479 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Dict
|
||||
|
||||
from dzgui.const.constants import SYSTEM_LOG
|
||||
|
||||
def build_missing(build: str) -> str:
|
||||
msg = (
|
||||
f"This server is running {build}. You can install "
|
||||
f"{build} by searching for it in your Steam library. "
|
||||
f"If you recently installed {build} or moved it to a different drive, "
|
||||
"restart Steam to allow these changes to synchronize, then try again."
|
||||
)
|
||||
return msg
|
||||
|
||||
|
||||
def build_path_invalid(build: str) -> str:
|
||||
msg = (
|
||||
f"Steam is reporting that {build} is installed at a non-existent location. "
|
||||
f"If you recently installed {build} or moved it to a different drive, "
|
||||
"restart Steam to allow these changes to synchronize, then try again."
|
||||
)
|
||||
return msg
|
||||
|
||||
# General
|
||||
dialog_header = "DZGUI - Dialog"
|
||||
dz = "DayZ"
|
||||
dz_exp = "DayZ Experimental"
|
||||
delimiter = "␞"
|
||||
|
||||
# GenericDialog
|
||||
main_menu = "Return to main menu"
|
||||
exit_app = "Exit"
|
||||
server_details = "Server details"
|
||||
modlist = "Modlist"
|
||||
input_required = "User input required"
|
||||
confirm = "Confirmation"
|
||||
notice = "Notice"
|
||||
wait = "Please wait"
|
||||
|
||||
# Wait dialogs
|
||||
ping = "Ping"
|
||||
mod = "Mod"
|
||||
_id = "ID"
|
||||
|
||||
# ContextMenu
|
||||
add_note = "Add note"
|
||||
edit_note = "Edit note"
|
||||
show_mods = "Show server-side mods"
|
||||
show_details = "Server details"
|
||||
refresh_players = "Refresh player count"
|
||||
open_workshop = "Open in Steam Workshop"
|
||||
delete_mod = "Delete mod"
|
||||
copy_name = "Copy name to clipboard"
|
||||
copy_ip = "Copy IP to clipboard"
|
||||
copy_log = "Copy record(s) to clipboard"
|
||||
add = "Add to my servers"
|
||||
remove = "Remove from my servers"
|
||||
remove_history = "Remove from history"
|
||||
|
||||
# Columns
|
||||
server_mod_cols = ["Mod", "ID", "Installed"]
|
||||
mod_cols = ["Mod", "Symlink", "Dir", "Size (MiB)", "Color"]
|
||||
log_cols = ["Timestamp", "Flag", "Traceback", "Message"]
|
||||
browser_cols = [
|
||||
"Name",
|
||||
"Map",
|
||||
"Perspective",
|
||||
"Gametime",
|
||||
"Players",
|
||||
"Maximum",
|
||||
"Queue",
|
||||
"IP",
|
||||
"Qport",
|
||||
"Ping",
|
||||
]
|
||||
|
||||
# DetailsDialog
|
||||
server_message = "Server message"
|
||||
workshop = "Enter/double click a row to open in Steam Workshop."
|
||||
|
||||
# LanDialog
|
||||
default_port = "Use default query port (27016)"
|
||||
custom_port = "Enter custom query port"
|
||||
scan_servers = "Scan LAN servers"
|
||||
select_port = "Select the query port"
|
||||
|
||||
# Errors
|
||||
config_not_found = "DZGUI configuration file not found. Please exit and restart."
|
||||
cannot_acquire_lock = "DZGUI is already open."
|
||||
something_wrong = "Something went wrong."
|
||||
steam_key_missing = "No Steam API key is set."
|
||||
malformed_mods = "Found mods on system, but was unable to parse results."
|
||||
steam_missing = "Local Steam installation is not set, possibly malformed config file."
|
||||
build_corrupted = "Steam settings or DayZ installation may be corrupted. Try restarting Steam."
|
||||
api_warn_msg = """No servers returned. Possible causes:
|
||||
no servers in favorites/history, local network issue, or API key on cooldown.
|
||||
Return to the main menu, wait 30s, and try again.
|
||||
If this issue persists, your API key may be defunct.
|
||||
"""
|
||||
server_timeout = "Timed out when querying server, check IP or try again later"
|
||||
server_error = "Error while contacting server, possibly timed out. Please wait and try again."
|
||||
server_protected = (
|
||||
"This server is password-protected and you will be "
|
||||
"prompted when connecting. Do you want to proceed?"
|
||||
)
|
||||
|
||||
|
||||
|
||||
# KeybindingsDialog
|
||||
navigation = {
|
||||
"Enter/space/double click": "select row item",
|
||||
"Down arrow": "move down a row/scroll down",
|
||||
"Up arrow": "move up a row/scroll up",
|
||||
"Right arrow": "jump to sidebar from main area",
|
||||
"Left arrow": "jump to main area from sidebar",
|
||||
"Tab": "cycle forward through elements",
|
||||
"Shift-tab": "cycle backward through elements",
|
||||
"ESC/Enter": "close dialogs",
|
||||
"?": "show/hide this dialog",
|
||||
"Ctrl-q": "Quit",
|
||||
}
|
||||
servers = {
|
||||
"Enter/space/double-click": "connect to server",
|
||||
"Right-click/Ctrl-l": "additional context menus",
|
||||
"Ctrl-r": "refresh players",
|
||||
"Ctrl-p": "refresh ping",
|
||||
"Ctrl-f": "jump to keyword search field",
|
||||
"Ctrl-m": "jump to maps field",
|
||||
"Ctrl-d": "toggle dry run (debug) mode",
|
||||
"ESC": "return to table",
|
||||
"1-9": "toggle filter 1-9 on/off",
|
||||
"0": "toggle filter 10",
|
||||
"Minus": "toggle filter 11",
|
||||
"Backslash": "toggle filter 12",
|
||||
}
|
||||
vim = {
|
||||
"j": "Move down a row/scroll up",
|
||||
"k": "Move up a row/scroll down",
|
||||
"l": "Jump to main area from sidebar",
|
||||
"h": "Jump to sidebar from main area",
|
||||
"gg": "Jump to first row/top of page",
|
||||
"G": "Jump to last row/bottom of page",
|
||||
}
|
||||
key_contexts = ["Servers", "Navigation", "Vim-style keys"]
|
||||
key_header = "Keybindings"
|
||||
|
||||
# ModSelectionPanel
|
||||
|
||||
# labels
|
||||
label_main_menu = "Main menu"
|
||||
|
||||
# buttons
|
||||
ping_servers = "Ping servers"
|
||||
debug_mode = "Debug mode"
|
||||
keys_button = "Keys"
|
||||
keys_tooltip = "Opens the keybindings dialog"
|
||||
debug_tooltip = (
|
||||
"Used to perform a dry run without\n"
|
||||
"actually connecting to a server"
|
||||
)
|
||||
ping_tooltip = (
|
||||
"Refresh the ping for visible servers.\n"
|
||||
"Available once per unique filter context"
|
||||
)
|
||||
|
||||
statusbar_helptext = "Select a row to see its detailed description"
|
||||
|
||||
# use e.g. filters.1pp
|
||||
filter_1pp = "1PP"
|
||||
filter_3pp = "3PP"
|
||||
filter_day = "Day"
|
||||
filter_night = "Night"
|
||||
filter_empty = "Empty"
|
||||
filter_full = "Full"
|
||||
filter_lowpop = "Low pop"
|
||||
filter_nonascii = "Non-ASCII"
|
||||
filter_duplicate = "Duplicate"
|
||||
filter_official = "Official"
|
||||
filter_unofficial = "Unoffic."
|
||||
filter_modded = "Modded"
|
||||
|
||||
# maps
|
||||
all_maps = "All maps"
|
||||
|
||||
# state
|
||||
unknown = "Unknown"
|
||||
none_provided = "None provided"
|
||||
disabled = "Disabled"
|
||||
enabled = "Enabled"
|
||||
null = "-"
|
||||
none = "None"
|
||||
unspecified = "not specified"
|
||||
|
||||
# DLC
|
||||
dlc_frostline = "Frostline"
|
||||
|
||||
# platform
|
||||
windows = "Windows"
|
||||
linux = "Linux"
|
||||
|
||||
# misc
|
||||
self_workshop = "Open Steam workshop"
|
||||
settings_updated = "Settings updated!"
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class ModPanelStrings:
|
||||
header: str
|
||||
unhighlight_stale: str
|
||||
unhighlight_stale_tooltip: str
|
||||
highlight_stale: str
|
||||
highlight_stale_tooltip: str
|
||||
delete_selected: str
|
||||
delete_selected_tooltip: str
|
||||
unselect_all: str
|
||||
unselect_all_tooltip: str
|
||||
select_all: str
|
||||
select_all_tooltip: str
|
||||
bulk_select: str
|
||||
clear_highlights: str
|
||||
select_stale: str
|
||||
select_stale_tooltip: str
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class Dialog:
|
||||
querying: str
|
||||
fetching: str
|
||||
filtering: str
|
||||
refreshing: str
|
||||
details: str
|
||||
modlist: str
|
||||
working: str
|
||||
updating_mods: str
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class Init:
|
||||
is_steam_running: str
|
||||
is_dayz_running: str
|
||||
requires_steam: str
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class Button:
|
||||
main_label: str
|
||||
main_tooltip: str
|
||||
mods_label: str
|
||||
mods_tooltip: str
|
||||
options_label: str
|
||||
options_tooltip: str
|
||||
help_label: str
|
||||
help_tooltip: str
|
||||
exit_label: str
|
||||
exit_tooltip: str
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class Crumbs:
|
||||
changelog: str
|
||||
history: str
|
||||
keys: str
|
||||
lan: str
|
||||
log: str
|
||||
main: str
|
||||
mods: str
|
||||
my_servers: str
|
||||
options: str
|
||||
servers: str
|
||||
thanks: str
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class Thanks:
|
||||
header: str
|
||||
description: str
|
||||
users: list[str]
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class Options:
|
||||
header: str
|
||||
steam_web: str
|
||||
bm_web: str
|
||||
enter_steam: str
|
||||
enter_bm: str
|
||||
steam_placeholder: str
|
||||
bm_placeholder: str
|
||||
name_placeholder: str
|
||||
last_used: str
|
||||
always_fs: str
|
||||
steam: str
|
||||
flatpak: str
|
||||
km: str
|
||||
mi: str
|
||||
client: str
|
||||
window_size: str
|
||||
distance: str
|
||||
name: str
|
||||
manual_dl: str
|
||||
auto_dl: str
|
||||
update: str
|
||||
install_mode: str
|
||||
force_update: str
|
||||
dl_eventbox: str
|
||||
force_eventbox: str
|
||||
self_update: str
|
||||
no_self_update: str
|
||||
api_keys: str
|
||||
prefs: str
|
||||
mods: str
|
||||
version: str
|
||||
branch: str
|
||||
stable: str
|
||||
testing: str
|
||||
manual_sub_msg: str
|
||||
|
||||
init = Init(
|
||||
is_steam_running = "Is Steam running? DZGUI must be run on top of Steam.",
|
||||
is_dayz_running = "Is DayZ already running? DZGUI cannot launch DayZ if another process is using it.",
|
||||
requires_steam = "DZGUI requires that Steam or Flatpak Steam be installed on the system.",
|
||||
)
|
||||
|
||||
mod_panel = ModPanelStrings(
|
||||
header = "Mod actions",
|
||||
unhighlight_stale = "Unhighlight stale",
|
||||
unhighlight_stale_tooltip = "Clears highlight from stale mods",
|
||||
highlight_stale = "Highlight stale",
|
||||
highlight_stale_tooltip = (
|
||||
"Shows locally-installed mods which are not used by any server "
|
||||
"in your Saved Servers"
|
||||
),
|
||||
delete_selected = "Delete selected",
|
||||
delete_selected_tooltip = "Deletes selected mods from the system",
|
||||
unselect_all = "Unselect all",
|
||||
unselect_all_tooltip = "Bulk unselects all mods",
|
||||
select_all = "Select all",
|
||||
select_all_tooltip = "Bulk selects all mods",
|
||||
bulk_select = "Bulk selects all currently highlighted mods",
|
||||
clear_highlights = "Clears highlights and reverts the table to a default state",
|
||||
select_stale = "Select stale",
|
||||
select_stale_tooltip = "Only selects highlighted stale mods",
|
||||
)
|
||||
|
||||
|
||||
thanks = Thanks(
|
||||
header = "# Special thanks",
|
||||
description = (
|
||||
"This page recognizes beta testers, collaborators, code "
|
||||
"contributors, and sponsors of the project in alphabetical order."
|
||||
),
|
||||
users = [
|
||||
"bongjutsu",
|
||||
"Deku",
|
||||
"dj3hac",
|
||||
"GaryBlackbourne",
|
||||
"jiriks74",
|
||||
"Johnofwrong",
|
||||
"MatheusLasserr",
|
||||
"nolan-perez",
|
||||
"scandalouss",
|
||||
"StevelDusa",
|
||||
"Thoughtduck216",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
options = Options(
|
||||
header="Options",
|
||||
steam_web="Steam API page",
|
||||
bm_web="Battlemetrics API page",
|
||||
enter_steam="Enter your Steam API key",
|
||||
enter_bm="Enter your Battlemetrics API key",
|
||||
steam_placeholder="Steam API key",
|
||||
bm_placeholder="Battlemetrics API key",
|
||||
name_placeholder="Identifies you to other players in-game",
|
||||
last_used="Last used dimensions",
|
||||
always_fs="Always fullscreen",
|
||||
steam="Steam",
|
||||
flatpak="Flatpak (experimental)",
|
||||
km="km (kilometers)",
|
||||
mi="mi (miles)",
|
||||
client="Steam client",
|
||||
window_size="Window size at boot",
|
||||
distance="Distance display",
|
||||
name="Player name",
|
||||
manual_dl="Manual",
|
||||
auto_dl="Auto",
|
||||
update="Update",
|
||||
install_mode = "Mod install mode",
|
||||
force_update = "Force update local mods",
|
||||
dl_eventbox = (
|
||||
"Manual: prompt to subscribe to mods in Steam. "
|
||||
"Auto: unmanned downloads."
|
||||
),
|
||||
force_eventbox = "Synchronize all local mods. Automatic mode must be enabled.",
|
||||
self_update = (
|
||||
"Stable: only contains stable features. "
|
||||
"Testing: pre-release beta, contains new features."
|
||||
),
|
||||
no_self_update = (
|
||||
"In-app updates are disabled when DZGUI is "
|
||||
"installed globally (e.g., via package manager)."
|
||||
),
|
||||
api_keys = "API Keys",
|
||||
prefs = "Preferences",
|
||||
mods = "Mods",
|
||||
version = "Version",
|
||||
branch = "DZGUI branch",
|
||||
stable = "Stable",
|
||||
testing = "Testing",
|
||||
manual_sub_msg = """When switching from MANUAL to AUTO mod install mode,
|
||||
DZGUI will manage mod installation and deletion for you.
|
||||
To prevent conflicts with Steam Workshop subscriptions and old mods from being downloaded
|
||||
when Steam updates, you should unsubscribe from any existing Workshop mods you manually subscribed to.
|
||||
Open your Profile > Workshop Items and select 'Unsubscribe from all'
|
||||
on the right-hand side.
|
||||
""",
|
||||
)
|
||||
|
||||
dialog = Dialog(
|
||||
querying = "Querying server",
|
||||
fetching = "Fetching server metadata",
|
||||
filtering = "Filtering servers",
|
||||
refreshing = "Refreshing player count",
|
||||
details = "Fetching details",
|
||||
modlist = "Fetching modlist",
|
||||
working = "Working",
|
||||
updating_mods = "Updating mods",
|
||||
)
|
||||
|
||||
buttons = Button(
|
||||
main_label="Main menu",
|
||||
main_tooltip="Search for and connect to servers",
|
||||
mods_label="Mods",
|
||||
mods_tooltip="Manage local mods",
|
||||
options_label="Options",
|
||||
options_tooltip="Advanced options",
|
||||
help_label="Help",
|
||||
help_tooltip="Troubleshooting and documentation",
|
||||
exit_label="Exit",
|
||||
exit_tooltip="Quits the application",
|
||||
)
|
||||
|
||||
crumbs = Crumbs(
|
||||
changelog="Help > Changelog",
|
||||
history="Main menu > Recent servers",
|
||||
keys="Keybindings",
|
||||
lan="Main menu > Local servers",
|
||||
log="Help > Debug log",
|
||||
main="Main menu",
|
||||
mods="Mods",
|
||||
my_servers="Main menu > Saved servers",
|
||||
options="Options",
|
||||
servers="Main menu > Server browser",
|
||||
thanks="Help > Special thanks"
|
||||
)
|
||||
|
||||
checkmark = "✓"
|
||||
no_mods = "No local mods found."
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class Flags:
|
||||
description: str
|
||||
version: str
|
||||
uninstall: str
|
||||
|
||||
flags = Flags(
|
||||
description="DayZ server browser and mod manager",
|
||||
version="Print version information",
|
||||
uninstall="Uninstall data files (use prior to 'pip uninstall')"
|
||||
)
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class FilePicker:
|
||||
title: str
|
||||
placeholder: str
|
||||
|
||||
picker = FilePicker(
|
||||
title = "Save diagnostic log to file",
|
||||
placeholder = SYSTEM_LOG,
|
||||
)
|
||||
|
||||
api_error = "API key validation error or timeout. See 'Help > Show debug log' for details."
|
||||
@@ -0,0 +1,50 @@
|
||||
import hashlib
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from dzgui.api.mods import get_local_mod_path, get_local_mod_ids, _hash
|
||||
from dzgui.const.constants import APPID_DAYZ, APPID_DAYZ_EXP
|
||||
from dzgui.const.enum import Preferences
|
||||
from dzgui.config.query import lookup
|
||||
|
||||
import dzgui.api.pefile as PeFile
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def rebuild_symlinks(config: Path) -> None:
|
||||
path = lookup(config, Preferences.DEFAULT)
|
||||
steam_path = Path(path)
|
||||
|
||||
dayz_path = PeFile.get_nested_app_path(steam_path, APPID_DAYZ)
|
||||
for file in dayz_path.iterdir():
|
||||
if file.is_symlink():
|
||||
file.unlink()
|
||||
workshop = get_local_mod_path(steam_path)
|
||||
for uid in get_local_mod_ids(steam_path):
|
||||
uid = str(uid)
|
||||
md5sum = _hash(str(uid))
|
||||
Path(dayz_path / md5sum).symlink_to(workshop / uid)
|
||||
|
||||
|
||||
def clone_symlinks(config: Path) -> None:
|
||||
"""
|
||||
Used after any symlink operation
|
||||
"""
|
||||
path = lookup(config, Preferences.DEFAULT)
|
||||
steam_path = Path(path)
|
||||
try:
|
||||
dayz_path = PeFile.get_nested_app_path(steam_path, APPID_DAYZ)
|
||||
exp_path = PeFile.get_nested_app_path(steam_path, APPID_DAYZ_EXP)
|
||||
except Exception as e:
|
||||
logger.critical(e)
|
||||
return
|
||||
|
||||
# TODO: test these two operations
|
||||
for file in exp_path.iterdir():
|
||||
if file.is_symlink():
|
||||
file.unlink()
|
||||
|
||||
for file in dayz_path.iterdir():
|
||||
if file.is_symlink():
|
||||
uid = file.name
|
||||
Path(exp_path / uid).symlink_to(dayz_path / uid)
|
||||
@@ -0,0 +1,668 @@
|
||||
import json
|
||||
import locale
|
||||
import logging
|
||||
import multiprocessing
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import subprocess
|
||||
import textwrap
|
||||
import threading
|
||||
import typing # noqa
|
||||
import warnings
|
||||
|
||||
from collections.abc import Callable
|
||||
from concurrent.futures import wait
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Literal, Any
|
||||
|
||||
import gi
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa
|
||||
|
||||
# TODO: most likely very little of this will be retained
|
||||
from dzgui.const.enum import (
|
||||
Preferences,
|
||||
Popup,
|
||||
RowType,
|
||||
NotebookPage,
|
||||
VAdjustment,
|
||||
MAIN_MENU_ROWS,
|
||||
)
|
||||
from dzgui.const.constants import NO_EXPAND, NO_FILL, EXPAND, FILL
|
||||
from dzgui.const.constants import APP_NAME, APP_NAME_LOWER, WINDOW_DEFAULT_X, WINDOW_DEFAULT_Y
|
||||
from dzgui.controllers.mc import Controller
|
||||
|
||||
from dzgui.util import css, dist, localize, strings, ip, deck, open_links
|
||||
from dzgui.util.format import pluralize
|
||||
|
||||
# GRID ITEMS
|
||||
# TODO: import notebook only and add components there?
|
||||
from dzgui.views.pages.changelog import Changelog
|
||||
from dzgui.views.pages.keys import Keybindings
|
||||
from dzgui.views.pages.thanks import Thanks
|
||||
from dzgui.views.pages.options import Options
|
||||
|
||||
from dzgui.views.components.mod_panel import ModSelectionPanel
|
||||
from dzgui.views.components.right_panel import RightPanel
|
||||
from dzgui.views.components.toast import Toast
|
||||
from dzgui.views.mixins.scrollable_mixin import ScrollableMixin
|
||||
|
||||
# TREES
|
||||
from dzgui.views.trees.tree_log import LogTreeView
|
||||
from dzgui.views.trees.tree_menu import MenuTreeView
|
||||
from dzgui.views.trees.tree_mods import ModTreeView
|
||||
from dzgui.views.trees.tree_servers import ServerTreeView
|
||||
|
||||
from dzgui.views.dialogs.generic import GenericDialog
|
||||
|
||||
# TODO: not going to be in base anymore
|
||||
import dzgui.util._json as JSON # noqa
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
# https://bugzilla.gnome.org/show_bug.cgi?id=708676
|
||||
warnings.filterwarnings("ignore", ".*g_value_get_int", Warning)
|
||||
|
||||
# TODO: move to controller
|
||||
cache: dict[str, int] = {}
|
||||
|
||||
# TODO: move to strings.py
|
||||
def format_metadata(row: RowType) -> str:
|
||||
prefix = row.dict["tooltip"]
|
||||
|
||||
if row == RowType.QUICK_CONNECT or row == RowType.CHNG_FAV:
|
||||
label = MainController.query_config(Preferences.FAV_LBL)
|
||||
if len(label) < 1:
|
||||
label = "unset"
|
||||
return f"{prefix} ({label})"
|
||||
else:
|
||||
return prefix
|
||||
|
||||
## TODO: move to configs/servers
|
||||
#def query_history() -> list | None:
|
||||
# history_file = MainController.get_prefs().paths.history
|
||||
# try:
|
||||
# with open(history_file, "r") as f:
|
||||
# rows = [row.rstrip("\n") for row in f]
|
||||
# except OSError:
|
||||
# rows = None
|
||||
# return rows
|
||||
#
|
||||
#def process_tree_option(choice: RowType) -> None:
|
||||
# context = AppNav.treeview.view
|
||||
# command = choice
|
||||
# cmd_string = command.dict["label"]
|
||||
# logger.info(f"Parsing tree option '{command}' for the context '{context}'")
|
||||
#
|
||||
# # server tables
|
||||
# if command == RowType.RESOLVE_IP:
|
||||
# record = AppNav.treeview.get_record()
|
||||
# wait_msg = command.dict["wait_msg"]
|
||||
# show_wait_dialog = True
|
||||
#
|
||||
# # TODO: needs threading, this is a slow process
|
||||
# # TODO: needs saving into config
|
||||
# # TODO: used by add/remove servers
|
||||
# real_ip = ip.resolve_ip(record)
|
||||
# #call_on_thread(
|
||||
# # show_wait_dialog, cmd_string, wait_msg, record, choice=choice
|
||||
# #)
|
||||
# return
|
||||
#
|
||||
# if command == RowType.QUICK_CONNECT:
|
||||
# record = MainController.query_config(Preferences.FAV_SRV)
|
||||
# if record == "":
|
||||
# AppNav.window.spawn_dialog("No favorite server currently set", Popup.NOTIFY)
|
||||
# return
|
||||
#
|
||||
# record = str_to_record(record)
|
||||
# thread_new_with_dialog(
|
||||
# AppNav.treeview.prepare_connection,
|
||||
# parse_shell_output,
|
||||
# "Querying server",
|
||||
# command,
|
||||
# [record],
|
||||
# )
|
||||
# return
|
||||
#
|
||||
## TODO: belongs in model
|
||||
#def str_to_record(record: str) -> Record | None:
|
||||
# r = record.split(":")
|
||||
# if len(r) != 3:
|
||||
# return None
|
||||
# return Record(r[0], int(r[1]), int(r[2]))
|
||||
#
|
||||
## TODO: ibid
|
||||
#def record_to_str(record: Record) -> str:
|
||||
# return f"{record.ip}:{record.gameport}:{record.qport}"
|
||||
#
|
||||
#
|
||||
#def connect_by_ip(enum: RowType, response: str) -> None:
|
||||
# def _prep(response: str) -> None:
|
||||
# record = Servers.validate_ip(response)
|
||||
# proc = AppNav.treeview.prepare_connection(record)
|
||||
# return proc
|
||||
#
|
||||
# thread_new_with_dialog(
|
||||
# _prep, parse_shell_output, "Querying IP", enum, [response]
|
||||
# )
|
||||
# return
|
||||
#
|
||||
#
|
||||
#def connect_by_id(enum: RowType, uid: str, key: str) -> None:
|
||||
# def _prep(key: str, response: str) -> None:
|
||||
# # TODO: if response is non numeric, raise dialog
|
||||
# if response.isnumeric() is False:
|
||||
# pass
|
||||
# # raise error
|
||||
# # raise BmIdError("ID must be numeric only")
|
||||
# # return
|
||||
# from dzgui.api.bm import map_id_to_record
|
||||
# try:
|
||||
# record = map_id_to_record(config, uid)
|
||||
# except Exception as e:
|
||||
# logger.critical(e)
|
||||
# # raise dialog
|
||||
# return
|
||||
# # proc = AppNav.treeview.prepare_connection(record)
|
||||
# # return proc
|
||||
#
|
||||
# #thread_new_with_dialog(
|
||||
# # _prep, parse_shell_output, "Querying API", enum, [key, response]
|
||||
# #)
|
||||
# #return
|
||||
#
|
||||
#
|
||||
#def process_user_input(enum: RowType) -> None:
|
||||
# prompt = enum.dict["prompt"]
|
||||
# link_label = enum.dict["link_label"]
|
||||
# cmd_string = enum.dict["label"]
|
||||
#
|
||||
# if enum == RowType.CONN_BY_ID:
|
||||
# key = MainController.query_config(Preferences.BM)
|
||||
# if len(key) == 0:
|
||||
# AppNav.window.spawn_dialog(
|
||||
# "No Battlemetrics API key is set; see Options", Popup.NOTIFY
|
||||
# )
|
||||
# return
|
||||
#
|
||||
# user_entry = EntryDialog(prompt, Popup.ENTRY, link_label, button_type=enum)
|
||||
# response = user_entry.get_input()
|
||||
#
|
||||
# if response is None:
|
||||
# logger.info("User aborted entry dialog")
|
||||
# return
|
||||
# logger.info(f"User entered: '{response}'")
|
||||
#
|
||||
# if enum == RowType.CONN_BY_IP:
|
||||
# connect_by_ip(enum, response)
|
||||
# return
|
||||
#
|
||||
# if enum == RowType.CONN_BY_ID:
|
||||
# connect_by_id(enum, response, key)
|
||||
# return
|
||||
#
|
||||
# show_wait_dialog = True
|
||||
# wait_msg = "Working"
|
||||
# call_on_thread(
|
||||
# show_wait_dialog, cmd_string, wait_msg, response, choice=enum
|
||||
# )
|
||||
# return
|
||||
|
||||
class OuterWindow(Gtk.Window):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(title=APP_NAME, border_width=10, icon_name=APP_NAME_LOWER)
|
||||
|
||||
self.hb = AppHeaderBar()
|
||||
MainController.set_mediator(AppNav)
|
||||
AppNav.window = self
|
||||
|
||||
# steam deck taskbar may occlude elements
|
||||
if MainController.get_prefs().is_steam_deck is False:
|
||||
self.set_titlebar(self.hb)
|
||||
|
||||
self.connect("delete-event", self._on_delete_event)
|
||||
self.connect("key-press-event", self._on_keypress)
|
||||
|
||||
MainController.set_resolution(self)
|
||||
|
||||
self.grid = Grid()
|
||||
self.toast = Toast()
|
||||
self.overlay = Gtk.Overlay()
|
||||
self.overlay.add_overlay(self.grid)
|
||||
self.overlay.add_overlay(self.toast)
|
||||
self.add(self.overlay)
|
||||
|
||||
self.show_all()
|
||||
|
||||
self.toast.set_visible(False)
|
||||
self.grid.right_panel.filters_vbox.set_visible(False)
|
||||
self.grid.right_panel.enable_ping_button(False)
|
||||
self.grid.sel_panel.set_visible(False)
|
||||
|
||||
# TODO: register from treeview or notebook
|
||||
AppNav.treeview = self.grid.notebook.scrollable_treelist.treeview
|
||||
|
||||
css.load_css()
|
||||
AppNav.grid.notebook.set_page_by_enum(NotebookPage.MAIN)
|
||||
|
||||
def _on_keypress(self, widget: Gtk.Widget, event: Gdk.EventKey) -> None:
|
||||
if event.state is Gdk.ModifierType.CONTROL_MASK \
|
||||
and event.keyval is Gdk.KEY_d:
|
||||
if AppNav.right_panel.filters_vbox.keyword_entry.is_focus():
|
||||
return
|
||||
if AppNav.right_panel.filters_vbox.maps_entry.is_focus():
|
||||
return
|
||||
AppNav.right_panel.toggle_debug()
|
||||
|
||||
def _on_delete_event(
|
||||
self, window: "OuterWindow", event: Gdk.EventKey
|
||||
) -> None:
|
||||
self.halt_proc_and_quit()
|
||||
|
||||
def halt_proc_and_quit(self) -> None:
|
||||
MainController.terminate_process()
|
||||
MainController.save_res_and_quit()
|
||||
|
||||
|
||||
# TODO: consolidate
|
||||
# wrap base tree class in this
|
||||
class ScrollableTree(Gtk.ScrolledWindow):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
self.treeview = MenuTreeView(MainController)
|
||||
self.add(self.treeview)
|
||||
|
||||
def _set_adjustment(self, adjustment: VAdjustment) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class AppHeaderBar(Gtk.HeaderBar):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.props.title = APP_NAME
|
||||
self.set_decoration_layout(":minimize,maximize,close")
|
||||
self.set_show_close_button(True)
|
||||
|
||||
|
||||
class ScrollableNote(ScrollableMixin, Gtk.Box):
|
||||
def __init__(self, content_box: Gtk.Box, back_button=False):
|
||||
super().__init__(orientation=Gtk.Orientation.VERTICAL)
|
||||
|
||||
self.scrollable = Gtk.ScrolledWindow()
|
||||
self.scrollable.set_vexpand(True)
|
||||
|
||||
self.back_button = Gtk.Button(
|
||||
label="Back", hexpand=True, halign=Gtk.Align.CENTER
|
||||
)
|
||||
|
||||
self.gutter = Gtk.Box(
|
||||
orientation=Gtk.Orientation.HORIZONTAL, valign=Gtk.Align.END
|
||||
)
|
||||
if back_button:
|
||||
self.gutter.add(self.back_button)
|
||||
self.back_button.connect("clicked", self._on_back_clicked)
|
||||
|
||||
self.scrollable.add(content_box)
|
||||
self.add(self.scrollable)
|
||||
self.add(self.gutter)
|
||||
|
||||
def _on_back_clicked(self, button: Gtk.Button) -> None:
|
||||
AppNav.notebook.return_prior()
|
||||
|
||||
|
||||
class Notebook(ScrollableMixin, Gtk.Notebook):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(show_tabs=False, show_border=False)
|
||||
|
||||
AppNav.notebook = self
|
||||
self.prior_page: int
|
||||
|
||||
# TODO: set internally
|
||||
self.scrollable_treelist = ScrollableTree()
|
||||
self.scrollable_treelist.set_hexpand(False)
|
||||
self.scrollable_treelist.set_vexpand(True)
|
||||
self.scrollable_treelist.type = None
|
||||
|
||||
self.change = Changelog(MainController)
|
||||
self.clog = ScrollableNote(self.change, back_button=False)
|
||||
self.clog.scrollable.set_propagate_natural_width(False)
|
||||
self.clog.type = RowType.CHANGELOG
|
||||
|
||||
# TODO: scrollable internally
|
||||
self.keys = ScrollableNote(Keybindings())
|
||||
self.keys.type = RowType.KEYBINDINGS
|
||||
|
||||
self.settings = Options(MainController)
|
||||
|
||||
# TODO: make all treeviews internally scrollable in base class
|
||||
# server and quad tables should have hexpand property set to True
|
||||
# add all treeviews as page and register them to AppNav and self.indexes
|
||||
# when switching to a treeview, update relevant view and just pop that page
|
||||
# instead of loading/unloading the model each time
|
||||
self.ssu = Gtk.ScrolledWindow()
|
||||
self.servers = ServerTreeView(MainController)
|
||||
self.ssu.add(self.servers)
|
||||
|
||||
self.quad = Gtk.ScrolledWindow()
|
||||
|
||||
self.scroll_mod = Gtk.ScrolledWindow()
|
||||
# TODO: register this table
|
||||
self.quad_table = ModTreeView(MainController)
|
||||
AppNav.modtreeview = self.quad_table
|
||||
self.scroll_mod.add(self.quad_table)
|
||||
|
||||
self.scroll_log = Gtk.ScrolledWindow()
|
||||
self.scroll_log.set_hexpand(True)
|
||||
self.log_table = LogTreeView(MainController)
|
||||
AppNav.logtreeview = self.log_table
|
||||
self.scroll_log.add(self.log_table)
|
||||
|
||||
self.thanks = ScrollableNote(Thanks(), back_button=False)
|
||||
|
||||
self.pages = {
|
||||
self.scrollable_treelist: NotebookPage.MAIN,
|
||||
self.clog: NotebookPage.CHANGELOG,
|
||||
self.keys: NotebookPage.KEYS,
|
||||
self.settings: NotebookPage.OPTIONS,
|
||||
self.ssu: NotebookPage.SERVERS,
|
||||
self.scroll_mod: NotebookPage.MODS,
|
||||
self.scroll_log: NotebookPage.LOG,
|
||||
self.thanks: NotebookPage.THANKS
|
||||
}
|
||||
self.indexes = {}
|
||||
|
||||
"""
|
||||
Note that due to historical reasons, GtkNotebook refuses to switch to a page
|
||||
unless the child widget is visible. Therefore, it is recommended to show child
|
||||
widgets before adding them to a notebook.
|
||||
"""
|
||||
for page in self.pages:
|
||||
page.show_all()
|
||||
index = self.append_page(page)
|
||||
enum = self.pages[page]
|
||||
crumbs = enum.dict["crumbs"]
|
||||
self.indexes[enum] = index
|
||||
|
||||
self.connect_after("switch-page", self._on_page_changed)
|
||||
self.connect("key-press-event", self._on_keypress)
|
||||
|
||||
def _on_mods_focus_change(self, widget: ModTreeView, event: Gdk.EventFocus) -> None:
|
||||
MainController.toggle_mod_panel(event.in_)
|
||||
|
||||
def _on_keypress(self, widget: Gtk.Widget, event: Gdk.EventKey) -> None:
|
||||
page = self.get_page()
|
||||
|
||||
match event.keyval:
|
||||
case Gdk.KEY_Return:
|
||||
page = self.get_page()
|
||||
if page:
|
||||
page.back_button.clicked()
|
||||
case Gdk.KEY_Right | Gdk.KEY_l:
|
||||
if event.state is Gdk.ModifierType.CONTROL_MASK:
|
||||
return
|
||||
AppNav.right_panel.focus_button_box()
|
||||
case Gdk.KEY_question:
|
||||
self.toggle_keybindings()
|
||||
|
||||
# NOTE: abort on non scrollable pages
|
||||
# TODO: this should be an internal property of those pages
|
||||
allowed = (NotebookPage.KEYS, NotebookPage.CHANGELOG, NotebookPage.THANKS)
|
||||
if self.pages[page] not in allowed:
|
||||
return
|
||||
|
||||
match event.keyval:
|
||||
case Gdk.KEY_k | Gdk.KEY_Up:
|
||||
page._set_adjustment(VAdjustment.UP)
|
||||
case Gdk.KEY_Down | Gdk.KEY_j:
|
||||
page._set_adjustment(VAdjustment.DOWN)
|
||||
case Gdk.KEY_g:
|
||||
page._set_adjustment(VAdjustment.TOP)
|
||||
case Gdk.KEY_G:
|
||||
page._set_adjustment(VAdjustment.BOTTOM)
|
||||
|
||||
def return_prior(self) -> None:
|
||||
page = self.get_nth_page(self.prior_page)
|
||||
# FIXME: check enum for NotebookPage.OPTIONS
|
||||
if hasattr(page, "steam_entry"):
|
||||
"""
|
||||
Gtk.Notebook focuses the first input field when changing pages;
|
||||
this workaround unhighlights the selected region and makes entry
|
||||
fields unfocusable prior to the page 'switch-page' signal,
|
||||
then makes them focusable again
|
||||
|
||||
Used when switching to keybindings dialog
|
||||
"""
|
||||
entries = page.steam_entry, page.bm_entry
|
||||
for entry in entries:
|
||||
entry.set_position(-1)
|
||||
entry.set_can_focus(False)
|
||||
self.set_current_page(self.prior_page)
|
||||
for entry in entries:
|
||||
entry.set_can_focus(True)
|
||||
self.set_current_page(self.prior_page)
|
||||
|
||||
def get_page_by_enum(self) -> NotebookPage | None:
|
||||
for k, v in self.indexes.items():
|
||||
if v == self.get_current_page():
|
||||
return k
|
||||
return None
|
||||
|
||||
def toggle_keybindings(self) -> None:
|
||||
cur_page = self.get_page_by_enum()
|
||||
if cur_page == NotebookPage.KEYS:
|
||||
self.return_prior()
|
||||
else:
|
||||
self.prior_page = self.get_current_page()
|
||||
self.set_page_by_enum(NotebookPage.KEYS)
|
||||
|
||||
def focus_current(self) -> None:
|
||||
widget = self.get_page()
|
||||
if widget:
|
||||
w = widget.get_children()[0]
|
||||
# NOTE: if item contains treeview, focus first cell
|
||||
try:
|
||||
w.focus_first_row()
|
||||
w.grab_focus()
|
||||
except Exception as e:
|
||||
w.grab_focus()
|
||||
|
||||
def get_page(self) -> Gtk.Widget | None:
|
||||
ind = self.get_current_page()
|
||||
widget = self.get_nth_page(ind)
|
||||
if not widget:
|
||||
return None
|
||||
return widget
|
||||
|
||||
def set_page_by_enum(self, enum: NotebookPage) -> None:
|
||||
self.prior_page = self.get_current_page()
|
||||
self.set_current_page(self.indexes[enum])
|
||||
self.focus_current()
|
||||
# TODO: should be an internal property of those pages
|
||||
blank = [
|
||||
NotebookPage.OPTIONS,
|
||||
NotebookPage.THANKS,
|
||||
NotebookPage.CHANGELOG,
|
||||
NotebookPage.LOG
|
||||
]
|
||||
if enum in blank:
|
||||
# TODO: delegate to controller
|
||||
AppNav.grid.statusbar.refresh(None)
|
||||
|
||||
def _on_page_changed(
|
||||
self, notebook: "Notebook", page: Gtk.Widget, page_num: int
|
||||
) -> None:
|
||||
enum = self.get_page_by_enum()
|
||||
is_mods = True if enum is NotebookPage.MODS else False
|
||||
MainController.toggle_mod_panel(is_mods)
|
||||
|
||||
crumbs = enum.dict["crumbs"]
|
||||
MainController.set_crumbs(crumbs)
|
||||
|
||||
if enum is NotebookPage.MODS:
|
||||
return
|
||||
|
||||
# NOTE: Main Menu and Help page are of the same type
|
||||
if enum is not NotebookPage.MAIN:
|
||||
return
|
||||
|
||||
# FIXME: brittle
|
||||
if crumbs == "Main menu":
|
||||
if AppNav.treeview.get_model()[0][0] == MainController.help_store[0][0]:
|
||||
MainController.set_crumbs("Help")
|
||||
|
||||
row = MAIN_MENU_ROWS[0]
|
||||
AppNav.grid.statusbar.refresh(row)
|
||||
|
||||
|
||||
class Statusbar(Gtk.Statusbar):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
|
||||
help_text = strings.statusbar_helptext
|
||||
self.set_text(help_text)
|
||||
|
||||
version = MainController.get_prefs().version
|
||||
self.status_right_label = Gtk.Label(label=version)
|
||||
self.add(self.status_right_label)
|
||||
|
||||
self.players = ""
|
||||
|
||||
def get_text(self) -> str:
|
||||
area = self.get_message_area()
|
||||
label = area.get_children()[0]
|
||||
return label.get_text()
|
||||
|
||||
def set_text(self, string: str) -> None:
|
||||
if string is None:
|
||||
return
|
||||
meta = self.get_context_id("Statusbar")
|
||||
self.push(meta, string)
|
||||
|
||||
def refresh(self, row: RowType) -> None:
|
||||
if row is None:
|
||||
formatted = ""
|
||||
else:
|
||||
formatted = format_metadata(row)
|
||||
self.set_text(formatted)
|
||||
|
||||
def append_distance(self, dist: str) -> None:
|
||||
# TODO: process strings in controller
|
||||
if dist == strings.unknown:
|
||||
dist = f"| Distance: {dist}"
|
||||
else:
|
||||
dist = f"| Distance: {dist}"
|
||||
self.set_text(self.players + dist)
|
||||
|
||||
def update_server_meta(self) -> None:
|
||||
model = AppNav.treeview.get_model()
|
||||
if model is None:
|
||||
players = 0
|
||||
hits = 0
|
||||
else:
|
||||
hits = len(model)
|
||||
players = 0
|
||||
for row in model:
|
||||
players += row[4]
|
||||
|
||||
# TODO: move to util.format
|
||||
players_pretty = pluralize("players", players)
|
||||
hits_pretty = pluralize("matches", hits)
|
||||
formatted = (
|
||||
f"Found {hits:n} {hits_pretty} with {players:n} {players_pretty}"
|
||||
)
|
||||
suffix = "| Distance: calculating..."
|
||||
|
||||
if players == 0:
|
||||
suffix = ""
|
||||
self.set_text(formatted + suffix)
|
||||
self.players = formatted
|
||||
|
||||
|
||||
class Grid(Gtk.Grid):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.set_column_homogeneous(True)
|
||||
|
||||
AppNav.grid = self
|
||||
|
||||
# FIXME: do not pass AppNav to right panel
|
||||
self.right_panel = RightPanel(AppNav, MainController)
|
||||
self.sel_panel = ModSelectionPanel(MainController)
|
||||
self.right_panel.pack_start(self.sel_panel, NO_EXPAND, NO_FILL, 0)
|
||||
|
||||
self.notebook = Notebook()
|
||||
self.statusbar = Statusbar()
|
||||
|
||||
self.breadcrumbs = Gtk.Label(halign=Gtk.Align.START)
|
||||
self.set_breadcrumbs(strings.label_main_menu)
|
||||
|
||||
self.attach(self.notebook, 0, 0, 3, 1)
|
||||
self.attach_next_to(
|
||||
self.breadcrumbs, self.notebook, Gtk.PositionType.TOP, 3, 1
|
||||
)
|
||||
self.attach_next_to(
|
||||
self.statusbar, self.notebook, Gtk.PositionType.BOTTOM, 3, 1
|
||||
)
|
||||
self.attach_next_to(
|
||||
self.right_panel, self.notebook, Gtk.PositionType.RIGHT, 1, 1
|
||||
)
|
||||
self.show_all()
|
||||
|
||||
def get_breadcrumbs(self) -> str:
|
||||
return self.breadcrumbs.get_text()
|
||||
|
||||
def set_breadcrumbs(self, text: str) -> None:
|
||||
self.breadcrumbs.set_markup(f"<b>{text}</b>")
|
||||
|
||||
|
||||
class App(Gtk.Application):
|
||||
def __init__(self, prefs) -> None:
|
||||
GLib.set_prgname(APP_NAME)
|
||||
MainController.set_prefs(prefs)
|
||||
|
||||
self.win = OuterWindow()
|
||||
|
||||
accel = Gtk.AccelGroup()
|
||||
accel.connect(
|
||||
Gdk.KEY_q,
|
||||
Gdk.ModifierType.CONTROL_MASK,
|
||||
Gtk.AccelFlags.VISIBLE,
|
||||
self._halt_window_subprocess,
|
||||
)
|
||||
self.win.add_accel_group(accel)
|
||||
|
||||
GLib.unix_signal_add(
|
||||
GLib.PRIORITY_DEFAULT, signal.SIGINT, self._catch_sigint
|
||||
)
|
||||
Gtk.main()
|
||||
|
||||
def _catch_sigint(self) -> None:
|
||||
self.win.halt_proc_and_quit()
|
||||
|
||||
def _halt_window_subprocess(
|
||||
self,
|
||||
accel_group: Gtk.AccelGroup,
|
||||
window: "OuterWindow",
|
||||
code: Gdk.EventKey,
|
||||
flag: Gdk.ModifierType,
|
||||
) -> None:
|
||||
self.win.halt_proc_and_quit()
|
||||
|
||||
|
||||
class AppNavigation:
|
||||
grid: Grid
|
||||
window: OuterWindow
|
||||
notebook: Notebook
|
||||
right_panel: RightPanel
|
||||
treeview: MenuTreeView
|
||||
modtreeview: ModTreeView
|
||||
logtreeview: LogTreeView
|
||||
# TODO: add tree_log and tree_server views here
|
||||
|
||||
AppNav = AppNavigation()
|
||||
MainController = Controller()
|
||||
@@ -0,0 +1,86 @@
|
||||
import logging
|
||||
import functools
|
||||
|
||||
import gi # noqa E402
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gtk, Gdk
|
||||
|
||||
from dzgui.const.enum import ButtonType
|
||||
from dzgui.const.enum import NotebookPage
|
||||
from dzgui.const.enum import RowType
|
||||
from dzgui.const.constants import NO_EXPAND, NO_FILL, EXPAND, FILL
|
||||
|
||||
from typing import Callable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class ContextualButton(Gtk.Button):
|
||||
def __init__(self, label, opens, tooltip, context):
|
||||
super().__init__(
|
||||
label=label,
|
||||
tooltip_text=tooltip
|
||||
)
|
||||
|
||||
self.context = context
|
||||
self.opens = opens
|
||||
|
||||
class ButtonBox(Gtk.Box):
|
||||
def __init__(self, controller):
|
||||
super().__init__(
|
||||
spacing=6,
|
||||
margin_top=0,
|
||||
margin_start=10,
|
||||
margin_end=10,
|
||||
orientation=Gtk.Orientation.VERTICAL,
|
||||
)
|
||||
|
||||
self.prior_button = ButtonType.MAIN_MENU
|
||||
self.controller = controller
|
||||
self.buttons = list()
|
||||
self.connect("key-press-event", self._on_keypress)
|
||||
prefs = controller.get_prefs()
|
||||
|
||||
for side_button in ButtonType:
|
||||
button = ContextualButton(
|
||||
label=side_button.dict["label"],
|
||||
opens=side_button.dict["opens"],
|
||||
tooltip=side_button.dict["tooltip"],
|
||||
context=side_button,
|
||||
)
|
||||
|
||||
# FIXME: if debug log fails to load, still opens table
|
||||
|
||||
size = (10, 10) if prefs.is_steam_deck else (50, 50)
|
||||
x, y = size
|
||||
button.set_size_request(x, y)
|
||||
|
||||
self.buttons.append(button)
|
||||
button.connect("clicked", self._on_selection_button_clicked)
|
||||
self.pack_start(button, NO_EXPAND, NO_FILL, 0)
|
||||
|
||||
|
||||
def _on_selection_button_clicked(self, button: Gtk.Button) -> None:
|
||||
self.controller.open_page_by_button(button)
|
||||
|
||||
def _walk_buttons(self, increment: int) -> None:
|
||||
for i, button in enumerate(self.buttons):
|
||||
if button.is_focus():
|
||||
n = i + increment
|
||||
if n == len(self.buttons):
|
||||
return
|
||||
if n == -1:
|
||||
return
|
||||
n = self.buttons[n]
|
||||
n.grab_focus()
|
||||
return
|
||||
|
||||
def _on_keypress(self, widget: Gtk.Widget, event: Gdk.EventKey) -> None:
|
||||
match event.keyval:
|
||||
case Gdk.KEY_h:
|
||||
self.controller.focus_notebook()
|
||||
case Gdk.KEY_j:
|
||||
self._walk_buttons(1)
|
||||
case Gdk.KEY_k:
|
||||
self._walk_buttons(-1)
|
||||
case Gdk.KEY_question:
|
||||
self.controller.open_keybindings()
|
||||
@@ -0,0 +1,17 @@
|
||||
import gi # noqa E402
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gtk, Gdk
|
||||
|
||||
class ConnectPanel(Gtk.Grid):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
self.label = Gtk.Label(label="Connect by IP/ID")
|
||||
self.label2 = Gtk.Label(label="Save server by IP/ID")
|
||||
self.entry1 = Gtk.Entry()
|
||||
self.entry2 = Gtk.Entry()
|
||||
|
||||
self.attach(self.label, 0, 0, 3, 1)
|
||||
self.attach_next_to(self.entry1, self.label, Gtk.PositionType.BOTTOM, 3, 1)
|
||||
self.attach_next_to(self.label2, self.entry1, Gtk.PositionType.BOTTOM, 3, 1)
|
||||
self.attach_next_to(self.entry2, self.label2, Gtk.PositionType.BOTTOM, 3, 1)
|
||||
@@ -0,0 +1,38 @@
|
||||
import gi # noqa E402
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gtk, Gdk
|
||||
|
||||
from dzgui.const.constants import HELP_BUBBLE
|
||||
|
||||
class InfoEventBox(Gtk.EventBox):
|
||||
def __init__(self, text: str, parent: Gtk.Widget):
|
||||
super().__init__(margin_start=10)
|
||||
|
||||
self.parent = parent
|
||||
self.text = text
|
||||
|
||||
self.icon = Gtk.Image.new_from_icon_name(
|
||||
HELP_BUBBLE, Gtk.IconSize.LARGE_TOOLBAR
|
||||
)
|
||||
self.icon.set_opacity(0.8)
|
||||
box = Gtk.Box()
|
||||
box.add(self.icon)
|
||||
|
||||
self.connect("enter-notify-event", self._on_enter_tooltip)
|
||||
self.connect("leave-notify-event", self._on_leave_tooltip)
|
||||
self.add(box)
|
||||
|
||||
def _on_enter_tooltip(
|
||||
self, eventbox: Gtk.EventBox, eventcrossing: Gdk.EventCrossing
|
||||
) -> None:
|
||||
self.icon.set_opacity(1)
|
||||
self.parent.controller.set_statusbar(self.text)
|
||||
|
||||
def _on_leave_tooltip(
|
||||
self, eventbox: Gtk.EventBox, eventcrossing: Gdk.EventCrossing
|
||||
) -> None:
|
||||
self.icon.set_opacity(0.8)
|
||||
self.parent.controller.set_statusbar("")
|
||||
|
||||
def set_text(self, text: str) -> None:
|
||||
self.text = text
|
||||
@@ -0,0 +1,286 @@
|
||||
import logging
|
||||
|
||||
import gi # noqa E402
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gtk, Gdk, Pango
|
||||
|
||||
from typing import Literal
|
||||
from dzgui.util import strings
|
||||
from dzgui.util.margins import set_surrounding_margins
|
||||
from dzgui.const.enum import FilterMode
|
||||
from dzgui.const.constants import NO_EXPAND, NO_FILL
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class FilterPanel(Gtk.Box):
|
||||
def __init__(self, appnav, controller):
|
||||
super().__init__(spacing=6)
|
||||
|
||||
# TODO: set strings in constants
|
||||
self.default_filters = {
|
||||
strings.filter_1pp: True,
|
||||
strings.filter_day: True,
|
||||
strings.filter_empty: False,
|
||||
strings.filter_3pp: True,
|
||||
strings.filter_night: True,
|
||||
strings.filter_full: False,
|
||||
strings.filter_lowpop: True,
|
||||
strings.filter_nonascii: False,
|
||||
strings.filter_duplicate: False,
|
||||
strings.filter_official: True,
|
||||
strings.filter_unofficial: True,
|
||||
strings.filter_modded: True,
|
||||
}
|
||||
|
||||
self.AppNav = appnav
|
||||
self.controller = controller
|
||||
|
||||
map_store = self.controller.get_map_store()
|
||||
|
||||
self.checks = []
|
||||
self.maps_hr = []
|
||||
self.enabled_filters = dict(self.default_filters)
|
||||
self.keyword_filter = ""
|
||||
self.selected_map = strings.all_maps
|
||||
self.prior_map = strings.all_maps
|
||||
|
||||
button_grid = Gtk.Grid(
|
||||
halign=Gtk.Align.CENTER, column_spacing=5, column_homogeneous=True
|
||||
)
|
||||
row = 1
|
||||
col = 0
|
||||
for check in self.default_filters.keys():
|
||||
checkbox = Gtk.CheckButton(label=check)
|
||||
label = checkbox.get_children()
|
||||
label[0].set_ellipsize(Pango.EllipsizeMode.END)
|
||||
|
||||
if self.default_filters[check]:
|
||||
checkbox.set_active(True)
|
||||
|
||||
col = col + 1
|
||||
if col > 3:
|
||||
row += 1
|
||||
col = 1
|
||||
button_grid.attach(checkbox, col, row, 1, 1)
|
||||
|
||||
checkbox.connect("toggled", self._on_check_toggled)
|
||||
self.checks.append(checkbox)
|
||||
|
||||
self.connect("button-release-event", self._on_button_release)
|
||||
self.set_orientation(Gtk.Orientation.VERTICAL)
|
||||
set_surrounding_margins(self, 10)
|
||||
self.set_margin_top(1)
|
||||
|
||||
self.filters_label = Gtk.Label(label="Filters")
|
||||
|
||||
self.keyword_entry = Gtk.Entry()
|
||||
self.keyword_entry.set_placeholder_text("Filter by keyword")
|
||||
self.keyword_entry.connect("activate", self._on_keyword_enter)
|
||||
self.keyword_entry.connect(
|
||||
"key-press-event", self._on_keyword_keypress
|
||||
)
|
||||
|
||||
completion = Gtk.EntryCompletion(inline_completion=True)
|
||||
completion.set_text_column(0)
|
||||
completion.set_minimum_key_length(1)
|
||||
completion.connect("match_selected", self._on_completer_match)
|
||||
|
||||
renderer_text = Gtk.CellRendererText(ellipsize=Pango.EllipsizeMode.END)
|
||||
self.maps_combo = Gtk.ComboBox.new_with_model_and_entry(map_store)
|
||||
self.maps_combo.set_entry_text_column(0)
|
||||
|
||||
self.maps_entry = self.maps_combo.get_child()
|
||||
self.maps_entry.set_completion(completion)
|
||||
self.maps_entry.set_placeholder_text("Filter by map")
|
||||
self.maps_entry.connect("changed", self._on_map_completion, True)
|
||||
self.maps_entry.connect("key-press-event", self._on_map_entry_keypress)
|
||||
|
||||
# FIXME: only giving two params to pack_start
|
||||
self.maps_combo.pack_start(renderer_text, True)
|
||||
self.maps_combo.connect("changed", self._on_map_changed)
|
||||
self.maps_combo.connect("key-press-event", self._on_combo_keypress)
|
||||
|
||||
self.pack_start(self.filters_label, NO_EXPAND, NO_FILL, 0)
|
||||
self.pack_start(self.keyword_entry, NO_EXPAND, NO_FILL, 0)
|
||||
self.pack_start(self.maps_combo, NO_EXPAND, NO_FILL, 0)
|
||||
|
||||
self.pack_start(button_grid, NO_EXPAND, NO_FILL, 0)
|
||||
|
||||
def set_unique_maps(self, maps: list) -> None:
|
||||
if len(maps) < 1:
|
||||
return
|
||||
# FIXME: clear typehints
|
||||
u_maps = set([row[1] for row in maps]) # type: ignore
|
||||
u_maps = sorted(u_maps) # type: ignore
|
||||
for m in u_maps:
|
||||
self.controller.append_map([m])
|
||||
self.maps_hr.append(m)
|
||||
|
||||
def get_filters(self) -> tuple:
|
||||
filters = []
|
||||
filters.append(self.selected_map)
|
||||
filters.append(self.keyword_filter)
|
||||
for k in self.enabled_filters:
|
||||
if not self.enabled_filters[k]:
|
||||
filters.append(k)
|
||||
return tuple(filters)
|
||||
|
||||
# used on personal/local server lists
|
||||
def enable_all_filters(self) -> None:
|
||||
for check in self.checks:
|
||||
check.set_active(True)
|
||||
for k in self.enabled_filters:
|
||||
self.enabled_filters[k] = True
|
||||
|
||||
def reinit_panel(self) -> None:
|
||||
self.keyword_entry.set_text("")
|
||||
self.keyword_filter = ""
|
||||
self.reinit_filters()
|
||||
self.set_visible(False)
|
||||
sel_panel = self.AppNav.grid.sel_panel
|
||||
if sel_panel.is_visible():
|
||||
sel_panel.set_visible(False)
|
||||
|
||||
def reinit_filters(self) -> None:
|
||||
self.enabled_filters = dict(self.default_filters)
|
||||
for check in self.checks:
|
||||
label = check.get_label()
|
||||
state = self.default_filters[label]
|
||||
check.set_active(state)
|
||||
|
||||
def _on_map_entry_keypress(
|
||||
self, entry: Gtk.Entry, event: Gdk.EventKey
|
||||
) -> None:
|
||||
match event.keyval:
|
||||
case Gdk.KEY_Return:
|
||||
text = entry.get_text()
|
||||
if text is None:
|
||||
return
|
||||
"""
|
||||
If entry is exact match for value in liststore,
|
||||
trigger map change function
|
||||
"""
|
||||
for i in enumerate(map_store): # type: ignore
|
||||
if text == i[1][0]:
|
||||
self.maps_combo.set_active(i[0])
|
||||
self._on_map_changed(self.maps_combo)
|
||||
case Gdk.KEY_Escape:
|
||||
GLib.idle_add(self.restore_focus_to_treeview)
|
||||
"""
|
||||
This is a workaround for widget.grab_remove()
|
||||
Sets cursor position to SOL when unfocusing
|
||||
"""
|
||||
text = self.maps_entry.get_text()
|
||||
self.maps_entry.set_position(len(text))
|
||||
case _:
|
||||
return
|
||||
|
||||
def _on_completer_match(
|
||||
self,
|
||||
completion: Gtk.EntryCompletion,
|
||||
model: Gtk.ListStore,
|
||||
it: Gtk.TreeIter,
|
||||
) -> None:
|
||||
self.maps_combo.set_active_iter(it)
|
||||
|
||||
def _on_map_completion(self, entry, editable):
|
||||
text = entry.get_text()
|
||||
completion = entry.get_completion()
|
||||
map_store = self.controller.get_map_store()
|
||||
if len(text) >= completion.get_minimum_key_length():
|
||||
completion.set_model(map_store)
|
||||
|
||||
def restore_focus_to_treeview(self) -> Literal[False]:
|
||||
self.AppNav.treeview.grab_focus()
|
||||
return False
|
||||
|
||||
def _on_keyword_keypress(
|
||||
self, entry: Gtk.Entry, event: Gdk.EventKey
|
||||
) -> bool:
|
||||
match event.keyval:
|
||||
case Gdk.KEY_Up:
|
||||
return True
|
||||
case Gdk.KEY_Down:
|
||||
return True
|
||||
case Gdk.KEY_Escape:
|
||||
GLib.idle_add(self.restore_focus_to_treeview)
|
||||
return True
|
||||
return False
|
||||
|
||||
def _on_combo_keypress(
|
||||
self, combo: Gtk.ComboBox, event: Gdk.EventKey
|
||||
) -> bool:
|
||||
match event.keyval:
|
||||
case Gdk.KEY_Down:
|
||||
self.maps_combo.popup()
|
||||
return True
|
||||
case _:
|
||||
return False
|
||||
|
||||
def set_prior_map(self, mapname: str) -> None:
|
||||
self.prior_map = mapname
|
||||
|
||||
def get_prior_map(self) -> str:
|
||||
return self.prior_map
|
||||
|
||||
def get_selected_map(self) -> str:
|
||||
return self.selected_map
|
||||
|
||||
def get_keyword_filter(self) -> str:
|
||||
return self.keyword_filter
|
||||
|
||||
def _on_keyword_enter(self, entry: Gtk.Entry) -> None:
|
||||
self.AppNav.window.set_keep_below(False)
|
||||
keyword = entry.get_text().lower()
|
||||
if keyword == self.keyword_filter:
|
||||
return
|
||||
if keyword.isspace():
|
||||
return
|
||||
logger.info(f"User filtered by keyword '{keyword}'")
|
||||
self.keyword_filter = keyword
|
||||
self.AppNav.treeview.filter(FilterMode.KEYWORD, keyword)
|
||||
|
||||
def _on_button_release(self, window, button) -> Literal[True]:
|
||||
return True
|
||||
|
||||
def get_active_combo(self) -> int:
|
||||
return self.maps_combo.get_active()
|
||||
|
||||
def set_active_combo(self, row: int) -> None:
|
||||
self.maps_combo.set_active(row)
|
||||
|
||||
def toggle_check(self, digit: int) -> None:
|
||||
check = self.checks[digit]
|
||||
state = check.get_active()
|
||||
check.set_active(not state)
|
||||
|
||||
def _on_check_toggled(self, button: Gtk.CheckButton) -> None:
|
||||
if not self.AppNav.treeview.is_server_context(self.AppNav.treeview.view):
|
||||
return
|
||||
label = button.get_label()
|
||||
state = button.get_active()
|
||||
logger.info(f"User toggled button '{label}' to {state}")
|
||||
if state:
|
||||
mode = FilterMode.TOGGLE_ON
|
||||
else:
|
||||
mode = FilterMode.TOGGLE_OFF
|
||||
|
||||
self.enabled_filters[label] = state
|
||||
self.AppNav.treeview.filter(mode, label)
|
||||
|
||||
def _on_map_changed(self, combo: Gtk.ComboBox) -> None:
|
||||
old_sel = self.selected_map
|
||||
model = combo.get_model()
|
||||
tree_iter = combo.get_active_iter()
|
||||
if tree_iter is None:
|
||||
return
|
||||
selection = model[tree_iter][0]
|
||||
if selection == old_sel:
|
||||
return
|
||||
if not selection:
|
||||
return
|
||||
logger.info(f"User selected map '{selection}'")
|
||||
self.prior_map = self.selected_map
|
||||
self.selected_map = selection
|
||||
self.maps_entry.set_text(selection)
|
||||
self.AppNav.treeview.filter(FilterMode.MAP)
|
||||
@@ -0,0 +1,10 @@
|
||||
import gi
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gtk
|
||||
|
||||
class Icon(Gtk.Image):
|
||||
def __init__(self, name: str, l_margin=0):
|
||||
super().__init__(icon_name=name,
|
||||
icon_size=Gtk.IconSize.BUTTON,
|
||||
margin_start=l_margin
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
import gi # noqa E402
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gtk
|
||||
|
||||
class LeftLabel(Gtk.Label):
|
||||
def __init__(self, text: str, tooltip: str = ""):
|
||||
super().__init__(
|
||||
label=text,
|
||||
halign=Gtk.Align.START,
|
||||
)
|
||||
self.set_tooltip_text(tooltip)
|
||||
pass
|
||||
@@ -0,0 +1,88 @@
|
||||
import threading
|
||||
|
||||
import gi # noqa E402
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gtk, Gdk, GLib
|
||||
|
||||
from dzgui.const.enum import ModButton, Popup
|
||||
from dzgui.const.constants import NO_EXPAND, FILL
|
||||
from dzgui.util import strings
|
||||
|
||||
from dzgui.views.dialogs.generic import GenericDialog
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dzgui.controllers.mc import Controller
|
||||
|
||||
class EnumeratedModButton(Gtk.Button):
|
||||
def __init__(self, enum: ModButton) -> None:
|
||||
super().__init__(margin_start=10, margin_end=10)
|
||||
|
||||
self.enum = enum
|
||||
self.set_label(enum.dict["label"])
|
||||
self.set_tooltip_text(enum.dict["tooltip"])
|
||||
|
||||
|
||||
class ModSelectionPanel(Gtk.Box):
|
||||
def __init__(self, controller: "Controller") -> None:
|
||||
super().__init__(spacing=6, orientation=Gtk.Orientation.VERTICAL, margin_top=15)
|
||||
|
||||
self.controller = controller
|
||||
|
||||
buttons = (
|
||||
ModButton.SELECT_ALL,
|
||||
ModButton.UNSELECT_ALL,
|
||||
ModButton.HIGHLIGHT_STALE,
|
||||
ModButton.DELETE_SELECTED,
|
||||
)
|
||||
|
||||
self.header = Gtk.Label()
|
||||
self.header.set_markup(f"<b>{strings.mod_panel.header}</b>")
|
||||
|
||||
self.main_panel = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
|
||||
for button in buttons:
|
||||
b = EnumeratedModButton(button)
|
||||
b.connect("clicked", self._on_button_clicked)
|
||||
self.main_panel.pack_start(b, NO_EXPAND, FILL, 0)
|
||||
|
||||
self.extra_panel = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
|
||||
for button in (ModButton.SELECT_STALE, ModButton.UNHIGHLIGHT_STALE):
|
||||
b = EnumeratedModButton(button)
|
||||
b.connect("clicked", self._on_button_clicked)
|
||||
b.set_sensitive(False)
|
||||
self.extra_panel.pack_start(b, NO_EXPAND, FILL, 0)
|
||||
|
||||
self.pack_start(self.header, NO_EXPAND, FILL, 0)
|
||||
self.pack_start(self.main_panel, NO_EXPAND, FILL, 0)
|
||||
self.pack_start(self.extra_panel, NO_EXPAND, FILL, 0)
|
||||
|
||||
def after_colorize(self) -> None:
|
||||
self.controller.unselect_all_mods()
|
||||
self.swap_sensitive(True)
|
||||
|
||||
def swap_sensitive(self, state: bool) -> None:
|
||||
for child in self.extra_panel.get_children():
|
||||
child.set_sensitive(state)
|
||||
for child in self.main_panel.get_children():
|
||||
if child.enum == ModButton.HIGHLIGHT_STALE:
|
||||
child.set_sensitive(not state)
|
||||
|
||||
def _on_button_clicked(self, button: EnumeratedModButton) -> None:
|
||||
match button.enum:
|
||||
case ModButton.SELECT_ALL:
|
||||
self.controller.toggle_mod_selection(True)
|
||||
case ModButton.UNSELECT_ALL:
|
||||
self.controller.toggle_mod_selection(False)
|
||||
case ModButton.DELETE_SELECTED:
|
||||
self.controller.delete_multiple_mods()
|
||||
|
||||
case ModButton.HIGHLIGHT_STALE:
|
||||
self.controller.set_callback(self.after_colorize)
|
||||
self.controller.highlight_stale()
|
||||
case ModButton.UNHIGHLIGHT_STALE:
|
||||
self.controller.uncolorize_mods()
|
||||
self.swap_sensitive(False)
|
||||
case ModButton.SELECT_STALE:
|
||||
self.controller.select_colorized()
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import gi # noqa E402
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gtk, Gdk
|
||||
|
||||
from dzgui.const.enum import Preferences
|
||||
from dzgui.views.components.buttonbox import ButtonBox
|
||||
from dzgui.views.components.filter_panel import FilterPanel
|
||||
from dzgui.views.components.icon import Icon
|
||||
from dzgui.const.constants import NO_EXPAND, NO_FILL, EXPAND, FILL, INPUT_KEYBOARD
|
||||
from dzgui.util import strings
|
||||
|
||||
# TODO: refactor depends on ServerTreeView
|
||||
class RightPanel(Gtk.Box):
|
||||
def __init__(self, appnav, controller):
|
||||
super().__init__(spacing=6, orientation=Gtk.Orientation.VERTICAL)
|
||||
|
||||
self.AppNav = appnav
|
||||
self.AppNav.right_panel = self
|
||||
self.controller = controller
|
||||
|
||||
self.button_vbox = ButtonBox(controller)
|
||||
self.filters_vbox = FilterPanel(appnav, controller)
|
||||
|
||||
for el in self.button_vbox, self.filters_vbox:
|
||||
padding = 0
|
||||
self.pack_start(el, NO_EXPAND, NO_FILL, padding)
|
||||
|
||||
self.ping = Gtk.Button(
|
||||
label=strings.ping_servers,
|
||||
margin_top=10,
|
||||
margin_start=80,
|
||||
margin_end=80,
|
||||
tooltip_text=strings.ping_tooltip,
|
||||
)
|
||||
self.ping.connect("clicked", self._on_ping_clicked)
|
||||
|
||||
self.debug_toggle = Gtk.ToggleButton(
|
||||
label=strings.debug_mode,
|
||||
margin_top=10,
|
||||
margin_start=80,
|
||||
margin_end=80,
|
||||
tooltip_text=strings.debug_tooltip,
|
||||
)
|
||||
|
||||
if controller.query_config(Preferences.DEBUG) == True:
|
||||
self.debug_toggle.set_active(True)
|
||||
self.debug_toggle.connect("toggled", self._on_debug_toggled)
|
||||
|
||||
i = Icon(INPUT_KEYBOARD)
|
||||
i.set_margin_start(5)
|
||||
self.question = Gtk.Button(
|
||||
label=strings.keys_button,
|
||||
margin_start=80,
|
||||
margin_end=80,
|
||||
tooltip_text=strings.keys_tooltip,
|
||||
image = i,
|
||||
)
|
||||
self.question.set_image_position(Gtk.PositionType.RIGHT)
|
||||
self.question.connect("clicked", self._on_question_clicked)
|
||||
|
||||
for el in self.ping, self.debug_toggle, self.question:
|
||||
padding = 0
|
||||
self.pack_start(el, NO_EXPAND, FILL, padding)
|
||||
|
||||
def enable_ping_button(self, state: bool) -> None:
|
||||
self.ping.set_visible(state)
|
||||
|
||||
def reinit_maps(self, rows: list) -> None:
|
||||
self.controller.reinit_map_store()
|
||||
# TODO: communicate with controller
|
||||
#controller.clear_map_store()
|
||||
#map_store.append(["All maps"])
|
||||
self.selected = "All maps"
|
||||
self.filters_vbox.set_unique_maps(rows)
|
||||
|
||||
def toggle_debug(self) -> None:
|
||||
if type(self.AppNav.window.get_focus()) is Gtk.Entry:
|
||||
return
|
||||
state = self.debug_toggle.get_active()
|
||||
self.debug_toggle.set_active(not state)
|
||||
|
||||
def _on_debug_toggled(self, button: Gtk.Button) -> None:
|
||||
state = button.get_active()
|
||||
grid = self.AppNav.grid
|
||||
self.controller.toggle_debug_mode()
|
||||
|
||||
def _on_ping_clicked(self, button: Gtk.Button) -> None:
|
||||
# TODO
|
||||
block_signals()
|
||||
|
||||
def _update_pings():
|
||||
# TODO
|
||||
rows = ModelManager.get_filtered()
|
||||
with ThreadPoolExecutor(100) as executor:
|
||||
futures = [
|
||||
executor.submit(Servers.ping, i, row)
|
||||
for i, row in enumerate(rows)
|
||||
]
|
||||
wait(futures)
|
||||
for future in futures:
|
||||
res = future.result()
|
||||
path = Gtk.TreePath.new_from_indices([res.iteration])
|
||||
temp_model[path][9] = res.ping
|
||||
ModelManager.ping_cache[res.addr] = res.ping
|
||||
self.AppNav.treeview.set_model(temp_model)
|
||||
self.AppNav.treeview.wait_dialog.destroy()
|
||||
self.AppNav.treeview.enable_ping_column(True)
|
||||
self.AppNav.treeview.grab_focus()
|
||||
self.AppNav.right_panel.ping.set_sensitive(False)
|
||||
|
||||
# TODO:
|
||||
unblock_signals()
|
||||
|
||||
temp_model = self.AppNav.treeview.get_model()
|
||||
self.AppNav.treeview.set_model(None)
|
||||
self.AppNav.treeview.wait_dialog = GenericDialog("Pinging servers", Popup.WAIT)
|
||||
self.AppNav.treeview.wait_dialog.show_all()
|
||||
thread = threading.Thread(target=_update_pings, args=())
|
||||
thread.start()
|
||||
|
||||
def _on_question_clicked(self, button: Gtk.Button) -> None:
|
||||
self.AppNav.grid.notebook.toggle_keybindings()
|
||||
|
||||
def focus_button_box(self) -> None:
|
||||
self.button_vbox.buttons[0].grab_focus()
|
||||
@@ -0,0 +1,46 @@
|
||||
from typing import Literal
|
||||
from dzgui.util import css
|
||||
|
||||
import gi
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gtk, GLib # noqa E402
|
||||
|
||||
|
||||
class Toast(Gtk.EventBox):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
|
||||
self.label = Gtk.Label(hexpand=True)
|
||||
self.box = Gtk.Box(
|
||||
orientation=Gtk.Orientation.HORIZONTAL,
|
||||
halign=Gtk.Align.CENTER,
|
||||
valign=Gtk.Align.CENTER,
|
||||
)
|
||||
self.box.add(self.label)
|
||||
self.add(self.box)
|
||||
self.box.set_size_request(200, 100)
|
||||
|
||||
css.add_class(self.box, "toast-label")
|
||||
|
||||
def set_text(self, text: str) -> None:
|
||||
self.label.set_text(text)
|
||||
|
||||
def set_text_and_fade(self, text: str) -> None:
|
||||
self.set_text(text)
|
||||
self.pop()
|
||||
self._defer_fade()
|
||||
|
||||
def fade_out(self) -> bool:
|
||||
if self.get_opacity() == 0:
|
||||
self.set_visible(False)
|
||||
self.set_opacity(1)
|
||||
return False
|
||||
self.set_opacity(self.get_opacity() - 0.03)
|
||||
return True
|
||||
|
||||
def pop(self) -> None:
|
||||
self.set_visible(True)
|
||||
|
||||
def _defer_fade(self) -> Literal[False]:
|
||||
GLib.timeout_add(30, self.fade_out)
|
||||
return False
|
||||
@@ -0,0 +1,15 @@
|
||||
from dzgui.views.components.icon import Icon
|
||||
from dzgui.const.constants import WEB_BROWSER
|
||||
|
||||
import gi
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gtk # noqa E402
|
||||
|
||||
|
||||
class WebButton(Gtk.Button):
|
||||
def __init__(self, label: str):
|
||||
super().__init__(label=label)
|
||||
|
||||
icon = Icon(WEB_BROWSER, l_margin=5)
|
||||
self.set_image(icon)
|
||||
self.set_image_position(Gtk.PositionType.RIGHT)
|
||||
@@ -0,0 +1,35 @@
|
||||
import textwrap
|
||||
|
||||
import gi
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gtk # noqa E402
|
||||
|
||||
class EarlyAlertDialog(Gtk.MessageDialog):
|
||||
def __init__(self, string):
|
||||
# TODO: strings
|
||||
super().__init__(
|
||||
title="DZGUI - Dialog",
|
||||
flags=0,
|
||||
text="ERROR",
|
||||
transient_for=None,
|
||||
buttons=Gtk.ButtonsType.OK
|
||||
)
|
||||
ok = self.action_area.get_children()[0]
|
||||
ok.set_label("OK")
|
||||
ok.connect("clicked", Gtk.main_quit)
|
||||
|
||||
msg = textwrap.fill(string, 50)
|
||||
self.format_secondary_text(msg)
|
||||
|
||||
self.action_area.set_margin_bottom(20)
|
||||
self.outer = self.get_content_area()
|
||||
self.outer.set_margin_start(30)
|
||||
self.outer.set_margin_end(30)
|
||||
|
||||
self.set_default_size(250, 100)
|
||||
|
||||
self.connect("delete-event", Gtk.main_quit)
|
||||
self.connect("destroy", Gtk.main_quit)
|
||||
|
||||
self.show_all()
|
||||
Gtk.main()
|
||||
@@ -0,0 +1,80 @@
|
||||
import gi
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa
|
||||
|
||||
from dzgui.const.enum import Popup
|
||||
|
||||
class EntryDialog(GenericDialog):
|
||||
def __init__(self, text: str, mode: Popup, link: str, button_type=None):
|
||||
super().__init__(text, mode)
|
||||
|
||||
"""
|
||||
Wraps Gtk.Entry in a dialog and provides basic response handling.
|
||||
Returns user input as a string or None.
|
||||
The Entry widget itself can be manipulated via the get_entry() method.
|
||||
"""
|
||||
|
||||
self.dialog = GenericDialog(text, mode)
|
||||
self.dialogBox = self.dialog.get_content_area()
|
||||
self.dialog.set_default_response(Gtk.ResponseType.OK)
|
||||
self.dialog.set_size_request(500, 0)
|
||||
|
||||
self.user_entry = Gtk.Entry()
|
||||
set_surrounding_margins(self.user_entry, 20)
|
||||
self.user_entry.set_margin_top(0)
|
||||
self.user_entry.set_size_request(250, 0)
|
||||
self.user_entry.set_activates_default(True)
|
||||
self.dialogBox.pack_start(self.user_entry, NO_EXPAND, NO_FILL, 0)
|
||||
|
||||
if link:
|
||||
button = Gtk.Button(label=link)
|
||||
button.set_margin_start(60)
|
||||
button.set_margin_end(60)
|
||||
button.connect("clicked", self._on_button_clicked, button_type)
|
||||
self.dialogBox.pack_end(button, NO_EXPAND, NO_FILL, 0)
|
||||
|
||||
self.ok = self.dialog.action_area.get_children()[1]
|
||||
self.ok.set_sensitive(False)
|
||||
self.user_entry.connect("insert-text", self._on_text_typed)
|
||||
self.user_entry.get_property("buffer").connect(
|
||||
"deleted-text", self._on_text_deleted
|
||||
)
|
||||
|
||||
def _is_valid_text(self, text: str) -> bool:
|
||||
if text.isspace():
|
||||
return False
|
||||
if len(text) == 0:
|
||||
return False
|
||||
return True
|
||||
|
||||
def _on_text_deleted(
|
||||
self, buffer: Gtk.EntryBuffer, position: int, chars: int
|
||||
) -> None:
|
||||
text = buffer.get_text()
|
||||
state = self._is_valid_text(text)
|
||||
self.ok.set_sensitive(state)
|
||||
|
||||
def _on_text_typed(
|
||||
self, entry: Gtk.Entry, text: str, length: int, pos: int
|
||||
) -> None:
|
||||
state = self._is_valid_text(text)
|
||||
self.ok.set_sensitive(state)
|
||||
|
||||
def _on_button_clicked(self, button: Gtk.Button, enum: RowType) -> None:
|
||||
result = open_links.open_link_by_rowtype(enum)
|
||||
if result is False:
|
||||
AppNav.window.spawn_dialog(strings.something_wrong, Popup.NOTIFY)
|
||||
|
||||
def get_entry(self) -> Gtk.Entry:
|
||||
return self.user_entry
|
||||
|
||||
def get_input(self) -> str | None:
|
||||
self.dialog.show_all()
|
||||
|
||||
response = self.dialog.run()
|
||||
text = self.user_entry.get_text()
|
||||
self.dialog.destroy()
|
||||
if (response == Gtk.ResponseType.OK) and (text != ""):
|
||||
return text
|
||||
else:
|
||||
return None
|
||||
@@ -0,0 +1,34 @@
|
||||
from pathlib import Path
|
||||
|
||||
import gi # noqa E402
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gtk, Gdk, GLib, GObject
|
||||
|
||||
from dzgui.util.strings import picker
|
||||
|
||||
|
||||
class FilePicker(Gtk.FileChooserDialog):
|
||||
def __init__(self, parent: Gtk.Window) -> None:
|
||||
super().__init__(
|
||||
title=picker.title,
|
||||
action=Gtk.FileChooserAction.SAVE,
|
||||
parent=parent,
|
||||
resizable=True,
|
||||
)
|
||||
self.add_buttons("_Cancel", Gtk.ResponseType.CANCEL)
|
||||
self.add_buttons("_Save", Gtk.ResponseType.OK)
|
||||
self.set_default_response(Gtk.ResponseType.OK)
|
||||
|
||||
self.set_current_name(picker.placeholder)
|
||||
|
||||
def pick_file(self) -> Path | None:
|
||||
res = self.run()
|
||||
if res in (Gtk.ResponseType.CANCEL, Gtk.ResponseType.DELETE_EVENT):
|
||||
self.destroy()
|
||||
return None
|
||||
if res == Gtk.ResponseType.OK:
|
||||
file = self.get_filename()
|
||||
if file is not None:
|
||||
self.destroy()
|
||||
return Path(file)
|
||||
return None
|
||||
@@ -0,0 +1,104 @@
|
||||
import textwrap
|
||||
from typing import Any, Literal, TYPE_CHECKING
|
||||
|
||||
from dzgui.const.constants import NO_EXPAND, NO_FILL
|
||||
from dzgui.const.enum import Popup, ButtonType, NotebookPage
|
||||
from dzgui.util import strings
|
||||
|
||||
import gi
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa E402
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dzgui.controllers.mc import Controller
|
||||
|
||||
class GenericDialog(Gtk.MessageDialog):
|
||||
def __init__(self, controller: "Controller", text: str, mode: Popup):
|
||||
match mode:
|
||||
case Popup.WAIT:
|
||||
dialog_type = Gtk.MessageType.INFO
|
||||
button_type = Gtk.ButtonsType.NONE
|
||||
header_text = strings.wait
|
||||
case Popup.NOTIFY | Popup.RETURN | Popup.QUIT:
|
||||
dialog_type = Gtk.MessageType.INFO
|
||||
button_type = Gtk.ButtonsType.OK
|
||||
header_text = strings.notice
|
||||
case Popup.CONFIRM:
|
||||
dialog_type = Gtk.MessageType.QUESTION
|
||||
button_type = Gtk.ButtonsType.OK_CANCEL
|
||||
header_text = strings.confirmation
|
||||
case Popup.ENTRY:
|
||||
dialog_type = Gtk.MessageType.QUESTION
|
||||
button_type = Gtk.ButtonsType.OK_CANCEL
|
||||
header_text = strings.input_required
|
||||
case Popup.MODLIST:
|
||||
dialog_type = Gtk.MessageType.INFO
|
||||
button_type = Gtk.ButtonsType.OK
|
||||
header_text = strings.modlist
|
||||
case Popup.DETAILS:
|
||||
dialog_type = Gtk.MessageType.INFO
|
||||
button_type = Gtk.ButtonsType.OK
|
||||
header_text = strings.server_details
|
||||
|
||||
# NOTE: steam deck prints <2> if dialog title is same as window title
|
||||
Gtk.MessageDialog.__init__(
|
||||
self,
|
||||
transient_for=controller.mediator.window,
|
||||
message_type=dialog_type,
|
||||
buttons=button_type,
|
||||
text=header_text,
|
||||
secondary_text=textwrap.fill(text, 50),
|
||||
title=strings.dialog_header,
|
||||
modal=True,
|
||||
)
|
||||
|
||||
self.controller = controller
|
||||
|
||||
if mode == Popup.WAIT:
|
||||
dialogBox = self.get_content_area()
|
||||
spinner = Gtk.Spinner()
|
||||
dialogBox.pack_end(spinner, NO_EXPAND, NO_FILL, 0)
|
||||
spinner.start()
|
||||
self.connect("delete-event", self._on_dialog_delete)
|
||||
|
||||
if mode == Popup.RETURN:
|
||||
button_label = strings.main_menu
|
||||
ok = self.action_area.get_children()[0]
|
||||
ok.set_label(button_label)
|
||||
ok.connect("clicked", self._return_to_main_menu)
|
||||
self.connect("delete-event", self._return_to_main_menu)
|
||||
|
||||
if mode == Popup.QUIT:
|
||||
button_label = strings.exit_app
|
||||
ok = self.action_area.get_children()[0]
|
||||
ok.set_label(button_label)
|
||||
ok.connect("clicked", self._quit)
|
||||
self.connect("delete-event", self._quit)
|
||||
|
||||
self.set_default_response(Gtk.ResponseType.OK)
|
||||
self.set_size_request(500, 0)
|
||||
self.set_position(Gtk.WindowPosition.CENTER_ON_PARENT)
|
||||
|
||||
self.action_area.set_layout(Gtk.ButtonBoxStyle.CENTER)
|
||||
self.action_area.set_margin_bottom(20)
|
||||
self.outer = self.get_content_area()
|
||||
self.outer.set_margin_start(30)
|
||||
self.outer.set_margin_end(30)
|
||||
|
||||
def _quit(self, *args: Any) -> None:
|
||||
self.controller.save_res_and_quit()
|
||||
|
||||
def _on_dialog_delete(
|
||||
self, response_id: Gtk.ResponseType, event: Gdk.Event
|
||||
) -> Literal[True]:
|
||||
"""
|
||||
Prevent manual dialog destruction
|
||||
"""
|
||||
return True
|
||||
|
||||
def _return_to_main_menu(self, widget: Gtk.Widget) -> None:
|
||||
self.controller.open_page(NotebookPage.MAIN)
|
||||
|
||||
def update_label(self, text: str) -> None:
|
||||
self.format_secondary_text(text)
|
||||
@@ -0,0 +1,152 @@
|
||||
from typing import Self
|
||||
|
||||
import gi
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa
|
||||
|
||||
from dzgui.util.import strings
|
||||
|
||||
class LanDialog(Gtk.MessageDialog):
|
||||
"""
|
||||
Performs integer validation on the provided port
|
||||
and blocks if out of range. Returns None if user cancels
|
||||
"""
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
transient_for=AppNav.window, #FIXME
|
||||
flags=0,
|
||||
message_type=Gtk.MessageType.INFO,
|
||||
buttons=Gtk.ButtonsType.OK_CANCEL,
|
||||
text=strings.scan_servers,
|
||||
secondary_text=strings.select_port,
|
||||
title=strings.dialog_header,
|
||||
modal=True,
|
||||
)
|
||||
|
||||
self.set_size_request(500, 0)
|
||||
self.set_position(Gtk.WindowPosition.CENTER_ON_PARENT)
|
||||
|
||||
buttons = [
|
||||
(strings.default_port, Port.DEFAULT),
|
||||
(strings.custom_port, Port.CUSTOM),
|
||||
]
|
||||
|
||||
self.button_box = Gtk.Box()
|
||||
self.button_box.set_orientation(Gtk.Orientation.VERTICAL)
|
||||
self.button_box.active_button = None
|
||||
|
||||
for k, v in buttons:
|
||||
button = Gtk.RadioButton(label=k)
|
||||
button.port = v
|
||||
button.connect("toggled", self._on_button_toggled)
|
||||
self.button_box.add(button)
|
||||
if v == Port.DEFAULT:
|
||||
self.button_box.active_button = button
|
||||
else:
|
||||
button.join_group(self.button_box.active_button)
|
||||
|
||||
self.entry = Gtk.Entry()
|
||||
self.button_box.add(self.entry)
|
||||
self.entry.set_no_show_all(True)
|
||||
|
||||
# TODO: strings
|
||||
self.warn_label = Gtk.Label(label="Invalid port")
|
||||
self.warn_label.set_no_show_all(True)
|
||||
self.button_box.add(self.warn_label)
|
||||
|
||||
content = self.get_content_area()
|
||||
content.pack_start(self.button_box, NO_EXPAND, NO_FILL, 0)
|
||||
content.set_margin_start(30)
|
||||
content.set_margin_end(30)
|
||||
content.show_all()
|
||||
|
||||
self.action_area.set_layout(Gtk.ButtonBoxStyle.CENTER)
|
||||
self.action_area.set_margin_bottom(20)
|
||||
|
||||
self.port = None
|
||||
self.ok = self.action_area.get_children()[1]
|
||||
|
||||
self.connect("response", self._on_dialog_response)
|
||||
self.connect("key-press-event", self._on_keypress)
|
||||
self.connect("delete-event", self.restore_context)
|
||||
|
||||
self.entry.connect("insert-text", self._on_text_typed)
|
||||
self.entry.get_property("buffer").connect(
|
||||
"deleted-text", self._on_text_deleted
|
||||
)
|
||||
|
||||
def _validate(self, text: str) -> None:
|
||||
state = ip.is_valid_port(text)
|
||||
self.ok.set_sensitive(not state)
|
||||
self.warn_label.set_visible(state)
|
||||
|
||||
# TODO: can put in validation method
|
||||
if len(text) == 0:
|
||||
self.warn_label.set_visible(False)
|
||||
|
||||
def _on_text_deleted(
|
||||
self, buffer: Gtk.EntryBuffer, position: int, chars: int
|
||||
) -> None:
|
||||
text = buffer.get_text()
|
||||
self._validate(text)
|
||||
|
||||
def _on_text_typed(
|
||||
self, entry: Gtk.Entry, text: str, length: int, pos: int) -> None:
|
||||
self._validate(entry.get_text() + text)
|
||||
|
||||
def restore_context(self, *args: Any) -> None:
|
||||
context = WindowContext.MAIN_MENU
|
||||
AppNav.treeview.set_view(context)
|
||||
|
||||
def _on_keypress(self, widget: Gtk.Widget, event: Gdk.EventKey) -> None:
|
||||
if event.keyval == Gdk.KEY_Return:
|
||||
if self.ok.is_sensitive():
|
||||
self.response(Gtk.ResponseType.OK)
|
||||
if self.button_box.get_children()[0].is_focus():
|
||||
self.response(Gtk.ResponseType.OK)
|
||||
else:
|
||||
self.restore_context()
|
||||
|
||||
if event.keyval == Gdk.KEY_Up:
|
||||
self.ok.set_sensitive(True)
|
||||
self.entry.set_text("")
|
||||
self.button_box.get_children()[0].grab_focus()
|
||||
|
||||
def _on_dialog_response(
|
||||
self, dialog: Self, response: Gtk.ResponseType
|
||||
) -> None:
|
||||
cancel_events = [
|
||||
Gtk.ResponseType.CLOSE,
|
||||
Gtk.ResponseType.CANCEL,
|
||||
Gtk.ResponseType.DELETE_EVENT,
|
||||
]
|
||||
|
||||
if response in cancel_events:
|
||||
self.restore_context()
|
||||
return
|
||||
|
||||
string = self.entry.get_text()
|
||||
port = self.button_box.active_button.port
|
||||
|
||||
match port:
|
||||
case Port.DEFAULT:
|
||||
self.port = UDP_PORT
|
||||
case Port.CUSTOM:
|
||||
if ip.is_valid_port(string):
|
||||
self.stop_emission_by_name("response")
|
||||
else:
|
||||
self.port = int(string)
|
||||
|
||||
def get_selected_port(self) -> int:
|
||||
return self.port
|
||||
|
||||
def _on_button_toggled(self, button: Gtk.Button) -> None:
|
||||
if button.get_active():
|
||||
self.button_box.active_button = button
|
||||
match button.port:
|
||||
case Port.DEFAULT:
|
||||
self.entry.set_visible(False)
|
||||
case Port.CUSTOM:
|
||||
self.entry.set_visible(True)
|
||||
self.entry.grab_focus()
|
||||
self.ok.set_sensitive(False)
|
||||
@@ -0,0 +1,54 @@
|
||||
import textwrap
|
||||
from typing import Self, TYPE_CHECKING
|
||||
|
||||
from dzgui.const.enum import Popup, Preferences
|
||||
from dzgui.const.constants import NO_EXPAND, NO_FILL
|
||||
from dzgui.util.open_links import open_user_workshop
|
||||
from dzgui.views.dialogs.generic import GenericDialog
|
||||
from dzgui.views.components.web_button import WebButton
|
||||
|
||||
import gi
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa E402
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dzgui.controllers.mc import Controller
|
||||
|
||||
|
||||
class WorkshopLinkDialog(GenericDialog):
|
||||
def __init__(self, controller: "Controller", text: str, button_label: str, uid: str):
|
||||
super().__init__(controller, text, Popup.NOTIFY)
|
||||
|
||||
text = textwrap.dedent(text)
|
||||
self.dialogBox = self.get_content_area()
|
||||
self.set_default_response(Gtk.ResponseType.OK)
|
||||
self.set_size_request(500, 0)
|
||||
|
||||
"""
|
||||
If the user's ID was successfully parsed from loginusers,
|
||||
a clickable link to their workshop subscriptions is added
|
||||
"""
|
||||
if uid is not None:
|
||||
button = WebButton(label=button_label)
|
||||
button.set_margin_start(60)
|
||||
button.set_margin_end(60)
|
||||
button.connect("clicked", self._on_button_clicked, uid)
|
||||
self.dialogBox.pack_end(button, NO_EXPAND, NO_FILL, 0)
|
||||
|
||||
self.show_all()
|
||||
self.connect("response", self._on_dialog_response)
|
||||
|
||||
def _on_button_clicked(self, button: Gtk.Button, uid: str) -> None:
|
||||
# TODO: currently checks user id on instantiation
|
||||
client = self.controller.query_config(Preferences.CLIENT)
|
||||
open_user_workshop(uid, client)
|
||||
|
||||
def _on_dialog_response(
|
||||
self, dialog: Self, resp: Gtk.ResponseType
|
||||
) -> None:
|
||||
match resp:
|
||||
case Gtk.ResponseType.DELETE_EVENT:
|
||||
return
|
||||
case Gtk.ResponseType.OK:
|
||||
self.destroy()
|
||||
@@ -0,0 +1,102 @@
|
||||
import gi
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa
|
||||
|
||||
from dzgui.const.enum import Popup
|
||||
|
||||
class ServerDetailsDialog(GenericDialog):
|
||||
def __init__(self, server_name: str, ip: str, qport: int):
|
||||
super().__init__(server_name, Popup.DETAILS)
|
||||
|
||||
dialog_box = self.get_content_area()
|
||||
self.set_default_response(Gtk.ResponseType.OK)
|
||||
self.set_size_request(800, 700)
|
||||
|
||||
self.ip = ip.split(":")[0]
|
||||
self.qport = qport
|
||||
self.store = Gtk.ListStore(str, str, Pango.Weight)
|
||||
|
||||
self.view = Gtk.TreeView(
|
||||
enable_search=False,
|
||||
search_column=-1,
|
||||
headers_visible=False,
|
||||
fixed_height_mode=True,
|
||||
)
|
||||
self.view.connect("row-activated", self._on_row_activated)
|
||||
|
||||
for i, column_title in enumerate(["Item", "Details"]):
|
||||
renderer = Gtk.CellRendererText(xalign=0)
|
||||
if i == 0:
|
||||
column = Gtk.TreeViewColumn(
|
||||
column_title, renderer, text=i, weight=2
|
||||
)
|
||||
else:
|
||||
column = Gtk.TreeViewColumn(column_title, renderer, text=i)
|
||||
column.set_sizing(Gtk.TreeViewColumnSizing.FIXED)
|
||||
if i != 2:
|
||||
self.view.append_column(column)
|
||||
column.set_sort_column_id(i)
|
||||
column.set_expand(True)
|
||||
|
||||
scrollable_tree = Gtk.ScrolledWindow()
|
||||
scrollable_tree.add(self.view)
|
||||
scrollable_tree.set_size_request(700, 200)
|
||||
|
||||
scrollable_message = Gtk.ScrolledWindow()
|
||||
desc = Gtk.Label(label=strings.server_message, valign=Gtk.Align.START)
|
||||
css.add_class(desc, "details-heading")
|
||||
box = Gtk.Box(
|
||||
orientation=Gtk.Orientation.VERTICAL, halign=Gtk.Align.CENTER
|
||||
)
|
||||
self.description = Gtk.Label(
|
||||
justify=Gtk.Justification.CENTER, wrap=True
|
||||
)
|
||||
sep = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
sep.set_margin_bottom(10)
|
||||
for el in desc, sep, self.description:
|
||||
box.add(el)
|
||||
scrollable_message.add(box)
|
||||
|
||||
dialog_box.pack_start(scrollable_tree, EXPAND, FILL, 0)
|
||||
dialog_box.pack_start(scrollable_message, EXPAND, FILL, 0)
|
||||
|
||||
self.wait_dialog = GenericDialog(strings.details, Popup.WAIT)
|
||||
self.wait_dialog.show_all()
|
||||
thread = threading.Thread(
|
||||
target=self._background, args=(self.wait_dialog, ip, qport)
|
||||
)
|
||||
thread.start()
|
||||
|
||||
def _on_row_activated(
|
||||
self,
|
||||
treeview: Gtk.TreeView,
|
||||
tree_iter: Gtk.TreeIter,
|
||||
col: Gtk.TreeViewColumn,
|
||||
) -> None:
|
||||
self.destroy()
|
||||
|
||||
def _load(self) -> None:
|
||||
if self.wait_dialog:
|
||||
self.wait_dialog.destroy()
|
||||
if self.success is False:
|
||||
AppNav.window.spawn_dialog(strings.server_error, Popup.NOTIFY)
|
||||
return
|
||||
self.show_all()
|
||||
self.run()
|
||||
self.destroy()
|
||||
|
||||
def _background(
|
||||
self, dialog: "GenericDialog", ip: str, qport: int
|
||||
) -> None:
|
||||
response = Servers.details(self.ip, self.qport)
|
||||
if response.success:
|
||||
for row in response.data:
|
||||
self.store.append(row + [Pango.Weight.BOLD])
|
||||
self.view.set_model(self.store)
|
||||
|
||||
text = response.description
|
||||
text = format_hyperlinks(text)
|
||||
self.description.set_markup(text)
|
||||
|
||||
self.success = response.success
|
||||
GLib.idle_add(self._load)
|
||||
@@ -0,0 +1,94 @@
|
||||
import gi
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa
|
||||
|
||||
class ModDialog(GenericDialog):
|
||||
def __init__(self, record: str):
|
||||
# TODO: center secondary text
|
||||
msg = strings.workshop
|
||||
super().__init__(textwrap.dedent(msg), Popup.MODLIST)
|
||||
|
||||
dialogBox = self.get_content_area()
|
||||
self.set_default_response(Gtk.ResponseType.OK)
|
||||
self.set_size_request(800, 500)
|
||||
|
||||
self.scrollable = Gtk.ScrolledWindow()
|
||||
self.view = Gtk.TreeView(
|
||||
enable_search=False, search_column=-1, fixed_height_mode=True
|
||||
)
|
||||
self.scrollable.add(self.view)
|
||||
set_surrounding_margins(self.scrollable, 20)
|
||||
|
||||
self.view.connect("row-activated", self._on_row_activated)
|
||||
|
||||
for i, column_title in enumerate(strings.server_mod_cols):
|
||||
renderer = Gtk.CellRendererText(ellipsize=Pango.EllipsizeMode.END)
|
||||
column = Gtk.TreeViewColumn(column_title, renderer, text=i)
|
||||
column.set_sizing(Gtk.TreeViewColumnSizing.FIXED)
|
||||
self.view.append_column(column)
|
||||
column.set_sort_column_id(i)
|
||||
match column_title:
|
||||
case strings.mod:
|
||||
column.set_fixed_width(350)
|
||||
case strings.id:
|
||||
column.set_fixed_width(200)
|
||||
case _:
|
||||
pass
|
||||
dialogBox.pack_end(self.scrollable, EXPAND, FILL, 0)
|
||||
|
||||
wait_dialog = GenericDialog(strings.modlist, Popup.WAIT)
|
||||
wait_dialog.show_all()
|
||||
thread = threading.Thread(
|
||||
target=self._background, args=(wait_dialog, record)
|
||||
)
|
||||
thread.start()
|
||||
|
||||
# TODO: why are we passing record here?
|
||||
def _background(self, dialog: "GenericDialog", record: str) -> None:
|
||||
def _load():
|
||||
dialog.destroy()
|
||||
# TODO: natively implement
|
||||
#if data.returncode == 1:
|
||||
# AppNav.window.spawn_dialog(strings.server_error, Popup.NOTIFY)
|
||||
# return
|
||||
self.show_all()
|
||||
self.set_markup(f"Modlist ({mod_count} mods)")
|
||||
self.run()
|
||||
self.destroy()
|
||||
|
||||
record = AppNav.treeview.get_record()
|
||||
if not record:
|
||||
return
|
||||
|
||||
# TODO: thread
|
||||
try:
|
||||
# TODO: get default_steam_path from config
|
||||
modlist = get_server_modlist(record)
|
||||
mod_count = self._parse_modlist_rows(modlist)
|
||||
except Exception:
|
||||
# TODO: needs to pop error dialog
|
||||
mod_count = 0
|
||||
modlist_store.clear()
|
||||
self.view.set_model(modlist_store)
|
||||
GLib.idle_add(_load)
|
||||
|
||||
def _parse_modlist_rows(self, rows: list) -> bool | int:
|
||||
for row in rows:
|
||||
modlist_store.append(row)
|
||||
return len(rows)
|
||||
|
||||
def _on_row_activated(
|
||||
self,
|
||||
treeview: Gtk.TreeView,
|
||||
tree_iter: Gtk.TreeIter,
|
||||
col: Gtk.TreeViewColumn,
|
||||
) -> None:
|
||||
select = treeview.get_selection()
|
||||
sels = select.get_selected_rows()
|
||||
(model, pathlist) = sels
|
||||
if len(pathlist) < 1:
|
||||
return
|
||||
path = pathlist[0]
|
||||
tree_iter = model.get_iter(path)
|
||||
mod_id = model.get_value(tree_iter, 1)
|
||||
call_bash_func("open_workshop_page", mod_id)
|
||||
@@ -0,0 +1,18 @@
|
||||
import gi
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gtk # noqa
|
||||
|
||||
class Watcher(Gtk.Dialog):
|
||||
def __init__():
|
||||
super().__init__()
|
||||
pass
|
||||
|
||||
def bump_progress(self):
|
||||
self.set_secondary_text("FOO")
|
||||
|
||||
#w = Watcher()
|
||||
#thread = threading()
|
||||
## compute here
|
||||
#GLib.idle_add(self._bump_progress)
|
||||
## raise window:
|
||||
#Gdk.present_with_time(Gdk.CURRENT_TIME)
|
||||
@@ -0,0 +1,75 @@
|
||||
import logging
|
||||
from dzgui.views.trees.tree_base import TreeView
|
||||
from dzgui.const.enum import Popup, ContextMenu
|
||||
|
||||
import gi
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa E402
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class ContextMixin(TreeView):
|
||||
def present_menu(self,
|
||||
widget: Gtk.Widget,
|
||||
event: Gdk.EventButton | Gdk.EventKey,
|
||||
) -> None:
|
||||
|
||||
#if self.is_selection_empty():
|
||||
# return
|
||||
|
||||
if event.type is Gdk.EventType.BUTTON_PRESS:
|
||||
try:
|
||||
pathinfo = self.get_path_at_pos(int(event.x), int(event.y))
|
||||
if pathinfo is None:
|
||||
return
|
||||
(path, col, cellx, celly) = pathinfo
|
||||
if path is None:
|
||||
return
|
||||
self.set_cursor(path, col, False)
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
if event.type is Gdk.EventType.KEY_PRESS:
|
||||
if event.state is not Gdk.ModifierType.CONTROL_MASK:
|
||||
return
|
||||
if event.keyval is not Gdk.KEY_l:
|
||||
return
|
||||
|
||||
group = self.menu
|
||||
self.context_menu = Gtk.Menu()
|
||||
for row in group.value:
|
||||
item = Gtk.MenuItem(label=row.dict["label"])
|
||||
item.connect("activate", self._on_menu_click, row)
|
||||
self.context_menu.append(item)
|
||||
|
||||
self.context_menu.show_all()
|
||||
|
||||
match event.type:
|
||||
case Gdk.EventType.KEY_PRESS:
|
||||
self.context_menu.popup_at_widget(
|
||||
widget, Gdk.Gravity.CENTER, Gdk.Gravity.WEST
|
||||
)
|
||||
case Gdk.EventType.BUTTON_PRESS | Gdk.EventType.BUTTON_RELEASE:
|
||||
self.context_menu.popup_at_pointer(event)
|
||||
|
||||
self.context_menu.select_first(False)
|
||||
|
||||
def _on_menu_click(self, widget: Gtk.MenuItem, enum: ContextMenu) -> None:
|
||||
"""
|
||||
Local mods page allows multi selection, so ensure that only focused row
|
||||
is selected. Used by ContextMenu.OPEN_WORKSHOP and ContextMenu.DELETE_MOD.
|
||||
|
||||
Mod tree right panel supports multi-delete, but context menu enforces
|
||||
single deletion on the focused row.
|
||||
|
||||
Debug log page allows multi selection and copy of rows, so special handling
|
||||
is used.
|
||||
"""
|
||||
if enum == ContextMenu.COPY_LOG_CLIPBOARD:
|
||||
model, records = self.get_selection().get_selected_rows()
|
||||
clipboard = self.controller.copy_log(records)
|
||||
self.controller.copy_clipboard(clipboard)
|
||||
else:
|
||||
path = self.get_focused_row_path()
|
||||
self.controller.menu_action(enum, path)
|
||||
@@ -0,0 +1,58 @@
|
||||
from dzgui.const.enum import CursorPosition
|
||||
from dzgui.const.constants import SEPARATOR
|
||||
|
||||
import gi
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa E402
|
||||
|
||||
|
||||
class CursorMixin:
|
||||
def _vim_nav(self, event: Gdk.EventKey):
|
||||
match event.keyval:
|
||||
case Gdk.KEY_g:
|
||||
self._move_cursor(CursorPosition.TOP)
|
||||
case Gdk.KEY_G:
|
||||
self._move_cursor(CursorPosition.BOTTOM)
|
||||
case Gdk.KEY_j:
|
||||
self._move_cursor(CursorPosition.DOWN)
|
||||
case Gdk.KEY_k:
|
||||
self._move_cursor(CursorPosition.UP)
|
||||
case Gdk.KEY_l | Gdk.KEY_Right:
|
||||
if event.state is Gdk.ModifierType.CONTROL_MASK:
|
||||
return
|
||||
self.controller.mediator.right_panel.focus_button_box()
|
||||
case _:
|
||||
return False
|
||||
|
||||
def _move_cursor(self, position: CursorPosition) -> bool:
|
||||
cur_row = self.get_focused_row_index()
|
||||
model = self.get_model()
|
||||
if model:
|
||||
end = len(model) - 1
|
||||
else:
|
||||
return True
|
||||
|
||||
if position == CursorPosition.DOWN:
|
||||
if cur_row == end:
|
||||
return True
|
||||
dest = cur_row + 1
|
||||
if model[dest][0] == SEPARATOR:
|
||||
dest += 1
|
||||
if position == CursorPosition.UP:
|
||||
if cur_row == 0:
|
||||
return True
|
||||
dest = cur_row - 1
|
||||
if model[dest][0] == "SEPARATOR":
|
||||
dest -= 1
|
||||
if position == CursorPosition.TOP:
|
||||
if cur_row == 0:
|
||||
return True
|
||||
dest = 0
|
||||
if position == CursorPosition.BOTTOM:
|
||||
if cur_row == end:
|
||||
return True
|
||||
dest = end
|
||||
|
||||
path = Gtk.TreePath.new_from_indices([dest])
|
||||
self.set_cursor(path)
|
||||
return True
|
||||
@@ -0,0 +1,16 @@
|
||||
from dzgui.views.trees.tree_base import TreeView
|
||||
|
||||
import gi
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa E402
|
||||
|
||||
|
||||
class ModsMixin:
|
||||
def _on_mod_row_activated(self,
|
||||
tree: TreeView,
|
||||
path: Gtk.TreePath,
|
||||
column: Gtk.TreeViewColumn
|
||||
) -> None:
|
||||
|
||||
path = self.get_focused_row_path()
|
||||
self.controller.open_mod_page(path)
|
||||
@@ -0,0 +1,21 @@
|
||||
from dzgui.const.enum import VAdjustment
|
||||
from dzgui.const.constants import SCROLL_INCREMENT
|
||||
|
||||
import gi
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gdk # noqa
|
||||
|
||||
class ScrollableMixin:
|
||||
def _set_adjustment(self, adjustment = VAdjustment) -> None:
|
||||
vadj = self.scrollable.get_vadjustment()
|
||||
match adjustment:
|
||||
case VAdjustment.TOP:
|
||||
adj = vadj.get_lower()
|
||||
case VAdjustment.BOTTOM:
|
||||
adj = vadj.get_upper()
|
||||
case VAdjustment.UP:
|
||||
adj = vadj.get_value() - SCROLL_INCREMENT
|
||||
case VAdjustment.DOWN:
|
||||
adj = vadj.get_value() + SCROLL_INCREMENT
|
||||
vadj.set_value(adj)
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
from typing import TYPE_CHECKING
|
||||
from importlib import resources
|
||||
|
||||
from dzgui.const.constants import APP_NAME_LOWER
|
||||
from dzgui.util.format import format_pango
|
||||
|
||||
import gi
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gtk # noqa E402
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dzgui.controllers.mc import Controller
|
||||
|
||||
class Changelog(Gtk.Box):
|
||||
def __init__(self, controller: "Controller"):
|
||||
super().__init__()
|
||||
|
||||
try:
|
||||
changelog = resources.read_text(APP_NAME_LOWER, "data/CHANGELOG.md")
|
||||
except OSError as e:
|
||||
changelog = "Error: Failed to read changelog"
|
||||
# TODO: log error
|
||||
|
||||
# TODO: should long text be wrapped?
|
||||
|
||||
formatted = format_pango(changelog)
|
||||
self.changelog_label = Gtk.Label(valign=Gtk.Align.START, margin=15)
|
||||
self.changelog_label.set_markup(formatted)
|
||||
self.add(self.changelog_label)
|
||||
@@ -0,0 +1,97 @@
|
||||
from dzgui.util.strings import navigation, servers, vim, key_header, key_contexts
|
||||
from dzgui.util.css import add_class
|
||||
|
||||
import gi
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gtk # noqa E402
|
||||
|
||||
|
||||
class Keybindings(Gtk.Box):
|
||||
"""
|
||||
Notebook page holding a prearranged grid
|
||||
of keybindings and their descriptions
|
||||
"""
|
||||
def __init__(self) -> None:
|
||||
super().__init__(orientation=Gtk.Orientation.VERTICAL)
|
||||
|
||||
label = Gtk.Label(label=key_header)
|
||||
add_class(label, "page-heading")
|
||||
self.add(label)
|
||||
|
||||
grid = self.build_grid([servers, navigation, vim])
|
||||
self.add(grid)
|
||||
|
||||
def build_keys(self, items: list) -> Gtk.Grid:
|
||||
grid = Gtk.Grid(row_spacing=10, column_homogeneous=True)
|
||||
grid.set_halign(Gtk.Align.START)
|
||||
row, col = 1, 0
|
||||
w, h = 1, 1
|
||||
sep = None
|
||||
for item in items:
|
||||
for k, v in item.items():
|
||||
desc = Gtk.Label(label=k)
|
||||
desc.set_halign(Gtk.Align.START)
|
||||
|
||||
key = Gtk.Label(label=v)
|
||||
key.set_halign(Gtk.Align.CENTER)
|
||||
|
||||
frame = Gtk.Frame()
|
||||
frame.add(key)
|
||||
add_class(frame, "frame")
|
||||
|
||||
col = col + 1
|
||||
if col > 1:
|
||||
row += 1
|
||||
col = 1
|
||||
if not sep:
|
||||
grid.attach(desc, col, row, w, h)
|
||||
else:
|
||||
grid.attach_next_to(
|
||||
desc, sep, Gtk.PositionType.BOTTOM, w, h
|
||||
)
|
||||
row += 1
|
||||
sep = None
|
||||
grid.attach_next_to(frame, desc, Gtk.PositionType.RIGHT, w, h)
|
||||
|
||||
l_spacer = Gtk.Label(label="")
|
||||
r_spacer = Gtk.Label(label="")
|
||||
grid.attach(l_spacer, col, row + 1, w, h)
|
||||
grid.attach_next_to(
|
||||
r_spacer, l_spacer, Gtk.PositionType.RIGHT, w, h
|
||||
)
|
||||
row += 1
|
||||
return grid
|
||||
|
||||
def build_sidebar(self, categories: list) -> Gtk.Grid:
|
||||
row, col = 0, 0
|
||||
w, h = 1, 1
|
||||
sidebar = Gtk.Grid(
|
||||
row_homogeneous=True, orientation=Gtk.Orientation.VERTICAL
|
||||
)
|
||||
for cat in categories:
|
||||
label = Gtk.Label(label=cat)
|
||||
add_class(label, "left-label")
|
||||
row += 1
|
||||
sidebar.attach(label, col, row, w, h)
|
||||
return sidebar
|
||||
|
||||
def build_grid(self, items: list) -> Gtk.Grid:
|
||||
grid = Gtk.Grid(
|
||||
row_spacing=20,
|
||||
halign=Gtk.Align.CENTER,
|
||||
margin_top=20,
|
||||
column_spacing=50,
|
||||
)
|
||||
|
||||
row, column = 1, 1
|
||||
w, h = 1, 1
|
||||
sidebar = self.build_sidebar(
|
||||
key_contexts
|
||||
)
|
||||
separator = Gtk.Separator()
|
||||
keys_box = self.build_keys(items)
|
||||
|
||||
grid.attach(sidebar, column, row, w, h)
|
||||
grid.attach_next_to(separator, sidebar, Gtk.PositionType.RIGHT, w, h)
|
||||
grid.attach_next_to(keys_box, separator, Gtk.PositionType.RIGHT, w, h)
|
||||
return grid
|
||||
@@ -0,0 +1,509 @@
|
||||
import gi
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gtk, Gdk # noqa
|
||||
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from dzgui.views.components.label import LeftLabel
|
||||
from dzgui.views.components.eventbox import InfoEventBox
|
||||
from dzgui.views.components.icon import Icon
|
||||
from dzgui.views.components.web_button import WebButton
|
||||
from dzgui.views.dialogs.link_dialog import WorkshopLinkDialog
|
||||
|
||||
from dzgui.util import strings, css, open_links
|
||||
from dzgui.const.enum import Preferences, RowType, Popup
|
||||
from dzgui.const.endpoints import STEAM_API_SETUP, BM_API_SETUP
|
||||
from dzgui.const.constants import (
|
||||
APPID_DAYZ,
|
||||
APPID_DAYZ_EXP,
|
||||
APPNAME_DAYZ,
|
||||
APPNAME_DAYZ_EXP,
|
||||
BETA_REPO,
|
||||
FLATPAK_RUN_CMD,
|
||||
FLATPAK_SANDBOX,
|
||||
NO_EXPAND,
|
||||
NO_FILL,
|
||||
STEAM_CMD,
|
||||
VIEW_CONCEAL,
|
||||
VIEW_REVEAL,
|
||||
)
|
||||
|
||||
from dzgui.config import query
|
||||
from dzgui.api import pefile as PeFile
|
||||
from dzgui.api.steam import find_user_id
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dzgui.controllers.mc import Controller
|
||||
|
||||
class Options(Gtk.Box):
|
||||
def __init__(self, controller: "Controller"):
|
||||
super().__init__(
|
||||
orientation=Gtk.Orientation.VERTICAL,
|
||||
margin_start=10,
|
||||
margin_end=10,
|
||||
)
|
||||
|
||||
self.controller = controller
|
||||
|
||||
self.DEFAULT_WIDTH = 1
|
||||
self.DEFAULT_HEIGHT = 1
|
||||
|
||||
# TODO: strings
|
||||
label = Gtk.Label(label=strings.options.header)
|
||||
label.set_halign(Gtk.Align.CENTER)
|
||||
css.add_class(label, "page-heading")
|
||||
self.add(label)
|
||||
|
||||
self.steam_entry = None
|
||||
self.bm_entry = None
|
||||
|
||||
self.steam = WebButton(label=strings.options.steam_web)
|
||||
self.steam.connect("clicked", self._on_link_button_clicked, STEAM_API_SETUP)
|
||||
|
||||
self.bm = WebButton(label=strings.options.bm_web)
|
||||
self.bm.connect("clicked", self._on_link_button_clicked, BM_API_SETUP)
|
||||
|
||||
self.steam_box = self._make_submit_field(
|
||||
strings.options.enter_steam, Preferences.STEAM, True
|
||||
)
|
||||
self.bm_box = self._make_submit_field(
|
||||
strings.options.enter_bm, Preferences.BM, True
|
||||
)
|
||||
api_rows = [
|
||||
[LeftLabel(strings.options.steam_placeholder), self.steam_box],
|
||||
[LeftLabel(strings.options.bm_placeholder), self.bm_box],
|
||||
]
|
||||
|
||||
self.player_box = self._make_submit_field(
|
||||
strings.options.name_placeholder, Preferences.NAME
|
||||
)
|
||||
self.fullscreen_toggle = self.make_binary_radio(
|
||||
strings.options.last_used,
|
||||
strings.options.always_fs,
|
||||
Preferences.WINDOW,
|
||||
)
|
||||
|
||||
self.client_combo = Gtk.ComboBoxText()
|
||||
# TODO: strings
|
||||
self.client_combo.append_text("Steam")
|
||||
self.client_combo.append_text("Flatpak")
|
||||
self.client_combo.append_text("Flatpak (container)")
|
||||
self.client_combo.set_active(0)
|
||||
self.client_combo.connect("changed", self._on_client_changed)
|
||||
hbox = Gtk.Box(spacing=5, halign=Gtk.Align.START)
|
||||
hbox.pack_start(self.client_combo, NO_EXPAND, NO_FILL, 0)
|
||||
|
||||
self.distance_toggle = self.make_binary_radio(
|
||||
strings.options.km, strings.options.mi, Preferences.DIST
|
||||
)
|
||||
|
||||
pref_rows = [
|
||||
[LeftLabel(strings.options.client), hbox],
|
||||
[LeftLabel(strings.options.window_size), self.fullscreen_toggle],
|
||||
[LeftLabel(strings.options.distance), self.distance_toggle],
|
||||
[LeftLabel(strings.options.name), self.player_box],
|
||||
]
|
||||
|
||||
self.mod_install_toggle = self.make_binary_radio(
|
||||
strings.options.manual_dl, strings.options.auto_dl, Preferences.INSTALL
|
||||
)
|
||||
self.force_button = Gtk.Button(label=strings.options.update)
|
||||
self.force_button.connect("clicked", self._on_force_update_clicked)
|
||||
|
||||
# NOTE: sensitivity state is updated after config file is loaded
|
||||
self.force_button.set_sensitive(False)
|
||||
|
||||
eb = InfoEventBox(strings.options.dl_eventbox, self)
|
||||
eb2 = InfoEventBox(strings.options.force_eventbox, self)
|
||||
|
||||
mod_rows = [
|
||||
[LeftLabel(strings.options.install_mode), self.mod_install_toggle, eb],
|
||||
[LeftLabel(strings.options.force_update), self.force_button, eb2],
|
||||
]
|
||||
|
||||
self.dayz_version_label = Gtk.Label(label=strings.null)
|
||||
self.dayz_exp_version_label = Gtk.Label(label=strings.null)
|
||||
|
||||
self.branch_combo = Gtk.ComboBoxText()
|
||||
self.branch_combo.append_text(strings.options.stable)
|
||||
self.branch_combo.append_text(strings.options.testing)
|
||||
self.branch_combo.set_active(0)
|
||||
self.branch_combo.connect("changed", self._on_branch_changed)
|
||||
self.branch_eb = InfoEventBox("", self)
|
||||
|
||||
version_rows = [
|
||||
[LeftLabel(APPNAME_DAYZ), self.dayz_version_label],
|
||||
[LeftLabel(APPNAME_DAYZ_EXP), self.dayz_exp_version_label],
|
||||
[LeftLabel(strings.options.branch), self.branch_combo, self.branch_eb],
|
||||
]
|
||||
|
||||
api_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
api_grid = self._make_grid(api_rows)
|
||||
api_box.add(api_grid)
|
||||
api_links_box = Gtk.Box(
|
||||
orientation=Gtk.Orientation.HORIZONTAL,
|
||||
margin_start=5,
|
||||
margin_end=5,
|
||||
margin_top=5,
|
||||
margin_bottom=10,
|
||||
homogeneous=True,
|
||||
spacing=10
|
||||
)
|
||||
api_links_box.add(self.steam)
|
||||
api_links_box.add(self.bm)
|
||||
api_box.add(api_links_box)
|
||||
|
||||
prefs_grid = self._make_grid(pref_rows)
|
||||
mods_grid = self._make_grid(mod_rows)
|
||||
version_grid = self._make_grid(version_rows)
|
||||
|
||||
col = 1
|
||||
row = 1
|
||||
grid = Gtk.Grid(
|
||||
orientation=Gtk.Orientation.VERTICAL,
|
||||
row_spacing=30,
|
||||
hexpand=True,
|
||||
)
|
||||
|
||||
for frame in [
|
||||
self.make_frame(api_box, strings.options.api_keys),
|
||||
self.make_frame(prefs_grid, strings.options.prefs),
|
||||
self.make_frame(mods_grid, strings.options.mods),
|
||||
self.make_frame(version_grid, strings.options.version),
|
||||
]:
|
||||
grid.attach(
|
||||
frame, col, row, self.DEFAULT_WIDTH, self.DEFAULT_HEIGHT
|
||||
)
|
||||
row += 1
|
||||
|
||||
self.scrollable = Gtk.ScrolledWindow(vexpand=True)
|
||||
self.scrollable.add(grid)
|
||||
self.add(self.scrollable)
|
||||
|
||||
def _on_link_button_clicked(self, button: Gtk.Button, url: str) -> None:
|
||||
open_links.open_link_by_url(url)
|
||||
|
||||
def _make_submit_field(
|
||||
self,
|
||||
placeholder: str,
|
||||
context: Preferences,
|
||||
private: bool = False,
|
||||
) -> Gtk.Box:
|
||||
|
||||
entry = Gtk.Entry(placeholder_text=placeholder, hexpand=True)
|
||||
button = Gtk.Button(label="Save")
|
||||
|
||||
button.connect("clicked", self._on_save_clicked, entry, context)
|
||||
entry.connect("insert-text", self._on_text_typed, context, button)
|
||||
entry.connect("activate", self._on_field_activated, context, button)
|
||||
entry.get_property("buffer").connect(
|
||||
"deleted-text", self._on_text_deleted, context, button
|
||||
)
|
||||
|
||||
if private:
|
||||
entry.set_icon_from_icon_name(
|
||||
Gtk.EntryIconPosition.SECONDARY, VIEW_REVEAL
|
||||
)
|
||||
entry.set_icon_activatable(Gtk.EntryIconPosition.SECONDARY, True)
|
||||
entry.connect("icon-release", self._on_icon_release)
|
||||
entry.set_visibility(False)
|
||||
|
||||
if context == Preferences.STEAM:
|
||||
self.steam_entry = entry
|
||||
else:
|
||||
self.bm_entry = entry
|
||||
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
|
||||
box.add(entry)
|
||||
box.add(button)
|
||||
|
||||
return box
|
||||
|
||||
def _on_field_activated(
|
||||
self, entry: Gtk.Entry, context: Preferences, button: Gtk.Button
|
||||
) -> None:
|
||||
text = entry.get_text()
|
||||
if not self._is_valid_text(text, context):
|
||||
return
|
||||
self._on_save_clicked(button, entry, context)
|
||||
|
||||
def _make_grid(self, rows: list) -> Gtk.Grid:
|
||||
grid = Gtk.Grid(
|
||||
orientation=Gtk.Orientation.VERTICAL,
|
||||
column_spacing=10,
|
||||
row_spacing=5,
|
||||
margin_start=5,
|
||||
margin_end=5,
|
||||
margin_top=10,
|
||||
margin_bottom=10,
|
||||
)
|
||||
row = 1
|
||||
for record in rows:
|
||||
col = 1
|
||||
for el in record:
|
||||
grid.attach(
|
||||
el, col, row, self.DEFAULT_WIDTH, self.DEFAULT_HEIGHT
|
||||
)
|
||||
col += 1
|
||||
row += 1
|
||||
return grid
|
||||
|
||||
def _on_save_clicked(
|
||||
self, button: Gtk.Button, entry: Gtk.Entry, enum: Preferences
|
||||
) -> None:
|
||||
old_text = self.controller.query_config(enum)
|
||||
button.set_sensitive(False)
|
||||
wait_msg = strings.dialog.working
|
||||
match enum:
|
||||
case Preferences.NAME:
|
||||
value = entry.get_text().strip()
|
||||
self.controller.update_config(enum, value)
|
||||
case Preferences.BM | Preferences.STEAM:
|
||||
text = "".join(entry.get_text().split())
|
||||
self.controller.set_callback(self.restore_api_text, old_text, entry)
|
||||
self.controller.update_api_key(text, enum)
|
||||
|
||||
def restore_api_text(self, text: str, entry: Gtk.Entry) -> None:
|
||||
entry.set_text(text)
|
||||
|
||||
def revert(self, mode: Preferences) -> None:
|
||||
if mode == Preferences.STEAM:
|
||||
self.steam_entry.set_text(self.old_steam)
|
||||
else:
|
||||
self.bm_entry.set_text(self.old_bm)
|
||||
pass
|
||||
|
||||
def _on_force_update_clicked(self, button: Gtk.Button) -> None:
|
||||
# TODO: unimplemented
|
||||
print("UNIMPLEMENTED")
|
||||
#wait_msg = strings.dialog.updating_mods
|
||||
#show_wait_dialog = True
|
||||
#call_on_thread(show_wait_dialog, cmd, wait_msg, "")
|
||||
|
||||
def _on_client_changed(self, combo: Gtk.ComboBoxText) -> None:
|
||||
client = combo.get_active_text()
|
||||
match client:
|
||||
case "Steam":
|
||||
value = STEAM_CMD
|
||||
case "Flatpak":
|
||||
value = FLATPAK_RUN_CMD
|
||||
case "Flatpak (container)":
|
||||
value = FLATPAK_SANDBOX
|
||||
self.controller.update_config(Preferences.CLIENT, value)
|
||||
|
||||
def _on_branch_changed(self, combo: Gtk.ComboBoxText) -> None:
|
||||
branch = combo.get_active_text()
|
||||
print("UNIMPLEMENTED")
|
||||
print(branch)
|
||||
## TODO: needs to trigger download process
|
||||
#self.controller.toggle_branch(branch)
|
||||
#branch = combo.get_active_text().lower()
|
||||
#self.controller.update_config("branch", branch)
|
||||
#scripts/update
|
||||
|
||||
def _on_radio_toggled(
|
||||
self, button: Gtk.RadioButton, context: Preferences
|
||||
) -> None:
|
||||
AppNav = self.controller.get_mediator()
|
||||
|
||||
state = button.get_group()[0].get_active()
|
||||
|
||||
try:
|
||||
self.controller.toggle_config(context)
|
||||
except Exception:
|
||||
button.handler_block_by_func(self._on_radio_toggled)
|
||||
self.populate_settings()
|
||||
button.handler_unblock_by_func(self._on_radio_toggled)
|
||||
|
||||
if context == Preferences.INSTALL:
|
||||
if self.controller.is_auto_install():
|
||||
self.force_button.set_sensitive(True)
|
||||
WorkshopLinkDialog(
|
||||
self.controller,
|
||||
strings.options.manual_sub_msg,
|
||||
strings.self_workshop,
|
||||
self.uid
|
||||
)
|
||||
else:
|
||||
self.force_button.set_sensitive(False)
|
||||
|
||||
|
||||
def _is_valid_text(self, text: str, context: Preferences) -> bool:
|
||||
if text.isspace():
|
||||
return False
|
||||
if len(text) == 0:
|
||||
return False
|
||||
|
||||
match context:
|
||||
case Preferences.NAME:
|
||||
old = self.old_name
|
||||
case Preferences.STEAM:
|
||||
old = self.old_steam
|
||||
case Preferences.BM:
|
||||
old = self.old_bm
|
||||
if text == old:
|
||||
return False
|
||||
return True
|
||||
|
||||
def _on_text_deleted(
|
||||
self,
|
||||
buffer: Gtk.EntryBuffer,
|
||||
position: int,
|
||||
chars: int,
|
||||
context: Preferences,
|
||||
button: Gtk.Button,
|
||||
) -> None:
|
||||
|
||||
text = buffer.get_text()
|
||||
state = self._is_valid_text(text, context)
|
||||
button.set_sensitive(state)
|
||||
|
||||
def _on_text_typed(
|
||||
self,
|
||||
entry: Gtk.Entry,
|
||||
text: str,
|
||||
length: int,
|
||||
pos: int,
|
||||
context: Preferences,
|
||||
button: Gtk.Button,
|
||||
) -> None:
|
||||
|
||||
buffer = entry.get_property("buffer")
|
||||
text = buffer.get_text() + text
|
||||
state = self._is_valid_text(text, context)
|
||||
button.set_sensitive(state)
|
||||
|
||||
def make_binary_radio(
|
||||
self,
|
||||
first_option: str,
|
||||
second_option: str,
|
||||
context: Preferences,
|
||||
) -> Gtk.Box:
|
||||
|
||||
hbox = Gtk.Box(spacing=5, halign=Gtk.Align.START)
|
||||
radio1 = Gtk.RadioButton.new_with_label(None, first_option)
|
||||
radio2 = Gtk.RadioButton.new_from_widget(radio1)
|
||||
radio2.set_label(second_option)
|
||||
radio1.connect("toggled", self._on_radio_toggled, context)
|
||||
hbox.pack_start(radio1, NO_EXPAND, NO_FILL, 0)
|
||||
hbox.pack_start(radio2, NO_EXPAND, NO_FILL, 0)
|
||||
|
||||
return hbox
|
||||
|
||||
def make_frame(self, widget: Gtk.Widget, text: str) -> Gtk.Box:
|
||||
label = Gtk.Label(label=text)
|
||||
label.set_halign(Gtk.Align.START)
|
||||
css.add_class(label, "settings-subheading")
|
||||
|
||||
frame = Gtk.Frame(hexpand=True)
|
||||
frame.add(widget)
|
||||
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
box.add(label)
|
||||
box.add(frame)
|
||||
|
||||
return box
|
||||
|
||||
def populate_settings(self) -> None:
|
||||
prefs = self.controller.get_prefs()
|
||||
if prefs.paths.config.is_file() is False:
|
||||
# in case file got deleted locally
|
||||
self.controller.spawn_dialog(strings.config_not_found, Popup.QUIT)
|
||||
return
|
||||
|
||||
config = query.get_config(prefs.paths.config)
|
||||
name = config["name"]
|
||||
default_steam_path = config["default_steam_path"]
|
||||
steam = config["steam_api"]
|
||||
bm = config["bm_api"]
|
||||
install = config["auto_install"]
|
||||
|
||||
steam_path = Path(default_steam_path)
|
||||
self.uid = find_user_id(steam_path)
|
||||
|
||||
self.old_steam = steam
|
||||
self.old_bm = bm
|
||||
self.old_name = name
|
||||
|
||||
self.steam_entry.set_text(steam)
|
||||
self.bm_entry.set_text(bm)
|
||||
self.player_box.get_children()[0].set_text(name)
|
||||
|
||||
# NOTE: suppress toggle signal until radios are built
|
||||
self._suppress_toggles(True)
|
||||
self.force_button.set_sensitive(install)
|
||||
for el, conf_state in [
|
||||
(self.mod_install_toggle, install),
|
||||
(self.fullscreen_toggle, config["fullscreen"]),
|
||||
(self.distance_toggle, config["use_miles"])
|
||||
]:
|
||||
el.get_children()[conf_state].set_active(True)
|
||||
self._suppress_toggles(False)
|
||||
|
||||
# NOTE: disable buttons if no text is set
|
||||
for field in (
|
||||
[name, self.player_box],
|
||||
[steam, self.steam_box],
|
||||
[bm, self.bm_box],
|
||||
):
|
||||
if field[0] == "":
|
||||
field[1].get_children()[1].set_sensitive(False)
|
||||
|
||||
try:
|
||||
pe_file_path = PeFile.get_pefile_path(
|
||||
steam_path, APPID_DAYZ
|
||||
)
|
||||
vers = PeFile.get_dayz_version(pe_file_path)
|
||||
dayz_version = PeFile.dayz_version_to_str(vers)
|
||||
except Exception:
|
||||
dayz_version = strings.null
|
||||
|
||||
try:
|
||||
exp_file_path = PeFile.get_pefile_path(
|
||||
steam_path, APPID_DAYZ_EXP
|
||||
)
|
||||
vers = PeFile.get_dayz_version(exp_file_path)
|
||||
dayz_exp_version = PeFile.dayz_version_to_str(vers)
|
||||
except Exception:
|
||||
dayz_exp_version = strings.null
|
||||
|
||||
self.dayz_version_label.set_text(dayz_version)
|
||||
self.dayz_exp_version_label.set_text(dayz_exp_version)
|
||||
|
||||
# TODO: not happy with this
|
||||
active_combo = query.get_client_index(config["client"])
|
||||
self.client_combo.set_active(active_combo)
|
||||
|
||||
|
||||
active_combo = 1 if config["branch"] == BETA_REPO else 0
|
||||
self.branch_combo.set_active(active_combo)
|
||||
self.branch_combo.set_sensitive(prefs.allow_updates)
|
||||
|
||||
if prefs.allow_updates is True:
|
||||
msg = strings.options.self_update
|
||||
else:
|
||||
msg = strings.options.no_self_update
|
||||
self.branch_eb.set_text(msg)
|
||||
|
||||
def _suppress_toggles(self, state: bool) -> None:
|
||||
for toggle in [
|
||||
self.mod_install_toggle,
|
||||
self.fullscreen_toggle,
|
||||
self.distance_toggle,
|
||||
]:
|
||||
self.controller.suppress_signal(self, toggle.get_children()[0], "_on_radio_toggled", state)
|
||||
|
||||
def _on_icon_release(
|
||||
self,
|
||||
widget: Gtk.Entry,
|
||||
icon_pos: Gtk.EntryIconPosition,
|
||||
event: Gdk.Event,
|
||||
) -> None:
|
||||
visible = widget.get_visibility()
|
||||
if visible:
|
||||
icon, state = VIEW_REVEAL, False
|
||||
else:
|
||||
icon, state = VIEW_CONCEAL, True
|
||||
widget.set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, icon)
|
||||
widget.set_visibility(state)
|
||||
@@ -0,0 +1,34 @@
|
||||
from dzgui.util.format import format_pango
|
||||
from dzgui.util.strings import thanks
|
||||
|
||||
import gi # noqa E402
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gtk
|
||||
|
||||
"""
|
||||
Special thanks page recognizing contributors to the project in alpha-order
|
||||
"""
|
||||
|
||||
# TODO: make scrollable, test long values/wrapping
|
||||
|
||||
class Thanks(Gtk.Box):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
orientation=Gtk.Orientation.VERTICAL,
|
||||
spacing=10
|
||||
)
|
||||
|
||||
|
||||
label = format_pango(thanks.header)
|
||||
header = Gtk.Label()
|
||||
header.set_markup(label)
|
||||
self.add(header)
|
||||
|
||||
description = Gtk.Label(label=thanks.description)
|
||||
self.add(description)
|
||||
|
||||
users = [f"- {user}" for user in sorted(thanks.users, key=str.lower)]
|
||||
pretty_users = "\n".join(users)
|
||||
body = Gtk.Label()
|
||||
body.set_markup(pretty_users)
|
||||
self.add(body)
|
||||
@@ -0,0 +1,185 @@
|
||||
import logging
|
||||
import multiprocessing
|
||||
import subprocess
|
||||
|
||||
import gi
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa
|
||||
|
||||
from dzgui.const.constants import SEPARATOR
|
||||
from dzgui.util.keys import is_navkey
|
||||
from dzgui.util import strings
|
||||
from typing import Any, Callable, TYPE_CHECKING
|
||||
|
||||
from dzgui.views.mixins.cursor_mixin import CursorMixin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dzgui.controllers.mc import Controller
|
||||
from dzgui.const.enum import ContextMenuGroup
|
||||
|
||||
class TreeView(CursorMixin, Gtk.TreeView):
|
||||
def __init__(self, controller: "Controller", menu: "ContextMenuGroup" = None) -> None:
|
||||
super().__init__(
|
||||
enable_search=False,
|
||||
search_column=-1,
|
||||
headers_visible=False,
|
||||
)
|
||||
|
||||
self.menu = menu
|
||||
self.controller = controller
|
||||
self.sel_blocked = False
|
||||
self.set_row_separator_func(self._separate)
|
||||
|
||||
self.selected_row = self.get_selection()
|
||||
self.selected_row.set_mode(Gtk.SelectionMode.SINGLE)
|
||||
self.selected_row.connect("changed", self._on_tree_selection_changed)
|
||||
|
||||
self.connect("row-activated", self._on_row_activated)
|
||||
self.connect("key-press-event", self._on_keypress)
|
||||
self.connect("key-release-event", self._on_key_release)
|
||||
|
||||
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(Gtk.TreeSelection,))
|
||||
def generic_treesel_changed(self, selection: Gtk.TreeSelection) -> None:
|
||||
pass
|
||||
|
||||
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST,
|
||||
arg_types=(Gtk.TreePath, Gtk.TreeViewColumn)
|
||||
)
|
||||
def generic_row_activated(self,
|
||||
path: Gtk.TreePath,
|
||||
column: Gtk.TreeViewColumn
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
def signal_emission(func: Callable) -> Callable:
|
||||
def wrapper(self, *args, **kwargs):
|
||||
self.controller.block_signals()
|
||||
func(self, *args, **kwargs)
|
||||
self.controller.unblock_signals()
|
||||
|
||||
return wrapper
|
||||
|
||||
def _separate(self, model: Gtk.ListStore, iter_: Gtk.TreeIter) -> bool:
|
||||
if model[iter_][0] == SEPARATOR:
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_current_iter(self) -> Gtk.TreeIter | None:
|
||||
it = self.get_selection().get_selected()[1]
|
||||
return it
|
||||
|
||||
def get_focused_row_path(self) -> Gtk.TreePath:
|
||||
return self.get_cursor().path
|
||||
|
||||
def get_focused_row_index(self) -> int:
|
||||
return self.get_cursor().path[0]
|
||||
|
||||
def get_selected_records(self) -> list:
|
||||
sel = self.get_selection()
|
||||
model, rows = sel.get_selected_rows()
|
||||
return [model[row] for row in rows]
|
||||
|
||||
def _on_keypress(
|
||||
self, treeview: Gtk.TreeView, event: Gdk.EventKey
|
||||
) -> None:
|
||||
|
||||
keyname = Gdk.keyval_name(event.keyval)
|
||||
if is_navkey(event.keyval):
|
||||
# TODO: investigate this
|
||||
if self.sel_blocked is False:
|
||||
self.controller.suppress_signal(
|
||||
self.controller.mediator.treeview,
|
||||
self.controller.mediator.treeview.selected_row,
|
||||
"_on_tree_selection_changed",
|
||||
True,
|
||||
)
|
||||
self._vim_nav(event)
|
||||
return
|
||||
|
||||
def _on_key_release(
|
||||
self, treeview: Gtk.TreeView, event: Gdk.EventKey
|
||||
) -> None:
|
||||
# TODO: explain this better
|
||||
"""
|
||||
Suppresses spamming on keydown
|
||||
"""
|
||||
if is_navkey(event.keyval):
|
||||
if self.sel_blocked is True:
|
||||
self.controller.suppress_signal(
|
||||
self.controller.mediator.treeview,
|
||||
self.controller.mediator.treeview.selected_row,
|
||||
"_on_tree_selection_changed",
|
||||
False,
|
||||
)
|
||||
selection = self.get_selection()
|
||||
self._on_tree_selection_changed(selection)
|
||||
|
||||
def _on_tree_selection_changed(self, selection: Gtk.TreeSelection) -> None:
|
||||
self.emit("generic_treesel_changed", selection)
|
||||
|
||||
def toggle_selection(self, state: bool) -> None:
|
||||
for i, row in enumerate(self.get_model()): # type: ignore
|
||||
path = Gtk.TreePath.new_from_indices([i])
|
||||
if state:
|
||||
self.get_selection().select_path(path)
|
||||
else:
|
||||
self.get_selection().unselect_path(path)
|
||||
|
||||
def focus_first_row(self) -> None:
|
||||
self.set_cursor(0)
|
||||
|
||||
def get_value_at_index(self, index: int) -> str:
|
||||
select = self.get_selection()
|
||||
sels = select.get_selected_rows()
|
||||
(model, pathlist) = sels
|
||||
if len(pathlist) < 1:
|
||||
return ""
|
||||
path = pathlist[0]
|
||||
tree_iter = model.get_iter(path)
|
||||
value = model.get_value(tree_iter, index)
|
||||
return value
|
||||
|
||||
def get_name(self) -> str:
|
||||
name = self.get_value_at_index(0)
|
||||
return name
|
||||
|
||||
def select_first_row(self):
|
||||
sel = self.get_selection()
|
||||
self._on_tree_selection_changed(sel)
|
||||
|
||||
def get_mpath(self) -> Gtk.TreePath | None:
|
||||
select = self.get_selection()
|
||||
sels = select.get_selected_rows()
|
||||
(model, pathlist) = sels
|
||||
if len(pathlist) < 1:
|
||||
return None
|
||||
path = pathlist[0]
|
||||
return path
|
||||
|
||||
@signal_emission
|
||||
def _on_row_activated(
|
||||
self,
|
||||
treeview: Gtk.TreeView,
|
||||
path: Gtk.TreePath,
|
||||
col: Gtk.TreeViewColumn,
|
||||
) -> None:
|
||||
self.emit("generic_row_activated", path, col)
|
||||
|
||||
def is_selection_empty(self) -> bool:
|
||||
# TODO: reduce duplicated methods
|
||||
sel = self.get_selection()
|
||||
sels = sel.get_selected_rows()
|
||||
(model, pathlist) = sels
|
||||
if len(pathlist) < 1:
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_selected_row(self) -> Gtk.TreeModelRow | None:
|
||||
ind = self.get_selected_row_index()
|
||||
model = self.get_model()
|
||||
if model is None:
|
||||
return None
|
||||
row = model[ind]
|
||||
return row
|
||||
@@ -0,0 +1,80 @@
|
||||
import logging
|
||||
import multiprocessing
|
||||
import subprocess
|
||||
|
||||
import gi
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa E402
|
||||
|
||||
from dzgui.util.keys import is_navkey
|
||||
from dzgui.util import strings
|
||||
from typing import Any, TYPE_CHECKING
|
||||
|
||||
from dzgui.views.trees.tree_base import TreeView
|
||||
from dzgui.views.mixins.context_mixin import ContextMixin
|
||||
from dzgui.const.enum import ContextMenuGroup
|
||||
|
||||
from typing import Any
|
||||
import dzgui.util._json as JSON # noqa
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dzgui.controllers.mc import Controller
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class LogTreeView(ContextMixin, TreeView):
|
||||
def __init__(self, controller: "Controller") -> None:
|
||||
super().__init__(controller, menu=ContextMenuGroup.LOG)
|
||||
|
||||
self.controller = controller
|
||||
|
||||
# TODO: maybe put this in init
|
||||
self.set_fixed_height_mode(True)
|
||||
self.set_headers_visible(True)
|
||||
self.get_selection().set_mode(Gtk.SelectionMode.MULTIPLE)
|
||||
|
||||
s = self.controller.get_log_store()
|
||||
self.set_model(s)
|
||||
|
||||
for i, column_title in enumerate(strings.log_cols):
|
||||
renderer = Gtk.CellRendererText()
|
||||
column = Gtk.TreeViewColumn(
|
||||
column_title, renderer, text=i
|
||||
)
|
||||
column.set_sizing(Gtk.TreeViewColumnSizing.FIXED)
|
||||
column.set_resizable(True)
|
||||
column.set_sort_column_id(i)
|
||||
self.append_column(column)
|
||||
|
||||
self.connect("button-press-event", self._on_log_button_press)
|
||||
self.connect("button-release-event", self._on_log_button_release)
|
||||
self.connect("key-press-event", self._on_log_keypress)
|
||||
|
||||
self.s = self.get_selection().get_selected_rows()
|
||||
|
||||
def _on_log_keypress(self, widget: Gtk.Widget, event: Gdk.EventKey) -> None:
|
||||
self.present_menu(widget, event)
|
||||
|
||||
def _on_log_button_press(self,
|
||||
widget: Gtk.Widget,
|
||||
event: Gdk.EventButton
|
||||
) -> bool:
|
||||
|
||||
"""
|
||||
Prevents context menu from deselecting rows
|
||||
"""
|
||||
if event.button == 3:
|
||||
self.s = self.get_selection().get_selected_rows()
|
||||
return True
|
||||
return False
|
||||
|
||||
def _on_log_button_release(self,
|
||||
widget: Gtk.Widget,
|
||||
event: Gdk.EventButton
|
||||
) -> None:
|
||||
if event.button == 3:
|
||||
self.present_menu(widget, event)
|
||||
self.get_selection().unselect_all()
|
||||
for row in self.s[1]:
|
||||
self.get_selection().select_path(row)
|
||||
@@ -0,0 +1,86 @@
|
||||
import logging
|
||||
import multiprocessing
|
||||
import subprocess
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import gi
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa
|
||||
|
||||
from dzgui.const.enum import RowType, NotebookPage
|
||||
from dzgui.util.open_links import open_link_by_rowtype
|
||||
from dzgui.views.trees.tree_base import TreeView
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dzgui.controllers.mc import Controller
|
||||
|
||||
class MenuTreeView(TreeView):
|
||||
"""
|
||||
Simple Gtk.ListStore representation of main
|
||||
menu options
|
||||
"""
|
||||
def __init__(self, controller: "Controller") -> None:
|
||||
super().__init__(controller)
|
||||
|
||||
self.controller = controller
|
||||
|
||||
renderer = Gtk.CellRendererText()
|
||||
column = Gtk.TreeViewColumn("Main menu", renderer, text=0)
|
||||
column.set_sizing(Gtk.TreeViewColumnSizing.FIXED)
|
||||
self.append_column(column)
|
||||
|
||||
row_store = self.controller.get_row_store()
|
||||
self.set_model(row_store)
|
||||
|
||||
self.selected_row = self.get_selection()
|
||||
|
||||
self.set_row_separator_func(self._separate)
|
||||
self.connect("generic_row_activated", self._parent_row_activated)
|
||||
self.connect("generic_treesel_changed", self._parent_selection_changed)
|
||||
|
||||
def _parent_row_activated(self,
|
||||
tree: TreeView,
|
||||
path: Gtk.TreePath,
|
||||
column: Gtk.TreeViewColumn
|
||||
) -> None:
|
||||
row_type = self.get_value_at_index(1)
|
||||
print(row_type)
|
||||
|
||||
match row_type:
|
||||
case RowType.THANKS:
|
||||
self.controller.open_page(NotebookPage.THANKS)
|
||||
case RowType.SERVER_BROWSER:
|
||||
# TODO: add threading
|
||||
# TODO: handle recent, saved, lan, etc.
|
||||
# self.controller.open_page(NotebookPage.SERVERS)
|
||||
return
|
||||
case RowType.SHOW_LOG:
|
||||
self.controller.populate_log()
|
||||
case RowType.CHANGELOG:
|
||||
self.controller.open_page(NotebookPage.CHANGELOG)
|
||||
case RowType.DUMP_LOG:
|
||||
self.controller.dump_diagnostics()
|
||||
pass
|
||||
|
||||
docs = [
|
||||
RowType.DOCS,
|
||||
RowType.DOCS_FALLBACK,
|
||||
RowType.BUGS,
|
||||
RowType.FORUM,
|
||||
RowType.SPONSOR,
|
||||
]
|
||||
if row_type in docs:
|
||||
# FIXME: prior method is returning a str, not enum
|
||||
open_link_by_rowtype(row_type)
|
||||
|
||||
def _parent_selection_changed(
|
||||
self,
|
||||
base_class: TreeView,
|
||||
sel: Gtk.TreeSelection
|
||||
) -> None:
|
||||
row = self.get_value_at_index(1)
|
||||
if row == "":
|
||||
return
|
||||
self.controller.set_statusbar_by_row(row)
|
||||
@@ -0,0 +1,88 @@
|
||||
import logging
|
||||
from typing import Any, Literal, TYPE_CHECKING
|
||||
|
||||
import gi
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa
|
||||
|
||||
from dzgui.const.enum import (
|
||||
ContextMenuGroup,
|
||||
RowType,
|
||||
)
|
||||
from dzgui.util import strings, localize
|
||||
from dzgui.views.trees.tree_base import TreeView
|
||||
from dzgui.views.mixins.context_mixin import ContextMixin
|
||||
from dzgui.views.mixins.mods_mixin import ModsMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dzgui.controllers.mc import Controller
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class ModTreeView(ModsMixin, ContextMixin, TreeView):
|
||||
def __init__(self, controller: "Controller") -> None:
|
||||
super().__init__(controller, menu=ContextMenuGroup.MOD)
|
||||
self.controller = controller
|
||||
|
||||
self.set_fixed_height_mode(True)
|
||||
self.set_headers_visible(True)
|
||||
|
||||
mod_store = self.controller.get_mod_store()
|
||||
self.set_model(mod_store)
|
||||
|
||||
for i, column_title in enumerate(strings.mod_cols):
|
||||
renderer = Gtk.CellRendererText()
|
||||
column = Gtk.TreeViewColumn(
|
||||
column_title, renderer, text=i, foreground=4
|
||||
)
|
||||
if i == 3:
|
||||
column.set_cell_data_func(
|
||||
renderer, self._format_float, func_data=None
|
||||
)
|
||||
if column_title == "Mod":
|
||||
column.set_fixed_width(500)
|
||||
else:
|
||||
column.set_fixed_width(150)
|
||||
# hidden color property column
|
||||
column.set_sizing(Gtk.TreeViewColumnSizing.FIXED)
|
||||
column.set_sort_column_id(i)
|
||||
if i != 4:
|
||||
self.append_column(column)
|
||||
|
||||
self.selected_row = self.get_selection()
|
||||
self.selected_row.set_mode(Gtk.SelectionMode.MULTIPLE)
|
||||
|
||||
self.connect("generic_row_activated", self._on_mod_row_activated)
|
||||
self.connect("generic_treesel_changed", self._parent_selection_changed)
|
||||
self.connect("button-press-event", self._on_mods_button_press)
|
||||
self.connect("key-press-event", self._on_mods_keypress)
|
||||
|
||||
self.focus_first_row()
|
||||
self.s = self.get_selection().get_selected_rows()
|
||||
|
||||
def _on_mods_keypress(self, widget: Gtk.Widget, event: Gdk.EventKey) -> None:
|
||||
self.present_menu(widget, event)
|
||||
|
||||
def _on_mods_button_press(self,
|
||||
widget: Gtk.Widget,
|
||||
event: Gdk.EventButton
|
||||
) -> None:
|
||||
if event.button == 3:
|
||||
self.present_menu(widget, event)
|
||||
|
||||
def _parent_selection_changed(self, base_class: TreeView, sel: Gtk.TreeSelection):
|
||||
pass
|
||||
|
||||
def _format_float(
|
||||
self,
|
||||
column: Gtk.TreeViewColumn,
|
||||
cell: Gtk.CellRendererText,
|
||||
model: Gtk.TreeModel,
|
||||
it: Gtk.TreeIter,
|
||||
data: Any,
|
||||
) -> Any:
|
||||
# https://docs.huihoo.com/pygtk/2.0-tutorial/sec-CellRenderers.html
|
||||
val = model[it][3]
|
||||
formatted = localize.number(val)
|
||||
cell.set_property("text", formatted)
|
||||
return
|
||||