feat: dzgui 7 alpha

This commit is contained in:
aclist committed 2025-12-08 17:32:25 +09:00
1 parent 1acdbd58c5
commit 2e5b771137
150 files changed
+10359

No files matched your search

View File
Whitespace-only changes.
View File
Whitespace-only changes.
+40
View File
@@ -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
+170
View File
@@ -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
+465
View File
@@ -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
+62
View File
@@ -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
+444
View File
@@ -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 []
+137
View File
@@ -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
View File
Whitespace-only changes.
+72
View File
@@ -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)
+42
View File
@@ -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)
+97
View File
@@ -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")
+60
View File
@@ -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
+10
View File
@@ -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
+25
View File
@@ -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
+12
View File
@@ -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
+90
View File
@@ -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,
)
+53
View File
@@ -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"
+23
View File
@@ -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"
+525
View File
@@ -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,
}
+15
View File
@@ -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
+545
View File
@@ -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)
+256
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 497 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 459 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 B

+23
View File
@@ -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
+22
View File
@@ -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)
+25
View File
@@ -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
+37
View File
@@ -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)
+23
View File
@@ -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)
+28
View File
@@ -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
+26
View File
@@ -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
+51
View File
@@ -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
"""
+50
View File
@@ -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
View File
Whitespace-only changes.
+115
View File
@@ -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)
+28
View File
@@ -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
View File
Whitespace-only changes.
+27
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
import shlex
def concat_bash_args(command: str) -> list[str]:
return shlex.split(command)
+17
View File
@@ -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
+50
View File
@@ -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
)
+30
View File
@@ -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
+88
View File
@@ -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)
+86
View File
@@ -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])
+44
View File
@@ -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"
+137
View File
@@ -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
+26
View File
@@ -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
+16
View File
@@ -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)
+28
View File
@@ -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])
+12
View File
@@ -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)
+61
View File
@@ -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])
+6
View File
@@ -0,0 +1,6 @@
import re
def redact(text: str) -> str:
r = r"(/home/)([^/])*"
cleaned = re.sub(r, r"/home/REDACTED", text)
return cleaned
+479
View File
@@ -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."
+50
View File
@@ -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)
View File
Whitespace-only changes.
+668
View File
@@ -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()
+86
View File
@@ -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()
+17
View File
@@ -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)
+38
View File
@@ -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
+286
View File
@@ -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)
+10
View File
@@ -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
)
+12
View File
@@ -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
+88
View File
@@ -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()
+125
View File
@@ -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()
+46
View File
@@ -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
+15
View File
@@ -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)
+35
View File
@@ -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()
+80
View File
@@ -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
+34
View File
@@ -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
+104
View File
@@ -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)
+152
View File
@@ -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)
+54
View File
@@ -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()
+102
View File
@@ -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)
+94
View File
@@ -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)
+18
View File
@@ -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)
+75
View File
@@ -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)
+58
View File
@@ -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
+16
View File
@@ -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)
+21
View File
@@ -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)
+30
View File
@@ -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)
+97
View File
@@ -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
+509
View File
@@ -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)
+34
View File
@@ -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)
+185
View File
@@ -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
+80
View File
@@ -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)
+86
View File
@@ -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)
+88
View File
@@ -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
Loaded 100 of 150 files, more files were not shown because too many files have changed in this diff. Show more