From 10037b86d860333661223fb26a0f2ccae151fe9b Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Fri, 9 Jan 2026 22:49:12 +0900 Subject: [PATCH 001/309] feat: dynamic haversine calc --- dzgui/controllers/mc.py | 58 ++++++++++++++++++-------- dzgui/util/dist.py | 67 +++++++++++++------------------ dzgui/util/format.py | 1 + dzgui/views/pages/servers.py | 37 ++++++++--------- dzgui/views/trees/tree_servers.py | 39 +++++++----------- 5 files changed, 105 insertions(+), 97 deletions(-) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index c3380eb..a38ab75 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -38,9 +38,10 @@ from dzgui.config import update from dzgui.config.query import lookup from dzgui.config.userprefs import UserPrefs from dzgui.controllers.model import ModelManager -from dzgui.util.diag import write_diagnostic from dzgui.util import cooldown, strings +from dzgui.util.diag import write_diagnostic from dzgui.util._json import read_json, write_json +from dzgui.util.localize import number from dzgui.util.open_links import open_workshop_page, open_user_workshop from dzgui.util.format import format_mods, format_player_count, pluralize from dzgui.util.redact import redact_log @@ -55,16 +56,17 @@ from gi.repository import Gtk, Gdk, GLib, GObject # noqa E402 logger = logging.getLogger(__name__) if TYPE_CHECKING: - from dzgui.views.components.buttonbox import ContextualButton - from dzgui.views.base import OuterWindow - from dzgui.views.trees.tree_base import TreeView - from dzgui.views.trees.tree_mods import ModTreeView - from dzgui.views.trees.tree_servers import ServerTreeView - from dzgui.views.trees.tree_log import LogTreeView + from dzgui.util.dist import Haversine from dzgui.views.base import Notebook, Grid, OuterWindow + from dzgui.views.components.buttonbox import ContextualButton from dzgui.views.components.statusbar import Statusbar from dzgui.views.components.right_panel import RightPanel from dzgui.views.pages.servers import ServerNotebook + from dzgui.views.trees.tree_base import TreeView + 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.trees.tree_log import LogTreeView class AppNavigation: window: "OuterWindow" @@ -79,13 +81,11 @@ class AppNavigation: saved: "ServerTreeView" recent: "ServerTreeView" lan: "ServerTreeView" - modtreeview: "ModTreeView" logtreeview: "LogTreeView" -# TODO: most server contexts can be dropped from this struct - class Controller: def __init__(self) -> None: + self.dist_cache: dict[str, "Haversine"] = {} self.crumbs_cache = "" self.mediator = AppNavigation() self.prefs: UserPrefs @@ -238,10 +238,29 @@ class Controller: Gtk.main_quit() def get_statusbar(self) -> str: - return self.mediator.grid.statusbar.get_text() + return self.mediator.statusbar.get_text() def set_statusbar(self, text: str) -> None: - self.mediator.grid.statusbar.set_text(text) + self.mediator.statusbar.set_text(text) + + def set_statusbar_placeholder(self, text: str) -> None: + self.statusbar_placeholder = text + + def set_statusbar_dist(self, haversine: "Haversine") -> None: + dist: str + if haversine is None: + dist = "Unknown" + else: + if self.query_config(Preferences.DIST) is True: + raw = round(haversine.as_miles()) + separated = number(raw) + dist = str(separated) + " mi" + else: + raw = round(haversine.as_kilometers()) + separated = number(raw) + dist = str(separated) + " km" + text = self.statusbar_placeholder + self.set_statusbar(f"{text} | Distance: {dist}") def delete_multiple_mods(self) -> None: sel = self.mediator.modtreeview.get_selection() @@ -327,7 +346,7 @@ class Controller: return False def set_statusbar_by_row(self, row: "RowType") -> None: - self.mediator.grid.statusbar.refresh(row) + self.mediator.statusbar.refresh(row) def toggle_server_panels(self, state: bool) -> None: self.mediator.grid.toggle_filter_panel(state) @@ -357,7 +376,7 @@ class Controller: case ButtonType.MODS: self.load_mods() case ButtonType.HELP: - self.mediator.grid.statusbar.refresh(RowType.CHANGELOG) + self.mediator.statusbar.refresh(RowType.CHANGELOG) pass case ButtonType.SERVERS: self.mediator.notebook.set_page_by_enum(button.opens) @@ -432,7 +451,7 @@ class Controller: 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) + self.mediator.statusbar.set_text(msg) def calc_mod_size(self) -> tuple[int, int]: model = self.model_manager.get_mod_store() @@ -614,7 +633,8 @@ class Controller: treeview = self.mediator.notebook.servers.get_active_treeview() model = treeview.get_model() status = format_player_count(model) - self.mediator.statusbar.set_text(status) + self.set_statusbar_placeholder(status) + self.set_statusbar(status + "| Calculating...") def propagate_column_width(self, col: Gtk.TreeViewColumn) -> None: GLib.idle_add(self.mediator.servers.update_tab_widths, col) @@ -651,10 +671,13 @@ class Controller: self.mediator.right_panel.focus_button_box() def present_servers(self) -> None: + tree = self.get_active_treeview() + # TODO: abstract self.grab_active_treeview() self.update_server_status() crumbs = self.mediator.servers.get_cached_label() self.set_crumbs(crumbs) + tree.emit("on_distcalc_started") def toggle_check(self, event: Gdk.EventKey) -> None: keyname = Gdk.keyval_name(event.keyval) @@ -677,3 +700,6 @@ class Controller: if len(fav) < 1: return None return fav + + def get_dist_cache(self) -> dict[str, "Haversine"]: + return self.dist_cache diff --git a/dzgui/util/dist.py b/dzgui/util/dist.py index 0d6f0d8..1d6ed0c 100644 --- a/dzgui/util/dist.py +++ b/dzgui/util/dist.py @@ -1,10 +1,9 @@ +import logging import multiprocessing from math import radians, cos, sin, asin, sqrt from typing import TYPE_CHECKING - -import dzgui.util.ip as ip -from dzgui.util.ip import GeolocationError +from dzgui.util.ip import GeolocationError, get_coords from dzgui.util.localize import number import gi @@ -13,6 +12,9 @@ from gi.repository import Gtk # noqa E402 if TYPE_CHECKING: from dzgui.util.ip import Coords + from dzgui.controllers.mc import Controller + +logger = logging.getLogger(__name__) class Haversine(): def __init__(self, lat1: float, lon1: float, lat2: float, lon2: float) -> None: @@ -35,53 +37,40 @@ class Haversine(): 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, - ): + controller: "Controller", + ) -> None: super().__init__() + self.controller = controller 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 + cache = self.controller.get_dist_cache() 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 + 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]) + dist = self.compare(self.ip) + self.result_queue.put([self.addr, dist]) + + def compare(self, remote: str) -> int | None: + prefs = self.controller.get_prefs() + local = prefs.coords + if local is None: + return None + try: + remote = get_coords(prefs.paths.ips, remote) + except GeolocationError: + return None + + # TODO: handle failed remote dist + haversine = Haversine(local.lat, local.lon, remote.lat, remote.lon) + return haversine diff --git a/dzgui/util/format.py b/dzgui/util/format.py index 49695d0..e6e6b3f 100644 --- a/dzgui/util/format.py +++ b/dzgui/util/format.py @@ -64,6 +64,7 @@ def format_player_count(model: Gtk.TreeModel | None) -> str: hits_pretty = pluralize("matches", hits) suffix = distance_suffix status = f"Found {hits:n} {hits_pretty} with {players:n} {players_pretty}" + return status return f"{status} | {suffix}" diff --git a/dzgui/views/pages/servers.py b/dzgui/views/pages/servers.py index 9e8fc1a..89c5790 100644 --- a/dzgui/views/pages/servers.py +++ b/dzgui/views/pages/servers.py @@ -52,22 +52,22 @@ class ServerNotebook(Gtk.ScrolledWindow): pass #store.append(["BAR", "a", "a", "a", 1, 1, 1, "1:1", 0, 0, "a", False]) else: - store.append(["BAR", "a", "a", "a", 0, 0, 0, "1:1", 0, 0, "a", False]) - store.append(["BAR", "a", "a", "a", 0, 0, 0, "1:1", 0, 0, "a", False]) - store.append(["BAR", "a", "a", "a", 0, 0, 0, "1:1", 0, 0, "a", False]) - store.append(["BAR", "a", "a", "a", 0, 0, 0, "1:1", 0, 0, "a", False]) - store.append(["BAR", "a", "a", "a", 0, 0, 0, "1:1", 0, 0, "a", False]) - store.append(["BAR", "a", "a", "a", 0, 0, 0, "1:1", 0, 0, "a", False]) - store.append(["BAR", "a", "a", "a", 0, 0, 0, "1:1", 0, 0, "a", False]) - store.append(["BAR", "a", "a", "a", 0, 0, 0, "1:1", 0, 0, "a", False]) - store.append(["BAR", "a", "a", "a", 0, 0, 0, "1:1", 0, 0, "a", False]) - store.append(["BAR", "a", "a", "a", 0, 0, 0, "1:1", 0, 0, "a", False]) - store.append(["BAR", "a", "a", "a", 0, 0, 0, "1:1", 0, 0, "a", False]) - store.append(["BAR", "a", "a", "a", 0, 0, 0, "1:1", 0, 0, "a", False]) - store.append(["BAR", "a", "a", "a", 0, 0, 0, "1:1", 0, 0, "a", False]) - store.append(["BAR", "a", "a", "a", 0, 0, 0, "1:1", 0, 0, "a", False]) - store.append(["BAR", "a", "a", "a", 0, 0, 0, "1:1", 0, 0, "a", False]) - store.append(["BAR", "a", "a", "a", 0, 0, 0, "1:1", 0, 0, "a", False]) + store.append(["BAR", "a", "a", "a", 0, 0, 0, "189.127.165.207:2302", 0, 0, "a", False]) + store.append(["BAR", "a", "a", "a", 0, 0, 0, "189.127.165.207:2302", 0, 0, "a", False]) + store.append(["BAR", "a", "a", "a", 0, 0, 0, "189.127.165.207:2302", 0, 0, "a", False]) + store.append(["BAR", "a", "a", "a", 0, 0, 0, "189.127.165.207:2302", 0, 0, "a", False]) + store.append(["BAR", "a", "a", "a", 0, 0, 0, "189.127.165.207:2302", 0, 0, "a", False]) + store.append(["BAR", "a", "a", "a", 0, 0, 0, "189.127.165.207:2302", 0, 0, "a", False]) + store.append(["BAR", "a", "a", "a", 0, 0, 0, "189.127.165.207:2302", 0, 0, "a", False]) + store.append(["BAR", "a", "a", "a", 0, 0, 0, "189.127.165.207:2302", 0, 0, "a", False]) + store.append(["BAR", "a", "a", "a", 0, 0, 0, "189.127.165.207:2302", 0, 0, "a", False]) + store.append(["BAR", "a", "a", "a", 0, 0, 0, "189.127.165.207:2302", 0, 0, "a", False]) + store.append(["BAR", "a", "a", "a", 0, 0, 0, "189.127.165.207:2302", 0, 0, "a", False]) + store.append(["BAR", "a", "a", "a", 0, 0, 0, "189.127.165.207:2302", 0, 0, "a", False]) + store.append(["BAR", "a", "a", "a", 0, 0, 0, "189.127.165.207:2302", 0, 0, "a", False]) + store.append(["BAR", "a", "a", "a", 0, 0, 0, "189.127.165.207:2302", 0, 0, "a", False]) + store.append(["BAR", "a", "a", "a", 0, 0, 0, "189.127.165.207:2302", 0, 0, "a", False]) + store.append(["BAR", "a", "a", "a", 0, 0, 0, "189.127.165.207:2302", 0, 0, "a", False]) tree.loaded = True # TODO: set model on init of servertreeview tree.set_model(store) @@ -110,12 +110,13 @@ class ServerNotebook(Gtk.ScrolledWindow): child.grab_focus() # FIXME: doesn't fire on first run - self.controller.update_server_status() + #self.controller.update_server_status() + self.controller.present_servers() self.controller.populate_model() # TODO: put in controller def query_test(self) -> None: - data = (["BAR", "a", "a", "a", 1, 1, 1, "1:1", 0, 0, "a", False]) + data = (["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False]) return data def get_cached_label(self) -> str: diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index 47edc6a..55ea5fb 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -92,7 +92,7 @@ class ServerTreeView(TreeView): column.connect("notify::fixed-width", self._on_col_width_changed) self.append_column(column) - self.connect("on_distcalc_started", self._on_calclat_started) + self.connect("on_distcalc_started", self._on_distcalc_started) self.connect("button-release-event", self._on_server_button_release) self.connect("key-press-event", self._on_server_keypress) self.connect("generic_row_activated", self._parent_row_activated) @@ -150,22 +150,28 @@ class ServerTreeView(TreeView): if self.current_proc and self.current_proc.is_alive(): self.current_proc.terminate() - def _on_calclat_started(self, treeview): - server_tooltip[0] = format_tooltip() - server_tooltip[1] = server_tooltip[0] + "| Distance: calculating..." - self.update_statusbar(server_tooltip[1]) + def _on_distcalc_started(self, treeview): + record = self.get_record() + if record is None: + return + ip = record.ip + self.current_proc = CalcDist(record.ip, self.queue, self.controller) + self.current_proc.start() def _check_result_queue(self) -> Literal[True]: + # TODO: trigger signal when changing page contexts + # TODO: delegate to controller latest_result = None while not self.queue.empty(): latest_result = self.queue.get() + cache = self.controller.get_dist_cache() if latest_result: addr = latest_result[0] - km = latest_result[1] - cache[addr] = km + haversine = latest_result[1] + cache[addr] = haversine # FIXME - self.statusbar.append_distance(km) + self.controller.set_statusbar_dist(haversine) return True def _on_server_keypress( @@ -261,23 +267,8 @@ class ServerTreeView(TreeView): print(self.get_value_at_index(0)) def _parent_selection_changed(self, base_class: TreeView, sel: Gtk.TreeSelection): - self.terminate_process() - record = self.get_record() - - if record is None: - return - - #model = self.get_model() - #if record is None: - # self.controller.update_server_status(model) - # return - - ip = record.ip - # TODO - #self.emit("on_distcalc_started") - #self.current_proc = CalcDist(self, record.ip, self.queue, cache) - #self.current_proc.start() + self.emit("on_distcalc_started") def get_record_string(self) -> str: addr = self.get_value_at_index(7) From e2ec093f67b4dbba336842436d4b9fb8c8f8457d Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 10 Jan 2026 21:30:09 +0900 Subject: [PATCH 002/309] feat: lan button WIP --- dzgui/const/constants.py | 2 +- dzgui/const/enum.py | 6 ++++ dzgui/controllers/mc.py | 10 +++++-- dzgui/init/update.py | 3 +- dzgui/views/base.py | 13 ++++---- dzgui/views/components/connect_panel.py | 40 +++++++++++++++++++++++-- dzgui/views/pages/mods.py | 11 ++++++- dzgui/views/pages/servers.py | 38 +++++++++++++++-------- dzgui/views/trees/tree_servers.py | 23 +++++++++++++- 9 files changed, 120 insertions(+), 26 deletions(-) diff --git a/dzgui/const/constants.py b/dzgui/const/constants.py index c3b586f..b98c8c9 100644 --- a/dzgui/const/constants.py +++ b/dzgui/const/constants.py @@ -4,7 +4,7 @@ UDP_PORT = 27016 VM_FILE = "/proc/sys/vm/max_map_count" MIN_COUNT = 1048576 -REQUEST_TIMEOUT = 3 +REQUEST_TIMEOUT = 1 APPNAME_DAYZ = "DayZ" APPNAME_DAYZ_EXP = "DayZ Experimental" diff --git a/dzgui/const/enum.py b/dzgui/const/enum.py index aa0dc9c..2c2ceed 100644 --- a/dzgui/const/enum.py +++ b/dzgui/const/enum.py @@ -435,3 +435,9 @@ HELP_MENU_ROWS = ( RowType.SEPARATOR, RowType.THANKS, ) + +class ServerTab(Enum): + BROWSER = 1 + SAVED = 2 + RECENT = 3 + LAN = 4 diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index a38ab75..9813734 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -652,7 +652,7 @@ class Controller: def populate_model(self) -> None: # TODO: always use same server model, store in servertreeview class - treeview = self.mediator.notebook.servers.get_active_treeview() + treeview = self.get_active_treeview() if treeview.get_loaded() is False: new_model = self.model_manager.new_model() # NOTE: set_query_func() @@ -671,13 +671,14 @@ class Controller: self.mediator.right_panel.focus_button_box() def present_servers(self) -> None: - tree = self.get_active_treeview() # TODO: abstract self.grab_active_treeview() self.update_server_status() crumbs = self.mediator.servers.get_cached_label() self.set_crumbs(crumbs) - tree.emit("on_distcalc_started") + # TODO: could emit this when treeview gains keyboard focus + #tree = self.get_active_treeview() + #tree.emit("on_distcalc_started") def toggle_check(self, event: Gdk.EventKey) -> None: keyname = Gdk.keyval_name(event.keyval) @@ -703,3 +704,6 @@ class Controller: def get_dist_cache(self) -> dict[str, "Haversine"]: return self.dist_cache + + def toggle_lan_panel(self, state: bool) -> None: + self.mediator.grid.conpan.set_visible(state) diff --git a/dzgui/init/update.py b/dzgui/init/update.py index 7449a61..f8c6c34 100644 --- a/dzgui/init/update.py +++ b/dzgui/init/update.py @@ -14,7 +14,8 @@ logger = logging.getLogger(__name__) def get_latest_release() -> str | None: tag = None - for url in [GITHUB_RELEASES, CODEBERG_RELEASES]: + # TODO: check order; github often has gateway errors + for url in [CODEBERG_RELEASES, GITHUB_RELEASES]: try: res = requests.get(url, timeout=REQUEST_TIMEOUT) if res.status_code == 200: diff --git a/dzgui/views/base.py b/dzgui/views/base.py index a1b5a13..3188e7b 100644 --- a/dzgui/views/base.py +++ b/dzgui/views/base.py @@ -217,6 +217,9 @@ class OuterWindow(Gtk.Window): css.load_css() MainController.open_page(NotebookPage.SERVERS) + # TODO: first run + MainController.mediator.grid.conpan.lan.set_visible(False) + self.grid.right_panel.sel_panel.hide() def _on_delete_event( self, window: "OuterWindow", event: Gdk.EventKey @@ -365,11 +368,11 @@ class Notebook(ScrollableMixin, Gtk.Notebook): # type: ignore status = enum.dict["statusbar"] MainController.set_crumbs(crumbs) - is_mods = True if enum is NotebookPage.MODS else False - is_servers = True if enum is NotebookPage.SERVERS else False - - MainController.toggle_mod_panel(is_mods) - MainController.toggle_server_panels(is_servers) + #is_mods = True if enum is NotebookPage.MODS else False + #is_servers = True if enum is NotebookPage.SERVERS else False + # TODO: use signals internal to servers.py + #MainController.toggle_mod_panel(is_mods) + #MainController.toggle_server_panels(is_servers) if status is False: MainController.set_statusbar("") diff --git a/dzgui/views/components/connect_panel.py b/dzgui/views/components/connect_panel.py index 7019ea0..d69b498 100644 --- a/dzgui/views/components/connect_panel.py +++ b/dzgui/views/components/connect_panel.py @@ -1,6 +1,7 @@ from typing import TYPE_CHECKING from dzgui.api.servers import validate_ip +from dzgui.const.constants import NO_EXPAND, NO_FILL, NO_PADDING from dzgui.util.css import add_class, remove_class from dzgui.util.strings import connect_panel from dzgui.views.components.buttons import WebButton @@ -13,10 +14,38 @@ from gi.repository import Gtk, Gdk # noqa E402 if TYPE_CHECKING: from dzgui.controllers.mc import Controller -class ConnectPanel(Gtk.Frame): +class LanPanel(Gtk.Frame): def __init__(self, controller: "Controller") -> None: super().__init__(margin_top=10, margin_bottom=5) + # TODO: use grid, more column spacing + # TODO: hide lan panel on other tabs + hbox = Gtk.Box(margin=10, spacing=5, halign=Gtk.Align.START) + # TODO: strings + label = BoldLabel("LAN query port") + radio1 = Gtk.RadioButton.new_with_label(None, "Default port (27016)") + radio2 = Gtk.RadioButton.new_from_widget(radio1) + radio2.set_label("Custom port") + self.entry = Gtk.Entry(placeholder_text="Query port") + self.button = Gtk.Button(label="Scan") + radio1.connect("toggled", self._on_radio_toggled) + hbox.pack_start(label, NO_EXPAND, NO_FILL, NO_PADDING) + hbox.pack_start(radio1, NO_EXPAND, NO_FILL, NO_PADDING) + hbox.pack_start(radio2, NO_EXPAND, NO_FILL, NO_PADDING) + hbox.pack_start(self.entry, NO_EXPAND, NO_FILL, NO_PADDING) + hbox.pack_start(self.button, NO_EXPAND, NO_FILL, NO_PADDING) + self.entry.set_sensitive(False) + self.button.set_sensitive(False) + self.add(hbox) + + def _on_radio_toggled(self, button: Gtk.RadioButton) -> None: + for el in self.entry, self.button: + el.set_sensitive(not button.get_active()) + +class ConnectPanel(Gtk.Box): + def __init__(self, controller: "Controller") -> None: + super().__init__(orientation=Gtk.Orientation.VERTICAL) + COLS = 1 ROWS = 1 self.classname = "invalid-entry" @@ -74,7 +103,14 @@ class ConnectPanel(Gtk.Frame): for el, sibling, pos, h_span, v_span in els: self.grid.attach_next_to(el, sibling, pos, h_span, v_span) - self.add(self.grid) + self.lan = LanPanel(self.controller) + self.add(self.lan) + + frame = Gtk.Frame(margin_top=10, margin_bottom=5) + frame.add(self.grid) + self.add(frame) + + self.lan.set_visible(False) def mark_valid(self) -> None: self.conn_server.set_sensitive(True) diff --git a/dzgui/views/pages/mods.py b/dzgui/views/pages/mods.py index 20e381f..77a76f4 100644 --- a/dzgui/views/pages/mods.py +++ b/dzgui/views/pages/mods.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING +from typing import Self, TYPE_CHECKING from dzgui.views.trees.tree_mods import ModTreeView import gi @@ -17,3 +17,12 @@ class Mods(Gtk.ScrolledWindow): self.add(self.tree) self.controller.register_widget("modtreeview", self.tree) + + self.connect("map", self._on_map) + self.connect("unmap", self._on_unmap) + + def _on_unmap(self, widget: Self) -> None: + self.controller.toggle_mod_panel(False) + + def _on_map(self, widget: Self) -> None: + self.controller.toggle_mod_panel(True) diff --git a/dzgui/views/pages/servers.py b/dzgui/views/pages/servers.py index 89c5790..6a76d06 100644 --- a/dzgui/views/pages/servers.py +++ b/dzgui/views/pages/servers.py @@ -2,7 +2,7 @@ import logging from typing import Self, TYPE_CHECKING -from dzgui.const.enum import ContextMenuGroup +from dzgui.const.enum import ContextMenuGroup, ServerTab from dzgui.views.trees.tree_servers import ServerTreeView from dzgui.util.strings import server_labels @@ -24,10 +24,10 @@ class ServerNotebook(Gtk.ScrolledWindow): self.controller.register_widget("servers", self) self.notebook = Gtk.Notebook(show_tabs=True) - self.browser = ServerTreeView(controller) - self.saved = ServerTreeView(controller) - self.recent = ServerTreeView(controller) - self.lan = ServerTreeView(controller) + self.browser = ServerTreeView(controller, ServerTab.BROWSER) + self.saved = ServerTreeView(controller, ServerTab.SAVED) + self.recent = ServerTreeView(controller, ServerTab.RECENT) + self.lan = ServerTreeView(controller, ServerTab.LAN) self.lan.set_query_func(self.query_test) # TODO: set context menu on init @@ -50,7 +50,6 @@ class ServerNotebook(Gtk.ScrolledWindow): store = func() if label == "LAN": pass - #store.append(["BAR", "a", "a", "a", 1, 1, 1, "1:1", 0, 0, "a", False]) else: store.append(["BAR", "a", "a", "a", 0, 0, 0, "189.127.165.207:2302", 0, 0, "a", False]) store.append(["BAR", "a", "a", "a", 0, 0, 0, "189.127.165.207:2302", 0, 0, "a", False]) @@ -79,6 +78,14 @@ class ServerNotebook(Gtk.ScrolledWindow): self.add(self.notebook) self.notebook.connect_after("switch-page", self._on_page_changed) self.connect("key-press-event", self._on_keypress) + self.connect("map", self._on_map) + self.connect("unmap", self._on_unmap) + + def _on_map(self, widget: Self) -> None: + self.controller.toggle_server_panels(True) + + def _on_unmap(self, widget: Self) -> None: + self.controller.toggle_server_panels(False) def _on_keypress(self, widget: Self, event: Gdk.EventKey) -> None: @@ -94,7 +101,6 @@ class ServerNotebook(Gtk.ScrolledWindow): def _on_page_changed(self, notebook: Gtk.Notebook, child: Gtk.Widget, index: int) -> None: # TODO: abstract - # TODO: load on first run label = self.notebook.get_tab_label_text(child) if label is None: return @@ -105,20 +111,28 @@ class ServerNotebook(Gtk.ScrolledWindow): # TODO: strings string = f"Servers > {text}" self.controller.set_crumbs(string) - self.tab_cache = string + self.set_cached_label(string) - child.grab_focus() - - # FIXME: doesn't fire on first run - #self.controller.update_server_status() self.controller.present_servers() self.controller.populate_model() + # TODO: start with lan panel hidden + # FIXME: grid conpan is not set up at init + # TODO: refresh button only usable if LAN has servers? + # or just remove it + #if self.get_active_treeview() is self.lan: + # self.controller.mediator.grid.conpan.lan.set_visible(True) + #else: + # self.controller.mediator.grid.conpan.lan.set_visible(False) + # TODO: put in controller def query_test(self) -> None: data = (["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False]) return data + def set_cached_label(self, label: str) -> None: + self.tab_cache = label + def get_cached_label(self) -> str: return self.tab_cache diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index 55ea5fb..d70aba8 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -12,6 +12,7 @@ from dzgui.const.enum import ( RowType, ) from dzgui.const.constants import UDP_PORT +from dzgui.const.enum import ServerTab from dzgui.api.servers import Record from dzgui.util.dist import CalcDist from dzgui.util.keys import is_navkey @@ -40,11 +41,12 @@ class ServerTreeView(TreeView): __gsignals__ = { "on_distcalc_started": (GObject.SignalFlags.RUN_FIRST, None, ()) } - def __init__(self, controller: "Controller") -> None: + def __init__(self, controller: "Controller", enum: ServerTab) -> None: super().__init__(controller) QUEUE_CHECK_DELAY = 200 + self.enum = enum self.loaded = False self.query_func: Callable = None @@ -97,9 +99,28 @@ class ServerTreeView(TreeView): self.connect("key-press-event", self._on_server_keypress) self.connect("generic_row_activated", self._parent_row_activated) self.connect("generic_treesel_changed", self._parent_selection_changed) + self.connect("map", self._on_map) + self.connect("unmap", self._on_unmap) + + self.connect("focus-in-event", self._on_kb_focus) GLib.timeout_add(QUEUE_CHECK_DELAY, self._check_result_queue) + def _on_kb_focus(self, a, b) -> None: + self.emit("on_distcalc_started") + + def get_enum(self) -> None: + return self.enum + + def _on_map(self, a) -> None: + if self.get_enum() is ServerTab.LAN: + self.controller.mediator.grid.conpan.lan.set_visible(True) + + def _on_unmap(self, a) -> None: + # TODO: could be done just when changing pages? + if self.get_enum() is ServerTab.LAN: + self.controller.mediator.grid.conpan.lan.set_visible(False) + def _on_key(self, menu: Gtk.Menu, event: Gdk.EventKey) -> bool | None: if not is_navkey(event.keyval): return False From 59ef15073f4c7489ee547d5ce4cbe7c1853dab2d Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 11 Jan 2026 01:52:29 +0900 Subject: [PATCH 003/309] feat: connect panel WIP --- dzgui/const/constants.py | 3 ++ dzgui/controllers/mc.py | 9 ++-- dzgui/util/strings.py | 2 +- dzgui/views/components/buttons.py | 65 +++++++++++++++++++++++-- dzgui/views/components/connect_panel.py | 57 +++++++++++++--------- dzgui/views/components/right_panel.py | 6 +-- dzgui/views/dialogs/link_dialog.py | 4 +- dzgui/views/pages/options.py | 2 +- dzgui/views/trees/tree_servers.py | 5 +- 9 files changed, 111 insertions(+), 42 deletions(-) diff --git a/dzgui/const/constants.py b/dzgui/const/constants.py index b98c8c9..c2c5956 100644 --- a/dzgui/const/constants.py +++ b/dzgui/const/constants.py @@ -30,6 +30,9 @@ APP_NAME_ABBR = "dzg" HEX_RED = "#FF0000" HEX_ORANGE = "#FFAC1C" +CLIPBOARD = "edit-copy-symbolic" +LIST_ADD = "list-add-symbolic" +STEAM_ICON = "steam_tray_mono" VIEW_CONCEAL = "view-conceal-symbolic" VIEW_REVEAL = "view-reveal-symbolic" INPUT_KEYBOARD = "input-keyboard-symbolic" diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 9813734..15d5bdf 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -696,12 +696,13 @@ class Controller: case _: return False - def get_favorite_label(self) -> str | None: + def get_favorite(self) -> tuple[str, str] | tuple[None, None]: fav = str(self.query_config(Preferences.FAV_LBL)) if len(fav) < 1: - return None - return fav - + return None, None + ip = str(self.query_config(Preferences.FAV_SRV)) + return fav, ip + def get_dist_cache(self) -> dict[str, "Haversine"]: return self.dist_cache diff --git a/dzgui/util/strings.py b/dzgui/util/strings.py index 037b8e6..d4da3c4 100644 --- a/dzgui/util/strings.py +++ b/dzgui/util/strings.py @@ -140,7 +140,7 @@ vim = { "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 first row/top of page", "G": "Jump to last row/bottom of page", } key_contexts = ["Servers", "Navigation", "Vim-style keys"] diff --git a/dzgui/views/components/buttons.py b/dzgui/views/components/buttons.py index 2bcbe7e..b6edada 100644 --- a/dzgui/views/components/buttons.py +++ b/dzgui/views/components/buttons.py @@ -1,25 +1,61 @@ -from dzgui.util.strings import refresh -from dzgui.views.components.icon import Icon -from dzgui.const.constants import REFRESH_ICON, WEB_BROWSER, INPUT_KEYBOARD +from dzgui.util.strings import refresh, connect_panel +from dzgui.const.constants import ( + CLIPBOARD, + INPUT_KEYBOARD, + LIST_ADD, + REFRESH_ICON, + STEAM_ICON, + WEB_BROWSER, +) import gi gi.require_version("Gtk", "3.0") from gi.repository import Gtk # noqa E402 +class Icon(Gtk.Image): + def __init__(self, name: str, l_margin=0) -> None: + super().__init__(icon_name=name, + icon_size=Gtk.IconSize.BUTTON, + margin_start=l_margin, + ypad=2, + ) + +class LargeIcon(Gtk.Image): + def __init__(self, name: str, l_margin=5) -> None: + super().__init__(icon_name=name, + icon_size=Gtk.IconSize.LARGE_TOOLBAR, + margin_start=l_margin + ) + + class IconButton(Gtk.Button): def __init__(self, icon: str, margin: int = 0) -> None: super().__init__() - i = Icon(icon, l_margin=margin) - self.set_image(i) + self.icon = Icon(icon, l_margin=margin) + self.set_image(self.icon) self.set_image_position(Gtk.PositionType.RIGHT) + class IconTextButton(IconButton): def __init__(self, icon: str, label: str) -> None: super().__init__(icon, margin=5) self.set_label(label) +class LargeIconTextButton(IconButton): + def __init__(self, icon: str, label: str) -> None: + super().__init__(icon) + + self.set_label(label) + self.set_image(LargeIcon(icon)) + + +class ClipboardButton(IconButton): + def __init__(self) -> None: + super().__init__(CLIPBOARD) + + class WebButton(IconTextButton): def __init__(self, label: str) -> None: super().__init__(icon=WEB_BROWSER, label=label) @@ -33,9 +69,28 @@ class RefreshButton(IconTextButton): self.set_margin_start(80) self.set_margin_end(80) + class KeysButton(IconTextButton): def __init__(self, label: str) -> None: super().__init__(icon=INPUT_KEYBOARD, label=label) self.set_margin_top(10) self.set_margin_start(80) self.set_margin_end(80) + + +class SteamConnectButton(LargeIconTextButton): + def __init__(self) -> None: + super().__init__(icon=STEAM_ICON, label=connect_panel.connect) + self.set_tooltip_text(connect_panel.connect_tooltip) + + +class SteamTextButton(SteamConnectButton): + def __init__(self, label: str) -> None: + super().__init__() + self.set_label(label) + + +class AddButton(IconTextButton): + def __init__(self) -> None: + super().__init__(icon=LIST_ADD, label=connect_panel.add) + self.set_tooltip_text(connect_panel.add_tooltip) diff --git a/dzgui/views/components/connect_panel.py b/dzgui/views/components/connect_panel.py index d69b498..4e96ab2 100644 --- a/dzgui/views/components/connect_panel.py +++ b/dzgui/views/components/connect_panel.py @@ -4,7 +4,7 @@ from dzgui.api.servers import validate_ip from dzgui.const.constants import NO_EXPAND, NO_FILL, NO_PADDING from dzgui.util.css import add_class, remove_class from dzgui.util.strings import connect_panel -from dzgui.views.components.buttons import WebButton +from dzgui.views.components.buttons import AddButton, ClipboardButton, IconTextButton, SteamConnectButton, WebButton from dzgui.views.components.labels import BoldLabel import gi @@ -23,13 +23,14 @@ class LanPanel(Gtk.Frame): hbox = Gtk.Box(margin=10, spacing=5, halign=Gtk.Align.START) # TODO: strings label = BoldLabel("LAN query port") + self.set_label_widget(label) radio1 = Gtk.RadioButton.new_with_label(None, "Default port (27016)") radio2 = Gtk.RadioButton.new_from_widget(radio1) radio2.set_label("Custom port") self.entry = Gtk.Entry(placeholder_text="Query port") self.button = Gtk.Button(label="Scan") radio1.connect("toggled", self._on_radio_toggled) - hbox.pack_start(label, NO_EXPAND, NO_FILL, NO_PADDING) + #hbox.pack_start(label, NO_EXPAND, NO_FILL, NO_PADDING) hbox.pack_start(radio1, NO_EXPAND, NO_FILL, NO_PADDING) hbox.pack_start(radio2, NO_EXPAND, NO_FILL, NO_PADDING) hbox.pack_start(self.entry, NO_EXPAND, NO_FILL, NO_PADDING) @@ -59,26 +60,18 @@ class ConnectPanel(Gtk.Box): self.entry.connect("key-press-event", self._on_entry_keypress) self.entry.connect("changed", self._on_text_changed) - # TODO: get ip as well? - user_fav = self.controller.get_favorite_label() + user_fav, user_ip = self.controller.get_favorite() - server_name = user_fav if user_fav is not None else connect_panel.no_fav - self.fav_label = Gtk.Label(label=server_name, halign=Gtk.Align.START) + server_name = f"{user_fav} ({user_ip})" if user_fav is not None else connect_panel.no_fav + self.fav_label = Gtk.Label(label=server_name, track_visited_links=False, halign=Gtk.Align.START, hexpand=True) scrollable_label = Gtk.ScrolledWindow() scrollable_label.add(self.fav_label) - self.fav_button = Gtk.Button(label=connect_panel.connect, - tooltip_text=connect_panel.connect_tooltip - ) + self.fav_button = SteamConnectButton() self.fav_edit = Gtk.Button(label=connect_panel.edit) - self.edit_server = WebButton(label="EDIT") - self.conn_server= Gtk.Button(label=connect_panel.connect, - tooltip_text=connect_panel.connect_tooltip - ) - self.add_server = Gtk.Button(label=connect_panel.add, - tooltip_text=connect_panel.add_tooltip - ) + self.add_server = AddButton() + self.conn_server = SteamConnectButton() self.conn_server.set_sensitive(False) self.add_server.set_sensitive(False) @@ -89,14 +82,15 @@ class ConnectPanel(Gtk.Box): conn_label = BoldLabel(connect_panel.favorite) self.grid = Gtk.Grid(margin=10, vexpand=False, column_spacing=15, row_spacing=5) - self.grid.attach(conn_label, 0, 0, COLS, ROWS) + #self.grid.attach(conn_label, 0, 0, COLS, ROWS) + self.grid.attach(self.entry, 0, 0, COLS, ROWS) els = ( - (scrollable_label, conn_label, Gtk.PositionType.RIGHT, 3, ROWS), - (self.fav_button, scrollable_label, Gtk.PositionType.RIGHT, COLS, ROWS), - (add_label, conn_label, Gtk.PositionType.BOTTOM, COLS, ROWS), - (self.entry, scrollable_label, Gtk.PositionType.BOTTOM, COLS, ROWS), - (self.add_server, self.fav_button, Gtk.PositionType.BOTTOM, COLS, ROWS), + #(scrollable_label, conn_label, Gtk.PositionType.RIGHT, 3, ROWS), + #(self.fav_button, scrollable_label, Gtk.PositionType.RIGHT, COLS, ROWS), + #(add_label, conn_label, Gtk.PositionType.BOTTOM, COLS, ROWS), + #(self.entry, scrollable_label, Gtk.PositionType.BOTTOM, COLS, ROWS), + (self.add_server, self.entry, Gtk.PositionType.RIGHT, COLS, ROWS), (self.conn_server, self.add_server, Gtk.PositionType.RIGHT, COLS, ROWS), ) @@ -106,12 +100,28 @@ class ConnectPanel(Gtk.Box): self.lan = LanPanel(self.controller) self.add(self.lan) - frame = Gtk.Frame(margin_top=10, margin_bottom=5) + label = BoldLabel("Favorite server") + frame = Gtk.Frame(margin_top=10, margin_bottom=5, label_widget=label) + b = ClipboardButton() + b.set_tooltip_text("Copy IP to clipboard") + b.connect("clicked", self._on_ip_clicked, user_ip) + grid = Gtk.Grid(margin=10, vexpand=False, column_spacing=15, row_spacing=5) + grid.attach(scrollable_label, 0, 0, 3, ROWS) + grid.attach_next_to(b, scrollable_label, Gtk.PositionType.RIGHT, COLS, ROWS) + grid.attach_next_to(self.fav_button, b, Gtk.PositionType.RIGHT, COLS, ROWS) + frame.add(grid) + self.add(frame) + + label = BoldLabel("Connect") + frame = Gtk.Frame(margin_top=10, margin_bottom=5, label_widget=label) frame.add(self.grid) self.add(frame) self.lan.set_visible(False) + def _on_ip_clicked(self, button: Gtk.Button, ip: str) -> None: + self.controller.copy_clipboard(ip) + def mark_valid(self) -> None: self.conn_server.set_sensitive(True) self.add_server.set_sensitive(True) @@ -141,6 +151,7 @@ class ConnectPanel(Gtk.Box): def set_fav_label(self, text: str) -> None: # TODO: called by controller when changing fav + # TODO: href logic self.fav_label.set_text(text) def _on_entry_keypress(self, entry: Gtk.Entry, event: Gdk.EventKey) -> None: diff --git a/dzgui/views/components/right_panel.py b/dzgui/views/components/right_panel.py index 4f81e7e..4874901 100644 --- a/dzgui/views/components/right_panel.py +++ b/dzgui/views/components/right_panel.py @@ -8,7 +8,6 @@ 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.mod_panel import ModSelectionPanel -from dzgui.views.components.icon import Icon from dzgui.views.components.buttons import RefreshButton, KeysButton from dzgui.const.constants import NO_EXPAND, NO_FILL, EXPAND, FILL, INPUT_KEYBOARD, NO_PADDING from dzgui.util.strings import refresh_tooltip, refresh, keys_button, keys_tooltip @@ -35,8 +34,9 @@ class RightPanel(Gtk.Box): self.refresh_button.connect("clicked", self._on_refresh_clicked) self.keys = KeysButton(keys_button) + self.keys.set_focus_on_click(False) self.keys.set_tooltip_text(keys_tooltip) - self.keys.connect("clicked", self._on_question_clicked) + self.keys.connect("clicked", self._on_keys_clicked) for el in self.button_vbox, self.keys, self.filters_vbox, self.refresh_button: self.pack_start(el, NO_EXPAND, FILL, NO_PADDING) @@ -100,7 +100,7 @@ class RightPanel(Gtk.Box): # thread = threading.Thread(target=_update_pings, args=()) # thread.start() - def _on_question_clicked(self, button: Gtk.Button) -> None: + def _on_keys_clicked(self, button: Gtk.Button) -> None: self.controller.open_keybindings() def focus_button_box(self) -> None: diff --git a/dzgui/views/dialogs/link_dialog.py b/dzgui/views/dialogs/link_dialog.py index b5db94f..ba7cded 100644 --- a/dzgui/views/dialogs/link_dialog.py +++ b/dzgui/views/dialogs/link_dialog.py @@ -5,7 +5,7 @@ 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.buttons import WebButton +from dzgui.views.components.buttons import SteamTextButton, WebButton import gi gi.require_version("Gtk", "3.0") @@ -30,7 +30,7 @@ class WorkshopLinkDialog(GenericDialog): a clickable link to their workshop subscriptions is added """ if uid is not None: - button = WebButton(label=button_label) + button = SteamTextButton(label=button_label) button.set_margin_start(60) button.set_margin_end(60) button.connect("clicked", self._on_button_clicked, uid) diff --git a/dzgui/views/pages/options.py b/dzgui/views/pages/options.py index 8d6fe55..8cf053c 100644 --- a/dzgui/views/pages/options.py +++ b/dzgui/views/pages/options.py @@ -416,9 +416,9 @@ class Options(Gtk.Box): frame = Gtk.Frame(hexpand=True) frame.add(widget) + frame.set_label_widget(label) box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) - box.add(label) box.add(frame) return box diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index d70aba8..4975cac 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -28,8 +28,6 @@ if TYPE_CHECKING: from dzgui.controllers.mc import Controller # TODO: restore missing methods -# TODO: add multiprocessing queue -# TODO: fix cache class EnumeratedMenuItem(Gtk.MenuItem): def __init__(self, enum: ContextMenu): @@ -190,7 +188,8 @@ class ServerTreeView(TreeView): if latest_result: addr = latest_result[0] haversine = latest_result[1] - cache[addr] = haversine + if addr not in cache: + cache[addr] = haversine # FIXME self.controller.set_statusbar_dist(haversine) return True From 45bd0e16ec1a0775d7078ca430ee4e2cb476f5eb Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 11 Jan 2026 04:53:15 +0900 Subject: [PATCH 004/309] feat: ExceptionDialog WIP --- CHANGELOG.md | 13 ++++- dzgui/data/CHANGELOG.md | 13 ++++- dzgui/views/base.py | 1 + dzgui/views/components/buttons.py | 12 ++++- dzgui/views/components/connect_panel.py | 48 ++++++++++--------- dzgui/views/components/right_panel.py | 3 ++ dzgui/views/dialogs/generic.py | 63 +++++++++++++++++++++++-- 7 files changed, 122 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 52eec94..6ff240a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,12 +15,21 @@ - Developers page (and -d flag) - Redact API key in log table - Integrate add/connect widgets into main menu +- Favorite/connect/LAN panels integrated with server views +- Colorized IP/ID validation +- Visual icons +- Integrated server notebook +- Retain position on servers +- Propagate width changes to all tables ## Changed - Reduce padding on keys button -- Add icon to keys button -- Add icons to API buttons - Boldface breadcrumbs +- Bold labels inside frames ## Unreleased - Setup wizard +- Copy favorite server IP to clipboard +- Set favorite server from tables +- Detailed/copyable trace in critical error dialogs +- Local documentation diff --git a/dzgui/data/CHANGELOG.md b/dzgui/data/CHANGELOG.md index 52eec94..6ff240a 100644 --- a/dzgui/data/CHANGELOG.md +++ b/dzgui/data/CHANGELOG.md @@ -15,12 +15,21 @@ - Developers page (and -d flag) - Redact API key in log table - Integrate add/connect widgets into main menu +- Favorite/connect/LAN panels integrated with server views +- Colorized IP/ID validation +- Visual icons +- Integrated server notebook +- Retain position on servers +- Propagate width changes to all tables ## Changed - Reduce padding on keys button -- Add icon to keys button -- Add icons to API buttons - Boldface breadcrumbs +- Bold labels inside frames ## Unreleased - Setup wizard +- Copy favorite server IP to clipboard +- Set favorite server from tables +- Detailed/copyable trace in critical error dialogs +- Local documentation diff --git a/dzgui/views/base.py b/dzgui/views/base.py index 3188e7b..19a6094 100644 --- a/dzgui/views/base.py +++ b/dzgui/views/base.py @@ -8,6 +8,7 @@ import signal import subprocess import textwrap import threading +import traceback import typing # noqa import warnings diff --git a/dzgui/views/components/buttons.py b/dzgui/views/components/buttons.py index b6edada..8517a11 100644 --- a/dzgui/views/components/buttons.py +++ b/dzgui/views/components/buttons.py @@ -1,3 +1,5 @@ +from typing import Callable, Self, TYPE_CHECKING + from dzgui.util.strings import refresh, connect_panel from dzgui.const.constants import ( CLIPBOARD, @@ -12,6 +14,9 @@ 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 Icon(Gtk.Image): def __init__(self, name: str, l_margin=0) -> None: @@ -52,8 +57,13 @@ class LargeIconTextButton(IconButton): class ClipboardButton(IconButton): - def __init__(self) -> None: + def __init__(self, controller: "Controller", data: str) -> None: super().__init__(CLIPBOARD) + self.controller = controller + self.connect("clicked", self._on_button_clicked, data) + + def _on_button_clicked(self, button: Self, data: str) -> None: + self.controller.copy_clipboard(data) class WebButton(IconTextButton): diff --git a/dzgui/views/components/connect_panel.py b/dzgui/views/components/connect_panel.py index 4e96ab2..03055c3 100644 --- a/dzgui/views/components/connect_panel.py +++ b/dzgui/views/components/connect_panel.py @@ -4,7 +4,7 @@ from dzgui.api.servers import validate_ip from dzgui.const.constants import NO_EXPAND, NO_FILL, NO_PADDING from dzgui.util.css import add_class, remove_class from dzgui.util.strings import connect_panel -from dzgui.views.components.buttons import AddButton, ClipboardButton, IconTextButton, SteamConnectButton, WebButton +from dzgui.views.components.buttons import AddButton, ClipboardButton, SteamConnectButton from dzgui.views.components.labels import BoldLabel import gi @@ -18,31 +18,40 @@ class LanPanel(Gtk.Frame): def __init__(self, controller: "Controller") -> None: super().__init__(margin_top=10, margin_bottom=5) - # TODO: use grid, more column spacing # TODO: hide lan panel on other tabs - hbox = Gtk.Box(margin=10, spacing=5, halign=Gtk.Align.START) # TODO: strings label = BoldLabel("LAN query port") self.set_label_widget(label) + radio1 = Gtk.RadioButton.new_with_label(None, "Default port (27016)") radio2 = Gtk.RadioButton.new_from_widget(radio1) + # TODO: new_with_label_from_widget radio2.set_label("Custom port") - self.entry = Gtk.Entry(placeholder_text="Query port") + + self.entry = Gtk.Entry(placeholder_text="Query port", sensitive=False) self.button = Gtk.Button(label="Scan") radio1.connect("toggled", self._on_radio_toggled) - #hbox.pack_start(label, NO_EXPAND, NO_FILL, NO_PADDING) + + # TODO: use grid, more column spacing + hbox = Gtk.Box(margin=10, spacing=5, halign=Gtk.Align.START) hbox.pack_start(radio1, NO_EXPAND, NO_FILL, NO_PADDING) hbox.pack_start(radio2, NO_EXPAND, NO_FILL, NO_PADDING) hbox.pack_start(self.entry, NO_EXPAND, NO_FILL, NO_PADDING) hbox.pack_start(self.button, NO_EXPAND, NO_FILL, NO_PADDING) - self.entry.set_sensitive(False) - self.button.set_sensitive(False) + self.add(hbox) def _on_radio_toggled(self, button: Gtk.RadioButton) -> None: for el in self.entry, self.button: el.set_sensitive(not button.get_active()) +class AddPanel(Gtk.Frame): + def __init__(self, controller: "Controller") -> None: + super().__init__(margin_top=10, margin_bottom=5) + + label = BoldLabel("Connect") + self.set_label_widget(label) + class ConnectPanel(Gtk.Box): def __init__(self, controller: "Controller") -> None: super().__init__(orientation=Gtk.Orientation.VERTICAL) @@ -60,9 +69,9 @@ class ConnectPanel(Gtk.Box): self.entry.connect("key-press-event", self._on_entry_keypress) self.entry.connect("changed", self._on_text_changed) - user_fav, user_ip = self.controller.get_favorite() + user_fav, self.fav_ip = self.controller.get_favorite() - server_name = f"{user_fav} ({user_ip})" if user_fav is not None else connect_panel.no_fav + server_name = f"{user_fav} ({self.fav_ip})" if user_fav is not None else connect_panel.no_fav self.fav_label = Gtk.Label(label=server_name, track_visited_links=False, halign=Gtk.Align.START, hexpand=True) scrollable_label = Gtk.ScrolledWindow() scrollable_label.add(self.fav_label) @@ -78,18 +87,10 @@ class ConnectPanel(Gtk.Box): if server_name is None: self.fav_button.set_sensitive(False) - add_label = BoldLabel(connect_panel.add_con) - conn_label = BoldLabel(connect_panel.favorite) - self.grid = Gtk.Grid(margin=10, vexpand=False, column_spacing=15, row_spacing=5) - #self.grid.attach(conn_label, 0, 0, COLS, ROWS) self.grid.attach(self.entry, 0, 0, COLS, ROWS) els = ( - #(scrollable_label, conn_label, Gtk.PositionType.RIGHT, 3, ROWS), - #(self.fav_button, scrollable_label, Gtk.PositionType.RIGHT, COLS, ROWS), - #(add_label, conn_label, Gtk.PositionType.BOTTOM, COLS, ROWS), - #(self.entry, scrollable_label, Gtk.PositionType.BOTTOM, COLS, ROWS), (self.add_server, self.entry, Gtk.PositionType.RIGHT, COLS, ROWS), (self.conn_server, self.add_server, Gtk.PositionType.RIGHT, COLS, ROWS), ) @@ -102,9 +103,11 @@ class ConnectPanel(Gtk.Box): label = BoldLabel("Favorite server") frame = Gtk.Frame(margin_top=10, margin_bottom=5, label_widget=label) - b = ClipboardButton() + b = ClipboardButton(self.controller, self.get_fav_ip()) + b.set_focus_on_click(False) b.set_tooltip_text("Copy IP to clipboard") - b.connect("clicked", self._on_ip_clicked, user_ip) + + # FIXME: height of connect buttons is not equivalent grid = Gtk.Grid(margin=10, vexpand=False, column_spacing=15, row_spacing=5) grid.attach(scrollable_label, 0, 0, 3, ROWS) grid.attach_next_to(b, scrollable_label, Gtk.PositionType.RIGHT, COLS, ROWS) @@ -117,10 +120,10 @@ class ConnectPanel(Gtk.Box): frame.add(self.grid) self.add(frame) - self.lan.set_visible(False) + #self.lan.set_visible(False) - def _on_ip_clicked(self, button: Gtk.Button, ip: str) -> None: - self.controller.copy_clipboard(ip) + def get_fav_ip(self) -> str: + return self.fav_ip def mark_valid(self) -> None: self.conn_server.set_sensitive(True) @@ -151,7 +154,6 @@ class ConnectPanel(Gtk.Box): def set_fav_label(self, text: str) -> None: # TODO: called by controller when changing fav - # TODO: href logic self.fav_label.set_text(text) def _on_entry_keypress(self, entry: Gtk.Entry, event: Gdk.EventKey) -> None: diff --git a/dzgui/views/components/right_panel.py b/dzgui/views/components/right_panel.py index 4874901..c657c8a 100644 --- a/dzgui/views/components/right_panel.py +++ b/dzgui/views/components/right_panel.py @@ -30,7 +30,9 @@ class RightPanel(Gtk.Box): self.sel_panel = ModSelectionPanel(controller) self.refresh_button = RefreshButton() + # TODO: consolidate into class self.refresh_button.set_tooltip_text(refresh_tooltip) + self.refresh_button.set_focus_on_click(False) self.refresh_button.connect("clicked", self._on_refresh_clicked) self.keys = KeysButton(keys_button) @@ -63,6 +65,7 @@ class RightPanel(Gtk.Box): return True def _on_refresh_clicked(self, button: RefreshButton) -> None: + # TODO: could be internal to refresh button class self.refresh_button.set_sensitive(False) self.refresh_button.set_label(f"{refresh} ({str(self.time)})") GLib.timeout_add_seconds(1, self.decrement) diff --git a/dzgui/views/dialogs/generic.py b/dzgui/views/dialogs/generic.py index d76fb19..e834e85 100644 --- a/dzgui/views/dialogs/generic.py +++ b/dzgui/views/dialogs/generic.py @@ -1,7 +1,7 @@ import textwrap -from typing import Any, Literal, TYPE_CHECKING +from typing import Any, Literal, Self, TYPE_CHECKING -from dzgui.const.constants import NO_EXPAND, NO_FILL +from dzgui.const.constants import NO_EXPAND, NO_FILL, EXPAND, FILL from dzgui.const.enum import Popup, ButtonType, NotebookPage from dzgui.util import strings @@ -13,6 +13,63 @@ from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa E402 if TYPE_CHECKING: from dzgui.controllers.mc import Controller +class ExceptionDialog(Gtk.MessageDialog): + def __init__(self, controller: "Controller", trace: str): + super().__init__( + transient_for=controller.mediator.window, + message_type=Gtk.MessageType.ERROR, + buttons=Gtk.ButtonsType.OK, + text="Error", + secondary_text="Something went wrong. See the detailed error below.", + title=strings.dialog_header, + modal=True, + ) + + # TODO: strings + self.set_size_request(550, 250) + + from dzgui.views.components.buttons import ClipboardButton + content = self.get_content_area() + content.set_spacing(0) + + box = Gtk.Box(hexpand=True, vexpand=True, orientation=Gtk.Orientation.VERTICAL) + textview = Gtk.TextView(wrap_mode=Gtk.WrapMode.WORD, editable=False, left_margin=10, right_margin=10) + textview.set_buffer(Gtk.TextBuffer(text=trace)) + # NOTE: box expands to end of content area + box.pack_start(textview, EXPAND, FILL, 10) + + action_area = self.get_action_area() + action_area.set_spacing(10) + action_area.set_margin_bottom(10) + action_area.set_layout(Gtk.ButtonBoxStyle.CENTER) + + but = ClipboardButton(controller, trace) + # TODO: flag to set button with text, or other class + but.set_label("Copy") + action_area.pack_start(but, True, True, 10) + # NOTE: reverse button order after insertion + action_area.set_direction(Gtk.TextDirection.RTL) + content.add(box) + + self.set_default_response(Gtk.ResponseType.OK) + self.connect("response", self._on_response) + self.show_all() + """ + usage: + from dzgui.views.dialogs.generic import ExceptionDialog + try: + a.banana() + except Exception as e: + trace = traceback.format_exc() + dialog = ExceptionDialog(MainController, trace) + dialog.run() + """ + + + def _on_response(self, dialog: Self, response: Gtk.ResponseType) -> None: + self.destroy() + + class GenericDialog(Gtk.MessageDialog): def __init__(self, controller: "Controller", text: str, mode: Popup): match mode: @@ -41,7 +98,6 @@ class GenericDialog(Gtk.MessageDialog): 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, @@ -49,6 +105,7 @@ class GenericDialog(Gtk.MessageDialog): buttons=button_type, text=header_text, secondary_text=textwrap.fill(text, 50), + # NOTE: steam deck prints <2> if dialog title is same as window title title=strings.dialog_header, modal=True, ) From 1bc21d60836d3095e7635917b8746a6a2d1edad5 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Mon, 12 Jan 2026 03:44:41 +0900 Subject: [PATCH 005/309] chore: normalize dialogs --- CHANGELOG.md | 1 + dzgui/const/enum.py | 1 - dzgui/controllers/mc.py | 27 +-- dzgui/data/CHANGELOG.md | 1 + dzgui/main.py | 8 +- dzgui/util/cooldown.py | 16 -- dzgui/util/strings.py | 23 +- dzgui/views/base.py | 6 +- dzgui/views/components/buttons.py | 20 +- dzgui/views/components/connect_panel.py | 134 ++++++------ dzgui/views/components/right_panel.py | 14 +- dzgui/views/dialogs/early_alert.py | 25 +-- dzgui/views/dialogs/generic.py | 269 +++++++++++++----------- dzgui/views/pages/options.py | 1 - tests/test_cooldown.py | 19 -- 15 files changed, 283 insertions(+), 282 deletions(-) delete mode 100644 dzgui/util/cooldown.py delete mode 100644 tests/test_cooldown.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ff240a..053e26e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ - Reduce padding on keys button - Boldface breadcrumbs - Bold labels inside frames +- Unsticky keys when clicking sidebar ## Unreleased - Setup wizard diff --git a/dzgui/const/enum.py b/dzgui/const/enum.py index 2c2ceed..147bfad 100644 --- a/dzgui/const/enum.py +++ b/dzgui/const/enum.py @@ -14,7 +14,6 @@ class Popup(Enum): NOTIFY = 2 CONFIRM = 3 ENTRY = 4 - RETURN = 5 MODLIST = 6 DETAILS = 7 QUIT = 8 diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 15d5bdf..515f315 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -2,12 +2,14 @@ import logging import shutil import threading import textwrap +import traceback from pathlib import Path from typing import Any, Callable, TYPE_CHECKING import dzgui.api.pefile as PeFile import dzgui.util._json as JSON # noqa +from dzgui.views.dialogs.generic import ExceptionDialog from dzgui.api.probe import test_steam_api, test_bm_api from dzgui.api.mods import ( @@ -38,7 +40,7 @@ from dzgui.config import update from dzgui.config.query import lookup from dzgui.config.userprefs import UserPrefs from dzgui.controllers.model import ModelManager -from dzgui.util import cooldown, strings +from dzgui.util import strings from dzgui.util.diag import write_diagnostic from dzgui.util._json import read_json, write_json from dzgui.util.localize import number @@ -89,7 +91,6 @@ class Controller: self.crumbs_cache = "" self.mediator = AppNavigation() self.prefs: UserPrefs - self.cooldown = 0 self.model_manager = ModelManager() @@ -291,14 +292,18 @@ class Controller: update.toggle_config(config, context) except Exception as e: logger.critical(e) - self.spawn_dialog(strings.something_wrong, Popup.NOTIFY) + trace = traceback.format_exc() + dialog = ExceptionDialog(self, trace) + dialog.run() 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) + trace = traceback.format_exc() + dialog = ExceptionDialog(self, trace) + dialog.run() # TODO: suppress signals # then reenable (or it spawns dialog twice) self.mediator.grid.notebook.settings.populate_settings() @@ -308,19 +313,6 @@ class Controller: #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 get_cooldown(self) -> None: - return self.cooldown - - 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() @@ -329,6 +321,7 @@ class Controller: notebook = self.mediator.grid.notebook notebook.focus_current() + # TODO: deprecated def spawn_dialog(self, msg: str, mode: Popup) -> bool: """ Spawns a GenericDialog transient to the OuterWindow diff --git a/dzgui/data/CHANGELOG.md b/dzgui/data/CHANGELOG.md index 6ff240a..053e26e 100644 --- a/dzgui/data/CHANGELOG.md +++ b/dzgui/data/CHANGELOG.md @@ -26,6 +26,7 @@ - Reduce padding on keys button - Boldface breadcrumbs - Bold labels inside frames +- Unsticky keys when clicking sidebar ## Unreleased - Setup wizard diff --git a/dzgui/main.py b/dzgui/main.py index a01a86c..a2bcd57 100644 --- a/dzgui/main.py +++ b/dzgui/main.py @@ -3,14 +3,13 @@ import logging import os import sys -from dzgui.api.mods import remove_stale_signatures +from typing import TYPE_CHECKING +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.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 @@ -42,8 +41,6 @@ parser.add_argument("-u", "--uninstall", action="store_true", help=flags.uninsta parser.add_argument("-d", "--developers", action="store_true", help=flags.developers) args = parser.parse_args() -from typing import TYPE_CHECKING - if TYPE_CHECKING: from pathlib import Path @@ -110,7 +107,6 @@ def main() -> None: # TODO: move into module if has_steam_client() is False: EarlyAlertDialog(init.requires_steam) - sys.exit(1) is_dayz_installed(XDG.config) is_dayz_running() diff --git a/dzgui/util/cooldown.py b/dzgui/util/cooldown.py deleted file mode 100644 index 413a4d0..0000000 --- a/dzgui/util/cooldown.py +++ /dev/null @@ -1,16 +0,0 @@ -from datetime import datetime - -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 diff --git a/dzgui/util/strings.py b/dzgui/util/strings.py index d4da3c4..7594aac 100644 --- a/dzgui/util/strings.py +++ b/dzgui/util/strings.py @@ -86,9 +86,10 @@ scan_servers = "Scan LAN servers" select_port = "Select the query port" # Errors +error_heading = "Error" config_not_found = "DZGUI configuration file not found. Please exit and restart." cannot_acquire_lock = "DZGUI is already open." -something_wrong = "Something went wrong." +something_wrong = "Something went wrong. See the detailed error below." 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." @@ -154,8 +155,6 @@ label_main_menu = "Main menu" # buttons ping_servers = "Ping servers" debug_mode = "Debug mode" -keys_button = "Keys" -keys_tooltip = "Toggles the keybindings dialog" debug_tooltip = ( "Used to perform a dry run without\n" "actually connecting to a server" @@ -539,6 +538,20 @@ connect_panel = ConnectPanel( ) distance_suffix = "Distance: calculating..." +dialog_error = "ERROR" -refresh = "Refresh" -refresh_tooltip = "Refresh server data" +@dataclass(slots=True, frozen=True) +class AtomicButton: + refresh: str + refresh_tooltip: str + copy: str + keys: str + keys_tooltip: str + +atomic_buttons = AtomicButton( + refresh="Refresh", + refresh_tooltip="Refresh server data", + copy="Copy", + keys="Keys", + keys_tooltip="Toggles the keybindings dialog", +) diff --git a/dzgui/views/base.py b/dzgui/views/base.py index 19a6094..3982d20 100644 --- a/dzgui/views/base.py +++ b/dzgui/views/base.py @@ -8,7 +8,6 @@ import signal import subprocess import textwrap import threading -import traceback import typing # noqa import warnings @@ -466,3 +465,8 @@ class App(Gtk.Application): self.win.halt_proc_and_quit() MainController = Controller() + +# TODO: icon sanity check, log result +#theme = Gtk.IconTheme.get_default() +#icons = theme.list_icons(None) +#print("steam_tray_mono" in icons) diff --git a/dzgui/views/components/buttons.py b/dzgui/views/components/buttons.py index 8517a11..76064ad 100644 --- a/dzgui/views/components/buttons.py +++ b/dzgui/views/components/buttons.py @@ -1,6 +1,6 @@ -from typing import Callable, Self, TYPE_CHECKING +from typing import Self, TYPE_CHECKING -from dzgui.util.strings import refresh, connect_panel +from dzgui.util.strings import atomic_buttons, connect_panel from dzgui.const.constants import ( CLIPBOARD, INPUT_KEYBOARD, @@ -47,6 +47,8 @@ class IconTextButton(IconButton): super().__init__(icon, margin=5) self.set_label(label) + self.set_focus_on_click(False) + class LargeIconTextButton(IconButton): def __init__(self, icon: str, label: str) -> None: @@ -56,12 +58,14 @@ class LargeIconTextButton(IconButton): self.set_image(LargeIcon(icon)) -class ClipboardButton(IconButton): +class ClipboardButton(IconTextButton): def __init__(self, controller: "Controller", data: str) -> None: - super().__init__(CLIPBOARD) + super().__init__(CLIPBOARD, atomic_buttons.copy) self.controller = controller self.connect("clicked", self._on_button_clicked, data) + self.set_tooltip_text("Copy IP to clipboard") + def _on_button_clicked(self, button: Self, data: str) -> None: self.controller.copy_clipboard(data) @@ -74,19 +78,21 @@ class WebButton(IconTextButton): class RefreshButton(IconTextButton): def __init__(self) -> None: - super().__init__(icon=REFRESH_ICON, label=refresh) + super().__init__(icon=REFRESH_ICON, label=atomic_buttons.refresh) self.set_margin_top(10) self.set_margin_start(80) self.set_margin_end(80) class KeysButton(IconTextButton): - def __init__(self, label: str) -> None: - super().__init__(icon=INPUT_KEYBOARD, label=label) + def __init__(self) -> None: + super().__init__(icon=INPUT_KEYBOARD, label=atomic_buttons.keys) self.set_margin_top(10) self.set_margin_start(80) self.set_margin_end(80) + self.set_tooltip_text(atomic_buttons.keys_tooltip) + class SteamConnectButton(LargeIconTextButton): def __init__(self) -> None: diff --git a/dzgui/views/components/connect_panel.py b/dzgui/views/components/connect_panel.py index 03055c3..136ac38 100644 --- a/dzgui/views/components/connect_panel.py +++ b/dzgui/views/components/connect_panel.py @@ -1,7 +1,6 @@ from typing import TYPE_CHECKING from dzgui.api.servers import validate_ip -from dzgui.const.constants import NO_EXPAND, NO_FILL, NO_PADDING from dzgui.util.css import add_class, remove_class from dzgui.util.strings import connect_panel from dzgui.views.components.buttons import AddButton, ClipboardButton, SteamConnectButton @@ -14,6 +13,9 @@ from gi.repository import Gtk, Gdk # noqa E402 if TYPE_CHECKING: from dzgui.controllers.mc import Controller +COLS = 1 +ROWS = 1 + class LanPanel(Gtk.Frame): def __init__(self, controller: "Controller") -> None: super().__init__(margin_top=10, margin_bottom=5) @@ -24,26 +26,24 @@ class LanPanel(Gtk.Frame): self.set_label_widget(label) radio1 = Gtk.RadioButton.new_with_label(None, "Default port (27016)") - radio2 = Gtk.RadioButton.new_from_widget(radio1) - # TODO: new_with_label_from_widget - radio2.set_label("Custom port") + radio2 = Gtk.RadioButton.new_with_label_from_widget(radio1, "Custom port") - self.entry = Gtk.Entry(placeholder_text="Query port", sensitive=False) + self.entry = Gtk.Entry(placeholder_text="Enter the query port (1-65535)", sensitive=False, hexpand=True) self.button = Gtk.Button(label="Scan") radio1.connect("toggled", self._on_radio_toggled) - # TODO: use grid, more column spacing - hbox = Gtk.Box(margin=10, spacing=5, halign=Gtk.Align.START) - hbox.pack_start(radio1, NO_EXPAND, NO_FILL, NO_PADDING) - hbox.pack_start(radio2, NO_EXPAND, NO_FILL, NO_PADDING) - hbox.pack_start(self.entry, NO_EXPAND, NO_FILL, NO_PADDING) - hbox.pack_start(self.button, NO_EXPAND, NO_FILL, NO_PADDING) + self.grid = Gtk.Grid(margin=10, vexpand=False, column_spacing=15, row_spacing=5) + self.grid.attach(radio1, 0, 0, COLS, ROWS) + # TODO: expression to attach in a line + self.grid.attach_next_to(radio2, radio1, Gtk.PositionType.RIGHT, COLS, ROWS) + self.grid.attach_next_to(self.entry, radio2, Gtk.PositionType.RIGHT, COLS, ROWS) + self.grid.attach_next_to(self.button, self.entry, Gtk.PositionType.RIGHT, COLS, ROWS) - self.add(hbox) + self.add(self.grid) def _on_radio_toggled(self, button: Gtk.RadioButton) -> None: - for el in self.entry, self.button: - el.set_sensitive(not button.get_active()) + self.entry.set_sensitive(not button.get_active()) + # TODO: validate custom port entry class AddPanel(Gtk.Frame): def __init__(self, controller: "Controller") -> None: @@ -52,14 +52,10 @@ class AddPanel(Gtk.Frame): label = BoldLabel("Connect") self.set_label_widget(label) -class ConnectPanel(Gtk.Box): - def __init__(self, controller: "Controller") -> None: - super().__init__(orientation=Gtk.Orientation.VERTICAL) - - COLS = 1 - ROWS = 1 - self.classname = "invalid-entry" - self.controller = controller + self.add_server = AddButton() + self.conn_server = SteamConnectButton() + self.conn_server.set_sensitive(False) + self.add_server.set_sensitive(False) self.entry = Gtk.Entry( placeholder_text=connect_panel.placeholder, @@ -69,24 +65,6 @@ class ConnectPanel(Gtk.Box): self.entry.connect("key-press-event", self._on_entry_keypress) self.entry.connect("changed", self._on_text_changed) - user_fav, self.fav_ip = self.controller.get_favorite() - - server_name = f"{user_fav} ({self.fav_ip})" if user_fav is not None else connect_panel.no_fav - self.fav_label = Gtk.Label(label=server_name, track_visited_links=False, halign=Gtk.Align.START, hexpand=True) - scrollable_label = Gtk.ScrolledWindow() - scrollable_label.add(self.fav_label) - - self.fav_button = SteamConnectButton() - self.fav_edit = Gtk.Button(label=connect_panel.edit) - - self.add_server = AddButton() - self.conn_server = SteamConnectButton() - - self.conn_server.set_sensitive(False) - self.add_server.set_sensitive(False) - if server_name is None: - self.fav_button.set_sensitive(False) - self.grid = Gtk.Grid(margin=10, vexpand=False, column_spacing=15, row_spacing=5) self.grid.attach(self.entry, 0, 0, COLS, ROWS) @@ -98,32 +76,7 @@ class ConnectPanel(Gtk.Box): for el, sibling, pos, h_span, v_span in els: self.grid.attach_next_to(el, sibling, pos, h_span, v_span) - self.lan = LanPanel(self.controller) - self.add(self.lan) - - label = BoldLabel("Favorite server") - frame = Gtk.Frame(margin_top=10, margin_bottom=5, label_widget=label) - b = ClipboardButton(self.controller, self.get_fav_ip()) - b.set_focus_on_click(False) - b.set_tooltip_text("Copy IP to clipboard") - - # FIXME: height of connect buttons is not equivalent - grid = Gtk.Grid(margin=10, vexpand=False, column_spacing=15, row_spacing=5) - grid.attach(scrollable_label, 0, 0, 3, ROWS) - grid.attach_next_to(b, scrollable_label, Gtk.PositionType.RIGHT, COLS, ROWS) - grid.attach_next_to(self.fav_button, b, Gtk.PositionType.RIGHT, COLS, ROWS) - frame.add(grid) - self.add(frame) - - label = BoldLabel("Connect") - frame = Gtk.Frame(margin_top=10, margin_bottom=5, label_widget=label) - frame.add(self.grid) - self.add(frame) - - #self.lan.set_visible(False) - - def get_fav_ip(self) -> str: - return self.fav_ip + self.add(self.grid) def mark_valid(self) -> None: self.conn_server.set_sensitive(True) @@ -152,12 +105,53 @@ class ConnectPanel(Gtk.Box): else: self.mark_invalid() - def set_fav_label(self, text: str) -> None: - # TODO: called by controller when changing fav - self.fav_label.set_text(text) - def _on_entry_keypress(self, entry: Gtk.Entry, event: Gdk.EventKey) -> None: if event.keyval == Gdk.KEY_Escape: # NOTE: unselect text entry.select_region(0, 0) self.controller.grab_active_treeview() + + +class ConnectPanel(Gtk.Box): + def __init__(self, controller: "Controller") -> None: + super().__init__(orientation=Gtk.Orientation.VERTICAL) + + self.classname = "invalid-entry" + self.controller = controller + + user_fav, self.fav_ip = self.controller.get_favorite() + + server_name = f"{user_fav} ({self.fav_ip})" if user_fav is not None else connect_panel.no_fav + self.fav_label = Gtk.Label(label=server_name, track_visited_links=False, halign=Gtk.Align.START, hexpand=True) + scrollable_label = Gtk.ScrolledWindow() + scrollable_label.add(self.fav_label) + + self.fav_button = SteamConnectButton() + self.fav_edit = Gtk.Button(label=connect_panel.edit) + if server_name is None: + self.fav_button.set_sensitive(False) + + self.lan = LanPanel(self.controller) + self.add_panel = AddPanel(self.controller) + + label = BoldLabel("Favorite server") + frame = Gtk.Frame(margin_top=10, margin_bottom=5, label_widget=label) + b = ClipboardButton(self.controller, self.get_fav_ip()) + + # FIXME: height of connect buttons is not equivalent + grid = Gtk.Grid(margin=10, vexpand=False, column_spacing=15, row_spacing=5) + grid.attach(scrollable_label, 0, 0, 3, ROWS) + grid.attach_next_to(b, scrollable_label, Gtk.PositionType.RIGHT, COLS, ROWS) + grid.attach_next_to(self.fav_button, b, Gtk.PositionType.RIGHT, COLS, ROWS) + frame.add(grid) + + for el in self.lan, frame, self.add_panel: + self.add(el) + + def get_fav_ip(self) -> str: + return self.fav_ip + + def set_fav_label(self, text: str) -> None: + # TODO: called by controller when changing fav + self.fav_label.set_text(text) + diff --git a/dzgui/views/components/right_panel.py b/dzgui/views/components/right_panel.py index c657c8a..b994ee0 100644 --- a/dzgui/views/components/right_panel.py +++ b/dzgui/views/components/right_panel.py @@ -10,7 +10,7 @@ from dzgui.views.components.filter_panel import FilterPanel from dzgui.views.components.mod_panel import ModSelectionPanel from dzgui.views.components.buttons import RefreshButton, KeysButton from dzgui.const.constants import NO_EXPAND, NO_FILL, EXPAND, FILL, INPUT_KEYBOARD, NO_PADDING -from dzgui.util.strings import refresh_tooltip, refresh, keys_button, keys_tooltip +from dzgui.util.strings import atomic_buttons if TYPE_CHECKING: from GLib import SOURCE_REMOVE @@ -31,14 +31,13 @@ class RightPanel(Gtk.Box): self.refresh_button = RefreshButton() # TODO: consolidate into class - self.refresh_button.set_tooltip_text(refresh_tooltip) + #self.refresh_button.set_tooltip_text(refresh_tooltip) self.refresh_button.set_focus_on_click(False) self.refresh_button.connect("clicked", self._on_refresh_clicked) - self.keys = KeysButton(keys_button) - self.keys.set_focus_on_click(False) - self.keys.set_tooltip_text(keys_tooltip) + self.keys = KeysButton() self.keys.connect("clicked", self._on_keys_clicked) + #self.keys.set_focus_on_click(False) for el in self.button_vbox, self.keys, self.filters_vbox, self.refresh_button: self.pack_start(el, NO_EXPAND, FILL, NO_PADDING) @@ -61,13 +60,14 @@ class RightPanel(Gtk.Box): self.refresh_button.set_label(refresh) self.refresh_button.set_sensitive(True) return GLib.SOURCE_REMOVE - self.refresh_button.set_label(f"{refresh} ({str(self.time)})") + # TODO: abstract this formatting logic + self.refresh_button.set_label(f"{atomic_buttons.refresh} ({str(self.time)})") return True def _on_refresh_clicked(self, button: RefreshButton) -> None: # TODO: could be internal to refresh button class self.refresh_button.set_sensitive(False) - self.refresh_button.set_label(f"{refresh} ({str(self.time)})") + self.refresh_button.set_label(f"{atomic_buttons.refresh} ({str(self.time)})") GLib.timeout_add_seconds(1, self.decrement) self.controller.refresh_tree() diff --git a/dzgui/views/dialogs/early_alert.py b/dzgui/views/dialogs/early_alert.py index 03b70ff..f4de721 100644 --- a/dzgui/views/dialogs/early_alert.py +++ b/dzgui/views/dialogs/early_alert.py @@ -1,22 +1,21 @@ import textwrap +import sys + +from typing import Self +from dzgui.util.strings import dialog_error, dialog_header 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 + def __init__(self, string: str) -> None: super().__init__( - title="DZGUI - Dialog", - flags=0, - text="ERROR", + title=dialog_header, + text=dialog_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) @@ -28,8 +27,10 @@ class EarlyAlertDialog(Gtk.MessageDialog): self.set_default_size(250, 100) - self.connect("delete-event", Gtk.main_quit) - self.connect("destroy", Gtk.main_quit) + self.connect("response", self._on_response) - self.show_all() - Gtk.main() + self.run() + self.destroy() + + def _on_response(self, dialog: Self, response: Gtk.ResponseType) -> None: + sys.exit(1) diff --git a/dzgui/views/dialogs/generic.py b/dzgui/views/dialogs/generic.py index e834e85..423caad 100644 --- a/dzgui/views/dialogs/generic.py +++ b/dzgui/views/dialogs/generic.py @@ -4,147 +4,103 @@ from typing import Any, Literal, Self, TYPE_CHECKING from dzgui.const.constants import NO_EXPAND, NO_FILL, EXPAND, FILL from dzgui.const.enum import Popup, ButtonType, NotebookPage from dzgui.util import strings +from dzgui.views.components.buttons import ClipboardButton 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 ExceptionDialog(Gtk.MessageDialog): - def __init__(self, controller: "Controller", trace: str): - super().__init__( - transient_for=controller.mediator.window, - message_type=Gtk.MessageType.ERROR, - buttons=Gtk.ButtonsType.OK, - text="Error", - secondary_text="Something went wrong. See the detailed error below.", - title=strings.dialog_header, - modal=True, - ) - - # TODO: strings - self.set_size_request(550, 250) - - from dzgui.views.components.buttons import ClipboardButton - content = self.get_content_area() - content.set_spacing(0) - - box = Gtk.Box(hexpand=True, vexpand=True, orientation=Gtk.Orientation.VERTICAL) - textview = Gtk.TextView(wrap_mode=Gtk.WrapMode.WORD, editable=False, left_margin=10, right_margin=10) - textview.set_buffer(Gtk.TextBuffer(text=trace)) - # NOTE: box expands to end of content area - box.pack_start(textview, EXPAND, FILL, 10) - - action_area = self.get_action_area() - action_area.set_spacing(10) - action_area.set_margin_bottom(10) - action_area.set_layout(Gtk.ButtonBoxStyle.CENTER) - - but = ClipboardButton(controller, trace) - # TODO: flag to set button with text, or other class - but.set_label("Copy") - action_area.pack_start(but, True, True, 10) - # NOTE: reverse button order after insertion - action_area.set_direction(Gtk.TextDirection.RTL) - content.add(box) - - self.set_default_response(Gtk.ResponseType.OK) - self.connect("response", self._on_response) - self.show_all() - """ - usage: - from dzgui.views.dialogs.generic import ExceptionDialog - try: - a.banana() - except Exception as e: - trace = traceback.format_exc() - dialog = ExceptionDialog(MainController, trace) - dialog.run() - """ - - - def _on_response(self, dialog: Self, response: Gtk.ResponseType) -> None: - self.destroy() +# TODO: reimplement as standalone dialogs +# NOTE: steam deck prints <2> if dialog title is same as window title +# 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 +# TODO: unused +# def update_label(self, text: str) -> None: +# self.format_secondary_text(text) 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 - - Gtk.MessageDialog.__init__( - self, + def __init__(self, + controller: "Controller", + text: str, + mtype: Gtk.MessageType, + buttons: Gtk.ButtonsType, + secondary: str + ) -> None: + super().__init__( transient_for=controller.mediator.window, - message_type=dialog_type, - buttons=button_type, - text=header_text, - secondary_text=textwrap.fill(text, 50), - # NOTE: steam deck prints <2> if dialog title is same as window title + message_type=mtype, + buttons=buttons, + text=text, + secondary_text=secondary, 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.set_default_response(Gtk.ResponseType.OK) + self.connect("response", self._on_response) + 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_response(self, dialog: Self, response: Gtk.ResponseType) -> None: + self.destroy() + + +class ConfirmationDialog(GenericDialog): + def __init__(self, controller: "Controller", secondary: str): + super().__init__( + controller=controller, + text=strings.confirm, + mtype=Gtk.MessageType.QUESTION, + buttons=Gtk.ButtonsType.OK_CANCEL, + secondary=secondary, + ) + + +class NotifyDialog(GenericDialog): + def __init__(self, controller: "Controller", secondary: str): + super().__init__( + controller=controller, + text=strings.notice, + mtype=Gtk.MessageType.INFO, + buttons=Gtk.ButtonsType.OK, + secondary=secondary, + ) + + +class WaitDialog(GenericDialog): + def __init__(self, controller: "Controller", secondary: str): + super().__init__( + controller=controller, + text=strings.wait, + mtype=Gtk.MessageType.INFO, + buttons=Gtk.ButtonsType.NONE, + secondary=secondary, + ) + + self.connect("delete-event", self._on_dialog_delete) + content = self.get_content_area() + spinner = Gtk.Spinner() + content.pack_end(spinner, NO_EXPAND, NO_FILL, 0) + + spinner.start() + self.show_all() def _on_dialog_delete( self, response_id: Gtk.ResponseType, event: Gdk.Event @@ -154,8 +110,81 @@ class GenericDialog(Gtk.MessageDialog): """ 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) +class QuitDialog(GenericDialog): + def __init__(self, controller: "Controller", secondary: str): + super().__init__( + controller=controller, + text=strings.wait, + mtype=Gtk.MessageType.INFO, + buttons=Gtk.ButtonsType.NONE, + secondary=secondary, + ) + + self.controller = controller + self.add_button(strings.exit_app, Gtk.ResponseType.OK) + + self.connect("response", self._on_response) + self.connect("delete-event", self._on_response) + + def _on_response(self, dialog: Self, response: Gtk.ResponseType) -> None: + self.controller.save_res_and_quit() + + +class ExceptionDialog(GenericDialog): + """ + Error dialog with rich traceback. + Usage: + try: + foo() + except Exception: + trace = traceback.format_exc() + dialog = ExceptionDialog(Controller, trace) + dialog.run() + """ + def __init__(self, controller: "Controller", trace: str): + super().__init__( + controller=controller, + text=strings.error_heading, + mtype=Gtk.MessageType.ERROR, + buttons=Gtk.ButtonsType.NONE, + secondary=strings.something_wrong, + ) + + # NOTE: box expands to end of content area + scrollable = Gtk.ScrolledWindow( + propagate_natural_height=True, + max_content_height=500 + ) + box = Gtk.Box(hexpand=True, vexpand=True, orientation=Gtk.Orientation.VERTICAL) + textview = Gtk.TextView( + wrap_mode=Gtk.WrapMode.WORD, + editable=False, + left_margin=10, + right_margin=10 + ) + textview.set_buffer(Gtk.TextBuffer(text=trace)) + box.pack_start(textview, EXPAND, FILL, 10) + scrollable.add(box) + + content = self.get_content_area() + content.set_spacing(0) + # FIXME: padding around top of content area when traceback is long + content.add(scrollable) + + copy_button = ClipboardButton(controller, trace) + self.add_action_widget(copy_button, Gtk.ResponseType.NONE) + self.add_button("OK", Gtk.ResponseType.OK) + action_area = self.get_action_area() + self.show_all() + + self.connect("response", self._on_response) + + def _on_response(self, dialog: Self, response: Gtk.ResponseType) -> None: + match response: + case Gtk.ResponseType.OK: + self.destroy() + case Gtk.ResponseType.NONE: + return True # type: ignore + case Gtk.ResponseType.DELETE_EVENT: + self.destroy() diff --git a/dzgui/views/pages/options.py b/dzgui/views/pages/options.py index 8cf053c..efcaf90 100644 --- a/dzgui/views/pages/options.py +++ b/dzgui/views/pages/options.py @@ -424,7 +424,6 @@ class Options(Gtk.Box): return box def populate_settings(self) -> None: - # TODO: controller.get_config() prefs = self.controller.get_prefs() if prefs.paths.config.is_file() is False: # NOTE: in case file got deleted locally diff --git a/tests/test_cooldown.py b/tests/test_cooldown.py deleted file mode 100644 index 32ce40c..0000000 --- a/tests/test_cooldown.py +++ /dev/null @@ -1,19 +0,0 @@ -import pytest -import time - -from dzgui.util import cooldown - - -def test_time_under(): - time_before = cooldown.get_time() - time.sleep(1) - result = cooldown.is_elapsed(time_before) - assert result is False - - -@pytest.mark.slow -def test_time_over(): - time_before = cooldown.get_time() - time.sleep(31) - result = cooldown.is_elapsed(time_before) - assert result is True From b119569cf5bbdbefec3ddf16fe2d353eaf0be0bb Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Mon, 12 Jan 2026 03:46:44 +0900 Subject: [PATCH 006/309] fix: move class attribs --- dzgui/views/components/connect_panel.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/dzgui/views/components/connect_panel.py b/dzgui/views/components/connect_panel.py index 136ac38..c5610ea 100644 --- a/dzgui/views/components/connect_panel.py +++ b/dzgui/views/components/connect_panel.py @@ -49,6 +49,9 @@ class AddPanel(Gtk.Frame): def __init__(self, controller: "Controller") -> None: super().__init__(margin_top=10, margin_bottom=5) + self.controller = controller + self.classname = "invalid-entry" + label = BoldLabel("Connect") self.set_label_widget(label) @@ -116,7 +119,6 @@ class ConnectPanel(Gtk.Box): def __init__(self, controller: "Controller") -> None: super().__init__(orientation=Gtk.Orientation.VERTICAL) - self.classname = "invalid-entry" self.controller = controller user_fav, self.fav_ip = self.controller.get_favorite() From fdb6b342f728fb7048fe72ee30280d9e945a8e64 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Mon, 12 Jan 2026 23:50:27 +0900 Subject: [PATCH 007/309] feat: dynamic port validation --- dzgui/controllers/mc.py | 3 +- dzgui/util/strings.py | 36 +++++- dzgui/views/components/buttons.py | 58 +++++++-- dzgui/views/components/connect_panel.py | 154 ++++++++++-------------- dzgui/views/components/right_panel.py | 48 ++------ dzgui/views/trees/tree_servers.py | 3 +- 6 files changed, 153 insertions(+), 149 deletions(-) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 515f315..2fbe2d8 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -694,7 +694,8 @@ class Controller: if len(fav) < 1: return None, None ip = str(self.query_config(Preferences.FAV_SRV)) - return fav, ip + addr = ip.split(":") + return fav, f"{addr[0]}:{addr[2]}" def get_dist_cache(self) -> dict[str, "Haversine"]: return self.dist_cache diff --git a/dzgui/util/strings.py b/dzgui/util/strings.py index 7594aac..a72bd6e 100644 --- a/dzgui/util/strings.py +++ b/dzgui/util/strings.py @@ -512,31 +512,55 @@ server_labels = ServerLabels( class ConnectPanel: connect: str add: str - edit: str - favorite: str add_con: str placeholder: str entry_tooltip: str add_tooltip: str connect_tooltip: str - no_fav: str connect_panel = ConnectPanel( connect="Connect", add="Add", - edit="Edit", - favorite="Favorite server", add_con="Add/connect", placeholder="Enter IP or Battlemetrics ID", entry_tooltip=( - "- IP: Format as IP:Query port\ne.g. 192.168.1.1:27016\n" + "- IP: format as IP:Query port\ne.g. 192.168.1.1:27016\n" "- Battlemetrics: numeric server ID\ne.g. 123456" ), add_tooltip="Add to my servers", connect_tooltip="Connect to this server", +) + +@dataclass(slots=True, frozen=True) +class FavPanel: + heading: str + no_fav: str + +fav_panel = FavPanel( + heading="Favorite server", no_fav="None set. Right click a server and select 'Set favorite' to set.", ) +@dataclass(slots=True, frozen=True) +class LanPanel: + heading: str + default_button: str + custom_button: str + scan_button: str + placeholder: str + entry_tooltip: str + +lan_panel = LanPanel( + heading="LAN query port", + default_button="Default port (27016)", + custom_button="Custom port", + scan_button="Scan", + placeholder="Enter the query port (1-65535)", + entry_tooltip="Specify the port to search for DayZ servers on the local network" +) + + + distance_suffix = "Distance: calculating..." dialog_error = "ERROR" diff --git a/dzgui/views/components/buttons.py b/dzgui/views/components/buttons.py index 76064ad..6a997c6 100644 --- a/dzgui/views/components/buttons.py +++ b/dzgui/views/components/buttons.py @@ -1,4 +1,4 @@ -from typing import Self, TYPE_CHECKING +from typing import Literal, Self, TYPE_CHECKING from dzgui.util.strings import atomic_buttons, connect_panel from dzgui.const.constants import ( @@ -12,22 +12,24 @@ from dzgui.const.constants import ( import gi gi.require_version("Gtk", "3.0") -from gi.repository import Gtk # noqa E402 +from gi.repository import Gtk, GLib # noqa E402 if TYPE_CHECKING: + from GLib import SOURCE_REMOVE from dzgui.controllers.mc import Controller class Icon(Gtk.Image): - def __init__(self, name: str, l_margin=0) -> None: + def __init__(self, name: str, l_margin: int = 0) -> None: super().__init__(icon_name=name, icon_size=Gtk.IconSize.BUTTON, margin_start=l_margin, ypad=2, ) + class LargeIcon(Gtk.Image): - def __init__(self, name: str, l_margin=5) -> None: + def __init__(self, name: str, l_margin: int = 5) -> None: super().__init__(icon_name=name, icon_size=Gtk.IconSize.LARGE_TOOLBAR, margin_start=l_margin @@ -40,6 +42,7 @@ class IconButton(Gtk.Button): self.icon = Icon(icon, l_margin=margin) self.set_image(self.icon) self.set_image_position(Gtk.PositionType.RIGHT) + self.set_focus_on_click(False) class IconTextButton(IconButton): @@ -47,13 +50,10 @@ class IconTextButton(IconButton): super().__init__(icon, margin=5) self.set_label(label) - self.set_focus_on_click(False) - class LargeIconTextButton(IconButton): def __init__(self, icon: str, label: str) -> None: super().__init__(icon) - self.set_label(label) self.set_image(LargeIcon(icon)) @@ -77,22 +77,60 @@ class WebButton(IconTextButton): class RefreshButton(IconTextButton): - def __init__(self) -> None: - super().__init__(icon=REFRESH_ICON, label=atomic_buttons.refresh) + def __init__(self, controller: "Controller") -> None: + super().__init__( + icon=REFRESH_ICON, + label=atomic_buttons.refresh, + ) + self.controller = controller + self.time = 30 + self.set_margin_top(10) self.set_margin_start(80) self.set_margin_end(80) + self.set_tooltip_text(atomic_buttons.refresh_tooltip) + + self.connect("clicked", self._on_refresh_clicked) + + def _on_refresh_clicked(self, button: Self) -> None: + self.set_sensitive(False) + self.show_time(True) + GLib.timeout_add_seconds(1, self.decrement) + self.controller.refresh_tree() + + def decrement(self) -> "SOURCE_REMOVE" | Literal[True]: + self.time -= 1 + if self.time == 0: + self.time = 30 + self.show_time(False) + self.set_sensitive(True) + return GLib.SOURCE_REMOVE + self.show_time(True) + return True + + def show_time(self, state: bool) -> None: + if state is True: + self.set_label(f"{atomic_buttons.refresh} ({str(self.time)})") + else: + self.set_label(atomic_buttons.refresh) class KeysButton(IconTextButton): - def __init__(self) -> None: + def __init__(self, controller: "Controller") -> None: super().__init__(icon=INPUT_KEYBOARD, label=atomic_buttons.keys) + + self.controller = controller self.set_margin_top(10) self.set_margin_start(80) self.set_margin_end(80) self.set_tooltip_text(atomic_buttons.keys_tooltip) + self.connect("clicked", self._on_keys_clicked) + + def _on_keys_clicked(self, button: Gtk.Button) -> None: + self.controller.open_keybindings() + class SteamConnectButton(LargeIconTextButton): def __init__(self) -> None: diff --git a/dzgui/views/components/connect_panel.py b/dzgui/views/components/connect_panel.py index c5610ea..efdb26d 100644 --- a/dzgui/views/components/connect_panel.py +++ b/dzgui/views/components/connect_panel.py @@ -2,8 +2,9 @@ from typing import TYPE_CHECKING from dzgui.api.servers import validate_ip from dzgui.util.css import add_class, remove_class -from dzgui.util.strings import connect_panel +from dzgui.util.strings import connect_panel, fav_panel, lan_panel from dzgui.views.components.buttons import AddButton, ClipboardButton, SteamConnectButton +from dzgui.views.components.entry import IpEntry, PortEntry from dzgui.views.components.labels import BoldLabel import gi @@ -21,29 +22,70 @@ class LanPanel(Gtk.Frame): super().__init__(margin_top=10, margin_bottom=5) # TODO: hide lan panel on other tabs - # TODO: strings - label = BoldLabel("LAN query port") + label = BoldLabel(lan_panel.heading) self.set_label_widget(label) - radio1 = Gtk.RadioButton.new_with_label(None, "Default port (27016)") - radio2 = Gtk.RadioButton.new_with_label_from_widget(radio1, "Custom port") + radio1 = Gtk.RadioButton.new_with_label(None, lan_panel.default_button) + radio2 = Gtk.RadioButton.new_with_label_from_widget(radio1, lan_panel.custom_button) - self.entry = Gtk.Entry(placeholder_text="Enter the query port (1-65535)", sensitive=False, hexpand=True) - self.button = Gtk.Button(label="Scan") + self.entry = PortEntry(controller) + self.scan = Gtk.Button(label=lan_panel.scan_button) + + self.entry.connect("string_validated", self._on_port_validated) radio1.connect("toggled", self._on_radio_toggled) self.grid = Gtk.Grid(margin=10, vexpand=False, column_spacing=15, row_spacing=5) self.grid.attach(radio1, 0, 0, COLS, ROWS) - # TODO: expression to attach in a line self.grid.attach_next_to(radio2, radio1, Gtk.PositionType.RIGHT, COLS, ROWS) self.grid.attach_next_to(self.entry, radio2, Gtk.PositionType.RIGHT, COLS, ROWS) - self.grid.attach_next_to(self.button, self.entry, Gtk.PositionType.RIGHT, COLS, ROWS) + self.grid.attach_next_to(self.scan, self.entry, Gtk.PositionType.RIGHT, COLS, ROWS) self.add(self.grid) def _on_radio_toggled(self, button: Gtk.RadioButton) -> None: self.entry.set_sensitive(not button.get_active()) - # TODO: validate custom port entry + if len(self.entry.get_text()) < 1: + self.scan.set_sensitive(False) + + def _on_port_validated(self, entry: Gtk.Entry, state: bool) -> None: + self.scan.set_sensitive(state) + +class FavPanel(Gtk.Frame): + def __init__(self, controller: "Controller") -> None: + super().__init__(margin_top=10, margin_bottom=5) + + label = BoldLabel(fav_panel.heading) + self.set_label_widget(label) + + self.controller = controller + + # TODO: improve upon this, cache fav ip when changed globally + user_fav, self.fav_ip = self.controller.get_favorite() + server_name = f"{user_fav} ({self.fav_ip})" if user_fav is not None else connect_panel.no_fav + self.fav_label = Gtk.Label(label=server_name, track_visited_links=False, halign=Gtk.Align.START, hexpand=True) + scrollable_label = Gtk.ScrolledWindow() + scrollable_label.add(self.fav_label) + + self.fav_button = SteamConnectButton() + if server_name is None: + self.fav_button.set_sensitive(False) + copy = ClipboardButton(self.controller, self.get_fav_ip()) + + # FIXME: height of connect buttons is not equivalent + grid = Gtk.Grid(margin=10, vexpand=False, column_spacing=15, row_spacing=5) + grid.attach(scrollable_label, 0, 0, 3, ROWS) + grid.attach_next_to(copy, scrollable_label, Gtk.PositionType.RIGHT, COLS, ROWS) + grid.attach_next_to(self.fav_button, copy, Gtk.PositionType.RIGHT, COLS, ROWS) + + self.add(grid) + + def get_fav_ip(self) -> str: + return self.fav_ip + + def set_fav_label(self, text: str) -> None: + # TODO: called by controller when changing fav + self.fav_label.set_text(text) + class AddPanel(Gtk.Frame): def __init__(self, controller: "Controller") -> None: @@ -60,59 +102,19 @@ class AddPanel(Gtk.Frame): self.conn_server.set_sensitive(False) self.add_server.set_sensitive(False) - self.entry = Gtk.Entry( - placeholder_text=connect_panel.placeholder, - hexpand=True, - tooltip_text=connect_panel.entry_tooltip - ) - self.entry.connect("key-press-event", self._on_entry_keypress) - self.entry.connect("changed", self._on_text_changed) + self.entry = IpEntry(controller) + self.entry.connect("string_validated", self._on_ip_validated) self.grid = Gtk.Grid(margin=10, vexpand=False, column_spacing=15, row_spacing=5) self.grid.attach(self.entry, 0, 0, COLS, ROWS) - - els = ( - (self.add_server, self.entry, Gtk.PositionType.RIGHT, COLS, ROWS), - (self.conn_server, self.add_server, Gtk.PositionType.RIGHT, COLS, ROWS), - ) - - for el, sibling, pos, h_span, v_span in els: - self.grid.attach_next_to(el, sibling, pos, h_span, v_span) + self.grid.attach_next_to(self.add_server, self.entry, Gtk.PositionType.RIGHT, COLS, ROWS) + self.grid.attach_next_to(self.conn_server, self.add_server, Gtk.PositionType.RIGHT, COLS, ROWS) self.add(self.grid) - def mark_valid(self) -> None: - self.conn_server.set_sensitive(True) - self.add_server.set_sensitive(True) - remove_class(self.entry, self.classname) - - def mark_invalid(self) -> None: - self.conn_server.set_sensitive(False) - self.add_server.set_sensitive(False) - add_class(self.entry, self.classname) - - def _on_text_changed(self, entry: Gtk.Entry) -> None: - # TODO: on submission, strip whitespace and newlines - text = entry.get_text() - if len(text) < 1: - self.conn_server.set_sensitive(False) - self.add_server.set_sensitive(False) - remove_class(entry, self.classname) - return - try: - validate_ip(text) - self.mark_valid() - except Exception: - if text.isdigit(): - self.mark_valid() - else: - self.mark_invalid() - - def _on_entry_keypress(self, entry: Gtk.Entry, event: Gdk.EventKey) -> None: - if event.keyval == Gdk.KEY_Escape: - # NOTE: unselect text - entry.select_region(0, 0) - self.controller.grab_active_treeview() + def _on_ip_validated(self, entry: Gtk.Entry, state: bool) -> None: + self.conn_server.set_sensitive(state) + self.add_server.set_sensitive(state) class ConnectPanel(Gtk.Box): @@ -121,39 +123,9 @@ class ConnectPanel(Gtk.Box): self.controller = controller - user_fav, self.fav_ip = self.controller.get_favorite() + self.lan = LanPanel(controller) + self.fav = FavPanel(controller) + self.add_panel = AddPanel(controller) - server_name = f"{user_fav} ({self.fav_ip})" if user_fav is not None else connect_panel.no_fav - self.fav_label = Gtk.Label(label=server_name, track_visited_links=False, halign=Gtk.Align.START, hexpand=True) - scrollable_label = Gtk.ScrolledWindow() - scrollable_label.add(self.fav_label) - - self.fav_button = SteamConnectButton() - self.fav_edit = Gtk.Button(label=connect_panel.edit) - if server_name is None: - self.fav_button.set_sensitive(False) - - self.lan = LanPanel(self.controller) - self.add_panel = AddPanel(self.controller) - - label = BoldLabel("Favorite server") - frame = Gtk.Frame(margin_top=10, margin_bottom=5, label_widget=label) - b = ClipboardButton(self.controller, self.get_fav_ip()) - - # FIXME: height of connect buttons is not equivalent - grid = Gtk.Grid(margin=10, vexpand=False, column_spacing=15, row_spacing=5) - grid.attach(scrollable_label, 0, 0, 3, ROWS) - grid.attach_next_to(b, scrollable_label, Gtk.PositionType.RIGHT, COLS, ROWS) - grid.attach_next_to(self.fav_button, b, Gtk.PositionType.RIGHT, COLS, ROWS) - frame.add(grid) - - for el in self.lan, frame, self.add_panel: + for el in self.lan, self.fav, self.add_panel: self.add(el) - - def get_fav_ip(self) -> str: - return self.fav_ip - - def set_fav_label(self, text: str) -> None: - # TODO: called by controller when changing fav - self.fav_label.set_text(text) - diff --git a/dzgui/views/components/right_panel.py b/dzgui/views/components/right_panel.py index b994ee0..00e26e3 100644 --- a/dzgui/views/components/right_panel.py +++ b/dzgui/views/components/right_panel.py @@ -1,27 +1,23 @@ from typing import Literal, TYPE_CHECKING -import gi # noqa E402 -gi.require_version("Gtk", "3.0") -from gi.repository import Gtk, Gdk, GLib - -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.mod_panel import ModSelectionPanel from dzgui.views.components.buttons import RefreshButton, KeysButton -from dzgui.const.constants import NO_EXPAND, NO_FILL, EXPAND, FILL, INPUT_KEYBOARD, NO_PADDING +from dzgui.const.constants import NO_EXPAND, NO_FILL, FILL, NO_PADDING from dzgui.util.strings import atomic_buttons +import gi +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk, GLib # noqa E402 + if TYPE_CHECKING: from GLib import SOURCE_REMOVE -# TODO: refactor depends on ServerTreeView class RightPanel(Gtk.Box): def __init__(self, controller): super().__init__(spacing=6, orientation=Gtk.Orientation.VERTICAL) - self.time = 30 - self.controller = controller self.controller.register_widget("right_panel", self) @@ -29,15 +25,8 @@ class RightPanel(Gtk.Box): self.filters_vbox = FilterPanel(controller) self.sel_panel = ModSelectionPanel(controller) - self.refresh_button = RefreshButton() - # TODO: consolidate into class - #self.refresh_button.set_tooltip_text(refresh_tooltip) - self.refresh_button.set_focus_on_click(False) - self.refresh_button.connect("clicked", self._on_refresh_clicked) - - self.keys = KeysButton() - self.keys.connect("clicked", self._on_keys_clicked) - #self.keys.set_focus_on_click(False) + self.refresh_button = RefreshButton(controller) + self.keys = KeysButton(controller) for el in self.button_vbox, self.keys, self.filters_vbox, self.refresh_button: self.pack_start(el, NO_EXPAND, FILL, NO_PADDING) @@ -48,29 +37,11 @@ class RightPanel(Gtk.Box): def reinit_maps(self, rows: list) -> None: self.controller.reinit_map_store() # TODO: communicate with controller - #controller.clear_map_store() + #self.controller.clear_map_store() #map_store.append(["All maps"]) self.selected = "All maps" self.filters_vbox.set_unique_maps(rows) - def decrement(self) -> "SOURCE_REMOVE" | Literal[True]: - self.time -= 1 - if self.time == 0: - self.time = 30 - self.refresh_button.set_label(refresh) - self.refresh_button.set_sensitive(True) - return GLib.SOURCE_REMOVE - # TODO: abstract this formatting logic - self.refresh_button.set_label(f"{atomic_buttons.refresh} ({str(self.time)})") - return True - - def _on_refresh_clicked(self, button: RefreshButton) -> None: - # TODO: could be internal to refresh button class - self.refresh_button.set_sensitive(False) - self.refresh_button.set_label(f"{atomic_buttons.refresh} ({str(self.time)})") - GLib.timeout_add_seconds(1, self.decrement) - self.controller.refresh_tree() - # TODO: reference #def _on_ping_clicked(self, button: Gtk.Button) -> None: # block_signals() @@ -103,8 +74,5 @@ class RightPanel(Gtk.Box): # thread = threading.Thread(target=_update_pings, args=()) # thread.start() - def _on_keys_clicked(self, button: Gtk.Button) -> None: - self.controller.open_keybindings() - def focus_button_box(self) -> None: self.button_vbox.buttons[0].grab_focus() diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index 4975cac..800d016 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -211,7 +211,8 @@ class ServerTreeView(TreeView): case Gdk.KEY_m: self.controller.mediator.grid.right_panel.filters_vbox.maps_entry.grab_focus() case Gdk.KEY_i: - self.controller.mediator.grid.conpan.entry.grab_focus() + # TODO: + self.controller.mediator.grid.conpan.add_panel.entry.grab_focus() else: match event.keyval: case Gdk.KEY_l | Gdk.KEY_Right: From 00ca59698cb2a889d784a3b99f3c6979b0b2c447 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Tue, 13 Jan 2026 02:56:56 +0900 Subject: [PATCH 008/309] feat: threading WIP --- dzgui/controllers/mc.py | 39 +++++++++++++++++++----- dzgui/util/strings.py | 6 ++-- dzgui/views/base.py | 4 ++- dzgui/views/components/right_panel.py | 3 +- dzgui/views/dialogs/generic.py | 3 +- dzgui/views/pages/servers.py | 43 ++++++++++++++++----------- 6 files changed, 66 insertions(+), 32 deletions(-) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 2fbe2d8..dd52852 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -91,8 +91,9 @@ class Controller: self.crumbs_cache = "" self.mediator = AppNavigation() self.prefs: UserPrefs - self.model_manager = ModelManager() + # TODO: poc + self.loaded = False def register_widget(self, attr: str, widget: Gtk.Widget) -> None: try: @@ -483,6 +484,8 @@ class Controller: # NOTE: manipulates mod store case ContextMenu.DELETE_MOD: self.delete_single_mod(path) + # TODO: hook treeview changed signal + # Gtk.TreeModel, row-inserted/row-deleted self.update_mod_statusbar() remove_stale_signatures( self.prefs.paths.config, @@ -545,11 +548,31 @@ class Controller: def unselect_all_mods(self) -> None: self.mediator.modtreeview.get_selection().unselect_all() + def dump_test(self) -> None: + import time + time.sleep(1) + # TODO: use model managers, etc. + self.data = (["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False]) + # TODO: refer to prior implementation--should need to load data into model while in thread + self.get_func_data() + self.destroy_on_idle() + + def get_func_data(self) -> tuple: + # TODO: use model manager + treeview = self.get_active_treeview() + model = treeview.get_model() + data = self.data + model.append(data) + treeview.set_loaded(True) + self.update_server_status() + treeview.grab_focus() + 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) + from dzgui.views.dialogs.generic import WaitDialog + self.wait_dialog = WaitDialog(self, strings.dialog.fetching) self.wait_dialog.show_all() thread = threading.Thread(target=func, args=args) thread.start() @@ -644,21 +667,21 @@ class Controller: self.populate_model() def populate_model(self) -> None: + print("POPULATING MODEL") # TODO: always use same server model, store in servertreeview class treeview = self.get_active_treeview() if treeview.get_loaded() is False: new_model = self.model_manager.new_model() # NOTE: set_query_func() func = treeview.get_query_func() + print(func) if func is not None: model = treeview.get_model() + print(model) + # TODO: this may lag? model.clear() - data = func() - # TODO: threading - model.append(data) - treeview.set_loaded(True) - self.update_server_status() - treeview.grab_focus() + self.set_callback(None, None)#self.get_func_data) + self.call_on_thread(func) def focus_button_box(self) -> None: self.mediator.right_panel.focus_button_box() diff --git a/dzgui/util/strings.py b/dzgui/util/strings.py index a72bd6e..709d816 100644 --- a/dzgui/util/strings.py +++ b/dzgui/util/strings.py @@ -549,6 +549,7 @@ class LanPanel: scan_button: str placeholder: str entry_tooltip: str + scan_tooltip: str lan_panel = LanPanel( heading="LAN query port", @@ -556,11 +557,10 @@ lan_panel = LanPanel( custom_button="Custom port", scan_button="Scan", placeholder="Enter the query port (1-65535)", - entry_tooltip="Specify the port to search for DayZ servers on the local network" + entry_tooltip="Specify the port to search for DayZ servers on the local network", + scan_tooltip="Scan for servers" ) - - distance_suffix = "Distance: calculating..." dialog_error = "ERROR" diff --git a/dzgui/views/base.py b/dzgui/views/base.py index 3982d20..bdcd715 100644 --- a/dzgui/views/base.py +++ b/dzgui/views/base.py @@ -216,10 +216,12 @@ class OuterWindow(Gtk.Window): self.show_all() css.load_css() - MainController.open_page(NotebookPage.SERVERS) # TODO: first run + MainController.open_page(NotebookPage.SERVERS) MainController.mediator.grid.conpan.lan.set_visible(False) self.grid.right_panel.sel_panel.hide() + # TODO: POC, trigger page change here + MainController.loaded = True def _on_delete_event( self, window: "OuterWindow", event: Gdk.EventKey diff --git a/dzgui/views/components/right_panel.py b/dzgui/views/components/right_panel.py index 00e26e3..0344e85 100644 --- a/dzgui/views/components/right_panel.py +++ b/dzgui/views/components/right_panel.py @@ -12,10 +12,11 @@ gi.require_version("Gtk", "3.0") from gi.repository import Gtk, GLib # noqa E402 if TYPE_CHECKING: + from dzgui.controllers.mc import Controller from GLib import SOURCE_REMOVE class RightPanel(Gtk.Box): - def __init__(self, controller): + def __init__(self, controller: "Controller"): super().__init__(spacing=6, orientation=Gtk.Orientation.VERTICAL) self.controller = controller diff --git a/dzgui/views/dialogs/generic.py b/dzgui/views/dialogs/generic.py index 423caad..367b700 100644 --- a/dzgui/views/dialogs/generic.py +++ b/dzgui/views/dialogs/generic.py @@ -100,7 +100,8 @@ class WaitDialog(GenericDialog): content.pack_end(spinner, NO_EXPAND, NO_FILL, 0) spinner.start() - self.show_all() + # FIXME: center on parent window + #self.show_all() def _on_dialog_delete( self, response_id: Gtk.ResponseType, event: Gdk.Event diff --git a/dzgui/views/pages/servers.py b/dzgui/views/pages/servers.py index 6a76d06..78579ba 100644 --- a/dzgui/views/pages/servers.py +++ b/dzgui/views/pages/servers.py @@ -29,6 +29,7 @@ class ServerNotebook(Gtk.ScrolledWindow): self.recent = ServerTreeView(controller, ServerTab.RECENT) self.lan = ServerTreeView(controller, ServerTab.LAN) + self.browser.set_query_func(self.query_test2) self.lan.set_query_func(self.query_test) # TODO: set context menu on init self.browser.set_context_menu(ContextMenuGroup.SERVER_BROWSER) @@ -51,23 +52,24 @@ class ServerNotebook(Gtk.ScrolledWindow): if label == "LAN": pass else: - store.append(["BAR", "a", "a", "a", 0, 0, 0, "189.127.165.207:2302", 0, 0, "a", False]) - store.append(["BAR", "a", "a", "a", 0, 0, 0, "189.127.165.207:2302", 0, 0, "a", False]) - store.append(["BAR", "a", "a", "a", 0, 0, 0, "189.127.165.207:2302", 0, 0, "a", False]) - store.append(["BAR", "a", "a", "a", 0, 0, 0, "189.127.165.207:2302", 0, 0, "a", False]) - store.append(["BAR", "a", "a", "a", 0, 0, 0, "189.127.165.207:2302", 0, 0, "a", False]) - store.append(["BAR", "a", "a", "a", 0, 0, 0, "189.127.165.207:2302", 0, 0, "a", False]) - store.append(["BAR", "a", "a", "a", 0, 0, 0, "189.127.165.207:2302", 0, 0, "a", False]) - store.append(["BAR", "a", "a", "a", 0, 0, 0, "189.127.165.207:2302", 0, 0, "a", False]) - store.append(["BAR", "a", "a", "a", 0, 0, 0, "189.127.165.207:2302", 0, 0, "a", False]) - store.append(["BAR", "a", "a", "a", 0, 0, 0, "189.127.165.207:2302", 0, 0, "a", False]) - store.append(["BAR", "a", "a", "a", 0, 0, 0, "189.127.165.207:2302", 0, 0, "a", False]) - store.append(["BAR", "a", "a", "a", 0, 0, 0, "189.127.165.207:2302", 0, 0, "a", False]) - store.append(["BAR", "a", "a", "a", 0, 0, 0, "189.127.165.207:2302", 0, 0, "a", False]) - store.append(["BAR", "a", "a", "a", 0, 0, 0, "189.127.165.207:2302", 0, 0, "a", False]) - store.append(["BAR", "a", "a", "a", 0, 0, 0, "189.127.165.207:2302", 0, 0, "a", False]) - store.append(["BAR", "a", "a", "a", 0, 0, 0, "189.127.165.207:2302", 0, 0, "a", False]) - tree.loaded = True + pass + #store.append(["BAR", "a", "a", "a", 0, 0, 0, "189.127.165.207:2302", 0, 0, "a", False]) + #store.append(["BAR", "a", "a", "a", 0, 0, 0, "189.127.165.207:2302", 0, 0, "a", False]) + #store.append(["BAR", "a", "a", "a", 0, 0, 0, "189.127.165.207:2302", 0, 0, "a", False]) + #store.append(["BAR", "a", "a", "a", 0, 0, 0, "189.127.165.207:2302", 0, 0, "a", False]) + #store.append(["BAR", "a", "a", "a", 0, 0, 0, "189.127.165.207:2302", 0, 0, "a", False]) + #store.append(["BAR", "a", "a", "a", 0, 0, 0, "189.127.165.207:2302", 0, 0, "a", False]) + #store.append(["BAR", "a", "a", "a", 0, 0, 0, "189.127.165.207:2302", 0, 0, "a", False]) + #store.append(["BAR", "a", "a", "a", 0, 0, 0, "189.127.165.207:2302", 0, 0, "a", False]) + #store.append(["BAR", "a", "a", "a", 0, 0, 0, "189.127.165.207:2302", 0, 0, "a", False]) + #store.append(["BAR", "a", "a", "a", 0, 0, 0, "189.127.165.207:2302", 0, 0, "a", False]) + #store.append(["BAR", "a", "a", "a", 0, 0, 0, "189.127.165.207:2302", 0, 0, "a", False]) + #store.append(["BAR", "a", "a", "a", 0, 0, 0, "189.127.165.207:2302", 0, 0, "a", False]) + #store.append(["BAR", "a", "a", "a", 0, 0, 0, "189.127.165.207:2302", 0, 0, "a", False]) + #store.append(["BAR", "a", "a", "a", 0, 0, 0, "189.127.165.207:2302", 0, 0, "a", False]) + #store.append(["BAR", "a", "a", "a", 0, 0, 0, "189.127.165.207:2302", 0, 0, "a", False]) + #store.append(["BAR", "a", "a", "a", 0, 0, 0, "189.127.165.207:2302", 0, 0, "a", False]) + #tree.loaded = True # TODO: set model on init of servertreeview tree.set_model(store) @@ -98,8 +100,9 @@ class ServerNotebook(Gtk.ScrolledWindow): return self.get_active_treeview().grab_focus() - def _on_page_changed(self, notebook: Gtk.Notebook, child: Gtk.Widget, index: int) -> None: + if self.controller.loaded is False: + return # TODO: abstract label = self.notebook.get_tab_label_text(child) if label is None: @@ -126,6 +129,10 @@ class ServerNotebook(Gtk.ScrolledWindow): # self.controller.mediator.grid.conpan.lan.set_visible(False) # TODO: put in controller + def query_test2(self) -> None: + # TODO: should trigger first page action after entire UI is loaded + self.controller.dump_test() + def query_test(self) -> None: data = (["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False]) return data From 2ef51698bd49c36d127189164ea10f1193b2e5c4 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 14 Jan 2026 01:23:18 +0900 Subject: [PATCH 009/309] feat: add derived generic entry types --- dzgui/views/components/entry.py | 119 ++++++++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 dzgui/views/components/entry.py diff --git a/dzgui/views/components/entry.py b/dzgui/views/components/entry.py new file mode 100644 index 0000000..735c29f --- /dev/null +++ b/dzgui/views/components/entry.py @@ -0,0 +1,119 @@ +from typing import Callable, TYPE_CHECKING + +from dzgui.api.servers import validate_ip +from dzgui.util.css import add_class, remove_class +from dzgui.util.strings import connect_panel, lan_panel + +import gi +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk, Gdk, GObject # noqa E402 + +if TYPE_CHECKING: + from dzgui.controllers.mc import Controller + +def validate_port(text: str) -> bool: + if text.isdigit(): + if int(text) < 1: + return False + if int(text) > 65535: + return False + return True + else: + return False + +def validate_ip_or_id(text: str) -> bool: + try: + validate_ip(text) + return True + except Exception: + if text.isdigit(): + return True + else: + return False + +class ValidatedEntry(Gtk.Entry): + __gsignals__ = { + "string_validated": (GObject.SignalFlags.RUN_FIRST, None, (object,)), + } + def __init__(self, controller: "Controller", func: Callable) -> None: + super().__init__( + hexpand=True, + placeholder_text=connect_panel.placeholder, + tooltip_text=connect_panel.entry_tooltip + ) + + self.func: Callable + self.set_validation_func(func) + self.controller = controller + + self.classname = "invalid-entry" + self.connect("key-press-event", self._on_entry_keypress) + self.connect("changed", self._on_text_changed) + + def mark_valid(self) -> None: + self.emit("string_validated", True) + remove_class(self, self.classname) + + def mark_invalid(self) -> None: + self.emit("string_validated", False) + add_class(self, self.classname) + + def mark_default(self) -> None: + self.emit("string_validated", False) + remove_class(self, self.classname) + + def set_validation_func(self, func: Callable) -> None: + self.func = func + + def insert_icon(self) -> None: + self.set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, "edit-clear-symbolic") + self.set_icon_activatable(Gtk.EntryIconPosition.SECONDARY, True) + self.connect("icon-release", self._on_icon_release) + + def remove_icon(self) -> None: + self.set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, "") + + def _on_text_changed(self, entry: Gtk.Entry) -> None: + # TODO: on submission, strip whitespace and newlines + # (validate_ip() is permissive of whitespace) + text = entry.get_text() + if len(text) < 1: + self.mark_default() + self.remove_icon() + return + if len(text) > 0: + self.insert_icon() + + if self.func(text) is True: + self.mark_valid() + else: + self.mark_invalid() + + def _on_icon_release( + self, + entry: Gtk.Entry, + icon_pos: Gtk.EntryIconPosition, + event: Gdk.Event, + ) -> None: + entry.set_text("") + self.remove_icon() + + + def _on_entry_keypress(self, entry: Gtk.Entry, event: Gdk.EventKey) -> None: + if event.keyval == Gdk.KEY_Escape: + # NOTE: unselect text + entry.select_region(0, 0) + self.controller.grab_active_treeview() + + +class IpEntry(ValidatedEntry): + def __init__(self, controller: "Controller") -> None: + super().__init__(controller, func=validate_ip_or_id) + self.set_placeholder_text(connect_panel.placeholder) + self.set_tooltip_text(connect_panel.entry_tooltip) + +class PortEntry(ValidatedEntry): + def __init__(self, controller: "Controller") -> None: + super().__init__(controller, func=validate_port) + self.set_placeholder_text(lan_panel.placeholder) + self.set_tooltip_text(lan_panel.entry_tooltip) From 716eaa187caac3163e14c6753baa6b931e24a7e7 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 14 Jan 2026 13:16:04 +0900 Subject: [PATCH 010/309] fix: convert exception to str --- dzgui/init/dayz.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dzgui/init/dayz.py b/dzgui/init/dayz.py index 1552ecb..1816693 100644 --- a/dzgui/init/dayz.py +++ b/dzgui/init/dayz.py @@ -18,5 +18,5 @@ def is_dayz_installed(config: Path) -> None: PeFile.get_app_path(Path(path) / LIBRARYFOLDERS_PATH, APPID_DAYZ) except Exception as e: logger.critical(e) - EarlyAlertDialog(e) + EarlyAlertDialog(str(e)) sys.exit(1) From 9a96711dac205c89f450c862d2d7899ffe13f556 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 14 Jan 2026 13:31:52 +0900 Subject: [PATCH 011/309] fix: test github endpoitn first --- dzgui/init/update.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dzgui/init/update.py b/dzgui/init/update.py index f8c6c34..889b557 100644 --- a/dzgui/init/update.py +++ b/dzgui/init/update.py @@ -15,7 +15,7 @@ logger = logging.getLogger(__name__) def get_latest_release() -> str | None: tag = None # TODO: check order; github often has gateway errors - for url in [CODEBERG_RELEASES, GITHUB_RELEASES]: + for url in [GITHUB_RELEASES, CODEBERG_RELEASES]: try: res = requests.get(url, timeout=REQUEST_TIMEOUT) if res.status_code == 200: From e49f452ba5c4c7ea0446c9fd801b292859b5fba4 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 15 Jan 2026 00:49:12 +0900 Subject: [PATCH 012/309] feat: shrink to fit WIP --- dzgui/views/base.py | 13 ++++++++++- dzgui/views/pages/servers.py | 38 +++++++++++-------------------- dzgui/views/trees/tree_servers.py | 30 ++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 26 deletions(-) diff --git a/dzgui/views/base.py b/dzgui/views/base.py index bdcd715..1628ad9 100644 --- a/dzgui/views/base.py +++ b/dzgui/views/base.py @@ -398,13 +398,20 @@ class Grid(Gtk.Grid): self.breadcrumbs = Gtk.Label(halign=Gtk.Align.START) self.set_breadcrumbs(strings.label_main_menu) + self.bu = Gtk.Button(label="Shrink to fit", halign=Gtk.Align.END) + self.crumb_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + self.crumb_box.add(self.breadcrumbs) + self.crumb_box.add(self.bu) + self.bu.connect("clicked", self._shrink) + self.notebook = Notebook() self.conpan = ConnectPanel(MainController) self.attach(self.notebook, 0, 0, MAX_COLS, SINGLE_ROW) + els = ( - (self.breadcrumbs, self.notebook, Gtk.PositionType.TOP, MAX_COLS, SINGLE_ROW), + (self.crumb_box, self.notebook, Gtk.PositionType.TOP, MAX_COLS, SINGLE_ROW), (self.conpan, self.notebook, Gtk.PositionType.BOTTOM, MAX_COLS, SINGLE_ROW), (self.statusbar, self.conpan, Gtk.PositionType.BOTTOM, MAX_COLS, SINGLE_ROW), (self.right_panel, self.notebook, Gtk.PositionType.RIGHT, SINGLE_COL, MAX_ROWS), @@ -414,6 +421,10 @@ class Grid(Gtk.Grid): self.show_all() + def _shrink(self, button: Gtk.Button) -> None: + tv = MainController.get_active_treeview() + tv.shrink_to_fit() + def toggle_filter_panel(self, state: bool) -> None: self.right_panel.filters_vbox.set_visible(state) diff --git a/dzgui/views/pages/servers.py b/dzgui/views/pages/servers.py index 78579ba..baf719a 100644 --- a/dzgui/views/pages/servers.py +++ b/dzgui/views/pages/servers.py @@ -31,15 +31,13 @@ class ServerNotebook(Gtk.ScrolledWindow): self.browser.set_query_func(self.query_test2) self.lan.set_query_func(self.query_test) - # TODO: set context menu on init + + # TODO: set context menu on init of ServerTreeView? self.browser.set_context_menu(ContextMenuGroup.SERVER_BROWSER) self.saved.set_context_menu(ContextMenuGroup.SAVED) self.recent.set_context_menu(ContextMenuGroup.RECENT) self.lan.set_context_menu(ContextMenuGroup.SCAN_LAN) - # TODO: set model manager for each tab on init - # TODO: add set_ and get_model_manager() methods - tabs = [ (self.browser, server_labels.browser, self.controller.get_server_store), (self.saved, server_labels.saved, self.controller.get_saved_store), @@ -53,24 +51,9 @@ class ServerNotebook(Gtk.ScrolledWindow): pass else: pass - #store.append(["BAR", "a", "a", "a", 0, 0, 0, "189.127.165.207:2302", 0, 0, "a", False]) - #store.append(["BAR", "a", "a", "a", 0, 0, 0, "189.127.165.207:2302", 0, 0, "a", False]) - #store.append(["BAR", "a", "a", "a", 0, 0, 0, "189.127.165.207:2302", 0, 0, "a", False]) - #store.append(["BAR", "a", "a", "a", 0, 0, 0, "189.127.165.207:2302", 0, 0, "a", False]) - #store.append(["BAR", "a", "a", "a", 0, 0, 0, "189.127.165.207:2302", 0, 0, "a", False]) - #store.append(["BAR", "a", "a", "a", 0, 0, 0, "189.127.165.207:2302", 0, 0, "a", False]) - #store.append(["BAR", "a", "a", "a", 0, 0, 0, "189.127.165.207:2302", 0, 0, "a", False]) - #store.append(["BAR", "a", "a", "a", 0, 0, 0, "189.127.165.207:2302", 0, 0, "a", False]) - #store.append(["BAR", "a", "a", "a", 0, 0, 0, "189.127.165.207:2302", 0, 0, "a", False]) - #store.append(["BAR", "a", "a", "a", 0, 0, 0, "189.127.165.207:2302", 0, 0, "a", False]) - #store.append(["BAR", "a", "a", "a", 0, 0, 0, "189.127.165.207:2302", 0, 0, "a", False]) - #store.append(["BAR", "a", "a", "a", 0, 0, 0, "189.127.165.207:2302", 0, 0, "a", False]) - #store.append(["BAR", "a", "a", "a", 0, 0, 0, "189.127.165.207:2302", 0, 0, "a", False]) - #store.append(["BAR", "a", "a", "a", 0, 0, 0, "189.127.165.207:2302", 0, 0, "a", False]) - #store.append(["BAR", "a", "a", "a", 0, 0, 0, "189.127.165.207:2302", 0, 0, "a", False]) - #store.append(["BAR", "a", "a", "a", 0, 0, 0, "189.127.165.207:2302", 0, 0, "a", False]) - #tree.loaded = True # TODO: set model on init of servertreeview + # TODO: set model manager for each tab on init + # TODO: add set_ and get_model_manager() methods tree.set_model(store) scrolled = Gtk.ScrolledWindow() @@ -83,6 +66,8 @@ class ServerNotebook(Gtk.ScrolledWindow): self.connect("map", self._on_map) self.connect("unmap", self._on_unmap) + self.set_crumbs("Server browser") + def _on_map(self, widget: Self) -> None: self.controller.toggle_server_panels(True) @@ -100,21 +85,24 @@ class ServerNotebook(Gtk.ScrolledWindow): return self.get_active_treeview().grab_focus() + def set_crumbs(self, text: str) -> None: + string = f"Servers > {text}" + self.controller.set_crumbs(string) + self.set_cached_label(string) + def _on_page_changed(self, notebook: Gtk.Notebook, child: Gtk.Widget, index: int) -> None: if self.controller.loaded is False: return # TODO: abstract label = self.notebook.get_tab_label_text(child) + if label is None: return # TODO: strings text = label.strip("*") self.notebook.set_tab_label_text(child, text) - # TODO: strings - string = f"Servers > {text}" - self.controller.set_crumbs(string) - self.set_cached_label(string) + self.set_crumbs(text) self.controller.present_servers() self.controller.populate_model() diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index 800d016..34d3621 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -88,6 +88,14 @@ class ServerTreeView(TreeView): column.set_fixed_width(800) if column_title == "Map": column.set_fixed_width(300) + # TODO: standardize widths based on column title and longest content + # TODO: resize col width func causes snapping behavior + if column_title == "Name": + column.set_fixed_width(500) + if column_title == "Map": + column.set_fixed_width(200) + if column_title == "IP": + column.set_fixed_width(240) column.connect("notify::fixed-width", self._on_col_width_changed) self.append_column(column) @@ -104,6 +112,28 @@ class ServerTreeView(TreeView): GLib.timeout_add(QUEUE_CHECK_DELAY, self._check_result_queue) + def shrink_to_fit(self) -> None: + cols = self.get_columns() + # TODO: run on only one treeview and propagate results + # TODO: does not shrink name, map, ip fields to fit + # TODO: col width changed signal is buggy on current treeview + for col in cols: + title = col.get_title() + if title == "Name": + continue + if title == "Map": + continue + if title == "IP": + continue + label = Gtk.Label(title) + pango = label.get_layout() + size = pango.get_pixel_size() + if size.width > 50: + width = size.width * 1.30 + else: + width = size.width * 1.65 + col.set_fixed_width(width) + def _on_kb_focus(self, a, b) -> None: self.emit("on_distcalc_started") From fdd6cefe4813f8fb2c4f06eea845e40686dfbd2f Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Mon, 19 Jan 2026 08:27:10 +0900 Subject: [PATCH 013/309] feat: server threading WIP --- dzgui/api/steam.py | 2 +- dzgui/controllers/mc.py | 183 ++++++++++++++---------- dzgui/util/ip.py | 1 + dzgui/views/base.py | 73 +--------- dzgui/views/components/buttonbox.py | 3 +- dzgui/views/components/connect_panel.py | 5 +- dzgui/views/components/statusbar.py | 24 ++-- dzgui/views/pages/options.py | 8 +- dzgui/views/pages/servers.py | 2 +- dzgui/views/trees/tree_base.py | 4 +- dzgui/views/trees/tree_servers.py | 4 +- 11 files changed, 147 insertions(+), 162 deletions(-) diff --git a/dzgui/api/steam.py b/dzgui/api/steam.py index ab24526..87691e0 100644 --- a/dzgui/api/steam.py +++ b/dzgui/api/steam.py @@ -53,7 +53,7 @@ def concat_mods(mods: list[str]) -> str: # 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): +def connect(addr: str, appid: int, name: str, mods: list) -> None: # TODO: get name from configs # TODO: concat_mods(mods): # @;@; diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index dd52852..4f1b4c2 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -5,7 +5,7 @@ import textwrap import traceback from pathlib import Path -from typing import Any, Callable, TYPE_CHECKING +from typing import Any, Callable, Literal, TYPE_CHECKING import dzgui.api.pefile as PeFile import dzgui.util._json as JSON # noqa @@ -246,6 +246,7 @@ class Controller: self.mediator.statusbar.set_text(text) def set_statusbar_placeholder(self, text: str) -> None: + # TODO: use statusbar stacks instead self.statusbar_placeholder = text def set_statusbar_dist(self, haversine: "Haversine") -> None: @@ -263,6 +264,7 @@ class Controller: dist = str(separated) + " km" text = self.statusbar_placeholder self.set_statusbar(f"{text} | Distance: {dist}") + self.mediator.statusbar.spinner.stop() def delete_multiple_mods(self) -> None: sel = self.mediator.modtreeview.get_selection() @@ -275,6 +277,7 @@ class Controller: self.update_mod_statusbar() def load_mods(self) -> None: + # TODO: threading model = self.model_manager.get_mod_store() model.clear() path = self.query_config(Preferences.DEFAULT) @@ -310,10 +313,6 @@ class Controller: 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 open_keybindings(self) -> None: notebook = self.mediator.grid.notebook notebook.toggle_keybindings() @@ -322,23 +321,6 @@ class Controller: notebook = self.mediator.grid.notebook notebook.focus_current() - # TODO: deprecated - 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.statusbar.refresh(row) @@ -366,7 +348,11 @@ class Controller: self.save_res_and_quit() return case ButtonType.OPTIONS: - self.mediator.grid.notebook.settings.populate_settings() + try: + # TODO: where to put config file check + self.mediator.grid.notebook.settings.populate_settings() + except Exception: + return case ButtonType.MODS: self.load_mods() case ButtonType.HELP: @@ -374,11 +360,10 @@ class Controller: pass case ButtonType.SERVERS: self.mediator.notebook.set_page_by_enum(button.opens) - # TODO: use cache - self.update_server_status() return self.mediator.notebook.set_page_by_enum(button.opens) + # TODO: set crumbs by signal self.set_crumbs(button.get_label()) def open_user_workshop(self, uid: str) -> None: @@ -445,7 +430,9 @@ class Controller: def update_mod_statusbar(self) -> None: total_mods, total_size = self.calc_mod_size() msg = format_mods(total_size, total_mods) + # TODO: combine self.mediator.statusbar.set_text(msg) + self.mediator.statusbar.spinner.stop() def calc_mod_size(self) -> tuple[int, int]: model = self.model_manager.get_mod_store() @@ -508,13 +495,16 @@ class Controller: log = self.prefs.paths.debug store = self.model_manager.get_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: - clean = redact_log(record) - store.append(clean) + # NOTE: this model is reloaded each time as log changes + try: + with open(log, "r") as f: + lines = [line.split(strings.delimiter) for line in f.read().splitlines()] + for record in lines: + clean = redact_log(record) + store.append(clean) + except Exception as e: + dialog = ExceptionDialog(self, str(e)) + dialog.run() self.open_page(NotebookPage.LOG) def select_colorized(self) -> None: @@ -552,20 +542,51 @@ class Controller: import time time.sleep(1) # TODO: use model managers, etc. - self.data = (["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False]) + self.data = ( + ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], + ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], + ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], + ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], + ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], + ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], + ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], + ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], + ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], + ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], + ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], + ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], + ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], + ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], + ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], + ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], + ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], + ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], + ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], + ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], + ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], + ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], + ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], + ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], + ) # TODO: refer to prior implementation--should need to load data into model while in thread self.get_func_data() self.destroy_on_idle() def get_func_data(self) -> tuple: + def test(): + # NOTE: do not insert model until main thread is idle + # TODO: grab from model manager and insert entire model + # TODO: do not start refresh button count until load finished + for row in data: + model.append(row) + treeview.set_loaded(True) + self.update_server_status() + treeview.grab_focus() # TODO: use model manager treeview = self.get_active_treeview() model = treeview.get_model() data = self.data - model.append(data) - treeview.set_loaded(True) - self.update_server_status() - treeview.grab_focus() + GLib.idle_add(test) def highlight_stale(self) -> None: self.call_on_thread(self.colorize_mods) @@ -577,14 +598,11 @@ class Controller: 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 get_callback(self) -> Callable | None: + return self.callback["func"] + + def get_callback_args(self) -> Any: + return self.callback["args"] def set_callback(self, callback: Callable | None, *args) -> None: """ @@ -593,6 +611,14 @@ class Controller: """ self.callback = { "func": callback, "args": args } + def destroy_on_idle(self) -> None: + self.wait_dialog.destroy() + func = self.get_callback() + if func is not None: + args = self.get_callback_args() + GLib.idle_add(func, *args) + self.set_callback(None, None) + def dump_diagnostics(self) -> None: picker = FilePicker(self.mediator.window) file = picker.pick_file() @@ -600,7 +626,8 @@ class Controller: try: write_diagnostic(self.prefs.paths.config, file) except Exception as e: - self.spawn_dialog(str(e), Popup.NOTIFY) + dialog = ExceptionDialog(self, str(e)) + dialog.run() def test_api_response(self, text: str, key: Preferences) -> None: if key is Preferences.STEAM: @@ -614,7 +641,8 @@ class Controller: self.destroy_on_idle() else: self.destroy_on_idle() - self.spawn_dialog(strings.api_error, Popup.NOTIFY) + dialog = ExceptionDialog(self, strings.api_error) + dialog.run() def update_api_key(self, text: str, key: Preferences) -> None: @@ -645,13 +673,6 @@ class Controller: logger.info(f"Using default window size {w},{h}") window.set_default_size(w, h) - def update_server_status(self) -> None: - treeview = self.mediator.notebook.servers.get_active_treeview() - model = treeview.get_model() - status = format_player_count(model) - self.set_statusbar_placeholder(status) - self.set_statusbar(status + "| Calculating...") - def propagate_column_width(self, col: Gtk.TreeViewColumn) -> None: GLib.idle_add(self.mediator.servers.update_tab_widths, col) @@ -662,25 +683,31 @@ class Controller: return self.crumbs_cache def refresh_tree(self) -> None: - treeview = self.mediator.notebook.servers.get_active_treeview() + treeview = self.get_active_treeview() treeview.set_loaded(False) self.populate_model() + def update_server_status(self) -> None: + # TODO: emit signal on statusbar only if page changed + treeview = self.get_active_treeview() + model = treeview.get_model() + status = format_player_count(model) + self.set_statusbar_placeholder(status) + self.set_statusbar(status) + self.mediator.statusbar.spinner.start() + def populate_model(self) -> None: - print("POPULATING MODEL") # TODO: always use same server model, store in servertreeview class treeview = self.get_active_treeview() if treeview.get_loaded() is False: new_model = self.model_manager.new_model() # NOTE: set_query_func() func = treeview.get_query_func() - print(func) if func is not None: model = treeview.get_model() - print(model) # TODO: this may lag? model.clear() - self.set_callback(None, None)#self.get_func_data) + self.set_callback(None, None) self.call_on_thread(func) def focus_button_box(self) -> None: @@ -688,7 +715,7 @@ class Controller: def present_servers(self) -> None: # TODO: abstract - self.grab_active_treeview() + # TODO: signal for crumbs self.update_server_status() crumbs = self.mediator.servers.get_cached_label() self.set_crumbs(crumbs) @@ -696,21 +723,26 @@ class Controller: #tree = self.get_active_treeview() #tree.emit("on_distcalc_started") - def toggle_check(self, event: Gdk.EventKey) -> None: - keyname = Gdk.keyval_name(event.keyval) - if keyname.isnumeric() and int(keyname) > 0: - digit = int(keyname) - 1 - self.mediator.grid.right_panel.filters_vbox.toggle_check(digit) - else: - match event.keyval: - case Gdk.KEY_0: - self.mediator.grid.right_panel.filters_vbox.toggle_check(9) - case Gdk.KEY_minus: - self.mediator.grid.right_panel.filters_vbox.toggle_check(10) - case Gdk.KEY_backslash: - self.mediator.grid.right_panel.filters_vbox.toggle_check(11) - case _: - return False + def toggle_check(self, event: Gdk.EventKey) -> None | Literal[False]: + mappings = { + Gdk.KEY_1: 0, + Gdk.KEY_2: 1, + Gdk.KEY_3: 2, + Gdk.KEY_4: 3, + Gdk.KEY_5: 4, + Gdk.KEY_6: 5, + Gdk.KEY_7: 6, + Gdk.KEY_8: 7, + Gdk.KEY_9: 8, + Gdk.KEY_0: 9, + Gdk.KEY_minus: 10, + Gdk.KEY_backslash: 11, + } + if event.keyval not in mappings: + return False + index = mappings[event.keyval] + # TODO: register filter panel widget + self.mediator.grid.right_panel.filters_vbox.toggle_check(index) def get_favorite(self) -> tuple[str, str] | tuple[None, None]: fav = str(self.query_config(Preferences.FAV_LBL)) @@ -723,5 +755,6 @@ class Controller: def get_dist_cache(self) -> dict[str, "Haversine"]: return self.dist_cache + # TODO: deprecated in favor of map/unmap def toggle_lan_panel(self, state: bool) -> None: self.mediator.grid.conpan.set_visible(state) diff --git a/dzgui/util/ip.py b/dzgui/util/ip.py index 5b2057b..f4dceb5 100644 --- a/dzgui/util/ip.py +++ b/dzgui/util/ip.py @@ -74,6 +74,7 @@ def get_coords(ips: "Path", ip: str) -> Coords: if split[2] == upper_digits[2]: if split[3] > upper_digits[3]: continue + # TODO: handle last digit equals if split[2] <= upper_digits[2]: return Coords(float(fields[-2]), float(fields[-1])) diff --git a/dzgui/views/base.py b/dzgui/views/base.py index 1628ad9..0a7102d 100644 --- a/dzgui/views/base.py +++ b/dzgui/views/base.py @@ -1,26 +1,16 @@ -import json -import locale import logging -import multiprocessing -import os -import re +#import multiprocessing import signal -import subprocess -import textwrap -import threading +#import threading import typing # noqa import warnings #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 TYPE_CHECKING, Literal -from dzgui.const.enum import NotebookPage, VAdjustment -from dzgui.const.constants import NO_EXPAND, NO_FILL, NO_PADDING +from dzgui.const.enum import NotebookPage from dzgui.const.constants import APP_NAME, APP_NAME_LOWER from dzgui.controllers.mc import Controller from dzgui.util import css, strings @@ -40,21 +30,9 @@ from dzgui.views.pages.servers import ServerNotebook from dzgui.views.pages.thanks import Thanks from dzgui.views.components.statusbar import Statusbar -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.dialogs.generic import GenericDialog from dzgui.views.mixins.scrollable_mixin import ScrollableMixin -# TREES -from dzgui.views.trees.tree_menu import MenuTreeView -from dzgui.views.trees.tree_log import LogTreeView -from dzgui.views.trees.tree_mods import ModTreeView -from dzgui.views.trees.tree_servers import ServerTreeView - -# TODO: not going to be in base anymore -import dzgui.util._json as JSON # noqa - if TYPE_CHECKING: from dzgui.config.userprefs import UserPrefs @@ -160,41 +138,6 @@ warnings.filterwarnings("ignore", ".*g_value_get_int", Warning) # #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: -# 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: @@ -216,6 +159,7 @@ class OuterWindow(Gtk.Window): self.show_all() css.load_css() + # TODO: first run MainController.open_page(NotebookPage.SERVERS) MainController.mediator.grid.conpan.lan.set_visible(False) @@ -344,7 +288,7 @@ class Notebook(ScrollableMixin, Gtk.Notebook): # type: ignore try: w.focus_first_row() w.grab_focus() - except Exception as e: + except Exception: w.grab_focus() def get_page(self) -> Gtk.Widget | None: @@ -370,12 +314,6 @@ class Notebook(ScrollableMixin, Gtk.Notebook): # type: ignore status = enum.dict["statusbar"] MainController.set_crumbs(crumbs) - #is_mods = True if enum is NotebookPage.MODS else False - #is_servers = True if enum is NotebookPage.SERVERS else False - # TODO: use signals internal to servers.py - #MainController.toggle_mod_panel(is_mods) - #MainController.toggle_server_panels(is_servers) - if status is False: MainController.set_statusbar("") if enum is NotebookPage.SERVERS: @@ -409,7 +347,6 @@ class Grid(Gtk.Grid): self.attach(self.notebook, 0, 0, MAX_COLS, SINGLE_ROW) - els = ( (self.crumb_box, self.notebook, Gtk.PositionType.TOP, MAX_COLS, SINGLE_ROW), (self.conpan, self.notebook, Gtk.PositionType.BOTTOM, MAX_COLS, SINGLE_ROW), diff --git a/dzgui/views/components/buttonbox.py b/dzgui/views/components/buttonbox.py index 8020698..6b2f99a 100644 --- a/dzgui/views/components/buttonbox.py +++ b/dzgui/views/components/buttonbox.py @@ -13,7 +13,8 @@ class ContextualButton(Gtk.Button): def __init__(self, label, opens, tooltip, context): super().__init__( label=label, - tooltip_text=tooltip + tooltip_text=tooltip, + focus_on_click=False, ) self.context = context diff --git a/dzgui/views/components/connect_panel.py b/dzgui/views/components/connect_panel.py index efdb26d..dd3b31e 100644 --- a/dzgui/views/components/connect_panel.py +++ b/dzgui/views/components/connect_panel.py @@ -1,7 +1,5 @@ from typing import TYPE_CHECKING -from dzgui.api.servers import validate_ip -from dzgui.util.css import add_class, remove_class from dzgui.util.strings import connect_panel, fav_panel, lan_panel from dzgui.views.components.buttons import AddButton, ClipboardButton, SteamConnectButton from dzgui.views.components.entry import IpEntry, PortEntry @@ -63,7 +61,8 @@ class FavPanel(Gtk.Frame): user_fav, self.fav_ip = self.controller.get_favorite() server_name = f"{user_fav} ({self.fav_ip})" if user_fav is not None else connect_panel.no_fav self.fav_label = Gtk.Label(label=server_name, track_visited_links=False, halign=Gtk.Align.START, hexpand=True) - scrollable_label = Gtk.ScrolledWindow() + # NOTE: disable vscrollbar to prevent layout jumping behavior + scrollable_label = Gtk.ScrolledWindow(vscrollbar_policy=Gtk.PolicyType.NEVER) scrollable_label.add(self.fav_label) self.fav_button = SteamConnectButton() diff --git a/dzgui/views/components/statusbar.py b/dzgui/views/components/statusbar.py index 4e817ca..914bfb7 100644 --- a/dzgui/views/components/statusbar.py +++ b/dzgui/views/components/statusbar.py @@ -10,32 +10,40 @@ from gi.repository import Gtk # noqa E402 if TYPE_CHECKING: from dzgui.controllers.mc import Controller -class Statusbar(Gtk.Statusbar): +class Statusbar(Gtk.Grid): def __init__(self, controller: "Controller") -> None: - super().__init__() + super().__init__(orientation=Gtk.Orientation.HORIZONTAL) self.controller = controller self.controller.register_widget("statusbar", self) help_text = strings.statusbar_helptext - self.set_text(help_text) + + self.statusbar = Gtk.Statusbar() + + self.spinner = Gtk.Spinner() + self.spinner.start() version = self.controller.get_prefs().version - self.status_right_label = Gtk.Label(label=version) - self.add(self.status_right_label) + self.status_right_label = Gtk.Label(label=version, hexpand=True, halign=Gtk.Align.END) + self.attach(self.statusbar, 0, 0, 3, 1) + self.attach_next_to(self.spinner, self.statusbar, Gtk.PositionType.RIGHT, 3, 1) + self.attach_next_to(self.status_right_label, self.spinner, Gtk.PositionType.RIGHT, 3, 1) + + self.set_text(help_text) self.players = "" def get_text(self) -> str: - area = self.get_message_area() + area = self.statusbar.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) + meta = self.statusbar.get_context_id("Statusbar") + self.statusbar.push(meta, string) def refresh(self, row: "RowType") -> None: if row is None: diff --git a/dzgui/views/pages/options.py b/dzgui/views/pages/options.py index efcaf90..1c36660 100644 --- a/dzgui/views/pages/options.py +++ b/dzgui/views/pages/options.py @@ -9,9 +9,10 @@ from dzgui.util import strings, css, open_links from dzgui.views.components.label import LeftLabel from dzgui.views.components.eventbox import InfoEventBox from dzgui.views.components.buttons import WebButton +from dzgui.views.dialogs.generic import ExceptionDialog from dzgui.views.dialogs.link_dialog import WorkshopLinkDialog -from dzgui.const.enum import Preferences, Popup +from dzgui.const.enum import Preferences from dzgui.const.endpoints import STEAM_API_SETUP, BM_API_SETUP from dzgui.const.constants import ( APPID_DAYZ, @@ -427,7 +428,10 @@ class Options(Gtk.Box): prefs = self.controller.get_prefs() if prefs.paths.config.is_file() is False: # NOTE: in case file got deleted locally - self.controller.spawn_dialog(strings.config_not_found, Popup.QUIT) + dialog = ExceptionDialog(self.controller, strings.config_not_found) + dialog.run() + raise Exception + # FIXME: return to main menu return config = query.get_config(prefs.paths.config) diff --git a/dzgui/views/pages/servers.py b/dzgui/views/pages/servers.py index baf719a..e06daba 100644 --- a/dzgui/views/pages/servers.py +++ b/dzgui/views/pages/servers.py @@ -51,7 +51,7 @@ class ServerNotebook(Gtk.ScrolledWindow): pass else: pass - # TODO: set model on init of servertreeview + # TODO: set model only on init of servertreeview # TODO: set model manager for each tab on init # TODO: add set_ and get_model_manager() methods tree.set_model(store) diff --git a/dzgui/views/trees/tree_base.py b/dzgui/views/trees/tree_base.py index 2886990..4243d1c 100644 --- a/dzgui/views/trees/tree_base.py +++ b/dzgui/views/trees/tree_base.py @@ -88,8 +88,10 @@ class TreeView(CursorMixin, Gtk.TreeView): # type: ignore ) -> None: if is_navkey(event.keyval): - # TODO: investigate this + # FIXME: will fail on mod list tv = self.controller.get_active_treeview() + if len(tv.get_model()) < 1: + return if self.sel_blocked is False: self.controller.suppress_signal( tv, diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index 34d3621..76c47ed 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -1,6 +1,7 @@ import logging import multiprocessing import subprocess +from typing import Self import gi gi.require_version("Gtk", "3.0") @@ -145,7 +146,6 @@ class ServerTreeView(TreeView): self.controller.mediator.grid.conpan.lan.set_visible(True) def _on_unmap(self, a) -> None: - # TODO: could be done just when changing pages? if self.get_enum() is ServerTab.LAN: self.controller.mediator.grid.conpan.lan.set_visible(False) @@ -199,7 +199,7 @@ class ServerTreeView(TreeView): if self.current_proc and self.current_proc.is_alive(): self.current_proc.terminate() - def _on_distcalc_started(self, treeview): + def _on_distcalc_started(self, treeview: Self): record = self.get_record() if record is None: return From 6f29b1318a7f75cb95fbff3ac6cee69c53a02784 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Mon, 19 Jan 2026 14:55:32 +0900 Subject: [PATCH 014/309] feat: initialize ServerTreeViews with atomic FilterModelManagers --- dzgui/controllers/mc.py | 68 +++++------- .../model.py => model/filtered_model.py} | 105 +++--------------- dzgui/views/pages/servers.py | 36 ++---- dzgui/views/trees/tree_base.py | 9 +- dzgui/views/trees/tree_servers.py | 13 ++- 5 files changed, 67 insertions(+), 164 deletions(-) rename dzgui/{controllers/model.py => model/filtered_model.py} (77%) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 4f1b4c2..df44210 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -39,7 +39,7 @@ from dzgui.const.constants import ( from dzgui.config import update from dzgui.config.query import lookup from dzgui.config.userprefs import UserPrefs -from dzgui.controllers.model import ModelManager +from dzgui.model.misc_model import ModelManager from dzgui.util import strings from dzgui.util.diag import write_diagnostic from dzgui.util._json import read_json, write_json @@ -91,7 +91,9 @@ class Controller: self.crumbs_cache = "" self.mediator = AppNavigation() self.prefs: UserPrefs - self.model_manager = ModelManager() + + self.model_man = ModelManager() + # TODO: poc self.loaded = False @@ -107,32 +109,20 @@ class Controller: def get_crumbs(self) -> str: return self.mediator.grid.get_breadcrumbs() - def get_server_store(self) -> Gtk.ListStore: - return self.model_manager.get_server_store() - - def get_saved_store(self) -> Gtk.ListStore: - return self.model_manager.get_saved_store() - - def get_recent_store(self) -> Gtk.ListStore: - return self.model_manager.get_recent_store() - - def get_lan_store(self) -> Gtk.ListStore: - return self.model_manager.get_lan_store() - def get_help_store(self) -> Gtk.ListStore: - return self.model_manager.get_help_store() + return self.model_man.get_help_store() def get_map_store(self) -> Gtk.ListStore: - return self.model_manager.get_map_store() + return self.model_man.get_map_store() def get_modlist_store(self) -> Gtk.ListStore: - return self.model_manager.get_modlist_store() + return self.model_man.get_modlist_store() def get_mod_store(self) -> Gtk.ListStore: - return self.model_manager.get_mod_store() + return self.model_man.get_mod_store() def get_log_store(self) -> Gtk.ListStore: - return self.model_manager.get_log_store() + return self.model_man.get_log_store() def terminate_process(self) -> None: # TODO: only used by server table multiprocessing queue @@ -152,10 +142,10 @@ class Controller: return self.query_config(Preferences.INSTALL) def reinit_map_store(self) -> None: - self.model_manager.set_all_maps() + self.model_man.set_all_maps() def append_map(self, map_row: list) -> None: - self.model_manager.append_map(map_row) + self.model_man.append_map(map_row) def unblock_signals(self) -> None: self.block_signals(False) @@ -197,7 +187,7 @@ class Controller: def toggle_debug_mode(self) -> None: self.toggle_config(Preferences.DEBUG) - def get_active_treeview(self) -> "TreeView": + def get_active_treeview(self) -> "ServerTreeView": return self.mediator.notebook.servers.get_active_treeview() def grab_active_treeview(self) -> None: @@ -278,7 +268,7 @@ class Controller: def load_mods(self) -> None: # TODO: threading - model = self.model_manager.get_mod_store() + model = self.model_man.get_mod_store() model.clear() path = self.query_config(Preferences.DEFAULT) mods = get_delimited_mods(Path(path)) @@ -394,7 +384,7 @@ class Controller: # 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() + model = self.model_man.get_mod_store() tree_iter = model.get_iter(tree_path) mod = model.get(tree_iter, 2)[0] return mod, tree_iter @@ -423,7 +413,7 @@ class Controller: except PeFile.AppNotInstalledError: pass - model = self.model_manager.get_mod_store() + model = self.model_man.get_mod_store() model.remove(it) @@ -435,7 +425,7 @@ class Controller: self.mediator.statusbar.spinner.stop() def calc_mod_size(self) -> tuple[int, int]: - model = self.model_manager.get_mod_store() + model = self.model_man.get_mod_store() total_mods = len(model) total_size = 0 for mod in model: @@ -493,7 +483,7 @@ class Controller: def populate_log(self) -> None: log = self.prefs.paths.debug - store = self.model_manager.get_log_store() + store = self.model_man.get_log_store() store.clear() # NOTE: this model is reloaded each time as log changes try: @@ -508,7 +498,7 @@ class Controller: self.open_page(NotebookPage.LOG) def select_colorized(self) -> None: - model = self.model_manager.get_mod_store() + model = self.model_man.get_mod_store() sel = self.mediator.modtreeview.get_selection() for mod in model: it = mod.iter @@ -517,14 +507,14 @@ class Controller: sel.select_path(path) def uncolorize_mods(self) -> None: - model = self.model_manager.get_mod_store() + model = self.model_man.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() + model = self.model_man.get_mod_store() stale = find_stale_mods(self.prefs.paths.config) for mod in model: it = mod.iter @@ -544,7 +534,7 @@ class Controller: # TODO: use model managers, etc. self.data = ( ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], - ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], + ["BAR", "a", "a", "a", 1, 1, 1, "172.111.51.156:2302", 0, 0, "a", False], ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], @@ -572,19 +562,19 @@ class Controller: self.get_func_data() self.destroy_on_idle() - def get_func_data(self) -> tuple: + def get_func_data(self): def test(): # NOTE: do not insert model until main thread is idle # TODO: grab from model manager and insert entire model # TODO: do not start refresh button count until load finished for row in data: - model.append(row) + manager.append_row(row) treeview.set_loaded(True) self.update_server_status() treeview.grab_focus() - # TODO: use model manager treeview = self.get_active_treeview() - model = treeview.get_model() + + manager = treeview.get_filter_man() data = self.data GLib.idle_add(test) @@ -697,16 +687,14 @@ class Controller: self.mediator.statusbar.spinner.start() def populate_model(self) -> None: - # TODO: always use same server model, store in servertreeview class treeview = self.get_active_treeview() if treeview.get_loaded() is False: - new_model = self.model_manager.new_model() - # NOTE: set_query_func() + # NOTE: cf. set_query_func() func = treeview.get_query_func() if func is not None: - model = treeview.get_model() + manager = treeview.get_filter_man() # TODO: this may lag? - model.clear() + manager.clear_model() self.set_callback(None, None) self.call_on_thread(func) diff --git a/dzgui/controllers/model.py b/dzgui/model/filtered_model.py similarity index 77% rename from dzgui/controllers/model.py rename to dzgui/model/filtered_model.py index 8dae7e4..06010b5 100644 --- a/dzgui/controllers/model.py +++ b/dzgui/model/filtered_model.py @@ -2,7 +2,7 @@ import re from dataclasses import dataclass from typing import Self -from dzgui.const.enum import FilterMode, HELP_MENU_ROWS +from dzgui.const.enum import FilterMode from dzgui.util import strings import gi @@ -10,7 +10,6 @@ gi.require_version("Gtk", "3.0") from gi.repository.Gtk import ListStore # noqa E402 from gi.repository import GObject, GLib # noqa E402 -@dataclass @dataclass(slots=True, frozen=True) class ServerColumns: name: str @@ -27,105 +26,38 @@ class ServerColumns: modded: bool -@dataclass(slots=True, frozen=True) -class ModCols: - name: str - symlink: str - directory: str - size: float - color: str - - -@dataclass(slots=True, frozen=True) -class LogCols: - timestamp: str - flag: str - traceback: str - msg: str - - -@dataclass(slots=True, frozen=True) -class ServerModCols: - name: str - uid: GObject.TYPE_INT64 - installed: str - - -@dataclass(slots=True, frozen=True) -class MenuCols: - name: str - hidden: GObject.TYPE_PYOBJECT - - -class ModelManager: +class FilteredModelManager: """ Manages access to cached ListStore resources and - performs filtering on behalf of TreeViews. + performs filtering on behalf of atomic TreeViews, + which share the same column structure. - Methods are not thread-safe in themselves. + A FilteredModelManager is attached to each ServerTreeView. + Filter methods are not thread-safe in themselves. """ def __init__(self) -> None: self.filter_cache: tuple self.ping_cache: dict[str, int] = {} - self.map_store = ListStore(str) - self.help_store = self.new_model_from_class(MenuCols) - self.mod_store = self.new_model_from_class(ModCols) - self.log_store = self.new_model_from_class(LogCols) - self.modlist_store = self.new_model_from_class(ServerModCols) - - self.server_store = self.new_model() - self.saved_store = self.new_model() - self.recent_store = self.new_model() - self.lan_store = self.new_model() - - for row in HELP_MENU_ROWS: - label = row.dict["label"] - self.help_store.append([label, row]) + self.model = self.new_model_from_class(ServerColumns) self.control_model = None self.filtered = None self.success = True - #def __new__(cls) -> Self: - # if not hasattr(cls, "instance"): - # cls.instance = super(ModelManager, cls).__new__(cls) - # return cls.instance + def append_row(self, row: list) -> None: + self.model.append(row) - def get_recent_store(self) -> ListStore: - return self.recent_store + def clear_model(self) -> None: + self.model.clear() - def get_lan_store(self) -> ListStore: - return self.lan_store - - def get_saved_store(self) -> ListStore: - return self.saved_store - - def get_server_store(self) -> ListStore: - return self.server_store + def get_model(self) -> ListStore: + return self.model def new_model_from_class(self, cls: type) -> ListStore: store = ListStore(*[ftype for field, ftype in cls.__annotations__.items()]) return store - def get_map_store(self) -> ListStore: - return self.map_store - - #def get_row_store(self) -> ListStore: - # return self.row_store - - def get_help_store(self) -> ListStore: - return self.help_store - - def get_mod_store(self) -> ListStore: - return self.mod_store - - def get_modlist_store(self) -> ListStore: - return self.modlist_store - - def get_log_store(self) -> ListStore: - return self.log_store - def filter(self, mode: FilterMode, *args, **kwargs) -> None: """ Native Gtk.TreeView.refilter() method was not performant enough @@ -290,10 +222,6 @@ class ModelManager: ) -> None: self.filter_cache[filters] = (model, rows) - def new_model(self) -> ListStore: - store = self.new_model_from_class(ServerColumns) - return store - def resync_model(self, addr: str, qport: int) -> None: """ Handle in-situ updates to model during @@ -344,10 +272,3 @@ class ModelManager: self.ping_cache = {} if full: self.control_model = None - - def set_all_maps(self) -> None: - self.map_store.clear() - self.map_store.append(["All maps"]) - - def append_map(self, row: list) -> None: - self.map_store.append(row) diff --git a/dzgui/views/pages/servers.py b/dzgui/views/pages/servers.py index e06daba..42fdfc2 100644 --- a/dzgui/views/pages/servers.py +++ b/dzgui/views/pages/servers.py @@ -24,38 +24,22 @@ class ServerNotebook(Gtk.ScrolledWindow): self.controller.register_widget("servers", self) self.notebook = Gtk.Notebook(show_tabs=True) - self.browser = ServerTreeView(controller, ServerTab.BROWSER) - self.saved = ServerTreeView(controller, ServerTab.SAVED) - self.recent = ServerTreeView(controller, ServerTab.RECENT) - self.lan = ServerTreeView(controller, ServerTab.LAN) + self.browser = ServerTreeView(controller, ServerTab.BROWSER, ContextMenuGroup.SERVER_BROWSER) + self.saved = ServerTreeView(controller, ServerTab.SAVED, ContextMenuGroup.SAVED) + self.recent = ServerTreeView(controller, ServerTab.RECENT, ContextMenuGroup.RECENT) + self.lan = ServerTreeView(controller, ServerTab.LAN, ContextMenuGroup.SCAN_LAN) self.browser.set_query_func(self.query_test2) self.lan.set_query_func(self.query_test) - # TODO: set context menu on init of ServerTreeView? - self.browser.set_context_menu(ContextMenuGroup.SERVER_BROWSER) - self.saved.set_context_menu(ContextMenuGroup.SAVED) - self.recent.set_context_menu(ContextMenuGroup.RECENT) - self.lan.set_context_menu(ContextMenuGroup.SCAN_LAN) - tabs = [ - (self.browser, server_labels.browser, self.controller.get_server_store), - (self.saved, server_labels.saved, self.controller.get_saved_store), - (self.recent, server_labels.recent, self.controller.get_recent_store), - (self.lan, server_labels.lan, self.controller.get_lan_store), - ] - - for tree, label, func in tabs: - store = func() - if label == "LAN": - pass - else: - pass - # TODO: set model only on init of servertreeview - # TODO: set model manager for each tab on init - # TODO: add set_ and get_model_manager() methods - tree.set_model(store) + (self.browser, server_labels.browser), + (self.saved, server_labels.saved), + (self.recent, server_labels.recent), + (self.lan, server_labels.lan), + ] + for tree, label in tabs: scrolled = Gtk.ScrolledWindow() scrolled.add(tree) self.notebook.append_page(scrolled, Gtk.Label(label=label)) diff --git a/dzgui/views/trees/tree_base.py b/dzgui/views/trees/tree_base.py index 4243d1c..b743958 100644 --- a/dzgui/views/trees/tree_base.py +++ b/dzgui/views/trees/tree_base.py @@ -88,14 +88,13 @@ class TreeView(CursorMixin, Gtk.TreeView): # type: ignore ) -> None: if is_navkey(event.keyval): - # FIXME: will fail on mod list - tv = self.controller.get_active_treeview() - if len(tv.get_model()) < 1: + # TODO: if model is None + if len(self.get_model()) < 1: return if self.sel_blocked is False: self.controller.suppress_signal( - tv, - tv.selected_row, + self, + self.selected_row, "_on_tree_selection_changed", True, ) diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index 76c47ed..c1e810d 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -15,6 +15,7 @@ from dzgui.const.enum import ( from dzgui.const.constants import UDP_PORT from dzgui.const.enum import ServerTab from dzgui.api.servers import Record +from dzgui.model.filtered_model import FilteredModelManager from dzgui.util.dist import CalcDist from dzgui.util.keys import is_navkey from dzgui.util import strings @@ -40,7 +41,7 @@ class ServerTreeView(TreeView): __gsignals__ = { "on_distcalc_started": (GObject.SignalFlags.RUN_FIRST, None, ()) } - def __init__(self, controller: "Controller", enum: ServerTab) -> None: + def __init__(self, controller: "Controller", enum: ServerTab, menu: ContextMenuGroup) -> None: super().__init__(controller) QUEUE_CHECK_DELAY = 200 @@ -49,10 +50,15 @@ class ServerTreeView(TreeView): self.loaded = False self.query_func: Callable = None + self.filter_man = FilteredModelManager() + model = self.filter_man.get_model() + self.set_model(model) + self.menu = Gtk.Menu() self.menu.connect("key-press-event", self._on_key) self.controller = controller + self.set_context_menu(menu) self.set_fixed_height_mode(True) self.set_headers_visible(True) @@ -113,6 +119,9 @@ class ServerTreeView(TreeView): GLib.timeout_add(QUEUE_CHECK_DELAY, self._check_result_queue) + def get_filter_man(self) -> FilteredModelManager: + return self.filter_man + def shrink_to_fit(self) -> None: cols = self.get_columns() # TODO: run on only one treeview and propagate results @@ -203,6 +212,8 @@ class ServerTreeView(TreeView): record = self.get_record() if record is None: return + # TODO: + self.controller.mediator.statusbar.spinner.start() ip = record.ip self.current_proc = CalcDist(record.ip, self.queue, self.controller) self.current_proc.start() From 5543d8cb38f3aa30641103a5ac7a3a95ee134a1f Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Mon, 19 Jan 2026 14:58:48 +0900 Subject: [PATCH 015/309] feat: add new model manager types --- dzgui/model/misc_model.py | 79 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 dzgui/model/misc_model.py diff --git a/dzgui/model/misc_model.py b/dzgui/model/misc_model.py new file mode 100644 index 0000000..4646514 --- /dev/null +++ b/dzgui/model/misc_model.py @@ -0,0 +1,79 @@ +from dataclasses import dataclass +from dzgui.const.enum import HELP_MENU_ROWS + +import gi +gi.require_version("Gtk", "3.0") +from gi.repository.Gtk import ListStore # noqa E402 +from gi.repository import GObject, GLib # noqa E402 + +@dataclass(slots=True, frozen=True) +class ModCols: + name: str + symlink: str + directory: str + size: float + color: str + + +@dataclass(slots=True, frozen=True) +class LogCols: + timestamp: str + flag: str + traceback: str + msg: str + + +@dataclass(slots=True, frozen=True) +class ServerModCols: + name: str + uid: GObject.TYPE_INT64 + installed: str + + +@dataclass(slots=True, frozen=True) +class MenuCols: + name: str + hidden: GObject.TYPE_PYOBJECT + + +class ModelManager: + """ + Manager for miscellaneous ListStores + """ + def __init__(self) -> None: + + self.map_store = ListStore(str) + self.help_store = self.new_model_from_class(MenuCols) + self.mod_store = self.new_model_from_class(ModCols) + self.log_store = self.new_model_from_class(LogCols) + self.modlist_store = self.new_model_from_class(ServerModCols) + + for row in HELP_MENU_ROWS: + label = row.dict["label"] + self.help_store.append([label, row]) + + def new_model_from_class(self, cls: type) -> ListStore: + store = ListStore(*[ftype for field, ftype in cls.__annotations__.items()]) + return store + + def get_map_store(self) -> ListStore: + return self.map_store + + def get_help_store(self) -> ListStore: + return self.help_store + + def get_mod_store(self) -> ListStore: + return self.mod_store + + def get_log_store(self) -> ListStore: + return self.log_store + + def get_modlist_store(self) -> ListStore: + return self.modlist_store + + def set_all_maps(self) -> None: + self.map_store.clear() + self.map_store.append(["All maps"]) + + def append_map(self, row: list) -> None: + self.map_store.append(row) From 495d6263bb81d573257a7c8088ae11045bf01c84 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Mon, 19 Jan 2026 15:20:09 +0900 Subject: [PATCH 016/309] feat: fetch model on startup --- dzgui/controllers/mc.py | 15 ++++++++++++++- dzgui/views/base.py | 1 + dzgui/views/components/filter_panel.py | 1 + dzgui/views/pages/servers.py | 18 +++++++++--------- dzgui/views/trees/tree_servers.py | 6 +++++- 5 files changed, 30 insertions(+), 11 deletions(-) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index df44210..376bdb5 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -528,7 +528,7 @@ class Controller: def unselect_all_mods(self) -> None: self.mediator.modtreeview.get_selection().unselect_all() - def dump_test(self) -> None: + def dump_test_1(self) -> None: import time time.sleep(1) # TODO: use model managers, etc. @@ -562,6 +562,17 @@ class Controller: self.get_func_data() self.destroy_on_idle() + def dump_test_2(self) -> None: + import time + time.sleep(1) + # TODO: use model managers, etc. + self.data = ( + ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], + ) + # TODO: refer to prior implementation--should need to load data into model while in thread + self.get_func_data() + self.destroy_on_idle() + def get_func_data(self): def test(): # NOTE: do not insert model until main thread is idle @@ -685,6 +696,8 @@ class Controller: self.set_statusbar_placeholder(status) self.set_statusbar(status) self.mediator.statusbar.spinner.start() + if len(model) < 1: + self.mediator.statusbar.spinner.stop() def populate_model(self) -> None: treeview = self.get_active_treeview() diff --git a/dzgui/views/base.py b/dzgui/views/base.py index 0a7102d..3fbb341 100644 --- a/dzgui/views/base.py +++ b/dzgui/views/base.py @@ -166,6 +166,7 @@ class OuterWindow(Gtk.Window): self.grid.right_panel.sel_panel.hide() # TODO: POC, trigger page change here MainController.loaded = True + MainController.populate_model() def _on_delete_event( self, window: "OuterWindow", event: Gdk.EventKey diff --git a/dzgui/views/components/filter_panel.py b/dzgui/views/components/filter_panel.py index ed6c1ef..974e273 100644 --- a/dzgui/views/components/filter_panel.py +++ b/dzgui/views/components/filter_panel.py @@ -49,6 +49,7 @@ class FilterPanel(Gtk.Box): ) row = 1 col = 0 + # TODO: use enumerated checks for check in self.default_filters.keys(): checkbox = Gtk.CheckButton(label=check) label = checkbox.get_children() diff --git a/dzgui/views/pages/servers.py b/dzgui/views/pages/servers.py index 42fdfc2..71a2b54 100644 --- a/dzgui/views/pages/servers.py +++ b/dzgui/views/pages/servers.py @@ -29,8 +29,8 @@ class ServerNotebook(Gtk.ScrolledWindow): self.recent = ServerTreeView(controller, ServerTab.RECENT, ContextMenuGroup.RECENT) self.lan = ServerTreeView(controller, ServerTab.LAN, ContextMenuGroup.SCAN_LAN) - self.browser.set_query_func(self.query_test2) - self.lan.set_query_func(self.query_test) + self.browser.set_query_func(self.controller.dump_test_1) + self.lan.set_query_func(self.controller.dump_test_2) tabs = [ (self.browser, server_labels.browser), @@ -101,13 +101,13 @@ class ServerNotebook(Gtk.ScrolledWindow): # self.controller.mediator.grid.conpan.lan.set_visible(False) # TODO: put in controller - def query_test2(self) -> None: - # TODO: should trigger first page action after entire UI is loaded - self.controller.dump_test() - - def query_test(self) -> None: - data = (["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False]) - return data + # def query_test2(self) -> None: + # # TODO: should trigger first page action after entire UI is loaded + # self.controller.dump_test_1() + # + # def query_test(self) -> None: + # data = (["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False]) + # return data def set_cached_label(self, label: str) -> None: self.tab_cache = label diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index c1e810d..9eaecfb 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -13,7 +13,7 @@ from dzgui.const.enum import ( RowType, ) from dzgui.const.constants import UDP_PORT -from dzgui.const.enum import ServerTab +from dzgui.const.enum import FilterMode, ServerTab from dzgui.api.servers import Record from dzgui.model.filtered_model import FilteredModelManager from dzgui.util.dist import CalcDist @@ -119,6 +119,10 @@ class ServerTreeView(TreeView): GLib.timeout_add(QUEUE_CHECK_DELAY, self._check_result_queue) + def filter(self, mode: FilterMode, label: str) -> None: + print("TOGGLE:", mode) + print("LABEL: ", label) + def get_filter_man(self) -> FilteredModelManager: return self.filter_man From 3092e5251576507c59f3a94b85074a3f85acd7b8 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Mon, 19 Jan 2026 16:15:26 +0900 Subject: [PATCH 017/309] feat: statusbar substack WIP --- dzgui/controllers/mc.py | 6 ++++-- dzgui/views/components/statusbar.py | 2 ++ dzgui/views/pages/servers.py | 3 ++- dzgui/views/trees/tree_servers.py | 16 +++++++++++++--- 4 files changed, 21 insertions(+), 6 deletions(-) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 376bdb5..ad298c2 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -421,8 +421,10 @@ class Controller: total_mods, total_size = self.calc_mod_size() msg = format_mods(total_size, total_mods) # TODO: combine - self.mediator.statusbar.set_text(msg) - self.mediator.statusbar.spinner.stop() + meta = self.mediator.statusbar.statusbar.get_context_id("Mods") + self.mediator.statusbar.statusbar.push(meta, msg) + #meta = self.mediator.statusbar.set_text(msg) + def calc_mod_size(self) -> tuple[int, int]: model = self.model_man.get_mod_store() diff --git a/dzgui/views/components/statusbar.py b/dzgui/views/components/statusbar.py index 914bfb7..47a52ff 100644 --- a/dzgui/views/components/statusbar.py +++ b/dzgui/views/components/statusbar.py @@ -43,6 +43,8 @@ class Statusbar(Gtk.Grid): if string is None: return meta = self.statusbar.get_context_id("Statusbar") + # TODO: substacks + # get_context_id(ServerTab) self.statusbar.push(meta, string) def refresh(self, row: "RowType") -> None: diff --git a/dzgui/views/pages/servers.py b/dzgui/views/pages/servers.py index 71a2b54..e96f10f 100644 --- a/dzgui/views/pages/servers.py +++ b/dzgui/views/pages/servers.py @@ -59,11 +59,12 @@ class ServerNotebook(Gtk.ScrolledWindow): self.controller.toggle_server_panels(False) def _on_keypress(self, widget: Self, event: Gdk.EventKey) -> None: - match event.keyval: case Gdk.KEY_n: self.notebook.next_page() case Gdk.KEY_p: + if event.state is Gdk.ModifierType.CONTROL_MASK: + return self.notebook.prev_page() case _: return diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index 9eaecfb..16d499a 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -46,7 +46,9 @@ class ServerTreeView(TreeView): QUEUE_CHECK_DELAY = 200 + self.controller = controller self.enum = enum + self.loaded = False self.query_func: Callable = None @@ -56,7 +58,6 @@ class ServerTreeView(TreeView): self.menu = Gtk.Menu() self.menu.connect("key-press-event", self._on_key) - self.controller = controller self.set_context_menu(menu) self.set_fixed_height_mode(True) @@ -67,7 +68,6 @@ class ServerTreeView(TreeView): prefs = self.controller.get_prefs() columns = prefs.paths.columns - try: data = JSON.read_json(columns) valid_json = True @@ -75,6 +75,7 @@ class ServerTreeView(TreeView): logger.critical(e) valid_json = False + # TODO: abstract browser_cols = strings.browser_cols for i, column_title in enumerate(browser_cols): renderer = Gtk.CellRendererText() @@ -114,7 +115,6 @@ class ServerTreeView(TreeView): self.connect("generic_treesel_changed", self._parent_selection_changed) self.connect("map", self._on_map) self.connect("unmap", self._on_unmap) - self.connect("focus-in-event", self._on_kb_focus) GLib.timeout_add(QUEUE_CHECK_DELAY, self._check_result_queue) @@ -149,6 +149,10 @@ class ServerTreeView(TreeView): col.set_fixed_width(width) def _on_kb_focus(self, a, b) -> None: + # TODO: may generate superfluous calc events when page changed but selection is the same + # this can probably be dropped in favor of statusbar cache system + # meta = self.controller.mediator.statusbar.statusbar.get_context_id("Mods") + # self.controller.mediator.statusbar.statusbar.pop(meta) self.emit("on_distcalc_started") def get_enum(self) -> None: @@ -163,6 +167,7 @@ class ServerTreeView(TreeView): self.controller.mediator.grid.conpan.lan.set_visible(False) def _on_key(self, menu: Gtk.Menu, event: Gdk.EventKey) -> bool | None: + # TODO: abstract as navigable context menu if not is_navkey(event.keyval): return False sel = menu.get_selected_item() @@ -258,6 +263,11 @@ class ServerTreeView(TreeView): case Gdk.KEY_i: # TODO: self.controller.mediator.grid.conpan.add_panel.entry.grab_focus() + case Gdk.KEY_p: + if self.enum is ServerTab.LAN: + self.controller.mediator.grid.conpan.lan.entry.grab_focus() + case Gdk.KEY_c: + print("copy to clipboard") else: match event.keyval: case Gdk.KEY_l | Gdk.KEY_Right: From 83d78e74de1027f8f2dc0df494be02e299144501 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Mon, 19 Jan 2026 16:20:02 +0900 Subject: [PATCH 018/309] fix: suppress keynav actions on small models --- dzgui/views/trees/tree_base.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/dzgui/views/trees/tree_base.py b/dzgui/views/trees/tree_base.py index b743958..28adbfc 100644 --- a/dzgui/views/trees/tree_base.py +++ b/dzgui/views/trees/tree_base.py @@ -89,7 +89,7 @@ class TreeView(CursorMixin, Gtk.TreeView): # type: ignore if is_navkey(event.keyval): # TODO: if model is None - if len(self.get_model()) < 1: + if len(self.get_model()) < 2: return if self.sel_blocked is False: self.controller.suppress_signal( @@ -113,6 +113,9 @@ class TreeView(CursorMixin, Gtk.TreeView): # type: ignore # self.get_selection().select_iter(it) # return True + if len(self.get_model()) < 2: + return + if is_navkey(event.keyval): if self.sel_blocked is True: self.controller.suppress_signal( From 2f9000ee80ce084515cf5b18ae516dc32f5ec0e7 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Mon, 19 Jan 2026 17:09:19 +0900 Subject: [PATCH 019/309] chore: register filter panel --- dzgui/controllers/mc.py | 25 ++++++++++--------------- dzgui/util/strings.py | 3 ++- dzgui/views/components/filter_panel.py | 1 + dzgui/views/trees/tree_servers.py | 4 ++-- 4 files changed, 15 insertions(+), 18 deletions(-) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index ad298c2..1bf179e 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -61,14 +61,15 @@ if TYPE_CHECKING: from dzgui.util.dist import Haversine from dzgui.views.base import Notebook, Grid, OuterWindow from dzgui.views.components.buttonbox import ContextualButton - from dzgui.views.components.statusbar import Statusbar + from dzgui.views.components.filter_panel import FilterPanel from dzgui.views.components.right_panel import RightPanel + from dzgui.views.components.statusbar import Statusbar from dzgui.views.pages.servers import ServerNotebook from dzgui.views.trees.tree_base import TreeView + 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.trees.tree_log import LogTreeView class AppNavigation: window: "OuterWindow" @@ -84,6 +85,7 @@ class AppNavigation: recent: "ServerTreeView" lan: "ServerTreeView" logtreeview: "LogTreeView" + filters: "FilterPanel" class Controller: def __init__(self) -> None: @@ -152,8 +154,8 @@ class Controller: 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, + self.mediator.filters, + self.mediator.filters.maps_combo, "_on_map_changed", state, ) @@ -164,9 +166,9 @@ class Controller: state, ) self.suppress_signal(self.mediator.menu, self.mediator.menu, "_on_keypress", state) - for check in self.mediator.grid.right_panel.filters_vbox.checks: + for check in self.mediator.filters.checks: self.suppress_signal( - self.mediator.grid.right_panel.filters_vbox, + self.mediator.filters, check, "_on_check_toggled", state, @@ -315,6 +317,7 @@ class Controller: self.mediator.statusbar.refresh(row) def toggle_server_panels(self, state: bool) -> None: + # TODO: this is going to be signal dependent now self.mediator.grid.toggle_filter_panel(state) self.mediator.grid.toggle_connect_panel(state) self.mediator.grid.toggle_refresh_button(state) @@ -551,14 +554,6 @@ class Controller: ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], - ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], - ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], - ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], - ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], - ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], - ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], - ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], - ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], ) # TODO: refer to prior implementation--should need to load data into model while in thread self.get_func_data() @@ -745,7 +740,7 @@ class Controller: return False index = mappings[event.keyval] # TODO: register filter panel widget - self.mediator.grid.right_panel.filters_vbox.toggle_check(index) + self.mediator.filters.toggle_check(index) def get_favorite(self) -> tuple[str, str] | tuple[None, None]: fav = str(self.query_config(Preferences.FAV_LBL)) diff --git a/dzgui/util/strings.py b/dzgui/util/strings.py index 709d816..31c0365 100644 --- a/dzgui/util/strings.py +++ b/dzgui/util/strings.py @@ -164,7 +164,8 @@ ping_tooltip = ( "Available once per unique filter context" ) -statusbar_helptext = "Select a row to see its detailed description" +# statusbar_helptext = "Select a row to see its detailed description" +statusbar_helptext = "No server metadata to list." # use e.g. filters.1pp filter_1pp = "1PP" diff --git a/dzgui/views/components/filter_panel.py b/dzgui/views/components/filter_panel.py index 974e273..60b381a 100644 --- a/dzgui/views/components/filter_panel.py +++ b/dzgui/views/components/filter_panel.py @@ -33,6 +33,7 @@ class FilterPanel(Gtk.Box): } self.controller = controller + self.controller.register_widget("filters", self) map_store = self.controller.get_map_store() diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index 16d499a..ab54f7b 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -257,9 +257,9 @@ class ServerTreeView(TreeView): self.refresh_player_count() case Gdk.KEY_f: # TODO: register filter panel instead of mediating thru right panel - self.controller.mediator.grid.right_panel.filters_vbox.keyword_entry.grab_focus() + self.controller.mediator.filters.keyword_entry.grab_focus() case Gdk.KEY_m: - self.controller.mediator.grid.right_panel.filters_vbox.maps_entry.grab_focus() + self.controller.mediator.filters.maps_entry.grab_focus() case Gdk.KEY_i: # TODO: self.controller.mediator.grid.conpan.add_panel.entry.grab_focus() From 9cc1ece0d702a5c8ec44fbb0c6ab7894ecffcd7f Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 21 Jan 2026 12:04:42 +0900 Subject: [PATCH 020/309] fix: statusbar race condition WIP --- dzgui/controllers/mc.py | 102 +++++++++++++++------------- dzgui/main.py | 9 ++- dzgui/util/dist.py | 16 +++-- dzgui/views/base.py | 58 +++++++++------- dzgui/views/components/buttons.py | 3 + dzgui/views/components/eventbox.py | 5 +- dzgui/views/components/statusbar.py | 34 ++++++++-- dzgui/views/pages/servers.py | 2 + dzgui/views/trees/tree_servers.py | 65 ++++++++---------- 9 files changed, 169 insertions(+), 125 deletions(-) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 1bf179e..3870b8e 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -1,7 +1,6 @@ import logging import shutil import threading -import textwrap import traceback from pathlib import Path @@ -17,23 +16,16 @@ from dzgui.api.mods import ( get_local_mod_path, find_stale_mods, _hash, - remove_stale_signatures -) -from dzgui.const.enum import ( - Preferences, - Popup, - NotebookPage, - ButtonType, - ContextMenu, - RowType + remove_stale_signatures, ) +from dzgui.const.enum import Preferences, NotebookPage, ButtonType, ContextMenu, RowType from dzgui.const.constants import ( APPID_DAYZ, APPID_DAYZ_EXP, HEX_RED, WINDOW_DEFAULT_X, - WINDOW_DEFAULT_Y + WINDOW_DEFAULT_Y, ) from dzgui.config import update @@ -45,19 +37,20 @@ from dzgui.util.diag import write_diagnostic from dzgui.util._json import read_json, write_json from dzgui.util.localize import number from dzgui.util.open_links import open_workshop_page, open_user_workshop -from dzgui.util.format import format_mods, format_player_count, pluralize +from dzgui.util.format import format_mods, format_player_count from dzgui.util.redact import redact_log from dzgui.views.dialogs.filepicker import FilePicker -from dzgui.views.dialogs.generic import GenericDialog import gi + gi.require_version("Gtk", "3.0") -from gi.repository import Gtk, Gdk, GLib, GObject # noqa E402 +from gi.repository import Gtk, Gdk, GLib, GObject # noqa E402 logger = logging.getLogger(__name__) if TYPE_CHECKING: + from dzgui.const.enum import ServerTab from dzgui.util.dist import Haversine from dzgui.views.base import Notebook, Grid, OuterWindow from dzgui.views.components.buttonbox import ContextualButton @@ -65,12 +58,12 @@ if TYPE_CHECKING: from dzgui.views.components.right_panel import RightPanel from dzgui.views.components.statusbar import Statusbar from dzgui.views.pages.servers import ServerNotebook - from dzgui.views.trees.tree_base import TreeView 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 + class AppNavigation: window: "OuterWindow" grid: "Grid" @@ -80,16 +73,16 @@ class AppNavigation: modtreeview: "ModTreeView" menu: "MenuTreeView" servers: "ServerNotebook" - browser: "ServerTreeView" saved: "ServerTreeView" recent: "ServerTreeView" lan: "ServerTreeView" logtreeview: "LogTreeView" filters: "FilterPanel" + class Controller: def __init__(self) -> None: - self.dist_cache: dict[str, "Haversine"] = {} + self.dist_cache: dict[str, "Haversine", "ServerTab"] = {} self.crumbs_cache = "" self.mediator = AppNavigation() self.prefs: UserPrefs @@ -128,7 +121,8 @@ class Controller: def terminate_process(self) -> None: # TODO: only used by server table multiprocessing queue - self.mediator.notebook.servers.browser.terminate_process() + tv = self.get_active_treeview() + tv.terminate_process() def get_prefs(self) -> UserPrefs: return self.prefs @@ -165,7 +159,9 @@ class Controller: "_on_tree_selection_changed", state, ) - self.suppress_signal(self.mediator.menu, self.mediator.menu, "_on_keypress", state) + self.suppress_signal( + self.mediator.menu, self.mediator.menu, "_on_keypress", state + ) for check in self.mediator.filters.checks: self.suppress_signal( self.mediator.filters, @@ -184,7 +180,7 @@ class Controller: else: widget.handler_unblock_by_func(func) # TODO: deprecated? - #self.mediator.menu.sel_blocked = state + # self.mediator.menu.sel_blocked = state def toggle_debug_mode(self) -> None: self.toggle_config(Preferences.DEBUG) @@ -234,14 +230,22 @@ class Controller: def get_statusbar(self) -> str: return self.mediator.statusbar.get_text() - def set_statusbar(self, text: str) -> None: - self.mediator.statusbar.set_text(text) + def remove_statusbar(self, context: str) -> None: + c = self.mediator.statusbar.statusbar.get_context_id(context) + self.mediator.statusbar.statusbar.remove_all(c) + + def set_statusbar(self, text: str, context: str) -> None: + self.mediator.statusbar.set_text(text, context) def set_statusbar_placeholder(self, text: str) -> None: # TODO: use statusbar stacks instead self.statusbar_placeholder = text - def set_statusbar_dist(self, haversine: "Haversine") -> None: + def set_statusbar_dist(self, haversine: "Haversine", enum: "ServerTab") -> None: + tv = self.get_active_treeview() + if tv.get_enum() != enum: + self.mediator.statusbar.spinner.stop() + return dist: str if haversine is None: dist = "Unknown" @@ -255,7 +259,8 @@ class Controller: separated = number(raw) dist = str(separated) + " km" text = self.statusbar_placeholder - self.set_statusbar(f"{text} | Distance: {dist}") + # TODO: abstract + self.set_statusbar(f"{text} | Distance: {dist}", "Servers") self.mediator.statusbar.spinner.stop() def delete_multiple_mods(self) -> None: @@ -328,7 +333,7 @@ class Controller: def show_developers_page(self) -> None: self.open_page(NotebookPage.DEVELOPERS) # TODO: put cursor on first row - #self.mediator.developers.focus_first_row() + # self.mediator.developers.focus_first_row() def open_page(self, page: NotebookPage) -> None: self.mediator.grid.notebook.set_page_by_enum(page) @@ -386,7 +391,9 @@ class Controller: 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]: + def get_mod_from_tree_path( + self, tree_path: Gtk.TreePath + ) -> tuple[str, Gtk.TreeIter]: model = self.model_man.get_mod_store() tree_iter = model.get_iter(tree_path) mod = model.get(tree_iter, 2)[0] @@ -403,7 +410,7 @@ class Controller: app_path = PeFile.get_nested_app_path(steam_path, APPID_DAYZ) md5 = _hash(mod) - symlink = app_path / md5 + symlink = app_path / md5 symlink.unlink() shutil.rmtree(mods_path / mod) @@ -419,15 +426,13 @@ class Controller: model = self.model_man.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) # TODO: combine - meta = self.mediator.statusbar.statusbar.get_context_id("Mods") - self.mediator.statusbar.statusbar.push(meta, msg) - #meta = self.mediator.statusbar.set_text(msg) - + # meta = self.mediator.statusbar.statusbar.get_context_id("Mods") + # self.mediator.statusbar.statusbar.push(meta, msg) + self.mediator.statusbar.set_text(msg, "Mods") def calc_mod_size(self) -> tuple[int, int]: model = self.model_man.get_mod_store() @@ -437,7 +442,6 @@ class Controller: total_size += mod[3] return total_mods, total_size - def menu_action(self, action: ContextMenu, path: Gtk.TreePath) -> None: match action: # NOTE: manipulates server stores @@ -470,14 +474,12 @@ class Controller: # Gtk.TreeModel, row-inserted/row-deleted self.update_mod_statusbar() remove_stale_signatures( - self.prefs.paths.config, - self.prefs.paths.version + 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: @@ -493,7 +495,9 @@ class Controller: # NOTE: this model is reloaded each time as log changes try: with open(log, "r") as f: - lines = [line.split(strings.delimiter) for line in f.read().splitlines()] + lines = [ + line.split(strings.delimiter) for line in f.read().splitlines() + ] for record in lines: clean = redact_log(record) store.append(clean) @@ -535,6 +539,7 @@ class Controller: def dump_test_1(self) -> None: import time + time.sleep(1) # TODO: use model managers, etc. self.data = ( @@ -561,6 +566,7 @@ class Controller: def dump_test_2(self) -> None: import time + time.sleep(1) # TODO: use model managers, etc. self.data = ( @@ -570,7 +576,7 @@ class Controller: self.get_func_data() self.destroy_on_idle() - def get_func_data(self): + def get_func_data(self) -> None: def test(): # NOTE: do not insert model until main thread is idle # TODO: grab from model manager and insert entire model @@ -580,6 +586,7 @@ class Controller: treeview.set_loaded(True) self.update_server_status() treeview.grab_focus() + treeview = self.get_active_treeview() manager = treeview.get_filter_man() @@ -591,6 +598,7 @@ class Controller: def call_on_thread(self, func: Callable, *args) -> None: from dzgui.views.dialogs.generic import WaitDialog + self.wait_dialog = WaitDialog(self, strings.dialog.fetching) self.wait_dialog.show_all() thread = threading.Thread(target=func, args=args) @@ -607,7 +615,7 @@ class Controller: Lets calling widgets register a callback function when spawning a threaded process """ - self.callback = { "func": callback, "args": args } + self.callback = {"func": callback, "args": args} def destroy_on_idle(self) -> None: self.wait_dialog.destroy() @@ -642,7 +650,6 @@ class Controller: dialog = ExceptionDialog(self, strings.api_error) dialog.run() - def update_api_key(self, text: str, key: Preferences) -> None: self.call_on_thread(self.test_api_response, text, key) @@ -688,13 +695,15 @@ class Controller: def update_server_status(self) -> None: # TODO: emit signal on statusbar only if page changed treeview = self.get_active_treeview() + context = str(treeview.enum) model = treeview.get_model() status = format_player_count(model) self.set_statusbar_placeholder(status) - self.set_statusbar(status) - self.mediator.statusbar.spinner.start() - if len(model) < 1: - self.mediator.statusbar.spinner.stop() + + self.set_statusbar(status, context) + if len(model) >= 1: + self.mediator.statusbar.spinner.start() + # self.mediator.statusbar.spinner.stop() def populate_model(self) -> None: treeview = self.get_active_treeview() @@ -718,8 +727,8 @@ class Controller: crumbs = self.mediator.servers.get_cached_label() self.set_crumbs(crumbs) # TODO: could emit this when treeview gains keyboard focus - #tree = self.get_active_treeview() - #tree.emit("on_distcalc_started") + # tree = self.get_active_treeview() + # tree.emit("on_distcalc_started") def toggle_check(self, event: Gdk.EventKey) -> None | Literal[False]: mappings = { @@ -739,7 +748,6 @@ class Controller: if event.keyval not in mappings: return False index = mappings[event.keyval] - # TODO: register filter panel widget self.mediator.filters.toggle_check(index) def get_favorite(self) -> tuple[str, str] | tuple[None, None]: diff --git a/dzgui/main.py b/dzgui/main.py index a2bcd57..de1124a 100644 --- a/dzgui/main.py +++ b/dzgui/main.py @@ -44,10 +44,12 @@ args = parser.parse_args() if TYPE_CHECKING: from pathlib import Path + # TODO: profile load time def make_parents(path: "Path") -> None: path.parent.mkdir(parents=True, exist_ok=True) + def uninstall() -> None: # TODO: uninstall data files (-u) # -u removes state, log, freedesktop @@ -56,6 +58,7 @@ def uninstall() -> None: # XDG_DATA_HOME/dzgui pass + def main() -> None: lock = lock_acquire() # noqa @@ -85,7 +88,9 @@ def main() -> None: migrate_cols_file(XDG.columns) copy_state_files(xdg_paths["XDG_STATE_HOME"]) - _format = "%(asctime)s␞%(levelname)s␞%(filename)s::%(funcName)s::%(lineno)s␞%(message)s" + _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) @@ -126,7 +131,7 @@ def main() -> None: local_coords, version, allow, - XDG + XDG, ) print("All OK. Loading UI...") App(prefs) diff --git a/dzgui/util/dist.py b/dzgui/util/dist.py index 1d6ed0c..70da21f 100644 --- a/dzgui/util/dist.py +++ b/dzgui/util/dist.py @@ -4,19 +4,20 @@ from math import radians, cos, sin, asin, sqrt from typing import TYPE_CHECKING from dzgui.util.ip import GeolocationError, get_coords -from dzgui.util.localize import number import gi + gi.require_version("Gtk", "3.0") from gi.repository import Gtk # noqa E402 if TYPE_CHECKING: - from dzgui.util.ip import Coords + from dzgui.const.enum import ServerTab from dzgui.controllers.mc import Controller logger = logging.getLogger(__name__) -class Haversine(): + +class Haversine: def __init__(self, lat1: float, lon1: float, lat2: float, lon2: float) -> None: R = 6371 * 1000 @@ -26,7 +27,7 @@ class Haversine(): lat1 = radians(lat1) lat2 = radians(lat2) - a = sin(dLat/2) ** 2 + cos(lat1) * cos(lat2) * sin(dLon/2) ** 2 + a = sin(dLat / 2) ** 2 + cos(lat1) * cos(lat2) * sin(dLon / 2) ** 2 c = 2 * asin(sqrt(a)) self.dist = R * c @@ -37,15 +38,18 @@ class Haversine(): def as_miles(self) -> float: return self.dist / 1609.344 + class CalcDist(multiprocessing.Process): def __init__( self, addr: str, + enum: "ServerTab", result_queue: multiprocessing.Queue, controller: "Controller", ) -> None: super().__init__() + self.enum = enum self.controller = controller self.result_queue = result_queue self.addr = addr @@ -55,11 +59,11 @@ class CalcDist(multiprocessing.Process): cache = self.controller.get_dist_cache() if self.addr in cache: logger.info(f"Address '{self.addr}' already in cache") - self.result_queue.put([self.addr, cache[self.addr]]) + self.result_queue.put([self.addr, cache[self.addr], self.enum]) return dist = self.compare(self.ip) - self.result_queue.put([self.addr, dist]) + self.result_queue.put([self.addr, dist, self.enum]) def compare(self, remote: str) -> int | None: prefs = self.controller.get_prefs() diff --git a/dzgui/views/base.py b/dzgui/views/base.py index 3fbb341..fa407b6 100644 --- a/dzgui/views/base.py +++ b/dzgui/views/base.py @@ -1,13 +1,7 @@ import logging -#import multiprocessing import signal -#import threading -import typing # noqa import warnings -#from concurrent.futures import wait -#from concurrent.futures import ThreadPoolExecutor - from typing import TYPE_CHECKING, Literal from dzgui.const.enum import NotebookPage @@ -37,6 +31,7 @@ if TYPE_CHECKING: from dzgui.config.userprefs import UserPrefs import gi + gi.require_version("Gtk", "3.0") from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa E402 @@ -46,10 +41,10 @@ warnings.filterwarnings("ignore", ".*g_value_get_int", Warning) # TODO: move to controller # kilometer cache; note, user may change measurement it partway through, flush cache -#cache: dict[str, int] = {} +# cache: dict[str, int] = {} ## TODO: move to configs/servers -#def query_history() -> list | None: +# def query_history() -> list | None: # history_file = MainController.get_prefs().paths.history # try: # with open(history_file, "r") as f: @@ -58,7 +53,7 @@ warnings.filterwarnings("ignore", ".*g_value_get_int", Warning) # rows = None # return rows # -#def process_tree_option(choice: RowType) -> None: +# def process_tree_option(choice: RowType) -> None: # # server tables # if command == RowType.RESOLVE_IP: # record = treeview.get_record() @@ -91,18 +86,18 @@ warnings.filterwarnings("ignore", ".*g_value_get_int", Warning) # return # ## TODO: belongs in model -#def str_to_record(record: str) -> Record | None: +# 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: +# 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 connect_by_ip(enum: RowType, response: str) -> None: # def _prep(response: str) -> None: # record = Servers.validate_ip(response) # proc = treeview.prepare_connection(record) @@ -114,7 +109,7 @@ warnings.filterwarnings("ignore", ".*g_value_get_int", Warning) # return # # -#def connect_by_id(enum: RowType, uid: str, key: str) -> None: +# 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: @@ -139,6 +134,7 @@ warnings.filterwarnings("ignore", ".*g_value_get_int", Warning) # # + class OuterWindow(Gtk.Window): def __init__(self) -> None: super().__init__(title=APP_NAME, border_width=10, icon_name=APP_NAME_LOWER) @@ -168,9 +164,7 @@ class OuterWindow(Gtk.Window): MainController.loaded = True MainController.populate_model() - def _on_delete_event( - self, window: "OuterWindow", event: Gdk.EventKey - ) -> None: + def _on_delete_event(self, window: "OuterWindow", event: Gdk.EventKey) -> None: self.halt_proc_and_quit() def halt_proc_and_quit(self) -> None: @@ -316,7 +310,7 @@ class Notebook(ScrollableMixin, Gtk.Notebook): # type: ignore MainController.set_crumbs(crumbs) if status is False: - MainController.set_statusbar("") + MainController.set_statusbar("", "") if enum is NotebookPage.SERVERS: MainController.present_servers() @@ -351,8 +345,20 @@ class Grid(Gtk.Grid): els = ( (self.crumb_box, self.notebook, Gtk.PositionType.TOP, MAX_COLS, SINGLE_ROW), (self.conpan, self.notebook, Gtk.PositionType.BOTTOM, MAX_COLS, SINGLE_ROW), - (self.statusbar, self.conpan, Gtk.PositionType.BOTTOM, MAX_COLS, SINGLE_ROW), - (self.right_panel, self.notebook, Gtk.PositionType.RIGHT, SINGLE_COL, MAX_ROWS), + ( + self.statusbar, + self.conpan, + Gtk.PositionType.BOTTOM, + MAX_COLS, + SINGLE_ROW, + ), + ( + self.right_panel, + self.notebook, + Gtk.PositionType.RIGHT, + SINGLE_COL, + MAX_ROWS, + ), ) for el, sibling, pos, h_span, v_span in els: self.attach_next_to(el, sibling, pos, h_span, v_span) @@ -372,7 +378,7 @@ class Grid(Gtk.Grid): def toggle_refresh_button(self, state: bool) -> None: self.right_panel.refresh_button.set_visible(state) - # TODO make this method internal to Statusbar + # TODO: make this method internal to Statusbar def get_breadcrumbs(self) -> str: return self.breadcrumbs.get_text() @@ -397,9 +403,7 @@ class App(Gtk.Application): ) self.win.add_accel_group(accel) - GLib.unix_signal_add( - GLib.PRIORITY_DEFAULT, signal.SIGINT, self._catch_sigint - ) + GLib.unix_signal_add(GLib.PRIORITY_DEFAULT, signal.SIGINT, self._catch_sigint) Gtk.main() def _catch_sigint(self) -> Literal[True]: @@ -415,9 +419,11 @@ class App(Gtk.Application): ) -> None: self.win.halt_proc_and_quit() + MainController = Controller() # TODO: icon sanity check, log result -#theme = Gtk.IconTheme.get_default() -#icons = theme.list_icons(None) -#print("steam_tray_mono" in icons) +# theme = Gtk.IconTheme.get_default() +# icons = theme.list_icons(None) +# print("steam_tray_mono" in icons) +# FIXME: diff --git a/dzgui/views/components/buttons.py b/dzgui/views/components/buttons.py index 6a997c6..47e4803 100644 --- a/dzgui/views/components/buttons.py +++ b/dzgui/views/components/buttons.py @@ -93,6 +93,9 @@ class RefreshButton(IconTextButton): self.connect("clicked", self._on_refresh_clicked) def _on_refresh_clicked(self, button: Self) -> None: + # TODO: get page enum + print(self.controller.mediator.notebook.get_page_by_enum()) + # if .MODS, .LOG, .OPTIONS, reload in place self.set_sensitive(False) self.show_time(True) GLib.timeout_add_seconds(1, self.decrement) diff --git a/dzgui/views/components/eventbox.py b/dzgui/views/components/eventbox.py index aa607de..2294ba7 100644 --- a/dzgui/views/components/eventbox.py +++ b/dzgui/views/components/eventbox.py @@ -26,13 +26,14 @@ class InfoEventBox(Gtk.EventBox): self, eventbox: Gtk.EventBox, eventcrossing: Gdk.EventCrossing ) -> None: self.icon.set_opacity(1) - self.parent.controller.set_statusbar(self.text) + self.parent.controller.set_statusbar(self.text, "Options") def _on_leave_tooltip( self, eventbox: Gtk.EventBox, eventcrossing: Gdk.EventCrossing ) -> None: self.icon.set_opacity(0.8) - self.parent.controller.set_statusbar("") + #self.parent.controller.set_statusbar("", "Options") + self.parent.controller.remove_statusbar("Options") def set_text(self, text: str) -> None: self.text = text diff --git a/dzgui/views/components/statusbar.py b/dzgui/views/components/statusbar.py index 47a52ff..43524be 100644 --- a/dzgui/views/components/statusbar.py +++ b/dzgui/views/components/statusbar.py @@ -19,6 +19,7 @@ class Statusbar(Gtk.Grid): help_text = strings.statusbar_helptext + self.context: int self.statusbar = Gtk.Statusbar() self.spinner = Gtk.Spinner() @@ -31,7 +32,7 @@ class Statusbar(Gtk.Grid): self.attach_next_to(self.spinner, self.statusbar, Gtk.PositionType.RIGHT, 3, 1) self.attach_next_to(self.status_right_label, self.spinner, Gtk.PositionType.RIGHT, 3, 1) - self.set_text(help_text) + self.set_text(help_text, "Help") self.players = "" def get_text(self) -> str: @@ -39,20 +40,41 @@ class Statusbar(Gtk.Grid): label = area.get_children()[0] return label.get_text() - def set_text(self, string: str) -> None: - if string is None: - return - meta = self.statusbar.get_context_id("Statusbar") + def set_text(self, string: str, context: str) -> None: + # if string is None: + # return + meta = self.statusbar.get_context_id(context) + #tv = self.controller.get_active_treeview() + #cur_context = tv.get_enum() + #cid = self.get_context_by_enum(cur_context) + #if cid != meta: + # print("requested: ", meta) + # print("current: ", cid) + # return # TODO: substacks # get_context_id(ServerTab) self.statusbar.push(meta, string) + #self.set_context(meta) + + + # TODO: type checking + # def get_context_by_enum(self, context: "ServerTab") -> int: + # cid = self.statusbar.get_context_id(str(context)) + # # TODO: substacks + # return cid + # + # def get_context(self) -> int: + # return self.context + # + # def set_context(self, context: int) -> None: + # self.context = context def refresh(self, row: "RowType") -> None: if row is None: formatted = "" else: formatted = self.format_metadata(row) - self.set_text(formatted) + self.set_text(formatted, "Help") def append_distance(self, dist: str) -> None: # TODO: process strings in controller diff --git a/dzgui/views/pages/servers.py b/dzgui/views/pages/servers.py index e96f10f..ca065e4 100644 --- a/dzgui/views/pages/servers.py +++ b/dzgui/views/pages/servers.py @@ -78,6 +78,8 @@ class ServerNotebook(Gtk.ScrolledWindow): def _on_page_changed(self, notebook: Gtk.Notebook, child: Gtk.Widget, index: int) -> None: if self.controller.loaded is False: return + + # self.controller.remove_statusbar("Servers") # TODO: abstract label = self.notebook.get_tab_label_text(child) diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index ab54f7b..3d00fb5 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -1,29 +1,24 @@ import logging import multiprocessing -import subprocess from typing import Self -import gi -gi.require_version("Gtk", "3.0") -from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa - -from dzgui.const.enum import ( - ContextMenu, - ContextMenuGroup, - RowType, - ) -from dzgui.const.constants import UDP_PORT -from dzgui.const.enum import FilterMode, ServerTab +from dzgui.const.enum import ContextMenu, ContextMenuGroup, FilterMode, ServerTab from dzgui.api.servers import Record from dzgui.model.filtered_model import FilteredModelManager from dzgui.util.dist import CalcDist from dzgui.util.keys import is_navkey from dzgui.util import strings -from typing import Any, Callable, Literal, TYPE_CHECKING +from typing import Callable, Literal, TYPE_CHECKING from dzgui.views.trees.tree_base import TreeView import dzgui.util._json as JSON # noqa +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa + + logger = logging.getLogger(__name__) if TYPE_CHECKING: @@ -31,6 +26,7 @@ if TYPE_CHECKING: # TODO: restore missing methods + class EnumeratedMenuItem(Gtk.MenuItem): def __init__(self, enum: ContextMenu): super().__init__(label=enum.dict["label"]) @@ -38,10 +34,11 @@ class EnumeratedMenuItem(Gtk.MenuItem): class ServerTreeView(TreeView): - __gsignals__ = { - "on_distcalc_started": (GObject.SignalFlags.RUN_FIRST, None, ()) - } - def __init__(self, controller: "Controller", enum: ServerTab, menu: ContextMenuGroup) -> None: + __gsignals__ = {"on_distcalc_started": (GObject.SignalFlags.RUN_FIRST, None, ())} + + def __init__( + self, controller: "Controller", enum: ServerTab, menu: ContextMenuGroup + ) -> None: super().__init__(controller) QUEUE_CHECK_DELAY = 200 @@ -181,11 +178,11 @@ class ServerTreeView(TreeView): case Gdk.KEY_j: if ind == len(children) - 1: return True - menu.select_item(children[ind+1]) + menu.select_item(children[ind + 1]) case Gdk.KEY_k: if ind - 1 < 0: return True - menu.select_item(children[ind-1]) + menu.select_item(children[ind - 1]) case Gdk.KEY_g: menu.select_item(children[0]) case Gdk.KEY_G: @@ -207,15 +204,12 @@ class ServerTreeView(TreeView): """ Propagate width change to other tabs """ - title = col.get_title() - size = col.get_width() - # NOTE: get final width after drag action completes GLib.idle_add(self.controller.propagate_column_width, col) def terminate_process(self) -> None: - if self.current_proc and self.current_proc.is_alive(): - self.current_proc.terminate() + if self.current_proc and self.current_proc.is_alive(): + self.current_proc.terminate() def _on_distcalc_started(self, treeview: Self): record = self.get_record() @@ -223,8 +217,9 @@ class ServerTreeView(TreeView): return # TODO: self.controller.mediator.statusbar.spinner.start() - ip = record.ip - self.current_proc = CalcDist(record.ip, self.queue, self.controller) + self.current_proc = CalcDist( + record.ip, self.get_enum(), self.queue, self.controller + ) self.current_proc.start() def _check_result_queue(self) -> Literal[True]: @@ -235,13 +230,15 @@ class ServerTreeView(TreeView): latest_result = self.queue.get() cache = self.controller.get_dist_cache() + if latest_result: + # NOTE: if CalcDist was spawned on other tab addr = latest_result[0] haversine = latest_result[1] if addr not in cache: cache[addr] = haversine # FIXME - self.controller.set_statusbar_dist(haversine) + self.controller.set_statusbar_dist(haversine, self.get_enum()) return True def _on_server_keypress( @@ -308,7 +305,7 @@ class ServerTreeView(TreeView): mod_context_items = [ContextMenu.OPEN_WORKSHOP, ContextMenu.DELETE_MOD] # TODO: dynamic menu entries - #for row in items: + # for row in items: # if row == ContextMenu.ADD_SERVER: # if self.is_in_favs(): # row = ContextMenu.REMOVE_SERVER @@ -326,18 +323,14 @@ class ServerTreeView(TreeView): if event.type is Gdk.EventType.KEY_PRESS and event.keyval is Gdk.KEY_l: if self.is_selection_empty(): return - self.menu.popup_at_widget( - widget, Gdk.Gravity.CENTER, Gdk.Gravity.WEST - ) + self.menu.popup_at_widget(widget, Gdk.Gravity.CENTER, Gdk.Gravity.WEST) else: self.menu.popup_at_pointer(event) self.menu.select_first(False) - def _parent_row_activated(self, - tree: TreeView, - path: Gtk.TreePath, - column: Gtk.TreeViewColumn - ) -> None: + def _parent_row_activated( + self, tree: TreeView, path: Gtk.TreePath, column: Gtk.TreeViewColumn + ) -> None: # TODO: process server connection # TODO: get record print(self.get_value_at_index(0)) From 934ff91f05add1b0bdcd7996924d63f218ebf87b Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 22 Jan 2026 12:01:18 +0900 Subject: [PATCH 021/309] fix: dynamic statusbar WIP --- dzgui/controllers/mc.py | 65 +++++++++++----- dzgui/views/base.py | 23 +++--- dzgui/views/components/statusbar.py | 110 ++++++++++++++++++---------- dzgui/views/pages/help.py | 5 +- dzgui/views/pages/mods.py | 2 + dzgui/views/pages/servers.py | 48 ++++++------ dzgui/views/trees/tree_menu.py | 17 +++-- dzgui/views/trees/tree_mods.py | 20 ++--- dzgui/views/trees/tree_servers.py | 21 ++++-- 9 files changed, 189 insertions(+), 122 deletions(-) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 3870b8e..19412b2 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -212,6 +212,7 @@ class Controller: except Exception as e: logger.critical(e) + logger.info("Normal user exit") if self.mediator.window.props.is_maximized: Gtk.main_quit() return @@ -232,20 +233,30 @@ class Controller: def remove_statusbar(self, context: str) -> None: c = self.mediator.statusbar.statusbar.get_context_id(context) - self.mediator.statusbar.statusbar.remove_all(c) + self.mediator.statusbar.statusbar.pop(c) - def set_statusbar(self, text: str, context: str) -> None: - self.mediator.statusbar.set_text(text, context) + def set_statusbar(self, text: str, context: str) -> int: + msg_id = self.mediator.statusbar.set_text(text, context) + return msg_id def set_statusbar_placeholder(self, text: str) -> None: # TODO: use statusbar stacks instead self.statusbar_placeholder = text def set_statusbar_dist(self, haversine: "Haversine", enum: "ServerTab") -> None: + # NOTE: prevent race condition where page was changed before distance was written + if self.mediator.notebook.get_page_by_enum() is not NotebookPage.SERVERS: + cid = self.mediator.statusbar.statusbar.get_context_id(str(enum)) + self.mediator.statusbar.statusbar.pop(cid) + self.mediator.statusbar.spinner.stop() + return + tv = self.get_active_treeview() if tv.get_enum() != enum: self.mediator.statusbar.spinner.stop() return + + # NOTE: user may have changed km/mi toggle, so recalculate dist: str if haversine is None: dist = "Unknown" @@ -285,7 +296,7 @@ class Controller: mod.append(None) model.append(mod) - self.update_mod_statusbar() + # self.update_mod_statusbar() def toggle_config(self, context: Preferences) -> None: config = self.prefs.paths.config @@ -342,7 +353,6 @@ class Controller: # TODO: consolidate methods with set_page_by_enum match button.context: case ButtonType.EXIT: - logger.info("Normal user exit") self.save_res_and_quit() return case ButtonType.OPTIONS: @@ -352,11 +362,12 @@ class Controller: except Exception: return case ButtonType.MODS: + # TODO: reload using refresh button, rather than on demand self.load_mods() case ButtonType.HELP: - self.mediator.statusbar.refresh(RowType.CHANGELOG) pass case ButtonType.SERVERS: + # TODO: drop after fixing crumbs signal self.mediator.notebook.set_page_by_enum(button.opens) return @@ -364,6 +375,13 @@ class Controller: # TODO: set crumbs by signal self.set_crumbs(button.get_label()) + def get_help_text(self) -> str: + tv = self.mediator.menu + model = self.get_help_store() + tree_iter = tv.get_focused_row_iter() + value = model.get_value(tree_iter, 0) + return value + def open_user_workshop(self, uid: str) -> None: # NOTE: uid may contain leading zeroes, not a real integer client = self.query_config(Preferences.CLIENT) @@ -426,13 +444,10 @@ class Controller: model = self.model_man.get_mod_store() model.remove(it) - def update_mod_statusbar(self) -> None: + def format_mod_statusbar(self) -> None: total_mods, total_size = self.calc_mod_size() msg = format_mods(total_size, total_mods) - # TODO: combine - # meta = self.mediator.statusbar.statusbar.get_context_id("Mods") - # self.mediator.statusbar.statusbar.push(meta, msg) - self.mediator.statusbar.set_text(msg, "Mods") + return msg def calc_mod_size(self) -> tuple[int, int]: model = self.model_man.get_mod_store() @@ -698,12 +713,24 @@ class Controller: context = str(treeview.enum) model = treeview.get_model() status = format_player_count(model) - self.set_statusbar_placeholder(status) + self.set_statusbar_placeholder(status) self.set_statusbar(status, context) - if len(model) >= 1: - self.mediator.statusbar.spinner.start() - # self.mediator.statusbar.spinner.stop() + #if len(model) >= 1: + # self.mediator.statusbar.spinner.start() + + def start_spinner(self) -> None: + # TODO: use signal instead + self.mediator.statusbar.start_spinner() + + def notify_statusbar_of_notebook_change(self) -> None: + enum = self.mediator.notebook.get_page_by_enum() + self.mediator.statusbar.emit("notebook_page_changed", enum) + + def notify_statusbar(self) -> None: + tv = self.get_active_treeview() + enum = tv.get_enum() + self.mediator.statusbar.emit("server_page_changed", enum) def populate_model(self) -> None: treeview = self.get_active_treeview() @@ -722,13 +749,13 @@ class Controller: def present_servers(self) -> None: # TODO: abstract - # TODO: signal for crumbs self.update_server_status() + # TODO: signal for crumbs crumbs = self.mediator.servers.get_cached_label() self.set_crumbs(crumbs) - # TODO: could emit this when treeview gains keyboard focus - # tree = self.get_active_treeview() - # tree.emit("on_distcalc_started") + + tree = self.get_active_treeview() + tree.emit("on_distcalc_started") def toggle_check(self, event: Gdk.EventKey) -> None | Literal[False]: mappings = { diff --git a/dzgui/views/base.py b/dzgui/views/base.py index fa407b6..af8134e 100644 --- a/dzgui/views/base.py +++ b/dzgui/views/base.py @@ -186,6 +186,7 @@ class Notebook(ScrollableMixin, Gtk.Notebook): # type: ignore self.prior_page: NotebookPage self.prior_status: str + self.is_return = False MainController.register_widget("notebook", self) @@ -253,8 +254,8 @@ class Notebook(ScrollableMixin, Gtk.Notebook): # type: ignore self.set_page_by_enum(self.prior_page) self.settings.unblock_text_entry() return + self.is_return = True self.set_page_by_enum(self.prior_page) - MainController.set_statusbar(self.prior_status) def get_page_by_enum(self) -> NotebookPage | None: for k, v in self.indexes.items(): @@ -267,7 +268,7 @@ class Notebook(ScrollableMixin, Gtk.Notebook): # type: ignore if cur_page == NotebookPage.KEYS: self.return_prior() else: - self.prior_status = MainController.get_statusbar() + # self.prior_status = MainController.get_statusbar() self.set_page_by_enum(NotebookPage.KEYS) def focus_current(self) -> None: @@ -280,11 +281,7 @@ class Notebook(ScrollableMixin, Gtk.Notebook): # type: ignore return w = widget.get_children()[0] - try: - w.focus_first_row() - w.grab_focus() - except Exception: - w.grab_focus() + w.grab_focus() def get_page(self) -> Gtk.Widget | None: ind = self.get_current_page() @@ -304,13 +301,19 @@ class Notebook(ScrollableMixin, Gtk.Notebook): # type: ignore self, notebook: "Notebook", page: Gtk.Widget, page_num: int ) -> None: enum = self.get_page_by_enum() + # TODO: crumbs signal if enum is not None: crumbs = enum.dict["crumbs"] - status = enum.dict["statusbar"] MainController.set_crumbs(crumbs) - if status is False: - MainController.set_statusbar("", "") + if self.is_return is True: + MainController.mediator.statusbar.emit( + "notebook_page_returned", self.prior_page + ) + else: + MainController.mediator.statusbar.emit("notebook_page_changed", enum) + + self.is_return = False if enum is NotebookPage.SERVERS: MainController.present_servers() diff --git a/dzgui/views/components/statusbar.py b/dzgui/views/components/statusbar.py index 43524be..394ab40 100644 --- a/dzgui/views/components/statusbar.py +++ b/dzgui/views/components/statusbar.py @@ -1,16 +1,26 @@ -from typing import TYPE_CHECKING +from warnings import deprecated +from typing import Self, Union, TYPE_CHECKING -from dzgui.const.enum import RowType, Preferences +from dzgui.const.enum import NotebookPage, RowType from dzgui.util import strings import gi + gi.require_version("Gtk", "3.0") -from gi.repository import Gtk # noqa E402 +from gi.repository import Gtk, GObject # noqa E402 if TYPE_CHECKING: + from dzgui.const.enum import ServerTab from dzgui.controllers.mc import Controller + class Statusbar(Gtk.Grid): + __gsignals__ = { + "server_page_changed": (GObject.SignalFlags.RUN_FIRST, None, (object,)), + "notebook_page_changed": (GObject.SignalFlags.RUN_FIRST, None, (object,)), + "notebook_page_returned": (GObject.SignalFlags.RUN_FIRST, None, (object,)), + } + def __init__(self, controller: "Controller") -> None: super().__init__(orientation=Gtk.Orientation.HORIZONTAL) @@ -26,48 +36,79 @@ class Statusbar(Gtk.Grid): self.spinner.start() version = self.controller.get_prefs().version - self.status_right_label = Gtk.Label(label=version, hexpand=True, halign=Gtk.Align.END) + self.status_right_label = Gtk.Label( + label=version, hexpand=True, halign=Gtk.Align.END + ) self.attach(self.statusbar, 0, 0, 3, 1) self.attach_next_to(self.spinner, self.statusbar, Gtk.PositionType.RIGHT, 3, 1) - self.attach_next_to(self.status_right_label, self.spinner, Gtk.PositionType.RIGHT, 3, 1) + self.attach_next_to( + self.status_right_label, self.spinner, Gtk.PositionType.RIGHT, 3, 1 + ) self.set_text(help_text, "Help") self.players = "" + self.connect("server_page_changed", self._on_server_page_changed) + self.connect("notebook_page_changed", self._on_notebook_page_changed) + self.connect("notebook_page_returned", self._on_notebook_page_returned) + + def _on_notebook_page_changed( + self, statusbar: Self, context: "NotebookPage" + ) -> None: + status = context.dict["statusbar"] + bar = "" + if status is False: + self.set_by_context(context, "") + return + + match context: + case NotebookPage.MODS: + bar = self.controller.format_mod_statusbar() + case NotebookPage.HELP: + bar = self.controller.get_help_text() + self.set_by_context(context, bar) + + def _on_notebook_page_returned( + self, statusbar: Self, prior_context: "NotebookPage" + ) -> None: + self.pop(prior_context) + + def _on_server_page_changed(self, statusbar: Self, context: "ServerTab") -> None: + self.pop(context) + + def start_spinner(self) -> None: + self.spinner.start() + + def stop_spinner(self) -> None: + self.spinner.stop() + + def pop(self, context: Union["ServerTab", "NotebookPage"]) -> None: + cid = self.statusbar.get_context_id(str(context)) + self.statusbar.pop(cid) + def get_text(self) -> str: area = self.statusbar.get_message_area() label = area.get_children()[0] return label.get_text() - def set_text(self, string: str, context: str) -> None: - # if string is None: - # return - meta = self.statusbar.get_context_id(context) - #tv = self.controller.get_active_treeview() - #cur_context = tv.get_enum() - #cid = self.get_context_by_enum(cur_context) - #if cid != meta: - # print("requested: ", meta) - # print("current: ", cid) - # return - # TODO: substacks - # get_context_id(ServerTab) + def set_by_context( + self, context: Union[NotebookPage, "ServerTab"], string: str + ) -> None: + meta = self.statusbar.get_context_id(str(context)) self.statusbar.push(meta, string) - #self.set_context(meta) + self.set_cache(string) + def get_cache(self) -> str: + return self.cache - # TODO: type checking - # def get_context_by_enum(self, context: "ServerTab") -> int: - # cid = self.statusbar.get_context_id(str(context)) - # # TODO: substacks - # return cid - # - # def get_context(self) -> int: - # return self.context - # - # def set_context(self, context: int) -> None: - # self.context = context + def set_cache(self, string: str) -> None: + self.cache = string + + def set_text(self, string: str, context: str) -> None: + meta = self.statusbar.get_context_id(context) + self.statusbar.push(meta, string) + self.set_cache(string) def refresh(self, row: "RowType") -> None: if row is None: @@ -76,14 +117,7 @@ class Statusbar(Gtk.Grid): formatted = self.format_metadata(row) self.set_text(formatted, "Help") - 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) - + @deprecated("use controller") def format_metadata(self, row: "RowType") -> str: prefix = row.dict["tooltip"] diff --git a/dzgui/views/pages/help.py b/dzgui/views/pages/help.py index 9fa4aa9..5e4d8a5 100644 --- a/dzgui/views/pages/help.py +++ b/dzgui/views/pages/help.py @@ -2,12 +2,14 @@ from typing import TYPE_CHECKING from dzgui.views.trees.tree_menu import MenuTreeView 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 Help(Gtk.ScrolledWindow): def __init__(self, controller: "Controller") -> None: super().__init__() @@ -15,7 +17,8 @@ class Help(Gtk.ScrolledWindow): self.add(self.treeview) self.controller = controller - self.controller.register_widget("menu", self.treeview) + + self.treeview.focus_first_row() def get_treeview(self) -> MenuTreeView: return self.treeview diff --git a/dzgui/views/pages/mods.py b/dzgui/views/pages/mods.py index 77a76f4..7947a6d 100644 --- a/dzgui/views/pages/mods.py +++ b/dzgui/views/pages/mods.py @@ -2,12 +2,14 @@ from typing import Self, TYPE_CHECKING from dzgui.views.trees.tree_mods import ModTreeView import gi + gi.require_version("Gtk", "3.0") from gi.repository import Gtk, Gdk # noqa if TYPE_CHECKING: from dzgui.controllers.mc import Controller + class Mods(Gtk.ScrolledWindow): def __init__(self, controller: "Controller") -> None: super().__init__() diff --git a/dzgui/views/pages/servers.py b/dzgui/views/pages/servers.py index ca065e4..617934f 100644 --- a/dzgui/views/pages/servers.py +++ b/dzgui/views/pages/servers.py @@ -7,6 +7,7 @@ from dzgui.views.trees.tree_servers import ServerTreeView from dzgui.util.strings import server_labels import gi + gi.require_version("Gtk", "3.0") from gi.repository import Gtk, Gdk, GLib # noqa E402 @@ -15,6 +16,7 @@ logger = logging.getLogger(__name__) if TYPE_CHECKING: from dzgui.controllers.mc import Controller + class ServerNotebook(Gtk.ScrolledWindow): def __init__(self, controller: "Controller"): super().__init__() @@ -24,9 +26,13 @@ class ServerNotebook(Gtk.ScrolledWindow): self.controller.register_widget("servers", self) self.notebook = Gtk.Notebook(show_tabs=True) - self.browser = ServerTreeView(controller, ServerTab.BROWSER, ContextMenuGroup.SERVER_BROWSER) + self.browser = ServerTreeView( + controller, ServerTab.BROWSER, ContextMenuGroup.SERVER_BROWSER + ) self.saved = ServerTreeView(controller, ServerTab.SAVED, ContextMenuGroup.SAVED) - self.recent = ServerTreeView(controller, ServerTab.RECENT, ContextMenuGroup.RECENT) + self.recent = ServerTreeView( + controller, ServerTab.RECENT, ContextMenuGroup.RECENT + ) self.lan = ServerTreeView(controller, ServerTab.LAN, ContextMenuGroup.SCAN_LAN) self.browser.set_query_func(self.controller.dump_test_1) @@ -50,6 +56,7 @@ class ServerNotebook(Gtk.ScrolledWindow): self.connect("map", self._on_map) self.connect("unmap", self._on_unmap) + # TODO: strings self.set_crumbs("Server browser") def _on_map(self, widget: Self) -> None: @@ -75,43 +82,28 @@ class ServerNotebook(Gtk.ScrolledWindow): self.controller.set_crumbs(string) self.set_cached_label(string) - def _on_page_changed(self, notebook: Gtk.Notebook, child: Gtk.Widget, index: int) -> None: + def _on_page_changed( + self, notebook: Gtk.Notebook, child: Gtk.Widget, index: int + ) -> None: if self.controller.loaded is False: return - # self.controller.remove_statusbar("Servers") + # TODO :signals + # emit signal to crumbs + # emit signal to statusbar # TODO: abstract label = self.notebook.get_tab_label_text(child) - if label is None: return + # TODO: strings text = label.strip("*") self.notebook.set_tab_label_text(child, text) - self.set_crumbs(text) self.controller.present_servers() self.controller.populate_model() - # TODO: start with lan panel hidden - # FIXME: grid conpan is not set up at init - # TODO: refresh button only usable if LAN has servers? - # or just remove it - #if self.get_active_treeview() is self.lan: - # self.controller.mediator.grid.conpan.lan.set_visible(True) - #else: - # self.controller.mediator.grid.conpan.lan.set_visible(False) - - # TODO: put in controller - # def query_test2(self) -> None: - # # TODO: should trigger first page action after entire UI is loaded - # self.controller.dump_test_1() - # - # def query_test(self) -> None: - # data = (["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False]) - # return data - def set_cached_label(self, label: str) -> None: self.tab_cache = label @@ -146,9 +138,13 @@ class ServerNotebook(Gtk.ScrolledWindow): continue for col in tab.get_columns(): if col.get_title() == title: - self.controller.suppress_signal(tab, col, "_on_col_width_changed", True) + self.controller.suppress_signal( + tab, col, "_on_col_width_changed", True + ) col.set_fixed_width(width) - self.controller.suppress_signal(tab, col, "_on_col_width_changed", False) + self.controller.suppress_signal( + tab, col, "_on_col_width_changed", False + ) def get_tabs(self) -> tuple: return (self.browser, self.saved, self.recent, self.lan) diff --git a/dzgui/views/trees/tree_menu.py b/dzgui/views/trees/tree_menu.py index da9cd9e..953d300 100644 --- a/dzgui/views/trees/tree_menu.py +++ b/dzgui/views/trees/tree_menu.py @@ -6,6 +6,7 @@ from dzgui.util.open_links import open_link_by_rowtype 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 @@ -14,11 +15,13 @@ 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) @@ -34,15 +37,15 @@ class MenuTreeView(TreeView): self.selected_row = self.get_selection() + self.controller.register_widget("menu", self) + 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: + def _parent_row_activated( + self, tree: TreeView, path: Gtk.TreePath, column: Gtk.TreeViewColumn + ) -> None: row_type = self.get_value_at_index(1) match row_type: @@ -73,9 +76,7 @@ class MenuTreeView(TreeView): open_link_by_rowtype(row_type) def _parent_selection_changed( - self, - base_class: TreeView, - sel: Gtk.TreeSelection + self, base_class: TreeView, sel: Gtk.TreeSelection ) -> None: row = self.get_value_at_index(1) if row == "": diff --git a/dzgui/views/trees/tree_mods.py b/dzgui/views/trees/tree_mods.py index b7a39f0..fcff526 100644 --- a/dzgui/views/trees/tree_mods.py +++ b/dzgui/views/trees/tree_mods.py @@ -7,9 +7,10 @@ from dzgui.views.mixins.context_mixin import ContextMixin from dzgui.views.mixins.mods_mixin import ModsMixin from dzgui.const.enum import ( ContextMenuGroup, - ) +) import gi + gi.require_version("Gtk", "3.0") from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa @@ -19,6 +20,7 @@ if TYPE_CHECKING: logger = logging.getLogger(__name__) + class ModTreeView(ModsMixin, ContextMixin, TreeView): def __init__(self, controller: "Controller") -> None: super().__init__(controller, menu=ContextMenuGroup.MOD) @@ -32,13 +34,9 @@ class ModTreeView(ModsMixin, ContextMixin, TreeView): for i, column_title in enumerate(strings.mod_cols): renderer = Gtk.CellRendererText() - column = Gtk.TreeViewColumn( - column_title, renderer, text=i, foreground=4 - ) + 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 - ) + column.set_cell_data_func(renderer, self._format_float, func_data=None) if column_title == "Mod": column.set_fixed_width(500) else: @@ -57,11 +55,10 @@ class ModTreeView(ModsMixin, ContextMixin, TreeView): 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: - #if event.keyval is Gdk.KEY_space: + # if event.keyval is Gdk.KEY_space: # it = self.get_focused_row_iter() # self.get_selection().select_iter(it) # path = self.get_focused_row_path() @@ -70,10 +67,7 @@ class ModTreeView(ModsMixin, ContextMixin, TreeView): # TODO: parse keys self.present_menu(widget, event) - def _on_mods_button_press(self, - widget: Gtk.Widget, - event: Gdk.EventButton - ) -> None: + def _on_mods_button_press(self, widget: Gtk.Widget, event: Gdk.EventButton) -> None: if event.button == 3: self.present_menu(widget, event) diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index 3d00fb5..d53519b 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -150,7 +150,8 @@ class ServerTreeView(TreeView): # this can probably be dropped in favor of statusbar cache system # meta = self.controller.mediator.statusbar.statusbar.get_context_id("Mods") # self.controller.mediator.statusbar.statusbar.pop(meta) - self.emit("on_distcalc_started") + pass + # self.emit("on_distcalc_started") def get_enum(self) -> None: return self.enum @@ -215,8 +216,17 @@ class ServerTreeView(TreeView): record = self.get_record() if record is None: return - # TODO: - self.controller.mediator.statusbar.spinner.start() + + cache = self.controller.get_dist_cache() + + if record.ip in cache: + haversine = cache[record.ip] + self.controller.set_statusbar_dist(haversine, None) + return + + if len(self.get_model()) > 1: + self.controller.start_spinner() + self.current_proc = CalcDist( record.ip, self.get_enum(), self.queue, self.controller ) @@ -232,12 +242,10 @@ class ServerTreeView(TreeView): cache = self.controller.get_dist_cache() if latest_result: - # NOTE: if CalcDist was spawned on other tab addr = latest_result[0] haversine = latest_result[1] if addr not in cache: cache[addr] = haversine - # FIXME self.controller.set_statusbar_dist(haversine, self.get_enum()) return True @@ -253,7 +261,6 @@ class ServerTreeView(TreeView): case Gdk.KEY_r: self.refresh_player_count() case Gdk.KEY_f: - # TODO: register filter panel instead of mediating thru right panel self.controller.mediator.filters.keyword_entry.grab_focus() case Gdk.KEY_m: self.controller.mediator.filters.maps_entry.grab_focus() @@ -344,7 +351,7 @@ class ServerTreeView(TreeView): qport = self.get_value_at_index(8) return f"{addr}:{qport}" - def get_record(self) -> dict | None: + def get_record(self) -> Record | None: # TODO: delegate to controller select = self.get_selection() sels = select.get_selected_rows() From 61f2d35f16e86a31e02f52c6ca0739c49148ae58 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 22 Jan 2026 14:20:45 +0900 Subject: [PATCH 022/309] fix: dynamic statusbar WIP --- dzgui/controllers/mc.py | 74 ++++++++++++----------------- dzgui/util/dist.py | 1 + dzgui/views/components/statusbar.py | 36 +++++++++++--- dzgui/views/trees/tree_servers.py | 9 ++-- 4 files changed, 64 insertions(+), 56 deletions(-) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 19412b2..43641a1 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -2,6 +2,7 @@ import logging import shutil import threading import traceback +from warnings import deprecated from pathlib import Path from typing import Any, Callable, Literal, TYPE_CHECKING @@ -83,6 +84,7 @@ class AppNavigation: class Controller: def __init__(self) -> None: self.dist_cache: dict[str, "Haversine", "ServerTab"] = {} + # TODO: store in crumbs class self.crumbs_cache = "" self.mediator = AppNavigation() self.prefs: UserPrefs @@ -98,6 +100,7 @@ class Controller: except AttributeError: logger.critical(f"{attr} is not a valid AppNavigation attribute.") + # TODO: relegate to crumbs class def set_crumbs(self, text: str) -> None: self.mediator.grid.set_breadcrumbs(text) @@ -231,29 +234,29 @@ class Controller: def get_statusbar(self) -> str: return self.mediator.statusbar.get_text() + @deprecated("use statusbar internal contexts") def remove_statusbar(self, context: str) -> None: c = self.mediator.statusbar.statusbar.get_context_id(context) self.mediator.statusbar.statusbar.pop(c) + # TODO: use set_by_context() def set_statusbar(self, text: str, context: str) -> int: msg_id = self.mediator.statusbar.set_text(text, context) return msg_id - def set_statusbar_placeholder(self, text: str) -> None: - # TODO: use statusbar stacks instead - self.statusbar_placeholder = text - def set_statusbar_dist(self, haversine: "Haversine", enum: "ServerTab") -> None: - # NOTE: prevent race condition where page was changed before distance was written - if self.mediator.notebook.get_page_by_enum() is not NotebookPage.SERVERS: - cid = self.mediator.statusbar.statusbar.get_context_id(str(enum)) - self.mediator.statusbar.statusbar.pop(cid) + tv = self.get_active_treeview() + context = tv.get_enum() + page = self.mediator.notebook.get_page_by_enum() + """ + NOTE: prevents race condition when notebook page changed, + but allows caching the distance in the background + """ + if page != NotebookPage.SERVERS: self.mediator.statusbar.spinner.stop() return - - tv = self.get_active_treeview() - if tv.get_enum() != enum: - self.mediator.statusbar.spinner.stop() + if enum != context: + self.mediator.statusbar.emit("distcalc_ended", None, context) return # NOTE: user may have changed km/mi toggle, so recalculate @@ -269,10 +272,8 @@ class Controller: raw = round(haversine.as_kilometers()) separated = number(raw) dist = str(separated) + " km" - text = self.statusbar_placeholder - # TODO: abstract - self.set_statusbar(f"{text} | Distance: {dist}", "Servers") - self.mediator.statusbar.spinner.stop() + + self.mediator.statusbar.emit("distcalc_ended", dist, context) def delete_multiple_mods(self) -> None: sel = self.mediator.modtreeview.get_selection() @@ -334,6 +335,8 @@ class Controller: def toggle_server_panels(self, state: bool) -> None: # TODO: this is going to be signal dependent now + # cf. map/unmap + #self.mediator.grid.emit("toggle panels", state) self.mediator.grid.toggle_filter_panel(state) self.mediator.grid.toggle_connect_panel(state) self.mediator.grid.toggle_refresh_button(state) @@ -343,8 +346,6 @@ class Controller: def show_developers_page(self) -> None: self.open_page(NotebookPage.DEVELOPERS) - # TODO: put cursor on first row - # self.mediator.developers.focus_first_row() def open_page(self, page: NotebookPage) -> None: self.mediator.grid.notebook.set_page_by_enum(page) @@ -599,10 +600,11 @@ class Controller: for row in data: manager.append_row(row) treeview.set_loaded(True) - self.update_server_status() + self.mediator.statusbar.emit("server_page_changed", context) treeview.grab_focus() treeview = self.get_active_treeview() + context = treeview.get_enum() manager = treeview.get_filter_man() data = self.data @@ -707,30 +709,15 @@ class Controller: treeview.set_loaded(False) self.populate_model() - def update_server_status(self) -> None: - # TODO: emit signal on statusbar only if page changed + def get_player_count(self) -> str: treeview = self.get_active_treeview() - context = str(treeview.enum) + context = treeview.get_enum() model = treeview.get_model() status = format_player_count(model) + return status - self.set_statusbar_placeholder(status) - self.set_statusbar(status, context) - #if len(model) >= 1: - # self.mediator.statusbar.spinner.start() - - def start_spinner(self) -> None: - # TODO: use signal instead - self.mediator.statusbar.start_spinner() - - def notify_statusbar_of_notebook_change(self) -> None: - enum = self.mediator.notebook.get_page_by_enum() - self.mediator.statusbar.emit("notebook_page_changed", enum) - - def notify_statusbar(self) -> None: - tv = self.get_active_treeview() - enum = tv.get_enum() - self.mediator.statusbar.emit("server_page_changed", enum) + def get_statusbar(self) -> None: + return self.mediator.statusbar def populate_model(self) -> None: treeview = self.get_active_treeview() @@ -748,15 +735,14 @@ class Controller: self.mediator.right_panel.focus_button_box() def present_servers(self) -> None: - # TODO: abstract - self.update_server_status() + treeview = self.get_active_treeview() + context = treeview.get_enum() + + self.mediator.statusbar.emit("server_page_changed", context) # TODO: signal for crumbs crumbs = self.mediator.servers.get_cached_label() self.set_crumbs(crumbs) - tree = self.get_active_treeview() - tree.emit("on_distcalc_started") - def toggle_check(self, event: Gdk.EventKey) -> None | Literal[False]: mappings = { Gdk.KEY_1: 0, diff --git a/dzgui/util/dist.py b/dzgui/util/dist.py index 70da21f..2eb6be3 100644 --- a/dzgui/util/dist.py +++ b/dzgui/util/dist.py @@ -1,5 +1,6 @@ import logging import multiprocessing + from math import radians, cos, sin, asin, sqrt from typing import TYPE_CHECKING diff --git a/dzgui/views/components/statusbar.py b/dzgui/views/components/statusbar.py index 394ab40..e11fa85 100644 --- a/dzgui/views/components/statusbar.py +++ b/dzgui/views/components/statusbar.py @@ -19,6 +19,8 @@ class Statusbar(Gtk.Grid): "server_page_changed": (GObject.SignalFlags.RUN_FIRST, None, (object,)), "notebook_page_changed": (GObject.SignalFlags.RUN_FIRST, None, (object,)), "notebook_page_returned": (GObject.SignalFlags.RUN_FIRST, None, (object,)), + "distcalc_ended": (GObject.SignalFlags.RUN_FIRST, None, (object, str)), + "server_row_changed": (GObject.SignalFlags.RUN_FIRST, None, ()), } def __init__(self, controller: "Controller") -> None: @@ -29,7 +31,7 @@ class Statusbar(Gtk.Grid): help_text = strings.statusbar_helptext - self.context: int + self.playercount = "" self.statusbar = Gtk.Statusbar() self.spinner = Gtk.Spinner() @@ -49,9 +51,11 @@ class Statusbar(Gtk.Grid): self.set_text(help_text, "Help") self.players = "" + self.connect("server_row_changed", self._on_server_row_changed) self.connect("server_page_changed", self._on_server_page_changed) self.connect("notebook_page_changed", self._on_notebook_page_changed) self.connect("notebook_page_returned", self._on_notebook_page_returned) + self.connect("distcalc_ended", self._on_distcalc_ended) def _on_notebook_page_changed( self, statusbar: Self, context: "NotebookPage" @@ -67,21 +71,39 @@ class Statusbar(Gtk.Grid): bar = self.controller.format_mod_statusbar() case NotebookPage.HELP: bar = self.controller.get_help_text() + case NotebookPage.SERVERS: + pass + self.set_by_context(context, bar) def _on_notebook_page_returned( - self, statusbar: Self, prior_context: "NotebookPage" + self, statusbar: Self, prior_context: NotebookPage ) -> None: self.pop(prior_context) - def _on_server_page_changed(self, statusbar: Self, context: "ServerTab") -> None: - self.pop(context) - - def start_spinner(self) -> None: + def _on_server_row_changed(self, statusbar: Self) -> None: self.spinner.start() - def stop_spinner(self) -> None: + def _on_distcalc_ended( + self, + statusbar: Self, + dist: Union[str, None], + context: Union["ServerTab", NotebookPage], + ) -> None: self.spinner.stop() + if dist is None: + self.set_by_context(context, self.playercount) + else: + pretty = f"{self.playercount} | Distance: {dist}" + self.set_by_context(context, pretty) + + def _on_server_page_changed(self, statusbar: Self, context: "ServerTab") -> None: + c = self.controller.get_player_count() + self.playercount = c + + self.set_by_context(context, c) + tree = self.controller.get_active_treeview() + tree.emit("on_distcalc_started") def pop(self, context: Union["ServerTab", "NotebookPage"]) -> None: cid = self.statusbar.get_context_id(str(context)) diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index d53519b..384fcb1 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -213,27 +213,26 @@ class ServerTreeView(TreeView): self.current_proc.terminate() def _on_distcalc_started(self, treeview: Self): + bar = self.controller.get_statusbar() + record = self.get_record() if record is None: return + bar.emit("server_row_changed") cache = self.controller.get_dist_cache() if record.ip in cache: haversine = cache[record.ip] - self.controller.set_statusbar_dist(haversine, None) + self.controller.set_statusbar_dist(haversine, self.get_enum()) return - if len(self.get_model()) > 1: - self.controller.start_spinner() - self.current_proc = CalcDist( record.ip, self.get_enum(), self.queue, self.controller ) self.current_proc.start() def _check_result_queue(self) -> Literal[True]: - # TODO: trigger signal when changing page contexts # TODO: delegate to controller latest_result = None while not self.queue.empty(): From 4cd900b42f8eb208fb0ad820a639ea3cc31169c8 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 22 Jan 2026 14:31:12 +0900 Subject: [PATCH 023/309] fix: dynamic statusbar WIP --- dzgui/views/components/statusbar.py | 2 +- dzgui/views/trees/tree_servers.py | 15 +++------------ 2 files changed, 4 insertions(+), 13 deletions(-) diff --git a/dzgui/views/components/statusbar.py b/dzgui/views/components/statusbar.py index e11fa85..b9e89c7 100644 --- a/dzgui/views/components/statusbar.py +++ b/dzgui/views/components/statusbar.py @@ -103,7 +103,7 @@ class Statusbar(Gtk.Grid): self.set_by_context(context, c) tree = self.controller.get_active_treeview() - tree.emit("on_distcalc_started") + tree.emit("distcalc_started") def pop(self, context: Union["ServerTab", "NotebookPage"]) -> None: cid = self.statusbar.get_context_id(str(context)) diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index 384fcb1..36bb31f 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -34,7 +34,7 @@ class EnumeratedMenuItem(Gtk.MenuItem): class ServerTreeView(TreeView): - __gsignals__ = {"on_distcalc_started": (GObject.SignalFlags.RUN_FIRST, None, ())} + __gsignals__ = {"distcalc_started": (GObject.SignalFlags.RUN_FIRST, None, ())} def __init__( self, controller: "Controller", enum: ServerTab, menu: ContextMenuGroup @@ -105,14 +105,13 @@ class ServerTreeView(TreeView): column.connect("notify::fixed-width", self._on_col_width_changed) self.append_column(column) - self.connect("on_distcalc_started", self._on_distcalc_started) + self.connect("distcalc_started", self._on_distcalc_started) self.connect("button-release-event", self._on_server_button_release) self.connect("key-press-event", self._on_server_keypress) self.connect("generic_row_activated", self._parent_row_activated) self.connect("generic_treesel_changed", self._parent_selection_changed) self.connect("map", self._on_map) self.connect("unmap", self._on_unmap) - self.connect("focus-in-event", self._on_kb_focus) GLib.timeout_add(QUEUE_CHECK_DELAY, self._check_result_queue) @@ -145,14 +144,6 @@ class ServerTreeView(TreeView): width = size.width * 1.65 col.set_fixed_width(width) - def _on_kb_focus(self, a, b) -> None: - # TODO: may generate superfluous calc events when page changed but selection is the same - # this can probably be dropped in favor of statusbar cache system - # meta = self.controller.mediator.statusbar.statusbar.get_context_id("Mods") - # self.controller.mediator.statusbar.statusbar.pop(meta) - pass - # self.emit("on_distcalc_started") - def get_enum(self) -> None: return self.enum @@ -343,7 +334,7 @@ class ServerTreeView(TreeView): def _parent_selection_changed(self, base_class: TreeView, sel: Gtk.TreeSelection): self.terminate_process() - self.emit("on_distcalc_started") + self.emit("distcalc_started") def get_record_string(self) -> str: addr = self.get_value_at_index(7) From 2aca6c3fa306cca8212ba8d935c382fc50e9ab05 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 22 Jan 2026 14:50:01 +0900 Subject: [PATCH 024/309] chore: clear linting errors --- dzgui/api/servers.py | 10 +++++---- dzgui/controllers/mc.py | 24 ++++++++++++++------ dzgui/model/filtered_model.py | 35 ++++++++++++++++------------- dzgui/views/components/statusbar.py | 2 +- dzgui/views/mixins/context_mixin.py | 9 +++++--- dzgui/views/trees/tree_servers.py | 2 +- 6 files changed, 51 insertions(+), 31 deletions(-) diff --git a/dzgui/api/servers.py b/dzgui/api/servers.py index 9a07193..f08c29b 100644 --- a/dzgui/api/servers.py +++ b/dzgui/api/servers.py @@ -1,5 +1,4 @@ import ipaddress -import json import logging import math import os @@ -23,6 +22,7 @@ import dayzquery logger = logging.getLogger(__name__) # TODO: confirm that patches from testing are incorporated here +# particularly around malformed values. check commit log params = [ r"\nor\1\map\chernarusplus\nor\1\map\sakhal\nor\1\map\enoch\empty\1\nor\1\map\namalsk", # noqa @@ -180,7 +180,7 @@ def parse_json(json: list) -> list: return rows -def query_direct(ip: str, qport: int, TIMEOUT: float=3.0) -> dict | None: +def query_direct(ip: str, qport: int, TIMEOUT: float = 3.0) -> dict | None: try: info = a2s.info((ip, qport), TIMEOUT) @@ -248,6 +248,7 @@ class Record: """ The gameport field is manipulated by the RowType.CONN_BY_IP method """ + ip: str gameport: int qport: int @@ -391,7 +392,7 @@ def ping(iteration: int, row: list) -> 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, + "filter": r"\appid" + rf"\{appid}" + param, "limit": LIMIT, "key": key, } @@ -401,7 +402,7 @@ def query_api(key: str, appid: int, param: str) -> Res: res.raise_for_status() parsed = True data = res.json() - except Exception as e: + except Exception: status = res.status_code parsed = False data = None @@ -435,6 +436,7 @@ def validate_ip(addr: str) -> Record: 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 diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 43641a1..3d16eec 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -92,7 +92,7 @@ class Controller: self.model_man = ModelManager() # TODO: poc - self.loaded = False + # self.loaded = False def register_widget(self, attr: str, widget: Gtk.Widget) -> None: try: @@ -231,9 +231,6 @@ class Controller: Gtk.main_quit() - def get_statusbar(self) -> str: - return self.mediator.statusbar.get_text() - @deprecated("use statusbar internal contexts") def remove_statusbar(self, context: str) -> None: c = self.mediator.statusbar.statusbar.get_context_id(context) @@ -264,6 +261,7 @@ class Controller: if haversine is None: dist = "Unknown" else: + # FIXME: always opens file; cache distance pref if self.query_config(Preferences.DIST) is True: raw = round(haversine.as_miles()) separated = number(raw) @@ -711,10 +709,9 @@ class Controller: def get_player_count(self) -> str: treeview = self.get_active_treeview() - context = treeview.get_enum() model = treeview.get_model() - status = format_player_count(model) - return status + count = format_player_count(model) + return count def get_statusbar(self) -> None: return self.mediator.statusbar @@ -777,3 +774,16 @@ class Controller: # TODO: deprecated in favor of map/unmap def toggle_lan_panel(self, state: bool) -> None: self.mediator.grid.conpan.set_visible(state) + + # TODO: use model manager, map and keyword caches + def get_filters(self) -> list: + self.mediator.filters.get_filters() + + def get_keyword(self) -> str: + self.mediator.filters.get_keyword_filter() + + def get_map(self) -> str: + self.mediator.filters.get_selected_map() + + def get_prio_map(self) -> str: + self.mediator.filters.get_prior_map() diff --git a/dzgui/model/filtered_model.py b/dzgui/model/filtered_model.py index 06010b5..b5a0f81 100644 --- a/dzgui/model/filtered_model.py +++ b/dzgui/model/filtered_model.py @@ -1,15 +1,20 @@ import re from dataclasses import dataclass -from typing import Self +from typing import TYPE_CHECKING from dzgui.const.enum import FilterMode from dzgui.util import strings import gi + gi.require_version("Gtk", "3.0") from gi.repository.Gtk import ListStore # noqa E402 from gi.repository import GObject, GLib # noqa E402 +if TYPE_CHECKING: + from dzgui.controllers.mc import Controller + + @dataclass(slots=True, frozen=True) class ServerColumns: name: str @@ -35,7 +40,8 @@ class FilteredModelManager: A FilteredModelManager is attached to each ServerTreeView. Filter methods are not thread-safe in themselves. """ - def __init__(self) -> None: + + def __init__(self, controller: "Controller") -> None: self.filter_cache: tuple self.ping_cache: dict[str, int] = {} @@ -63,13 +69,15 @@ class FilteredModelManager: 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() + filters = self.controller.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) + GLib.idle_add( + self.controller.mediator.get_active_treeview()._filter_cleanup + ) return match mode: @@ -77,8 +85,7 @@ class FilteredModelManager: rows = self.filter_initial(filters) case FilterMode.MAP: - panel = AppNav.right_panel.filters_vbox - prior_map = panel.get_prior_map() + prior_map = self.controller.get_prior_map() if prior_map == "All maps": rows = self.filter_map(filters) @@ -111,7 +118,7 @@ class FilteredModelManager: self.set_cache(filters, clone, rows) self.set_store(clone) - GLib.idle_add(AppNav.treeview._filter_cleanup) + GLib.idle_add(self.controller.mediator.get_active_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())) @@ -131,7 +138,7 @@ class FilteredModelManager: Multi-filtration for any context starts by narrowing by map """ rows = self.filtered - panel = AppNav.right_panel.filters_vbox + panel = self.controller.get_map() sel_map = panel.get_selected_map() if sel_map == "All maps": @@ -141,7 +148,7 @@ class FilteredModelManager: return rows def filter_keyword(self, filters: tuple) -> list: - keyword = AppNav.right_panel.filters_vbox.get_keyword_filter() + keyword = self.controller.get_keyword() rows = self.filtered if keyword == "": @@ -163,7 +170,7 @@ class FilteredModelManager: pairs = { strings.filter_3pp: strings.filter_1pp, strings.filter_day: strings.filter_night, - strings.filter_official: strings.filter_unofficial + strings.filter_official: strings.filter_unofficial, } for k, v in pairs.items(): if k in filters and v in filters: @@ -217,9 +224,7 @@ class FilteredModelManager: self.set_filtered(self.filter_toggle_off(filters, f)) return self.filtered - def set_cache( - self, filters: tuple, model: ListStore | None, rows: list - ) -> None: + def set_cache(self, filters: tuple, model: ListStore | None, rows: list) -> None: self.filter_cache[filters] = (model, rows) def resync_model(self, addr: str, qport: int) -> None: @@ -233,11 +238,11 @@ class FilteredModelManager: self.control_model.remove(row) self.wipe_cache() - filters = AppNav.right_panel.filters_vbox.get_filters() + filters = self.controller.get_filters() refiltered = self.filter_toggle_on(filters) self.set_filtered(refiltered) self.set_success(True) - GLib.idle_add(AppNav.treeview._filter_cleanup) + GLib.idle_add(self.controller.mediator.get_active_treeview()._filter_cleanup) def convert_model_to_list(self, model: ListStore) -> list: return [[el for el in row] for row in model] diff --git a/dzgui/views/components/statusbar.py b/dzgui/views/components/statusbar.py index b9e89c7..bd228e7 100644 --- a/dzgui/views/components/statusbar.py +++ b/dzgui/views/components/statusbar.py @@ -1,7 +1,7 @@ from warnings import deprecated from typing import Self, Union, TYPE_CHECKING -from dzgui.const.enum import NotebookPage, RowType +from dzgui.const.enum import NotebookPage, Preferences, RowType from dzgui.util import strings import gi diff --git a/dzgui/views/mixins/context_mixin.py b/dzgui/views/mixins/context_mixin.py index 7b37313..ca021bd 100644 --- a/dzgui/views/mixins/context_mixin.py +++ b/dzgui/views/mixins/context_mixin.py @@ -3,20 +3,23 @@ from dzgui.views.trees.tree_base import TreeView from dzgui.const.enum import 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, + def present_menu( + self, widget: Gtk.Widget, event: Gdk.EventButton | Gdk.EventKey, ) -> None: - #if self.is_selection_empty(): - # return + if self.is_selection_empty(): + return if event.type is Gdk.EventType.BUTTON_PRESS: try: diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index 36bb31f..f940663 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -50,7 +50,7 @@ class ServerTreeView(TreeView): self.query_func: Callable = None self.filter_man = FilteredModelManager() - model = self.filter_man.get_model() + model = self.filter_man.get_model(controller) self.set_model(model) self.menu = Gtk.Menu() From df5f146a6ef3588362d83a555e09d69c6042bf19 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 22 Jan 2026 14:56:28 +0900 Subject: [PATCH 025/309] fix: pass controller --- dzgui/controllers/mc.py | 4 ++-- dzgui/views/trees/tree_servers.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 3d16eec..4f8a8cc 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -91,8 +91,8 @@ class Controller: self.model_man = ModelManager() - # TODO: poc - # self.loaded = False + # NOTE: suppress requests until entire UI is loaded + self.loaded = False def register_widget(self, attr: str, widget: Gtk.Widget) -> None: try: diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index f940663..956a9e7 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -49,8 +49,8 @@ class ServerTreeView(TreeView): self.loaded = False self.query_func: Callable = None - self.filter_man = FilteredModelManager() - model = self.filter_man.get_model(controller) + self.filter_man = FilteredModelManager(controller) + model = self.filter_man.get_model() self.set_model(model) self.menu = Gtk.Menu() From 93c5acb66da387d5425abd5e07f97951f241b330 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 22 Jan 2026 16:03:06 +0900 Subject: [PATCH 026/309] chore: drop unused enums --- dzgui/const/enum.py | 262 +++++++++++---------------------- dzgui/controllers/mc.py | 1 + dzgui/util/open_links.py | 4 - dzgui/views/trees/tree_menu.py | 5 - 4 files changed, 85 insertions(+), 187 deletions(-) diff --git a/dzgui/const/enum.py b/dzgui/const/enum.py index 147bfad..50b1755 100644 --- a/dzgui/const/enum.py +++ b/dzgui/const/enum.py @@ -9,6 +9,13 @@ class Port(Enum): CUSTOM = 2 +class ServerTab(Enum): + BROWSER = 1 + SAVED = 2 + RECENT = 3 + LAN = 4 + + class Popup(Enum): WAIT = 1 NOTIFY = 2 @@ -59,20 +66,47 @@ class EnumWithAttrs(Enum): 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",} + 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): @@ -93,173 +127,53 @@ class RowType(EnumWithAttrs): 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", - } + # TODO: add to strings + # 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", + "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) ⧉", @@ -315,12 +229,9 @@ class ContextMenuGroup(Enum): """ Groupings of context menu items """ - MOD = ( - ContextMenu.OPEN_WORKSHOP, ContextMenu.DELETE_MOD - ) - LOG = ( - ContextMenu.COPY_LOG_CLIPBOARD, - ) + + MOD = (ContextMenu.OPEN_WORKSHOP, ContextMenu.DELETE_MOD) + LOG = (ContextMenu.COPY_LOG_CLIPBOARD,) SERVER_BROWSER = ( ContextMenu.CONNECT, ContextMenu.ADD_SERVER, @@ -366,6 +277,7 @@ class ContextMenuGroup(Enum): ContextMenu.REFRESH_PLAYERS, ) + class ModButton(EnumWithAttrs): SELECT_ALL = { "label": strings.mod_panel.select_all, @@ -373,7 +285,7 @@ class ModButton(EnumWithAttrs): } UNSELECT_ALL = { "label": strings.mod_panel.unselect_all, - "tooltip": strings.mod_panel.unselect_all_tooltip + "tooltip": strings.mod_panel.unselect_all_tooltip, } HIGHLIGHT_STALE = { "label": strings.mod_panel.highlight_stale, @@ -388,8 +300,8 @@ class ModButton(EnumWithAttrs): "tooltip": strings.mod_panel.delete_selected_tooltip, } SELECT_STALE = { - "label": strings.mod_panel.select_stale, - "tooltip": strings.mod_panel.select_stale_tooltip, + "label": strings.mod_panel.select_stale, + "tooltip": strings.mod_panel.select_stale_tooltip, } @@ -407,8 +319,8 @@ class ButtonType(EnumWithAttrs): OPTIONS = { "label": strings.buttons.options_label, "tooltip": strings.buttons.options_tooltip, - "opens": NotebookPage.OPTIONS - } + "opens": NotebookPage.OPTIONS, + } HELP = { "label": strings.buttons.help_label, "tooltip": strings.buttons.help_tooltip, @@ -434,9 +346,3 @@ HELP_MENU_ROWS = ( RowType.SEPARATOR, RowType.THANKS, ) - -class ServerTab(Enum): - BROWSER = 1 - SAVED = 2 - RECENT = 3 - LAN = 4 diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 4f8a8cc..7acdf4c 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -518,6 +518,7 @@ class Controller: except Exception as e: dialog = ExceptionDialog(self, str(e)) dialog.run() + return self.open_page(NotebookPage.LOG) def select_colorized(self) -> None: diff --git a/dzgui/util/open_links.py b/dzgui/util/open_links.py index 7d0f92e..60db2a6 100644 --- a/dzgui/util/open_links.py +++ b/dzgui/util/open_links.py @@ -12,10 +12,6 @@ links = { 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, } diff --git a/dzgui/views/trees/tree_menu.py b/dzgui/views/trees/tree_menu.py index 953d300..de0b132 100644 --- a/dzgui/views/trees/tree_menu.py +++ b/dzgui/views/trees/tree_menu.py @@ -51,11 +51,6 @@ class MenuTreeView(TreeView): 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: From 0306cc43800d84b5c7cee8d601d7167fd0fec7ea Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 22 Jan 2026 16:09:38 +0900 Subject: [PATCH 027/309] chore: deprecation warnings --- dzgui/main.py | 3 +++ dzgui/views/components/statusbar.py | 11 ++--------- dzgui/views/trees/tree_servers.py | 2 +- 3 files changed, 6 insertions(+), 10 deletions(-) diff --git a/dzgui/main.py b/dzgui/main.py index de1124a..34d3122 100644 --- a/dzgui/main.py +++ b/dzgui/main.py @@ -2,6 +2,7 @@ import argparse import logging import os import sys +import warnings from typing import TYPE_CHECKING @@ -70,6 +71,8 @@ def main() -> None: uninstall() sys.exit(0) + if args.developers is True: + warnings.filterwarnings("default", category=DeprecationWarning) version = get_version() set_locale() diff --git a/dzgui/views/components/statusbar.py b/dzgui/views/components/statusbar.py index bd228e7..511e24b 100644 --- a/dzgui/views/components/statusbar.py +++ b/dzgui/views/components/statusbar.py @@ -1,7 +1,7 @@ from warnings import deprecated from typing import Self, Union, TYPE_CHECKING -from dzgui.const.enum import NotebookPage, Preferences, RowType +from dzgui.const.enum import NotebookPage, RowType from dzgui.util import strings import gi @@ -142,11 +142,4 @@ class Statusbar(Gtk.Grid): @deprecated("use controller") def format_metadata(self, row: "RowType") -> str: prefix = row.dict["tooltip"] - - if row == RowType.QUICK_CONNECT or row == RowType.CHNG_FAV: - label = self.controller.query_config(Preferences.FAV_LBL) - if len(label) < 1: - label = "unset" - return f"{prefix} ({label})" - else: - return prefix + return prefix diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index 956a9e7..ed6e9b3 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -135,7 +135,7 @@ class ServerTreeView(TreeView): continue if title == "IP": continue - label = Gtk.Label(title) + label = Gtk.Label(label=title) pango = label.get_layout() size = pango.get_pixel_size() if size.width > 50: From 936bc074a508cbc6395c80421a4bcfbc7816ff50 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 22 Jan 2026 19:41:41 +0900 Subject: [PATCH 028/309] chore: dynamic statusbar WIP --- CHANGELOG.md | 1 + dzgui/controllers/mc.py | 38 +++++++++++++---------------- dzgui/data/CHANGELOG.md | 1 + dzgui/views/base.py | 4 +-- dzgui/views/components/statusbar.py | 24 ++++++++++-------- dzgui/views/pages/servers.py | 2 +- dzgui/views/trees/tree_menu.py | 2 ++ pyproject.toml | 2 +- 8 files changed, 39 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 053e26e..8e1750f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ - Integrated server notebook - Retain position on servers - Propagate width changes to all tables +- Remember tree position in menus ## Changed - Reduce padding on keys button diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 7acdf4c..b66bbd7 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -374,12 +374,12 @@ class Controller: # TODO: set crumbs by signal self.set_crumbs(button.get_label()) - def get_help_text(self) -> str: + def get_help_row(self) -> str: tv = self.mediator.menu model = self.get_help_store() tree_iter = tv.get_focused_row_iter() - value = model.get_value(tree_iter, 0) - return value + value = model.get_value(tree_iter, 1) + return value.dict["tooltip"] def open_user_workshop(self, uid: str) -> None: # NOTE: uid may contain leading zeroes, not a real integer @@ -719,28 +719,24 @@ class Controller: def populate_model(self) -> None: treeview = self.get_active_treeview() - if treeview.get_loaded() is False: - # NOTE: cf. set_query_func() - func = treeview.get_query_func() - if func is not None: - manager = treeview.get_filter_man() - # TODO: this may lag? - manager.clear_model() - self.set_callback(None, None) - self.call_on_thread(func) + if treeview.get_loaded() is True: + self.mediator.statusbar.emit("server_page_changed", treeview.get_enum()) + return + + func = treeview.get_query_func() + if func is None: + self.mediator.statusbar.emit("server_page_changed", treeview.get_enum()) + return + manager = treeview.get_filter_man() + # TODO: this may lag? + manager.clear_model() + self.set_callback(None, None) + self.call_on_thread(func) + def focus_button_box(self) -> None: self.mediator.right_panel.focus_button_box() - def present_servers(self) -> None: - treeview = self.get_active_treeview() - context = treeview.get_enum() - - self.mediator.statusbar.emit("server_page_changed", context) - # TODO: signal for crumbs - crumbs = self.mediator.servers.get_cached_label() - self.set_crumbs(crumbs) - def toggle_check(self, event: Gdk.EventKey) -> None | Literal[False]: mappings = { Gdk.KEY_1: 0, diff --git a/dzgui/data/CHANGELOG.md b/dzgui/data/CHANGELOG.md index 053e26e..8e1750f 100644 --- a/dzgui/data/CHANGELOG.md +++ b/dzgui/data/CHANGELOG.md @@ -21,6 +21,7 @@ - Integrated server notebook - Retain position on servers - Propagate width changes to all tables +- Remember tree position in menus ## Changed - Reduce padding on keys button diff --git a/dzgui/views/base.py b/dzgui/views/base.py index af8134e..156be48 100644 --- a/dzgui/views/base.py +++ b/dzgui/views/base.py @@ -314,8 +314,8 @@ class Notebook(ScrollableMixin, Gtk.Notebook): # type: ignore MainController.mediator.statusbar.emit("notebook_page_changed", enum) self.is_return = False - if enum is NotebookPage.SERVERS: - MainController.present_servers() + #if enum is NotebookPage.SERVERS: + # MainController.present_servers() class Grid(Gtk.Grid): diff --git a/dzgui/views/components/statusbar.py b/dzgui/views/components/statusbar.py index 511e24b..2115e76 100644 --- a/dzgui/views/components/statusbar.py +++ b/dzgui/views/components/statusbar.py @@ -2,7 +2,6 @@ from warnings import deprecated from typing import Self, Union, TYPE_CHECKING from dzgui.const.enum import NotebookPage, RowType -from dzgui.util import strings import gi @@ -29,13 +28,10 @@ class Statusbar(Gtk.Grid): self.controller = controller self.controller.register_widget("statusbar", self) - help_text = strings.statusbar_helptext - self.playercount = "" self.statusbar = Gtk.Statusbar() self.spinner = Gtk.Spinner() - self.spinner.start() version = self.controller.get_prefs().version self.status_right_label = Gtk.Label( @@ -48,7 +44,6 @@ class Statusbar(Gtk.Grid): self.status_right_label, self.spinner, Gtk.PositionType.RIGHT, 3, 1 ) - self.set_text(help_text, "Help") self.players = "" self.connect("server_row_changed", self._on_server_row_changed) @@ -60,6 +55,9 @@ class Statusbar(Gtk.Grid): def _on_notebook_page_changed( self, statusbar: Self, context: "NotebookPage" ) -> None: + if self.controller.loaded is False: + return + status = context.dict["statusbar"] bar = "" if status is False: @@ -70,9 +68,13 @@ class Statusbar(Gtk.Grid): case NotebookPage.MODS: bar = self.controller.format_mod_statusbar() case NotebookPage.HELP: - bar = self.controller.get_help_text() + bar = self.controller.get_help_row() case NotebookPage.SERVERS: - pass + # TODO: + from dzgui.const.enum import ServerTab + + self.emit("server_page_changed", ServerTab.BROWSER) + return self.set_by_context(context, bar) @@ -98,10 +100,10 @@ class Statusbar(Gtk.Grid): self.set_by_context(context, pretty) def _on_server_page_changed(self, statusbar: Self, context: "ServerTab") -> None: - c = self.controller.get_player_count() - self.playercount = c + count = self.controller.get_player_count() + self.playercount = count - self.set_by_context(context, c) + self.set_by_context(context, count) tree = self.controller.get_active_treeview() tree.emit("distcalc_started") @@ -132,7 +134,9 @@ class Statusbar(Gtk.Grid): self.statusbar.push(meta, string) self.set_cache(string) + @deprecated("use set_by_context()") def refresh(self, row: "RowType") -> None: + # FIXME: brittle if row is None: formatted = "" else: diff --git a/dzgui/views/pages/servers.py b/dzgui/views/pages/servers.py index 617934f..781ddaa 100644 --- a/dzgui/views/pages/servers.py +++ b/dzgui/views/pages/servers.py @@ -101,7 +101,7 @@ class ServerNotebook(Gtk.ScrolledWindow): self.notebook.set_tab_label_text(child, text) self.set_crumbs(text) - self.controller.present_servers() + #self.controller.present_servers() self.controller.populate_model() def set_cached_label(self, label: str) -> None: diff --git a/dzgui/views/trees/tree_menu.py b/dzgui/views/trees/tree_menu.py index de0b132..9629c44 100644 --- a/dzgui/views/trees/tree_menu.py +++ b/dzgui/views/trees/tree_menu.py @@ -76,4 +76,6 @@ class MenuTreeView(TreeView): row = self.get_value_at_index(1) if row == "": return + if self.controller.loaded is False: + return self.controller.set_statusbar_by_row(row) diff --git a/pyproject.toml b/pyproject.toml index 6f6ef5f..9864d41 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ dependencies = [ "packaging==25.0", "psutil==7.1.3", "python-a2s", - "requests==2.32.5" + "requests==2.32.5", ] [project.scripts] From e794aa82dc12fa57f729d74b333d1ea535a100a9 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 24 Jan 2026 08:51:22 +0900 Subject: [PATCH 029/309] chore: dynamic statusbar WIP --- dzgui/controllers/mc.py | 25 +------------------- dzgui/util/dist.py | 3 ++- dzgui/util/ip.py | 38 +++++++++++++++---------------- dzgui/views/base.py | 44 +++++++++++++++++++----------------- dzgui/views/pages/servers.py | 25 ++++---------------- pyproject.toml | 1 + 6 files changed, 49 insertions(+), 87 deletions(-) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index b66bbd7..d668b2d 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -81,11 +81,9 @@ class AppNavigation: filters: "FilterPanel" -class Controller: +class Controller(GObject.GObject): def __init__(self) -> None: self.dist_cache: dict[str, "Haversine", "ServerTab"] = {} - # TODO: store in crumbs class - self.crumbs_cache = "" self.mediator = AppNavigation() self.prefs: UserPrefs @@ -100,13 +98,6 @@ class Controller: except AttributeError: logger.critical(f"{attr} is not a valid AppNavigation attribute.") - # TODO: relegate to crumbs class - def set_crumbs(self, text: str) -> None: - self.mediator.grid.set_breadcrumbs(text) - - def get_crumbs(self) -> str: - return self.mediator.grid.get_breadcrumbs() - def get_help_store(self) -> Gtk.ListStore: return self.model_man.get_help_store() @@ -363,16 +354,8 @@ class Controller: case ButtonType.MODS: # TODO: reload using refresh button, rather than on demand self.load_mods() - case ButtonType.HELP: - pass - case ButtonType.SERVERS: - # TODO: drop after fixing crumbs signal - self.mediator.notebook.set_page_by_enum(button.opens) - return self.mediator.notebook.set_page_by_enum(button.opens) - # TODO: set crumbs by signal - self.set_crumbs(button.get_label()) def get_help_row(self) -> str: tv = self.mediator.menu @@ -697,12 +680,6 @@ class Controller: def propagate_column_width(self, col: Gtk.TreeViewColumn) -> None: GLib.idle_add(self.mediator.servers.update_tab_widths, col) - def set_crumbs_cache(self, text: str) -> None: - self.crumbs_cache = text - - def get_crumbs_cache(self) -> str: - return self.crumbs_cache - def refresh_tree(self) -> None: treeview = self.get_active_treeview() treeview.set_loaded(False) diff --git a/dzgui/util/dist.py b/dzgui/util/dist.py index 2eb6be3..34472ba 100644 --- a/dzgui/util/dist.py +++ b/dzgui/util/dist.py @@ -67,13 +67,14 @@ class CalcDist(multiprocessing.Process): self.result_queue.put([self.addr, dist, self.enum]) def compare(self, remote: str) -> int | None: + # TODO: cache this prefs = self.controller.get_prefs() local = prefs.coords if local is None: return None try: remote = get_coords(prefs.paths.ips, remote) - except GeolocationError: + except Exception: return None # TODO: handle failed remote dist diff --git a/dzgui/util/ip.py b/dzgui/util/ip.py index f4dceb5..a9deccd 100644 --- a/dzgui/util/ip.py +++ b/dzgui/util/ip.py @@ -2,6 +2,7 @@ import a2s import requests import shutil import subprocess +from warnings import deprecated from dataclasses import dataclass from typing import TYPE_CHECKING @@ -14,6 +15,7 @@ from dzgui.const.endpoints import COORDS_API, IP_ECHO if TYPE_CHECKING: from pathlib import Path + @dataclass class Coords: lat: float @@ -51,11 +53,11 @@ def get_coords(ips: "Path", ip: str) -> Coords: prefix = f"^{split[0]}.{split[1]}." - proc = subprocess.run( - ["/usr/bin/grep", "-E", prefix, ips], - capture_output=True, - text=True - ) + if shutil.which("rg") is not None: + args = ["/usr/bin/rg", prefix, ips] + else: + args = ["/usr/bin/grep", "-E", prefix, ips] + proc = subprocess.run(args, capture_output=True, text=True) if proc.returncode != 0: raise GeolocationError("Failed to split records") @@ -80,9 +82,9 @@ def get_coords(ips: "Path", ip: str) -> Coords: 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( [ @@ -90,10 +92,10 @@ def get_local_ip() -> str: "-4", "+short", "myip.opendns.com", - "@resolver1.opendns.com" + "@resolver1.opendns.com", ], capture_output=True, - text=True + text=True, ) if proc.returncode == 0: @@ -110,7 +112,7 @@ def resolve_ip(address: Record) -> Record: """ 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 + contain the real game port. This is merely used when saving a record as a UUID. """ res = a2s.info((address.ip, address.qport)) @@ -121,17 +123,13 @@ def resolve_ip(address: Record) -> Record: 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 - ): + 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 + +@deprecated("use ips.csv") +def get_local_coords(ip: str): + url = COORDS_API + "/" + ip + # local res=$(curl -Ls "$url" | jq -r '"\(.lat)\n\(.lon)"') + return url diff --git a/dzgui/views/base.py b/dzgui/views/base.py index 156be48..631fda0 100644 --- a/dzgui/views/base.py +++ b/dzgui/views/base.py @@ -300,22 +300,20 @@ class Notebook(ScrollableMixin, Gtk.Notebook): # type: ignore def _on_page_changed( self, notebook: "Notebook", page: Gtk.Widget, page_num: int ) -> None: - enum = self.get_page_by_enum() + return + # enum = self.get_page_by_enum() # TODO: crumbs signal - if enum is not None: - crumbs = enum.dict["crumbs"] - MainController.set_crumbs(crumbs) + # if enum is not None: + # crumbs = enum.dict["crumbs"] + # MainController.set_crumbs(crumbs) - if self.is_return is True: - MainController.mediator.statusbar.emit( - "notebook_page_returned", self.prior_page - ) - else: - MainController.mediator.statusbar.emit("notebook_page_changed", enum) - - self.is_return = False - #if enum is NotebookPage.SERVERS: - # MainController.present_servers() + # if self.is_return is True: + # MainController.mediator.statusbar.emit( + # "notebook_page_returned", self.prior_page + # ) + # else: + # MainController.mediator.statusbar.emit("notebook_page_changed", enum) + # self.is_return = False class Grid(Gtk.Grid): @@ -331,17 +329,21 @@ class Grid(Gtk.Grid): self.statusbar = Statusbar(MainController) self.right_panel = RightPanel(MainController) - self.breadcrumbs = Gtk.Label(halign=Gtk.Align.START) - self.set_breadcrumbs(strings.label_main_menu) + # self.breadcrumbs = Gtk.Label(halign=Gtk.Align.START) + # self.set_breadcrumbs(strings.label_main_menu) - self.bu = Gtk.Button(label="Shrink to fit", halign=Gtk.Align.END) - self.crumb_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) - self.crumb_box.add(self.breadcrumbs) - self.crumb_box.add(self.bu) - self.bu.connect("clicked", self._shrink) + # self.bu = Gtk.Button(label="Shrink to fit", halign=Gtk.Align.END) + # self.bu.connect("clicked", self._shrink) + + # self.crumb_box.add(self.bu) self.notebook = Notebook() self.conpan = ConnectPanel(MainController) + from dzgui.views.components.crumbs import Breadcrumbs + + self.breadcrumbs = Breadcrumbs(MainController) + self.crumb_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + self.crumb_box.add(self.breadcrumbs) self.attach(self.notebook, 0, 0, MAX_COLS, SINGLE_ROW) diff --git a/dzgui/views/pages/servers.py b/dzgui/views/pages/servers.py index 781ddaa..81ad88c 100644 --- a/dzgui/views/pages/servers.py +++ b/dzgui/views/pages/servers.py @@ -21,7 +21,6 @@ class ServerNotebook(Gtk.ScrolledWindow): def __init__(self, controller: "Controller"): super().__init__() - self.tab_cache = "" self.controller = controller self.controller.register_widget("servers", self) self.notebook = Gtk.Notebook(show_tabs=True) @@ -56,9 +55,6 @@ class ServerNotebook(Gtk.ScrolledWindow): self.connect("map", self._on_map) self.connect("unmap", self._on_unmap) - # TODO: strings - self.set_crumbs("Server browser") - def _on_map(self, widget: Self) -> None: self.controller.toggle_server_panels(True) @@ -77,10 +73,10 @@ class ServerNotebook(Gtk.ScrolledWindow): return self.get_active_treeview().grab_focus() - def set_crumbs(self, text: str) -> None: - string = f"Servers > {text}" - self.controller.set_crumbs(string) - self.set_cached_label(string) + def get_current_tab_text(self) -> None: + ind = self.notebook.get_current_page() + child = self.notebook.get_nth_page(ind) + return self.notebook.get_tab_label_text(child) def _on_page_changed( self, notebook: Gtk.Notebook, child: Gtk.Widget, index: int @@ -88,10 +84,6 @@ class ServerNotebook(Gtk.ScrolledWindow): if self.controller.loaded is False: return - # TODO :signals - # emit signal to crumbs - # emit signal to statusbar - # TODO: abstract label = self.notebook.get_tab_label_text(child) if label is None: return @@ -99,17 +91,8 @@ class ServerNotebook(Gtk.ScrolledWindow): # TODO: strings text = label.strip("*") self.notebook.set_tab_label_text(child, text) - self.set_crumbs(text) - - #self.controller.present_servers() self.controller.populate_model() - def set_cached_label(self, label: str) -> None: - self.tab_cache = label - - def get_cached_label(self) -> str: - return self.tab_cache - def get_active_treeview(self) -> ServerTreeView: index = self.notebook.get_current_page() scrollable = self.notebook.get_nth_page(index) diff --git a/pyproject.toml b/pyproject.toml index 9864d41..17dfca4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,6 +65,7 @@ build-backend = "setuptools.build_meta" line-length = 88 indent-width = 4 extend-exclude = ["tree_servers.py", "tree_server_mods.py", "dialogs", "right_panel.py", "filter_panel.py", "base.py", "*lib/a2s", "dist.py"] +lint.unfixable = ["F401"] [tool.ruff.format] quote-style = "double" From eb05cbdbfdaf5dac2e582518168286aa1a3b0bc9 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 24 Jan 2026 08:55:00 +0900 Subject: [PATCH 030/309] chore: dynamic breadcrumbs --- dzgui/views/base.py | 32 ++------------------------------ 1 file changed, 2 insertions(+), 30 deletions(-) diff --git a/dzgui/views/base.py b/dzgui/views/base.py index 631fda0..d5096c9 100644 --- a/dzgui/views/base.py +++ b/dzgui/views/base.py @@ -14,6 +14,7 @@ from dzgui.util.format import embolden # TODO: import notebook only and add components there? from dzgui.views.pages.changelog import Changelog from dzgui.views.components.connect_panel import ConnectPanel +from dzgui.views.components.crumbs import Breadcrumbs from dzgui.views.pages.devs import Developers from dzgui.views.pages.help import Help from dzgui.views.pages.keys import Keybindings @@ -185,8 +186,6 @@ class Notebook(ScrollableMixin, Gtk.Notebook): # type: ignore super().__init__(show_tabs=False) self.prior_page: NotebookPage - self.prior_status: str - self.is_return = False MainController.register_widget("notebook", self) @@ -254,7 +253,6 @@ class Notebook(ScrollableMixin, Gtk.Notebook): # type: ignore self.set_page_by_enum(self.prior_page) self.settings.unblock_text_entry() return - self.is_return = True self.set_page_by_enum(self.prior_page) def get_page_by_enum(self) -> NotebookPage | None: @@ -268,7 +266,6 @@ class Notebook(ScrollableMixin, Gtk.Notebook): # type: ignore if cur_page == NotebookPage.KEYS: self.return_prior() else: - # self.prior_status = MainController.get_statusbar() self.set_page_by_enum(NotebookPage.KEYS) def focus_current(self) -> None: @@ -300,20 +297,7 @@ class Notebook(ScrollableMixin, Gtk.Notebook): # type: ignore def _on_page_changed( self, notebook: "Notebook", page: Gtk.Widget, page_num: int ) -> None: - return - # enum = self.get_page_by_enum() - # TODO: crumbs signal - # if enum is not None: - # crumbs = enum.dict["crumbs"] - # MainController.set_crumbs(crumbs) - - # if self.is_return is True: - # MainController.mediator.statusbar.emit( - # "notebook_page_returned", self.prior_page - # ) - # else: - # MainController.mediator.statusbar.emit("notebook_page_changed", enum) - # self.is_return = False + pass class Grid(Gtk.Grid): @@ -329,17 +313,13 @@ class Grid(Gtk.Grid): self.statusbar = Statusbar(MainController) self.right_panel = RightPanel(MainController) - # self.breadcrumbs = Gtk.Label(halign=Gtk.Align.START) - # self.set_breadcrumbs(strings.label_main_menu) # self.bu = Gtk.Button(label="Shrink to fit", halign=Gtk.Align.END) # self.bu.connect("clicked", self._shrink) - # self.crumb_box.add(self.bu) self.notebook = Notebook() self.conpan = ConnectPanel(MainController) - from dzgui.views.components.crumbs import Breadcrumbs self.breadcrumbs = Breadcrumbs(MainController) self.crumb_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) @@ -383,14 +363,6 @@ class Grid(Gtk.Grid): def toggle_refresh_button(self, state: bool) -> None: self.right_panel.refresh_button.set_visible(state) - # TODO: make this method internal to Statusbar - def get_breadcrumbs(self) -> str: - return self.breadcrumbs.get_text() - - def set_breadcrumbs(self, text: str) -> None: - crumbs = embolden(text) - self.breadcrumbs.set_markup(crumbs) - class App(Gtk.Application): def __init__(self, prefs: "UserPrefs") -> None: From 69a881cee8c96eb6f40a3554ce3828cd73a86709 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 24 Jan 2026 09:11:54 +0900 Subject: [PATCH 031/309] feat: emit warning if icon is missing --- dzgui/controllers/mc.py | 6 +- dzgui/util/strings.py | 147 +++++++++++++++++++++++----------------- dzgui/views/base.py | 13 ++-- 3 files changed, 94 insertions(+), 72 deletions(-) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index d668b2d..59e373e 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -467,7 +467,7 @@ class Controller(GObject.GObject): # NOTE: manipulates mod store case ContextMenu.DELETE_MOD: self.delete_single_mod(path) - # TODO: hook treeview changed signal + # TODO: hook treeview changed signal automatically # Gtk.TreeModel, row-inserted/row-deleted self.update_mod_statusbar() remove_stale_signatures( @@ -539,7 +539,6 @@ class Controller(GObject.GObject): import time time.sleep(1) - # TODO: use model managers, etc. self.data = ( ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], ["BAR", "a", "a", "a", 1, 1, 1, "172.111.51.156:2302", 0, 0, "a", False], @@ -564,9 +563,7 @@ class Controller(GObject.GObject): def dump_test_2(self) -> None: import time - time.sleep(1) - # TODO: use model managers, etc. self.data = ( ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], ) @@ -582,6 +579,7 @@ class Controller(GObject.GObject): for row in data: manager.append_row(row) treeview.set_loaded(True) + # TODO: emit custom "servers finished loading" signal self.mediator.statusbar.emit("server_page_changed", context) treeview.grab_focus() diff --git a/dzgui/util/strings.py b/dzgui/util/strings.py index 31c0365..94529cd 100644 --- a/dzgui/util/strings.py +++ b/dzgui/util/strings.py @@ -2,6 +2,7 @@ from dataclasses import dataclass from dzgui.const.constants import SYSTEM_LOG + def build_missing(build: str) -> str: msg = ( f"This server is running {build}. You can install " @@ -20,6 +21,7 @@ def build_path_invalid(build: str) -> str: ) return msg + # General dialog_header = "DZGUI - Dialog" dz = "DayZ" @@ -93,21 +95,24 @@ something_wrong = "Something went wrong. See the detailed error below." 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." +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 = ( +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", @@ -155,13 +160,9 @@ label_main_menu = "Main menu" # buttons ping_servers = "Ping servers" debug_mode = "Debug mode" -debug_tooltip = ( - "Used to perform a dry run without\n" - "actually connecting to a server" -) +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" + "Refresh the ping for visible servers.\n" "Available once per unique filter context" ) # statusbar_helptext = "Select a row to see its detailed description" @@ -235,12 +236,14 @@ class Dialog: 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: servers_label: str @@ -254,6 +257,7 @@ class Button: exit_label: str exit_tooltip: str + @dataclass(slots=True, frozen=True) class Crumbs: changelog: str @@ -266,12 +270,14 @@ class Crumbs: thanks: str developers: str + @dataclass(slots=True, frozen=True) class Thanks: header: str description: str users: list[str] + @dataclass(slots=True, frozen=True) class Options: header: str @@ -310,42 +316,43 @@ class Options: 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.", + 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 = ( + 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", + 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 = ( + header="# Special thanks", + description=( "This page recognizes beta testers, collaborators, code " "contributors, and sponsors of the project in alphabetical order.\n" "If you wish to be removed from this list, please submit a ticket." - ), - users = [ + ), + users=[ "bongjutsu", "Deku", "dj3hac", @@ -383,29 +390,28 @@ options = Options( 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." + 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 = ( + 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 = ( + 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, + 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. @@ -415,14 +421,14 @@ 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", + 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( @@ -454,6 +460,7 @@ checkmark = "✓" no_mods = "No local mods found." no_servers = "No server metadata to list." + @dataclass(slots=True, frozen=True) class Flags: description: str @@ -461,6 +468,7 @@ class Flags: uninstall: str developers: str + flags = Flags( description="DayZ server browser and mod manager", version="Print version information", @@ -468,17 +476,21 @@ flags = Flags( developers="Enables developer debugging features", ) + @dataclass(slots=True, frozen=True) class FilePicker: title: str placeholder: str + picker = FilePicker( - title = "Save diagnostic log to file", - placeholder = SYSTEM_LOG, + title="Save diagnostic log to file", + placeholder=SYSTEM_LOG, ) -api_error = "API key validation error or timeout. See 'Help > Show debug log' for details." +api_error = ( + "API key validation error or timeout. See 'Help > Show debug log' for details." +) @dataclass(slots=True, frozen=True) @@ -488,13 +500,15 @@ class DevelopersPage: prefs_label: str columns: list[str] + developers = DevelopersPage( header="Developers", paths_label="Resolved XDG paths", prefs_label="Boot preferences", - columns=["Key", "Value"] + columns=["Key", "Value"], ) + @dataclass(slots=True, frozen=True) class ServerLabels: browser: str @@ -502,6 +516,7 @@ class ServerLabels: recent: str lan: str + server_labels = ServerLabels( browser="Server Browser", saved="Saved Servers", @@ -509,6 +524,7 @@ server_labels = ServerLabels( lan="LAN", ) + @dataclass(slots=True, frozen=True) class ConnectPanel: connect: str @@ -519,6 +535,7 @@ class ConnectPanel: add_tooltip: str connect_tooltip: str + connect_panel = ConnectPanel( connect="Connect", add="Add", @@ -532,16 +549,19 @@ connect_panel = ConnectPanel( connect_tooltip="Connect to this server", ) + @dataclass(slots=True, frozen=True) class FavPanel: heading: str no_fav: str + fav_panel = FavPanel( heading="Favorite server", no_fav="None set. Right click a server and select 'Set favorite' to set.", ) + @dataclass(slots=True, frozen=True) class LanPanel: heading: str @@ -552,6 +572,7 @@ class LanPanel: entry_tooltip: str scan_tooltip: str + lan_panel = LanPanel( heading="LAN query port", default_button="Default port (27016)", @@ -559,12 +580,13 @@ lan_panel = LanPanel( scan_button="Scan", placeholder="Enter the query port (1-65535)", entry_tooltip="Specify the port to search for DayZ servers on the local network", - scan_tooltip="Scan for servers" + scan_tooltip="Scan for servers", ) distance_suffix = "Distance: calculating..." dialog_error = "ERROR" + @dataclass(slots=True, frozen=True) class AtomicButton: refresh: str @@ -573,6 +595,7 @@ class AtomicButton: keys: str keys_tooltip: str + atomic_buttons = AtomicButton( refresh="Refresh", refresh_tooltip="Refresh server data", @@ -580,3 +603,5 @@ atomic_buttons = AtomicButton( keys="Keys", keys_tooltip="Toggles the keybindings dialog", ) + +steam_icon_missing="Steam icon not found in IconTheme" diff --git a/dzgui/views/base.py b/dzgui/views/base.py index d5096c9..636c87b 100644 --- a/dzgui/views/base.py +++ b/dzgui/views/base.py @@ -5,10 +5,9 @@ import warnings from typing import TYPE_CHECKING, Literal from dzgui.const.enum import NotebookPage -from dzgui.const.constants import APP_NAME, APP_NAME_LOWER +from dzgui.const.constants import APP_NAME, APP_NAME_LOWER, STEAM_TRAY_MONO from dzgui.controllers.mc import Controller from dzgui.util import css, strings -from dzgui.util.format import embolden # NOTEBOOK ITEMS # TODO: import notebook only and add components there? @@ -399,8 +398,8 @@ class App(Gtk.Application): MainController = Controller() -# TODO: icon sanity check, log result -# theme = Gtk.IconTheme.get_default() -# icons = theme.list_icons(None) -# print("steam_tray_mono" in icons) -# FIXME: +theme = Gtk.IconTheme.get_default() +icons = theme.list_icons(None) +if STEAM_TRAY_MONO not in icons: + logger.warn(strings.steam_icon_missing) + warnings.warn(strings.steam_icon_missing, stacklevel=2) From c952fef5e825d40c6f982faa22a156b164ec5851 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 24 Jan 2026 09:12:37 +0900 Subject: [PATCH 032/309] fix: use correct constant name --- dzgui/views/base.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dzgui/views/base.py b/dzgui/views/base.py index 636c87b..e4b2567 100644 --- a/dzgui/views/base.py +++ b/dzgui/views/base.py @@ -5,7 +5,7 @@ import warnings from typing import TYPE_CHECKING, Literal from dzgui.const.enum import NotebookPage -from dzgui.const.constants import APP_NAME, APP_NAME_LOWER, STEAM_TRAY_MONO +from dzgui.const.constants import APP_NAME, APP_NAME_LOWER, STEAM_ICON from dzgui.controllers.mc import Controller from dzgui.util import css, strings @@ -400,6 +400,6 @@ MainController = Controller() theme = Gtk.IconTheme.get_default() icons = theme.list_icons(None) -if STEAM_TRAY_MONO not in icons: +if STEAM_ICON not in icons: logger.warn(strings.steam_icon_missing) warnings.warn(strings.steam_icon_missing, stacklevel=2) From f05374bc7de2078bfbc0fde0231e13d2120276c2 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 24 Jan 2026 13:30:01 +0900 Subject: [PATCH 033/309] chore: register signals --- dzgui/controllers/mc.py | 9 +--- dzgui/views/base.py | 6 +-- dzgui/views/components/entry.py | 21 +++++--- dzgui/views/components/eventbox.py | 16 ++++-- dzgui/views/components/statusbar.py | 81 ++++++++++++++++------------- dzgui/views/pages/options.py | 78 +++++++++++++-------------- dzgui/views/trees/tree_base.py | 44 ++++++++-------- dzgui/views/trees/tree_menu.py | 11 ---- dzgui/views/trees/tree_mods.py | 4 +- dzgui/views/trees/tree_servers.py | 6 ++- 10 files changed, 139 insertions(+), 137 deletions(-) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 59e373e..dd5594b 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -227,7 +227,7 @@ class Controller(GObject.GObject): c = self.mediator.statusbar.statusbar.get_context_id(context) self.mediator.statusbar.statusbar.pop(c) - # TODO: use set_by_context() + @deprecated("use set_by_context") def set_statusbar(self, text: str, context: str) -> int: msg_id = self.mediator.statusbar.set_text(text, context) return msg_id @@ -319,12 +319,8 @@ class Controller(GObject.GObject): notebook = self.mediator.grid.notebook notebook.focus_current() - def set_statusbar_by_row(self, row: "RowType") -> None: - self.mediator.statusbar.refresh(row) - def toggle_server_panels(self, state: bool) -> None: - # TODO: this is going to be signal dependent now - # cf. map/unmap + # TODO: this is going to be signal dependent now, cf. map/unmap #self.mediator.grid.emit("toggle panels", state) self.mediator.grid.toggle_filter_panel(state) self.mediator.grid.toggle_connect_panel(state) @@ -579,7 +575,6 @@ class Controller(GObject.GObject): for row in data: manager.append_row(row) treeview.set_loaded(True) - # TODO: emit custom "servers finished loading" signal self.mediator.statusbar.emit("server_page_changed", context) treeview.grab_focus() diff --git a/dzgui/views/base.py b/dzgui/views/base.py index e4b2567..f842202 100644 --- a/dzgui/views/base.py +++ b/dzgui/views/base.py @@ -310,9 +310,6 @@ class Grid(Gtk.Grid): MainController.register_widget("grid", self) - self.statusbar = Statusbar(MainController) - self.right_panel = RightPanel(MainController) - # self.bu = Gtk.Button(label="Shrink to fit", halign=Gtk.Align.END) # self.bu.connect("clicked", self._shrink) # self.crumb_box.add(self.bu) @@ -320,6 +317,9 @@ class Grid(Gtk.Grid): self.notebook = Notebook() self.conpan = ConnectPanel(MainController) + self.statusbar = Statusbar(MainController) + self.right_panel = RightPanel(MainController) + self.breadcrumbs = Breadcrumbs(MainController) self.crumb_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) self.crumb_box.add(self.breadcrumbs) diff --git a/dzgui/views/components/entry.py b/dzgui/views/components/entry.py index 735c29f..37093ea 100644 --- a/dzgui/views/components/entry.py +++ b/dzgui/views/components/entry.py @@ -5,12 +5,14 @@ from dzgui.util.css import add_class, remove_class from dzgui.util.strings import connect_panel, lan_panel import gi + gi.require_version("Gtk", "3.0") -from gi.repository import Gtk, Gdk, GObject # noqa E402 +from gi.repository import Gtk, Gdk, GObject # noqa E402 if TYPE_CHECKING: from dzgui.controllers.mc import Controller + def validate_port(text: str) -> bool: if text.isdigit(): if int(text) < 1: @@ -21,6 +23,7 @@ def validate_port(text: str) -> bool: else: return False + def validate_ip_or_id(text: str) -> bool: try: validate_ip(text) @@ -31,15 +34,13 @@ def validate_ip_or_id(text: str) -> bool: else: return False + class ValidatedEntry(Gtk.Entry): - __gsignals__ = { - "string_validated": (GObject.SignalFlags.RUN_FIRST, None, (object,)), - } def __init__(self, controller: "Controller", func: Callable) -> None: super().__init__( hexpand=True, placeholder_text=connect_panel.placeholder, - tooltip_text=connect_panel.entry_tooltip + tooltip_text=connect_panel.entry_tooltip, ) self.func: Callable @@ -50,6 +51,10 @@ class ValidatedEntry(Gtk.Entry): self.connect("key-press-event", self._on_entry_keypress) self.connect("changed", self._on_text_changed) + @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(bool,)) + def string_validated(self, valid: bool) -> None: + pass + def mark_valid(self) -> None: self.emit("string_validated", True) remove_class(self, self.classname) @@ -66,7 +71,9 @@ class ValidatedEntry(Gtk.Entry): self.func = func def insert_icon(self) -> None: - self.set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, "edit-clear-symbolic") + self.set_icon_from_icon_name( + Gtk.EntryIconPosition.SECONDARY, "edit-clear-symbolic" + ) self.set_icon_activatable(Gtk.EntryIconPosition.SECONDARY, True) self.connect("icon-release", self._on_icon_release) @@ -98,7 +105,6 @@ class ValidatedEntry(Gtk.Entry): entry.set_text("") self.remove_icon() - def _on_entry_keypress(self, entry: Gtk.Entry, event: Gdk.EventKey) -> None: if event.keyval == Gdk.KEY_Escape: # NOTE: unselect text @@ -112,6 +118,7 @@ class IpEntry(ValidatedEntry): self.set_placeholder_text(connect_panel.placeholder) self.set_tooltip_text(connect_panel.entry_tooltip) + class PortEntry(ValidatedEntry): def __init__(self, controller: "Controller") -> None: super().__init__(controller, func=validate_port) diff --git a/dzgui/views/components/eventbox.py b/dzgui/views/components/eventbox.py index 2294ba7..fc05f2e 100644 --- a/dzgui/views/components/eventbox.py +++ b/dzgui/views/components/eventbox.py @@ -1,14 +1,20 @@ +from typing import TYPE_CHECKING from dzgui.const.constants import HELP_BUBBLE import gi + gi.require_version("Gtk", "3.0") from gi.repository import Gtk, Gdk # noqa E402 +if TYPE_CHECKING: + from dzgui.controllers.mc import Controller + + class InfoEventBox(Gtk.EventBox): - def __init__(self, text: str, parent: Gtk.Widget): + def __init__(self, text: str, controller: "Controller"): super().__init__(margin_start=10) - self.parent = parent + self.controller = controller self.text = text self.icon = Gtk.Image.new_from_icon_name( @@ -26,14 +32,14 @@ class InfoEventBox(Gtk.EventBox): self, eventbox: Gtk.EventBox, eventcrossing: Gdk.EventCrossing ) -> None: self.icon.set_opacity(1) - self.parent.controller.set_statusbar(self.text, "Options") + self.controller.set_statusbar(self.text, "Options") def _on_leave_tooltip( self, eventbox: Gtk.EventBox, eventcrossing: Gdk.EventCrossing ) -> None: self.icon.set_opacity(0.8) - #self.parent.controller.set_statusbar("", "Options") - self.parent.controller.remove_statusbar("Options") + # self.parent.controller.set_statusbar("", "Options") + self.controller.remove_statusbar("Options") def set_text(self, text: str) -> None: self.text = text diff --git a/dzgui/views/components/statusbar.py b/dzgui/views/components/statusbar.py index 2115e76..73ff261 100644 --- a/dzgui/views/components/statusbar.py +++ b/dzgui/views/components/statusbar.py @@ -1,7 +1,7 @@ from warnings import deprecated from typing import Self, Union, TYPE_CHECKING -from dzgui.const.enum import NotebookPage, RowType +from dzgui.const.enum import NotebookPage, ServerTab import gi @@ -11,17 +11,11 @@ from gi.repository import Gtk, GObject # noqa E402 if TYPE_CHECKING: from dzgui.const.enum import ServerTab from dzgui.controllers.mc import Controller + from dzgui.views.trees.tree_base import TreeView + from dzgui.views.base import Notebook class Statusbar(Gtk.Grid): - __gsignals__ = { - "server_page_changed": (GObject.SignalFlags.RUN_FIRST, None, (object,)), - "notebook_page_changed": (GObject.SignalFlags.RUN_FIRST, None, (object,)), - "notebook_page_returned": (GObject.SignalFlags.RUN_FIRST, None, (object,)), - "distcalc_ended": (GObject.SignalFlags.RUN_FIRST, None, (object, str)), - "server_row_changed": (GObject.SignalFlags.RUN_FIRST, None, ()), - } - def __init__(self, controller: "Controller") -> None: super().__init__(orientation=Gtk.Orientation.HORIZONTAL) @@ -46,37 +40,58 @@ class Statusbar(Gtk.Grid): self.players = "" + controller.mediator.menu.connect( + "generic_treesel_changed", self._help_row_changed + ) + controller.mediator.notebook.connect_after( + "switch-page", self._on_notebook_page_changed + ) self.connect("server_row_changed", self._on_server_row_changed) self.connect("server_page_changed", self._on_server_page_changed) - self.connect("notebook_page_changed", self._on_notebook_page_changed) - self.connect("notebook_page_returned", self._on_notebook_page_returned) self.connect("distcalc_ended", self._on_distcalc_ended) + @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object,)) + def server_page_changed(self, tab: ServerTab) -> None: + pass + + @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=()) + def server_row_changed(self) -> None: + pass + + @GObject.Signal( + flags=GObject.SignalFlags.RUN_LAST, + arg_types=( + object, + object, + ), + ) + def distcalc_ended( + self, dist: Union[str, None], context: Union["ServerTab", NotebookPage] + ) -> None: + pass + def _on_notebook_page_changed( - self, statusbar: Self, context: "NotebookPage" + self, notebook: "Notebook", child: Gtk.Widget, index: int ) -> None: if self.controller.loaded is False: return - status = context.dict["statusbar"] - bar = "" - if status is False: - self.set_by_context(context, "") + enum = notebook.get_page_by_enum() + show_statusbar = enum.dict["statusbar"] + if show_statusbar is False: + self.set_by_context(enum, "") return - match context: + match enum: case NotebookPage.MODS: bar = self.controller.format_mod_statusbar() case NotebookPage.HELP: bar = self.controller.get_help_row() case NotebookPage.SERVERS: - # TODO: - from dzgui.const.enum import ServerTab - self.emit("server_page_changed", ServerTab.BROWSER) return - self.set_by_context(context, bar) + self.set_by_context(enum, bar) def _on_notebook_page_returned( self, statusbar: Self, prior_context: NotebookPage @@ -96,9 +111,12 @@ class Statusbar(Gtk.Grid): if dist is None: self.set_by_context(context, self.playercount) else: - pretty = f"{self.playercount} | Distance: {dist}" + pretty = self.append_distance(dist) self.set_by_context(context, pretty) + def append_distance(self, dist: str) -> str: + return f"{self.playercount} | Distance: {dist}" + def _on_server_page_changed(self, statusbar: Self, context: "ServerTab") -> None: count = self.controller.get_player_count() self.playercount = count @@ -134,16 +152,9 @@ class Statusbar(Gtk.Grid): self.statusbar.push(meta, string) self.set_cache(string) - @deprecated("use set_by_context()") - def refresh(self, row: "RowType") -> None: - # FIXME: brittle - if row is None: - formatted = "" - else: - formatted = self.format_metadata(row) - self.set_text(formatted, "Help") - - @deprecated("use controller") - def format_metadata(self, row: "RowType") -> str: - prefix = row.dict["tooltip"] - return prefix + def _help_row_changed(self, tree: "TreeView", sel: Gtk.TreeSelection) -> None: + row = tree.get_value_at_index(1) + if self.controller.loaded is False: + return + tooltip = row.dict["tooltip"] + self.set_by_context(NotebookPage.HELP, tooltip) diff --git a/dzgui/views/pages/options.py b/dzgui/views/pages/options.py index 1c36660..2183cab 100644 --- a/dzgui/views/pages/options.py +++ b/dzgui/views/pages/options.py @@ -28,15 +28,17 @@ from dzgui.const.constants import ( STEAM_CMD, VIEW_CONCEAL, VIEW_REVEAL, - ) +) import gi + gi.require_version("Gtk", "3.0") from gi.repository import Gtk, Gdk # noqa if TYPE_CHECKING: from dzgui.controllers.mc import Controller + class Options(Gtk.Box): def __init__(self, controller: "Controller"): super().__init__( @@ -115,8 +117,8 @@ class Options(Gtk.Box): # 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) + eb = InfoEventBox(strings.options.dl_eventbox, controller) + eb2 = InfoEventBox(strings.options.force_eventbox, controller) mod_rows = [ [LeftLabel(strings.options.install_mode), self.mod_install_toggle, eb], @@ -131,7 +133,7 @@ class Options(Gtk.Box): 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) + self.branch_eb = InfoEventBox("", controller) version_rows = [ [LeftLabel(APPNAME_DAYZ), self.dayz_version_label], @@ -149,7 +151,7 @@ class Options(Gtk.Box): margin_top=5, margin_bottom=10, homogeneous=True, - spacing=10 + spacing=10, ) api_links_box.add(self.steam) api_links_box.add(self.bm) @@ -167,15 +169,13 @@ class Options(Gtk.Box): hexpand=True, ) - developers=Gtk.Button(label="Developers", halign=Gtk.Align.START) + developers = Gtk.Button(label="Developers", halign=Gtk.Align.START) developers.connect("clicked", self._on_developers_clicked) prefs = self.controller.get_prefs() is_developer = prefs.is_developer if is_developer: - grid.attach( - developers, 1, 0, self.DEFAULT_WIDTH, self.DEFAULT_HEIGHT - ) + grid.attach(developers, 1, 0, self.DEFAULT_WIDTH, self.DEFAULT_HEIGHT) for frame in [ self.make_frame(api_box, strings.options.api_keys), @@ -183,9 +183,7 @@ class Options(Gtk.Box): 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 - ) + grid.attach(frame, col, row, self.DEFAULT_WIDTH, self.DEFAULT_HEIGHT) row += 1 self.scrollable = Gtk.ScrolledWindow(vexpand=True) @@ -221,13 +219,11 @@ class Options(Gtk.Box): 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 + "deleted-text", self._on_text_deleted, context, button ) if private: - entry.set_icon_from_icon_name( - Gtk.EntryIconPosition.SECONDARY, VIEW_REVEAL - ) + 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) @@ -244,7 +240,7 @@ class Options(Gtk.Box): return box def _on_field_activated( - self, entry: Gtk.Entry, context: Preferences, button: Gtk.Button + self, entry: Gtk.Entry, context: Preferences, button: Gtk.Button ) -> None: text = entry.get_text() if not self._is_valid_text(text, context): @@ -265,9 +261,7 @@ class Options(Gtk.Box): for record in rows: col = 1 for el in record: - grid.attach( - el, col, row, self.DEFAULT_WIDTH, self.DEFAULT_HEIGHT - ) + grid.attach(el, col, row, self.DEFAULT_WIDTH, self.DEFAULT_HEIGHT) col += 1 row += 1 return grid @@ -299,9 +293,9 @@ class Options(Gtk.Box): 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, "") + # 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: # TODO: use two columns or constants here, not strings @@ -320,14 +314,12 @@ class Options(Gtk.Box): 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 + # 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: + def _on_radio_toggled(self, button: Gtk.RadioButton, context: Preferences) -> None: try: self.controller.toggle_config(context) except Exception: @@ -342,12 +334,11 @@ class Options(Gtk.Box): self.controller, strings.options.manual_sub_msg, strings.self_workshop, - self.uid + self.uid, ) else: self.force_button.set_sensitive(False) - def _is_valid_text(self, text: str, context: Preferences) -> bool: if text.isspace(): return False @@ -458,7 +449,7 @@ class Options(Gtk.Box): for el, conf_state in [ (self.mod_install_toggle, install), (self.fullscreen_toggle, config["fullscreen"]), - (self.distance_toggle, config["use_miles"]) + (self.distance_toggle, config["use_miles"]), ]: el.get_children()[conf_state].set_active(True) self._suppress_toggles(False) @@ -473,18 +464,14 @@ class Options(Gtk.Box): field[1].get_children()[1].set_sensitive(False) try: - pe_file_path = PeFile.get_pefile_path( - steam_path, APPID_DAYZ - ) + 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 - ) + 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: @@ -497,13 +484,16 @@ class Options(Gtk.Box): 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.controller.suppress_signal(self, self.branch_combo, "_on_branch_changed", True) + self.controller.suppress_signal( + self, self.branch_combo, "_on_branch_changed", True + ) self.branch_combo.set_active(active_combo) self.branch_combo.set_sensitive(prefs.allow_updates) - self.controller.suppress_signal(self, self.branch_combo, "_on_branch_changed", False) + self.controller.suppress_signal( + self, self.branch_combo, "_on_branch_changed", False + ) if prefs.allow_updates is True: msg = strings.options.self_update @@ -517,7 +507,9 @@ class Options(Gtk.Box): self.fullscreen_toggle, self.distance_toggle, ]: - self.controller.suppress_signal(self, toggle.get_children()[0], "_on_radio_toggled", state) + self.controller.suppress_signal( + self, toggle.get_children()[0], "_on_radio_toggled", state + ) def _on_icon_release( self, diff --git a/dzgui/views/trees/tree_base.py b/dzgui/views/trees/tree_base.py index 28adbfc..f5e44b4 100644 --- a/dzgui/views/trees/tree_base.py +++ b/dzgui/views/trees/tree_base.py @@ -7,6 +7,7 @@ from dzgui.util.keys import is_navkey from dzgui.views.mixins.cursor_mixin import CursorMixin import gi + gi.require_version("Gtk", "3.0") from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa @@ -16,8 +17,11 @@ if TYPE_CHECKING: from dzgui.controllers.mc import Controller from dzgui.const.enum import ContextMenuGroup + class TreeView(CursorMixin, Gtk.TreeView): # type: ignore - def __init__(self, controller: "Controller", menu: "ContextMenuGroup" = None) -> None: + def __init__( + self, controller: "Controller", menu: "ContextMenuGroup" = None + ) -> None: super().__init__( enable_search=False, search_column=-1, @@ -32,7 +36,6 @@ class TreeView(CursorMixin, Gtk.TreeView): # type: ignore 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) @@ -41,22 +44,22 @@ class TreeView(CursorMixin, Gtk.TreeView): # type: ignore def generic_treesel_changed(self, selection: Gtk.TreeSelection) -> None: pass - @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, - arg_types=(Gtk.TreePath, Gtk.TreeViewColumn) + @GObject.Signal( + flags=GObject.SignalFlags.RUN_LAST, arg_types=(Gtk.TreePath, Gtk.TreeViewColumn) ) - def generic_row_activated(self, - path: Gtk.TreePath, - column: 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 + # @deprecated("signals are localized now") + # 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: @@ -83,9 +86,7 @@ class TreeView(CursorMixin, Gtk.TreeView): # type: ignore model, rows = sel.get_selected_rows() return [model[row] for row in rows] - def _on_keypress( - self, treeview: Gtk.TreeView, event: Gdk.EventKey - ) -> None: + def _on_keypress(self, treeview: Gtk.TreeView, event: Gdk.EventKey) -> None: if is_navkey(event.keyval): # TODO: if model is None @@ -101,14 +102,13 @@ class TreeView(CursorMixin, Gtk.TreeView): # type: ignore self._vim_nav(event) return - def _on_key_release( - self, treeview: Gtk.TreeView, event: Gdk.EventKey - ) -> None: + def _on_key_release(self, treeview: Gtk.TreeView, event: Gdk.EventKey) -> None: # TODO: explain this better """ Suppresses spamming on keydown """ - #if event.keyval is Gdk.KEY_space: + # TODO: multisel + # if event.keyval is Gdk.KEY_space: # it = self.get_focused_row_iter() # self.get_selection().select_iter(it) # return True @@ -169,7 +169,7 @@ class TreeView(CursorMixin, Gtk.TreeView): # type: ignore path = pathlist[0] return path - @signal_emission + # @signal_emission def _on_row_activated( self, treeview: Gtk.TreeView, diff --git a/dzgui/views/trees/tree_menu.py b/dzgui/views/trees/tree_menu.py index 9629c44..2319a24 100644 --- a/dzgui/views/trees/tree_menu.py +++ b/dzgui/views/trees/tree_menu.py @@ -41,7 +41,6 @@ class MenuTreeView(TreeView): 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 @@ -69,13 +68,3 @@ class MenuTreeView(TreeView): 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 - if self.controller.loaded is False: - return - self.controller.set_statusbar_by_row(row) diff --git a/dzgui/views/trees/tree_mods.py b/dzgui/views/trees/tree_mods.py index fcff526..2ccb85c 100644 --- a/dzgui/views/trees/tree_mods.py +++ b/dzgui/views/trees/tree_mods.py @@ -55,16 +55,16 @@ class ModTreeView(ModsMixin, ContextMixin, TreeView): self.connect("button-press-event", self._on_mods_button_press) self.connect("key-press-event", self._on_mods_keypress) - self.s = self.get_selection().get_selected_rows() + # self.s = self.get_selection().get_selected_rows() def _on_mods_keypress(self, widget: Gtk.Widget, event: Gdk.EventKey) -> None: + # TODO: multiselect # if event.keyval is Gdk.KEY_space: # it = self.get_focused_row_iter() # self.get_selection().select_iter(it) # path = self.get_focused_row_path() # self.set_cursor(path) # return False - # TODO: parse keys self.present_menu(widget, event) def _on_mods_button_press(self, widget: Gtk.Widget, event: Gdk.EventButton) -> None: diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index ed6e9b3..93a6727 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -34,8 +34,6 @@ class EnumeratedMenuItem(Gtk.MenuItem): class ServerTreeView(TreeView): - __gsignals__ = {"distcalc_started": (GObject.SignalFlags.RUN_FIRST, None, ())} - def __init__( self, controller: "Controller", enum: ServerTab, menu: ContextMenuGroup ) -> None: @@ -115,6 +113,10 @@ class ServerTreeView(TreeView): GLib.timeout_add(QUEUE_CHECK_DELAY, self._check_result_queue) + @GObject.Signal(flags=GObject.SignalFlags.RUN_FIRST, arg_types=()) + def distcalc_started(self) -> None: + pass + def filter(self, mode: FilterMode, label: str) -> None: print("TOGGLE:", mode) print("LABEL: ", label) From 2beea2af2035021f39dc290360be06dba625ccba Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 24 Jan 2026 13:33:15 +0900 Subject: [PATCH 034/309] chore: fix linting errors --- dzgui/config/query.py | 6 +++--- dzgui/controllers/mc.py | 2 +- dzgui/views/components/statusbar.py | 1 - dzgui/views/trees/tree_base.py | 11 +---------- 4 files changed, 5 insertions(+), 15 deletions(-) diff --git a/dzgui/config/query.py b/dzgui/config/query.py index 7e77af3..50f3c34 100644 --- a/dzgui/config/query.py +++ b/dzgui/config/query.py @@ -35,7 +35,7 @@ def get_config(path: Path) -> dict: def get_favorites(path: Path) -> list[str]: try: conf = get_config(path) - except Exception as e: + except Exception: pass return conf["ip_list"] @@ -54,7 +54,7 @@ def enum_to_key(enum: Preferences) -> str: def get_client_index(client: str) -> int: if client == STEAM_CMD: return 0 - elif client == FLATPAK_RUN_CMD: + if client == FLATPAK_RUN_CMD: return 1 - else: + if client == FLATPAK_SANDBOX: return 2 diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index dd5594b..b2843e6 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -19,7 +19,7 @@ from dzgui.api.mods import ( _hash, remove_stale_signatures, ) -from dzgui.const.enum import Preferences, NotebookPage, ButtonType, ContextMenu, RowType +from dzgui.const.enum import Preferences, NotebookPage, ButtonType, ContextMenu from dzgui.const.constants import ( APPID_DAYZ, diff --git a/dzgui/views/components/statusbar.py b/dzgui/views/components/statusbar.py index 73ff261..c66f213 100644 --- a/dzgui/views/components/statusbar.py +++ b/dzgui/views/components/statusbar.py @@ -1,4 +1,3 @@ -from warnings import deprecated from typing import Self, Union, TYPE_CHECKING from dzgui.const.enum import NotebookPage, ServerTab diff --git a/dzgui/views/trees/tree_base.py b/dzgui/views/trees/tree_base.py index f5e44b4..b428d53 100644 --- a/dzgui/views/trees/tree_base.py +++ b/dzgui/views/trees/tree_base.py @@ -1,6 +1,6 @@ import logging -from typing import Callable, TYPE_CHECKING +from typing import TYPE_CHECKING from dzgui.const.constants import SEPARATOR from dzgui.util.keys import is_navkey @@ -52,15 +52,6 @@ class TreeView(CursorMixin, Gtk.TreeView): # type: ignore ) -> None: pass - # @deprecated("signals are localized now") - # 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 From 655bb41f59f0583b800796f79c5a7120b2d47888 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 24 Jan 2026 13:37:32 +0900 Subject: [PATCH 035/309] fix: focus mod tree on init --- dzgui/views/trees/tree_mods.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/dzgui/views/trees/tree_mods.py b/dzgui/views/trees/tree_mods.py index 2ccb85c..358b9d3 100644 --- a/dzgui/views/trees/tree_mods.py +++ b/dzgui/views/trees/tree_mods.py @@ -1,13 +1,11 @@ import logging -from typing import Any, TYPE_CHECKING +from typing import Any, Self, TYPE_CHECKING 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 -from dzgui.const.enum import ( - ContextMenuGroup, -) +from dzgui.const.enum import ContextMenuGroup import gi @@ -54,8 +52,12 @@ class ModTreeView(ModsMixin, ContextMixin, TreeView): 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.connect("map", self._on_map) - # self.s = self.get_selection().get_selected_rows() + def _on_map(self, tree: Self) -> None: + # TODO: placeholder logic + # identify root cause of row unfocus on init + self.set_cursor(0) def _on_mods_keypress(self, widget: Gtk.Widget, event: Gdk.EventKey) -> None: # TODO: multiselect From 6141145fbecf34ed561a0a0f1bf51d5fe13ffa22 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 24 Jan 2026 13:46:56 +0900 Subject: [PATCH 036/309] fix: early abort if ip matches --- dzgui/util/ip.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dzgui/util/ip.py b/dzgui/util/ip.py index a9deccd..ae3f9d8 100644 --- a/dzgui/util/ip.py +++ b/dzgui/util/ip.py @@ -69,6 +69,8 @@ def get_coords(ips: "Path", ip: str) -> Coords: upper = fields[1] upper_digits = ip2list(upper) + if ip == upper: + return Coords(float(fields[-2]), float(fields[-1])) if split[2] > upper_digits[2]: continue @@ -76,7 +78,6 @@ def get_coords(ips: "Path", ip: str) -> Coords: if split[2] == upper_digits[2]: if split[3] > upper_digits[3]: continue - # TODO: handle last digit equals if split[2] <= upper_digits[2]: return Coords(float(fields[-2]), float(fields[-1])) From ab9c97234e11756a40d751f97374ea965e7aa5e5 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 24 Jan 2026 15:56:04 +0900 Subject: [PATCH 037/309] feat: lazy load WIP --- dzgui/api/servers.py | 8 ++-- dzgui/const/constants.py | 2 +- dzgui/controllers/mc.py | 67 ++++++++++++++++++++++++------- dzgui/model/filtered_model.py | 8 +++- dzgui/views/pages/servers.py | 3 +- dzgui/views/trees/tree_servers.py | 28 ++++++++++++- 6 files changed, 94 insertions(+), 22 deletions(-) diff --git a/dzgui/api/servers.py b/dzgui/api/servers.py index f08c29b..e603c1a 100644 --- a/dzgui/api/servers.py +++ b/dzgui/api/servers.py @@ -124,7 +124,7 @@ def parse_json(json: list) -> list: view = strings.filter_3pp if "external" in r: - provider = strings.filter_unoffiical + provider = strings.filter_unofficial else: provider = strings.filter_official @@ -396,16 +396,16 @@ def query_api(key: str, appid: int, param: str) -> Res: "limit": LIMIT, "key": key, } - try: - res = requests.get(STEAM_SERVERS, params=payload, timeout=REQUEST_TIMEOUT) + res = requests.get(STEAM_SERVERS, params=payload, timeout=5) res.raise_for_status() parsed = True + status = 200 data = res.json() except Exception: - status = res.status_code parsed = False data = None + status = 403 finally: return Res(status, parsed, data) diff --git a/dzgui/const/constants.py b/dzgui/const/constants.py index c2c5956..294bc01 100644 --- a/dzgui/const/constants.py +++ b/dzgui/const/constants.py @@ -4,7 +4,7 @@ UDP_PORT = 27016 VM_FILE = "/proc/sys/vm/max_map_count" MIN_COUNT = 1048576 -REQUEST_TIMEOUT = 1 +REQUEST_TIMEOUT = 5 APPNAME_DAYZ = "DayZ" APPNAME_DAYZ_EXP = "DayZ Experimental" diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index b2843e6..f405e15 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -4,10 +4,13 @@ import threading import traceback from warnings import deprecated +from concurrent.futures import wait +from concurrent.futures import ThreadPoolExecutor from pathlib import Path from typing import Any, Callable, Literal, TYPE_CHECKING import dzgui.api.pefile as PeFile +import dzgui.api.servers as Servers import dzgui.util._json as JSON # noqa from dzgui.views.dialogs.generic import ExceptionDialog @@ -353,6 +356,42 @@ class Controller(GObject.GObject): self.mediator.notebook.set_page_by_enum(button.opens) + def _dump_api(self): + key = self.query_config(Preferences.STEAM) + job = Servers.query_api + params = Servers.params + serv = [] + with ThreadPoolExecutor() as executor: + futures = [ + executor.submit(job, key, APPID_DAYZ, param) + for param in params + ] + wait(futures) + for future in futures: + res = future.result() + if res.status != 200 or not res.parsed: + # ModelManager.set_store(None) + # ModelManager.set_success(False) + # GLib.idle_add(self._filter_cleanup) + print("status error") + return + j = res.json + serv += j["response"]["servers"] + + res = Servers.query_api(key, APPID_DAYZ_EXP, "") + if res.status == 200 and res.parsed is True: + j = res.json + serv += j["response"]["servers"] + + parsed = Servers.parse_json(serv) + self.data = parsed + self.get_func_data() + self.destroy_on_idle() + # try: + # except Exception as e: + # print(e) + # return parsed + def get_help_row(self) -> str: tv = self.mediator.menu model = self.get_help_store() @@ -538,20 +577,6 @@ class Controller(GObject.GObject): self.data = ( ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], ["BAR", "a", "a", "a", 1, 1, 1, "172.111.51.156:2302", 0, 0, "a", False], - ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], - ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], - ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], - ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], - ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], - ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], - ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], - ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], - ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], - ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], - ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], - ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], - ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], - ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], ) # TODO: refer to prior implementation--should need to load data into model while in thread self.get_func_data() @@ -562,6 +587,16 @@ class Controller(GObject.GObject): time.sleep(1) self.data = ( ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], + ["BAR", "a", "a", "a", 1, 1, 1, "172.111.51.156:2302", 0, 0, "a", False], + ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], + ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], + ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], + ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], + ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], + ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], + ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], + ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], + ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], ) # TODO: refer to prior implementation--should need to load data into model while in thread self.get_func_data() @@ -572,8 +607,11 @@ class Controller(GObject.GObject): # NOTE: do not insert model until main thread is idle # TODO: grab from model manager and insert entire model # TODO: do not start refresh button count until load finished + # TODO: append rows into filtered model + # when complete, destroy dialog and set model for row in data: manager.append_row(row) + treeview.set_model(manager.get_model()) treeview.set_loaded(True) self.mediator.statusbar.emit("server_page_changed", context) treeview.grab_focus() @@ -699,6 +737,7 @@ class Controller(GObject.GObject): return manager = treeview.get_filter_man() # TODO: this may lag? + treeview.set_model(None) manager.clear_model() self.set_callback(None, None) self.call_on_thread(func) diff --git a/dzgui/model/filtered_model.py b/dzgui/model/filtered_model.py index b5a0f81..7505a89 100644 --- a/dzgui/model/filtered_model.py +++ b/dzgui/model/filtered_model.py @@ -46,17 +46,23 @@ class FilteredModelManager: self.ping_cache: dict[str, int] = {} self.model = self.new_model_from_class(ServerColumns) + self.real_model = self.new_model_from_class(ServerColumns) self.control_model = None self.filtered = None self.success = True def append_row(self, row: list) -> None: - self.model.append(row) + self.real_model.append(row) + # NOTE: append null rows to thin client + self.model.append() def clear_model(self) -> None: self.model.clear() + def get_real_model(self) -> ListStore: + return self.real_model + def get_model(self) -> ListStore: return self.model diff --git a/dzgui/views/pages/servers.py b/dzgui/views/pages/servers.py index 81ad88c..06904fe 100644 --- a/dzgui/views/pages/servers.py +++ b/dzgui/views/pages/servers.py @@ -34,8 +34,9 @@ class ServerNotebook(Gtk.ScrolledWindow): ) self.lan = ServerTreeView(controller, ServerTab.LAN, ContextMenuGroup.SCAN_LAN) - self.browser.set_query_func(self.controller.dump_test_1) + # self.browser.set_query_func(self.controller.dump_test_2) self.lan.set_query_func(self.controller.dump_test_2) + self.browser.set_query_func(self.controller._dump_api) tabs = [ (self.browser, server_labels.browser), diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index 93a6727..7941d16 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -1,6 +1,6 @@ import logging import multiprocessing -from typing import Self +from typing import Any, Self from dzgui.const.enum import ContextMenu, ContextMenuGroup, FilterMode, ServerTab from dzgui.api.servers import Record @@ -76,6 +76,7 @@ class ServerTreeView(TreeView): renderer = Gtk.CellRendererText() column = Gtk.TreeViewColumn(column_title, renderer, text=i) column.set_sizing(Gtk.TreeViewColumnSizing.FIXED) + column.set_cell_data_func(renderer, self._lazy_load, func_data=i) column.set_resizable(True) column.set_sort_column_id(i) @@ -335,6 +336,8 @@ class ServerTreeView(TreeView): print(self.get_value_at_index(0)) def _parent_selection_changed(self, base_class: TreeView, sel: Gtk.TreeSelection): + # TODO: lazy load + # https://stackoverflow.com/questions/3164262/lazy-loaded-list-view-in-gtk self.terminate_process() self.emit("distcalc_started") @@ -348,6 +351,7 @@ class ServerTreeView(TreeView): select = self.get_selection() sels = select.get_selected_rows() (model, pathlist) = sels + # TODO: clean up per lazy load logic if len(pathlist) < 1: return None path = pathlist[0] @@ -355,6 +359,8 @@ class ServerTreeView(TreeView): if not model: return None addr = model[path][7] + if addr is None: + return qport = model[path][8] ip = addr.split(":")[0] gameport = int(addr.split(":")[1]) @@ -365,3 +371,23 @@ class ServerTreeView(TreeView): def set_loaded(self, status: bool) -> None: self.loaded = status + + def _lazy_load( + self, + column: Gtk.TreeViewColumn, + cell: Gtk.CellRendererText, + model: Gtk.TreeModel, + it: Gtk.TreeIter, + col_index: int, + ) -> Any: + # TODO: initially load empty rows + # TODO: if cell is in visible range + path = model.get_path(it) + row_index = path.get_indices()[0] + start, end = self.get_visible_range() + if row_index >= start[0] <= end[0]: + real_model = self.filter_man.get_real_model() + value = real_model[row_index][col_index] + cell.set_property("text", str(value)) + else: + return From e8e78ee6a1c9c58c0bcb421318d6c3517774fdb2 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 24 Jan 2026 17:19:12 +0900 Subject: [PATCH 038/309] fix: handle null results --- dzgui/controllers/mc.py | 82 +++++++++++++------------------ dzgui/model/filtered_model.py | 4 +- dzgui/views/pages/servers.py | 4 +- dzgui/views/trees/tree_base.py | 2 + dzgui/views/trees/tree_servers.py | 20 ++++---- 5 files changed, 52 insertions(+), 60 deletions(-) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index f405e15..4281931 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -12,7 +12,7 @@ from typing import Any, Callable, Literal, TYPE_CHECKING import dzgui.api.pefile as PeFile import dzgui.api.servers as Servers import dzgui.util._json as JSON # noqa -from dzgui.views.dialogs.generic import ExceptionDialog +from dzgui.views.dialogs.generic import ExceptionDialog, WaitDialog from dzgui.api.probe import test_steam_api, test_bm_api from dzgui.api.mods import ( @@ -182,6 +182,10 @@ class Controller(GObject.GObject): def toggle_debug_mode(self) -> None: self.toggle_config(Preferences.DEBUG) + def get_active_context(self) -> NotebookPage: + tv = self.get_active_treeview() + return tv.get_enum() + def get_active_treeview(self) -> "ServerTreeView": return self.mediator.notebook.servers.get_active_treeview() @@ -236,11 +240,10 @@ class Controller(GObject.GObject): return msg_id def set_statusbar_dist(self, haversine: "Haversine", enum: "ServerTab") -> None: - tv = self.get_active_treeview() - context = tv.get_enum() + context = self.get_active_context() page = self.mediator.notebook.get_page_by_enum() """ - NOTE: prevents race condition when notebook page changed, + NOTE: prevents race condition when server tab changed, but allows caching the distance in the background """ if page != NotebookPage.SERVERS: @@ -289,8 +292,6 @@ class Controller(GObject.GObject): mod.append(None) model.append(mod) - # self.update_mod_statusbar() - def toggle_config(self, context: Preferences) -> None: config = self.prefs.paths.config try: @@ -356,7 +357,7 @@ class Controller(GObject.GObject): self.mediator.notebook.set_page_by_enum(button.opens) - def _dump_api(self): + def dump_api(self): key = self.query_config(Preferences.STEAM) job = Servers.query_api params = Servers.params @@ -370,10 +371,7 @@ class Controller(GObject.GObject): for future in futures: res = future.result() if res.status != 200 or not res.parsed: - # ModelManager.set_store(None) - # ModelManager.set_success(False) - # GLib.idle_add(self._filter_cleanup) - print("status error") + self.push_data(None) return j = res.json serv += j["response"]["servers"] @@ -383,14 +381,9 @@ class Controller(GObject.GObject): j = res.json serv += j["response"]["servers"] + # TODO: ping column pass parsed = Servers.parse_json(serv) - self.data = parsed - self.get_func_data() - self.destroy_on_idle() - # try: - # except Exception as e: - # print(e) - # return parsed + self.push_data(parsed) def get_help_row(self) -> str: tv = self.mediator.menu @@ -574,18 +567,16 @@ class Controller(GObject.GObject): import time time.sleep(1) - self.data = ( + data = ( ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], ["BAR", "a", "a", "a", 1, 1, 1, "172.111.51.156:2302", 0, 0, "a", False], ) - # TODO: refer to prior implementation--should need to load data into model while in thread - self.get_func_data() - self.destroy_on_idle() + self.push_data(data) def dump_test_2(self) -> None: import time time.sleep(1) - self.data = ( + data = ( ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], ["BAR", "a", "a", "a", 1, 1, 1, "172.111.51.156:2302", 0, 0, "a", False], ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], @@ -598,37 +589,34 @@ class Controller(GObject.GObject): ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], ) - # TODO: refer to prior implementation--should need to load data into model while in thread - self.get_func_data() - self.destroy_on_idle() + self.push_data(data) - def get_func_data(self) -> None: - def test(): - # NOTE: do not insert model until main thread is idle - # TODO: grab from model manager and insert entire model - # TODO: do not start refresh button count until load finished - # TODO: append rows into filtered model - # when complete, destroy dialog and set model - for row in data: - manager.append_row(row) - treeview.set_model(manager.get_model()) - treeview.set_loaded(True) + def push_data(self, data: tuple) -> None: + def cleanup(): + # TODO: rename signal self.mediator.statusbar.emit("server_page_changed", context) + # TODO: may be superfluous treeview.grab_focus() + self.destroy_on_idle() treeview = self.get_active_treeview() - context = treeview.get_enum() + context = self.get_active_context() - manager = treeview.get_filter_man() - data = self.data - GLib.idle_add(test) + if data is None: + insert = None + else: + manager = treeview.get_filter_man() + for row in data: + manager.append_row(row) + insert = manager.get_model() + treeview.set_model(insert) + treeview.set_loaded(True) + GLib.idle_add(cleanup) def highlight_stale(self) -> None: self.call_on_thread(self.colorize_mods) def call_on_thread(self, func: Callable, *args) -> None: - from dzgui.views.dialogs.generic import WaitDialog - self.wait_dialog = WaitDialog(self, strings.dialog.fetching) self.wait_dialog.show_all() thread = threading.Thread(target=func, args=args) @@ -650,6 +638,8 @@ class Controller(GObject.GObject): def destroy_on_idle(self) -> None: self.wait_dialog.destroy() func = self.get_callback() + self.mediator.window.set_sensitive(True) + # TODO: spawn error dialog if API crawl failed if func is not None: args = self.get_callback_args() GLib.idle_add(func, *args) @@ -735,14 +725,12 @@ class Controller(GObject.GObject): if func is None: self.mediator.statusbar.emit("server_page_changed", treeview.get_enum()) return - manager = treeview.get_filter_man() - # TODO: this may lag? treeview.set_model(None) + manager = treeview.get_filter_man() manager.clear_model() self.set_callback(None, None) self.call_on_thread(func) - def focus_button_box(self) -> None: self.mediator.right_panel.focus_button_box() @@ -791,5 +779,5 @@ class Controller(GObject.GObject): def get_map(self) -> str: self.mediator.filters.get_selected_map() - def get_prio_map(self) -> str: + def get_prior_map(self) -> str: self.mediator.filters.get_prior_map() diff --git a/dzgui/model/filtered_model.py b/dzgui/model/filtered_model.py index 7505a89..c807b75 100644 --- a/dzgui/model/filtered_model.py +++ b/dzgui/model/filtered_model.py @@ -53,9 +53,9 @@ class FilteredModelManager: self.success = True def append_row(self, row: list) -> None: - self.real_model.append(row) + # self.real_model.append(row) # NOTE: append null rows to thin client - self.model.append() + self.model.append(row) def clear_model(self) -> None: self.model.clear() diff --git a/dzgui/views/pages/servers.py b/dzgui/views/pages/servers.py index 06904fe..55589e9 100644 --- a/dzgui/views/pages/servers.py +++ b/dzgui/views/pages/servers.py @@ -34,9 +34,8 @@ class ServerNotebook(Gtk.ScrolledWindow): ) self.lan = ServerTreeView(controller, ServerTab.LAN, ContextMenuGroup.SCAN_LAN) - # self.browser.set_query_func(self.controller.dump_test_2) self.lan.set_query_func(self.controller.dump_test_2) - self.browser.set_query_func(self.controller._dump_api) + self.browser.set_query_func(self.controller.dump_api) tabs = [ (self.browser, server_labels.browser), @@ -92,6 +91,7 @@ class ServerNotebook(Gtk.ScrolledWindow): # TODO: strings text = label.strip("*") self.notebook.set_tab_label_text(child, text) + # NOTE: spawns a thread self.controller.populate_model() def get_active_treeview(self) -> ServerTreeView: diff --git a/dzgui/views/trees/tree_base.py b/dzgui/views/trees/tree_base.py index b428d53..ac1f47b 100644 --- a/dzgui/views/trees/tree_base.py +++ b/dzgui/views/trees/tree_base.py @@ -104,6 +104,8 @@ class TreeView(CursorMixin, Gtk.TreeView): # type: ignore # self.get_selection().select_iter(it) # return True + if self.get_model() is None: + return if len(self.get_model()) < 2: return diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index 7941d16..a8ee840 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -1,6 +1,7 @@ import logging import multiprocessing from typing import Any, Self +from warnings import deprecated from dzgui.const.enum import ContextMenu, ContextMenuGroup, FilterMode, ServerTab from dzgui.api.servers import Record @@ -24,8 +25,6 @@ logger = logging.getLogger(__name__) if TYPE_CHECKING: from dzgui.controllers.mc import Controller -# TODO: restore missing methods - class EnumeratedMenuItem(Gtk.MenuItem): def __init__(self, enum: ContextMenu): @@ -76,7 +75,6 @@ class ServerTreeView(TreeView): renderer = Gtk.CellRendererText() column = Gtk.TreeViewColumn(column_title, renderer, text=i) column.set_sizing(Gtk.TreeViewColumnSizing.FIXED) - column.set_cell_data_func(renderer, self._lazy_load, func_data=i) column.set_resizable(True) column.set_sort_column_id(i) @@ -336,8 +334,6 @@ class ServerTreeView(TreeView): print(self.get_value_at_index(0)) def _parent_selection_changed(self, base_class: TreeView, sel: Gtk.TreeSelection): - # TODO: lazy load - # https://stackoverflow.com/questions/3164262/lazy-loaded-list-view-in-gtk self.terminate_process() self.emit("distcalc_started") @@ -351,7 +347,6 @@ class ServerTreeView(TreeView): select = self.get_selection() sels = select.get_selected_rows() (model, pathlist) = sels - # TODO: clean up per lazy load logic if len(pathlist) < 1: return None path = pathlist[0] @@ -372,6 +367,7 @@ class ServerTreeView(TreeView): def set_loaded(self, status: bool) -> None: self.loaded = status + @deprecated("Currently unused") def _lazy_load( self, column: Gtk.TreeViewColumn, @@ -380,11 +376,17 @@ class ServerTreeView(TreeView): it: Gtk.TreeIter, col_index: int, ) -> Any: - # TODO: initially load empty rows - # TODO: if cell is in visible range + """ + Lazy load contents from model manager into visible CellRenderers on demand + N.B., all row data will be stringified + cf. Gtk.TreeViewColumn.set_cell_data_func() + """ path = model.get_path(it) row_index = path.get_indices()[0] - start, end = self.get_visible_range() + try: + start, end = self.get_visible_range() + except Exception: + return if row_index >= start[0] <= end[0]: real_model = self.filter_man.get_real_model() value = real_model[row_index][col_index] From 5d6a7c9775969396fd2e91e825fd4c0d82467ea3 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 25 Jan 2026 09:52:59 +0900 Subject: [PATCH 039/309] feat: threading decorator --- dzgui/controllers/mc.py | 32 +++++++++++++++++++++-------- dzgui/model/filtered_model.py | 18 ++++++++-------- dzgui/views/components/mod_panel.py | 7 ++++--- dzgui/views/pages/servers.py | 2 +- dzgui/views/trees/tree_servers.py | 5 ++--- 5 files changed, 40 insertions(+), 24 deletions(-) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 4281931..9191cdb 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -95,6 +95,15 @@ class Controller(GObject.GObject): # NOTE: suppress requests until entire UI is loaded self.loaded = False + def call_on_thread(func: Callable) -> Callable: + def wrapper(*args, **kwargs): + self = args[0] + self.wait_dialog = WaitDialog(self, strings.dialog.fetching) + self.wait_dialog.show_all() + thread = threading.Thread(target=func, args=args) + thread.start() + return wrapper + def register_widget(self, attr: str, widget: Gtk.Widget) -> None: try: setattr(self.mediator, attr, widget) @@ -613,14 +622,15 @@ class Controller(GObject.GObject): treeview.set_loaded(True) GLib.idle_add(cleanup) + @call_on_thread def highlight_stale(self) -> None: - self.call_on_thread(self.colorize_mods) + self.colorize_mods() - def call_on_thread(self, func: Callable, *args) -> None: - self.wait_dialog = WaitDialog(self, strings.dialog.fetching) - self.wait_dialog.show_all() - thread = threading.Thread(target=func, args=args) - thread.start() + # def call_on_thread(self, func: Callable, *args) -> None: + # self.wait_dialog = WaitDialog(self, strings.dialog.fetching) + # self.wait_dialog.show_all() + # thread = threading.Thread(target=func, args=args) + # thread.start() def get_callback(self) -> Callable | None: return self.callback["func"] @@ -670,8 +680,9 @@ class Controller(GObject.GObject): dialog = ExceptionDialog(self, strings.api_error) dialog.run() + @call_on_thread def update_api_key(self, text: str, key: Preferences) -> None: - self.call_on_thread(self.test_api_response, text, key) + self.test_api_response(text, key) def set_resolution(self, window: "OuterWindow") -> None: if self.prefs.is_game_mode: @@ -715,6 +726,10 @@ class Controller(GObject.GObject): def get_statusbar(self) -> None: return self.mediator.statusbar + @call_on_thread + def thread_data_func(self, func: Callable) -> None: + func() + def populate_model(self) -> None: treeview = self.get_active_treeview() if treeview.get_loaded() is True: @@ -729,7 +744,8 @@ class Controller(GObject.GObject): manager = treeview.get_filter_man() manager.clear_model() self.set_callback(None, None) - self.call_on_thread(func) + self.thread_data_func(func) + #self.call_on_thread(func) def focus_button_box(self) -> None: self.mediator.right_panel.focus_button_box() diff --git a/dzgui/model/filtered_model.py b/dzgui/model/filtered_model.py index c807b75..10c4e88 100644 --- a/dzgui/model/filtered_model.py +++ b/dzgui/model/filtered_model.py @@ -115,7 +115,7 @@ class FilteredModelManager: row[9] = self.ping_cache[row[7]] if len(rows) > 0: - clone = self.new_model() + clone = self.new_model_from_class(ServerColumns) rows = self.sort_rows(rows) for row in rows: clone.append(row) @@ -243,7 +243,7 @@ class FilteredModelManager: if row[7] == addr and row[8] == qport: self.control_model.remove(row) - self.wipe_cache() + # self.wipe_cache() filters = self.controller.get_filters() refiltered = self.filter_toggle_on(filters) self.set_filtered(refiltered) @@ -276,10 +276,10 @@ class FilteredModelManager: 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 + # 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 diff --git a/dzgui/views/components/mod_panel.py b/dzgui/views/components/mod_panel.py index 109969a..e961759 100644 --- a/dzgui/views/components/mod_panel.py +++ b/dzgui/views/components/mod_panel.py @@ -6,12 +6,14 @@ from dzgui.util.strings import mod_panel from dzgui.views.components.labels import BoldLabel import gi + gi.require_version("Gtk", "3.0") -from gi.repository import Gtk # noqa E402 +from gi.repository import Gtk # noqa E402 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) @@ -61,7 +63,7 @@ class ModSelectionPanel(Gtk.Box): self.swap_sensitive(True) def swap_sensitive(self, state: bool) -> None: - for child in self.extra_panel.get_children(): + for child in self.stale_panel.get_children(): child.set_sensitive(state) for child in self.main_panel.get_children(): if child.enum == ModButton.HIGHLIGHT_STALE: @@ -84,4 +86,3 @@ class ModSelectionPanel(Gtk.Box): self.swap_sensitive(False) case ModButton.SELECT_STALE: self.controller.select_colorized() - diff --git a/dzgui/views/pages/servers.py b/dzgui/views/pages/servers.py index 55589e9..b55b2c5 100644 --- a/dzgui/views/pages/servers.py +++ b/dzgui/views/pages/servers.py @@ -35,7 +35,7 @@ class ServerNotebook(Gtk.ScrolledWindow): self.lan = ServerTreeView(controller, ServerTab.LAN, ContextMenuGroup.SCAN_LAN) self.lan.set_query_func(self.controller.dump_test_2) - self.browser.set_query_func(self.controller.dump_api) + self.browser.set_query_func(self.controller.dump_test_2) tabs = [ (self.browser, server_labels.browser), diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index a8ee840..b17be9f 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -378,7 +378,8 @@ class ServerTreeView(TreeView): ) -> Any: """ Lazy load contents from model manager into visible CellRenderers on demand - N.B., all row data will be stringified + N.B., all row data types will be stringified and must be fetched + from separate model manager, not internally from TreeView cf. Gtk.TreeViewColumn.set_cell_data_func() """ path = model.get_path(it) @@ -391,5 +392,3 @@ class ServerTreeView(TreeView): real_model = self.filter_man.get_real_model() value = real_model[row_index][col_index] cell.set_property("text", str(value)) - else: - return From da8722ec2aa135859a345d6895f654d01604b464 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 25 Jan 2026 10:14:51 +0900 Subject: [PATCH 040/309] feat: filtered model WIP --- dzgui/controllers/mc.py | 21 ++++++++------- dzgui/model/filtered_model.py | 36 ++++++++++++-------------- dzgui/views/components/filter_panel.py | 28 ++++++++------------ dzgui/views/pages/servers.py | 2 +- 4 files changed, 41 insertions(+), 46 deletions(-) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 9191cdb..90064ac 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -22,7 +22,7 @@ from dzgui.api.mods import ( _hash, remove_stale_signatures, ) -from dzgui.const.enum import Preferences, NotebookPage, ButtonType, ContextMenu +from dzgui.const.enum import FilterMode, Preferences, NotebookPage, ButtonType, ContextMenu from dzgui.const.constants import ( APPID_DAYZ, @@ -615,9 +615,12 @@ class Controller(GObject.GObject): insert = None else: manager = treeview.get_filter_man() - for row in data: - manager.append_row(row) + manager.set_control(data) + manager.filter(FilterMode.INITIAL) + #for row in data: + # manager.append_row(row) insert = manager.get_model() + print(insert) treeview.set_model(insert) treeview.set_loaded(True) GLib.idle_add(cleanup) @@ -740,12 +743,12 @@ class Controller(GObject.GObject): if func is None: self.mediator.statusbar.emit("server_page_changed", treeview.get_enum()) return + # TODO: on legacy version, model clearing happens in thread treeview.set_model(None) manager = treeview.get_filter_man() - manager.clear_model() + # manager.clear_model() self.set_callback(None, None) self.thread_data_func(func) - #self.call_on_thread(func) def focus_button_box(self) -> None: self.mediator.right_panel.focus_button_box() @@ -787,13 +790,13 @@ class Controller(GObject.GObject): # TODO: use model manager, map and keyword caches def get_filters(self) -> list: - self.mediator.filters.get_filters() + return self.mediator.filters.get_filters() def get_keyword(self) -> str: - self.mediator.filters.get_keyword_filter() + return self.mediator.filters.get_keyword_filter() def get_map(self) -> str: - self.mediator.filters.get_selected_map() + return self.mediator.filters.get_selected_map() def get_prior_map(self) -> str: - self.mediator.filters.get_prior_map() + return self.mediator.filters.get_prior_map() diff --git a/dzgui/model/filtered_model.py b/dzgui/model/filtered_model.py index 10c4e88..8825aa0 100644 --- a/dzgui/model/filtered_model.py +++ b/dzgui/model/filtered_model.py @@ -42,29 +42,25 @@ class FilteredModelManager: """ def __init__(self, controller: "Controller") -> None: - self.filter_cache: tuple + self.controller = controller + + self.filter_cache = {} self.ping_cache: dict[str, int] = {} - self.model = self.new_model_from_class(ServerColumns) - self.real_model = self.new_model_from_class(ServerColumns) + self.ephemeral_model = self.new_model_from_class(ServerColumns) self.control_model = None self.filtered = None self.success = True def append_row(self, row: list) -> None: - # self.real_model.append(row) - # NOTE: append null rows to thin client - self.model.append(row) + self.ephemeral_model.append(row) def clear_model(self) -> None: - self.model.clear() - - def get_real_model(self) -> ListStore: - return self.real_model + self.ephemeral_model.clear() def get_model(self) -> ListStore: - return self.model + return self.ephemeral_model def new_model_from_class(self, cls: type) -> ListStore: store = ListStore(*[ftype for field, ftype in cls.__annotations__.items()]) @@ -79,7 +75,7 @@ class FilteredModelManager: if filters in self.filter_cache: cache = self.filter_cache[filters] - self.set_store(cache[0]) + self.set_model(cache[0]) self.set_filtered(cache[1]) GLib.idle_add( self.controller.mediator.get_active_treeview()._filter_cleanup @@ -123,8 +119,7 @@ class FilteredModelManager: clone = None self.set_cache(filters, clone, rows) - self.set_store(clone) - GLib.idle_add(self.controller.mediator.get_active_treeview()._filter_cleanup) + self.set_model(clone) def sort_rows(self, rows: list) -> list: rows.sort(key=lambda x: re.sub(r"[^A-Za-z0-9]+", "", x[0].lower())) @@ -261,13 +256,16 @@ class FilteredModelManager: 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_model(self, model: ListStore | None) -> None: + """ + ListStore representation of the model + """ + self.ephemeral_model = model def set_control(self, rows: list) -> None: + """ + Raw representation of the model, no transformations + """ self.control_model = rows def set_success(self, result: bool) -> None: diff --git a/dzgui/views/components/filter_panel.py b/dzgui/views/components/filter_panel.py index 60b381a..e5d87df 100644 --- a/dzgui/views/components/filter_panel.py +++ b/dzgui/views/components/filter_panel.py @@ -1,18 +1,20 @@ import logging from typing import Literal -import gi # noqa E402 -gi.require_version("Gtk", "3.0") -from gi.repository import Gtk, Gdk, Pango, GLib - from dzgui.const.enum import FilterMode from dzgui.const.constants import NO_EXPAND, NO_FILL, NO_PADDING from dzgui.util import strings from dzgui.util.margins import set_surrounding_margins from dzgui.views.components.labels import BoldLabel +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk, Gdk, Pango, GLib # noqa E402 + logger = logging.getLogger(__name__) + class FilterPanel(Gtk.Box): def __init__(self, controller): super().__init__(spacing=6, vexpand=False) @@ -41,7 +43,7 @@ class FilterPanel(Gtk.Box): self.maps_hr = [] self.enabled_filters = dict(self.default_filters) - self.keyword_filter: str + self.keyword_filter = "" self.selected_map: str = strings.all_maps self.prior_map: str = strings.all_maps @@ -79,9 +81,7 @@ class FilterPanel(Gtk.Box): 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 - ) + self.keyword_entry.connect("key-press-event", self._on_keyword_keypress) completion = Gtk.EntryCompletion(inline_completion=True) completion.set_text_column(0) @@ -142,9 +142,7 @@ class FilterPanel(Gtk.Box): state = self.default_filters[label] check.set_active(state) - def _on_map_entry_keypress( - self, entry: Gtk.Entry, event: Gdk.EventKey - ) -> None: + def _on_map_entry_keypress(self, entry: Gtk.Entry, event: Gdk.EventKey) -> None: match event.keyval: case Gdk.KEY_Return: text = entry.get_text() @@ -189,9 +187,7 @@ class FilterPanel(Gtk.Box): view.grab_focus() return False - def _on_keyword_keypress( - self, entry: Gtk.Entry, event: Gdk.EventKey - ) -> bool: + def _on_keyword_keypress(self, entry: Gtk.Entry, event: Gdk.EventKey) -> bool: match event.keyval: case Gdk.KEY_Up: return True @@ -202,9 +198,7 @@ class FilterPanel(Gtk.Box): return True return False - def _on_combo_keypress( - self, combo: Gtk.ComboBox, event: Gdk.EventKey - ) -> bool: + def _on_combo_keypress(self, combo: Gtk.ComboBox, event: Gdk.EventKey) -> bool: match event.keyval: case Gdk.KEY_Down: self.maps_combo.popup() diff --git a/dzgui/views/pages/servers.py b/dzgui/views/pages/servers.py index b55b2c5..55589e9 100644 --- a/dzgui/views/pages/servers.py +++ b/dzgui/views/pages/servers.py @@ -35,7 +35,7 @@ class ServerNotebook(Gtk.ScrolledWindow): self.lan = ServerTreeView(controller, ServerTab.LAN, ContextMenuGroup.SCAN_LAN) self.lan.set_query_func(self.controller.dump_test_2) - self.browser.set_query_func(self.controller.dump_test_2) + self.browser.set_query_func(self.controller.dump_api) tabs = [ (self.browser, server_labels.browser), From 4f04d26231e9a6971e8f9982b0799ca2d217ef0f Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 25 Jan 2026 10:48:57 +0900 Subject: [PATCH 041/309] feat: filtered model WIP --- dzgui/controllers/mc.py | 50 +++++++++++++++++++++----- dzgui/model/filtered_model.py | 9 +++-- dzgui/views/components/filter_panel.py | 4 +-- dzgui/views/trees/tree_servers.py | 4 --- 4 files changed, 48 insertions(+), 19 deletions(-) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 90064ac..09be8ca 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -392,7 +392,7 @@ class Controller(GObject.GObject): # TODO: ping column pass parsed = Servers.parse_json(serv) - self.push_data(parsed) + self.push_data(parsed, FilterMode.INITIAL) def get_help_row(self) -> str: tv = self.mediator.menu @@ -600,7 +600,7 @@ class Controller(GObject.GObject): ) self.push_data(data) - def push_data(self, data: tuple) -> None: + def push_data(self, data: tuple, mode: FilterMode) -> None: def cleanup(): # TODO: rename signal self.mediator.statusbar.emit("server_page_changed", context) @@ -615,12 +615,11 @@ class Controller(GObject.GObject): insert = None else: manager = treeview.get_filter_man() - manager.set_control(data) - manager.filter(FilterMode.INITIAL) - #for row in data: - # manager.append_row(row) + # TODO: consolidate into filter manager + if mode == FilterMode.INITIAL: + manager.set_control(data) + manager.filter(mode) insert = manager.get_model() - print(insert) treeview.set_model(insert) treeview.set_loaded(True) GLib.idle_add(cleanup) @@ -635,6 +634,26 @@ class Controller(GObject.GObject): # thread = threading.Thread(target=func, args=args) # thread.start() + #def filter_cleanup(self) -> None: + # tv = self.get_active_treeview() + # m = tv.filter_man.get_model() + # # TODO: model should not be getting updated in this thread + # # delegate to self.push_data() + # # keeping in mind that server refresh button has to wipe control model + # tv.set_model(m) + + #@call_on_thread + #def filter_threaded(self, mode: FilterMode, label: str) -> None: + # tv = self.get_active_treeview() + # tv.filter_man.filter(mode, label) + # self.set_callback(self.filter_cleanup) + # self.destroy_on_idle() + + #def filter_model(self, mode: FilterMode, label: str) -> None: + # tv = self.get_active_treeview() + # tv.set_model(None) + # self.filter_threaded(mode, label) + def get_callback(self) -> Callable | None: return self.callback["func"] @@ -733,6 +752,21 @@ class Controller(GObject.GObject): def thread_data_func(self, func: Callable) -> None: func() + @call_on_thread + def filter_threaded(self, mode: FilterMode, label: str) -> None: + print(mode) + tv = self.get_active_treeview() + tv.filter_man.filter(mode, label) + self.push_data("", mode) + + def refilter_model(self, mode: FilterMode, label: str) -> None: + print(mode) + tv = self.get_active_treeview() + tv.set_model(None) + self.set_callback(None, None) + self.filter_threaded(mode, label) + + def populate_model(self) -> None: treeview = self.get_active_treeview() if treeview.get_loaded() is True: @@ -745,7 +779,7 @@ class Controller(GObject.GObject): return # TODO: on legacy version, model clearing happens in thread treeview.set_model(None) - manager = treeview.get_filter_man() + # manager = treeview.get_filter_man() # manager.clear_model() self.set_callback(None, None) self.thread_data_func(func) diff --git a/dzgui/model/filtered_model.py b/dzgui/model/filtered_model.py index 8825aa0..f15a77e 100644 --- a/dzgui/model/filtered_model.py +++ b/dzgui/model/filtered_model.py @@ -77,9 +77,9 @@ class FilteredModelManager: cache = self.filter_cache[filters] self.set_model(cache[0]) self.set_filtered(cache[1]) - GLib.idle_add( - self.controller.mediator.get_active_treeview()._filter_cleanup - ) + # GLib.idle_add( + # self.controller.mediator.get_active_treeview()._filter_cleanup + # ) return match mode: @@ -139,8 +139,7 @@ class FilteredModelManager: Multi-filtration for any context starts by narrowing by map """ rows = self.filtered - panel = self.controller.get_map() - sel_map = panel.get_selected_map() + sel_map = self.controller.get_map() if sel_map == "All maps": return rows diff --git a/dzgui/views/components/filter_panel.py b/dzgui/views/components/filter_panel.py index e5d87df..5c07755 100644 --- a/dzgui/views/components/filter_panel.py +++ b/dzgui/views/components/filter_panel.py @@ -246,7 +246,7 @@ class FilterPanel(Gtk.Box): check.set_active(not state) def _on_check_toggled(self, button: Gtk.CheckButton) -> None: - treeview = self.controller.get_active_treeview() + # treeview = self.controller.get_active_treeview() label = button.get_label() state = button.get_active() logger.info(f"User toggled button '{label}' to {state}") @@ -256,7 +256,7 @@ class FilterPanel(Gtk.Box): mode = FilterMode.TOGGLE_OFF self.enabled_filters[label] = state - treeview.filter(mode, label) + self.controller.refilter_model(mode, label) def _on_map_changed(self, combo: Gtk.ComboBox) -> None: treeview = self.controller.get_active_treeview() diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index b17be9f..50e0565 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -116,10 +116,6 @@ class ServerTreeView(TreeView): def distcalc_started(self) -> None: pass - def filter(self, mode: FilterMode, label: str) -> None: - print("TOGGLE:", mode) - print("LABEL: ", label) - def get_filter_man(self) -> FilteredModelManager: return self.filter_man From d2d5fdb59f8b1234330ebc38c2d95c193df824c6 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Mon, 26 Jan 2026 12:28:00 +0900 Subject: [PATCH 042/309] chore: clerical fixes --- dzgui/controllers/mc.py | 32 ++++++++++++++++++-------- dzgui/model/filtered_model.py | 4 ---- dzgui/views/components/filter_panel.py | 3 +-- dzgui/views/trees/tree_servers.py | 3 ++- pyproject.toml | 2 +- 5 files changed, 26 insertions(+), 18 deletions(-) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 09be8ca..367575c 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -380,6 +380,7 @@ class Controller(GObject.GObject): for future in futures: res = future.result() if res.status != 200 or not res.parsed: + # TODO: pop warning dialog self.push_data(None) return j = res.json @@ -600,13 +601,22 @@ class Controller(GObject.GObject): ) self.push_data(data) + def cleanup(self): + treeview = self.get_active_treeview() + context = self.get_active_context() + # TODO: rename signal + self.mediator.statusbar.emit("server_page_changed", context) + # TODO: may be superfluous + treeview.grab_focus() + self.destroy_on_idle() + def push_data(self, data: tuple, mode: FilterMode) -> None: - def cleanup(): - # TODO: rename signal - self.mediator.statusbar.emit("server_page_changed", context) - # TODO: may be superfluous - treeview.grab_focus() - self.destroy_on_idle() + #def cleanup(): + # # TODO: rename signal + # self.mediator.statusbar.emit("server_page_changed", context) + # # TODO: may be superfluous + # treeview.grab_focus() + # self.destroy_on_idle() treeview = self.get_active_treeview() context = self.get_active_context() @@ -618,11 +628,12 @@ class Controller(GObject.GObject): # TODO: consolidate into filter manager if mode == FilterMode.INITIAL: manager.set_control(data) + # TODO: init maps here manager.filter(mode) insert = manager.get_model() treeview.set_model(insert) treeview.set_loaded(True) - GLib.idle_add(cleanup) + GLib.idle_add(self.cleanup) @call_on_thread def highlight_stale(self) -> None: @@ -754,19 +765,19 @@ class Controller(GObject.GObject): @call_on_thread def filter_threaded(self, mode: FilterMode, label: str) -> None: - print(mode) tv = self.get_active_treeview() tv.filter_man.filter(mode, label) self.push_data("", mode) def refilter_model(self, mode: FilterMode, label: str) -> None: - print(mode) tv = self.get_active_treeview() + # TODO: shouldn't empty model be None? + if len(tv.get_model()) == 0: + return tv.set_model(None) self.set_callback(None, None) self.filter_threaded(mode, label) - def populate_model(self) -> None: treeview = self.get_active_treeview() if treeview.get_loaded() is True: @@ -823,6 +834,7 @@ class Controller(GObject.GObject): self.mediator.grid.conpan.set_visible(state) # TODO: use model manager, map and keyword caches + # TODO: model cache that hooks checkbox signal def get_filters(self) -> list: return self.mediator.filters.get_filters() diff --git a/dzgui/model/filtered_model.py b/dzgui/model/filtered_model.py index f15a77e..0f649fd 100644 --- a/dzgui/model/filtered_model.py +++ b/dzgui/model/filtered_model.py @@ -77,9 +77,6 @@ class FilteredModelManager: cache = self.filter_cache[filters] self.set_model(cache[0]) self.set_filtered(cache[1]) - # GLib.idle_add( - # self.controller.mediator.get_active_treeview()._filter_cleanup - # ) return match mode: @@ -242,7 +239,6 @@ class FilteredModelManager: refiltered = self.filter_toggle_on(filters) self.set_filtered(refiltered) self.set_success(True) - GLib.idle_add(self.controller.mediator.get_active_treeview()._filter_cleanup) def convert_model_to_list(self, model: ListStore) -> list: return [[el for el in row] for row in model] diff --git a/dzgui/views/components/filter_panel.py b/dzgui/views/components/filter_panel.py index 5c07755..86ed068 100644 --- a/dzgui/views/components/filter_panel.py +++ b/dzgui/views/components/filter_panel.py @@ -228,8 +228,7 @@ class FilterPanel(Gtk.Box): return logger.info(f"User filtered by keyword '{keyword}'") self.keyword_filter = keyword - treeview = self.controller.get_active_treeview() - treeview.filter(FilterMode.KEYWORD, keyword) + self.controller.refilter_model(FilterMode.KEYWORD, keyword) def _on_button_release(self, window, button) -> Literal[True]: return True diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index 50e0565..a518b94 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -385,6 +385,7 @@ class ServerTreeView(TreeView): except Exception: return if row_index >= start[0] <= end[0]: - real_model = self.filter_man.get_real_model() + # NOTE: fetch raw data rows + real_model = self.filter_man.get_control() value = real_model[row_index][col_index] cell.set_property("text", str(value)) diff --git a/pyproject.toml b/pyproject.toml index 17dfca4..5694ed6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,7 +64,7 @@ build-backend = "setuptools.build_meta" [tool.ruff] line-length = 88 indent-width = 4 -extend-exclude = ["tree_servers.py", "tree_server_mods.py", "dialogs", "right_panel.py", "filter_panel.py", "base.py", "*lib/a2s", "dist.py"] +extend-exclude = ["tree_server_mods.py", "dialogs", "*lib/a2s"] lint.unfixable = ["F401"] [tool.ruff.format] From 8205559f2e0dcaf48956c6fac66b3a12ed559f16 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Mon, 26 Jan 2026 13:28:10 +0900 Subject: [PATCH 043/309] feat: spawn dialog on API timeout --- dzgui/api/servers.py | 6 +++- dzgui/const/constants.py | 2 +- dzgui/controllers/mc.py | 42 +++++--------------------- dzgui/views/components/filter_panel.py | 1 + dzgui/views/dialogs/generic.py | 31 ++++++++++--------- dzgui/views/trees/tree_base.py | 4 +-- 6 files changed, 32 insertions(+), 54 deletions(-) diff --git a/dzgui/api/servers.py b/dzgui/api/servers.py index e603c1a..c31ee5b 100644 --- a/dzgui/api/servers.py +++ b/dzgui/api/servers.py @@ -397,7 +397,11 @@ def query_api(key: str, appid: int, param: str) -> Res: "key": key, } try: - res = requests.get(STEAM_SERVERS, params=payload, timeout=5) + """ + NOTE: the global default timeout is None; + using a low timeout (~5s) sometimes fails + """ + res = requests.get(STEAM_SERVERS, params=payload, timeout=REQUEST_TIMEOUT) res.raise_for_status() parsed = True status = 200 diff --git a/dzgui/const/constants.py b/dzgui/const/constants.py index 294bc01..29d72d5 100644 --- a/dzgui/const/constants.py +++ b/dzgui/const/constants.py @@ -4,7 +4,7 @@ UDP_PORT = 27016 VM_FILE = "/proc/sys/vm/max_map_count" MIN_COUNT = 1048576 -REQUEST_TIMEOUT = 5 +REQUEST_TIMEOUT = 10 APPNAME_DAYZ = "DayZ" APPNAME_DAYZ_EXP = "DayZ Experimental" diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 367575c..1afbc93 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -381,7 +381,7 @@ class Controller(GObject.GObject): res = future.result() if res.status != 200 or not res.parsed: # TODO: pop warning dialog - self.push_data(None) + self.push_data(None, FilterMode.INITIAL) return j = res.json serv += j["response"]["servers"] @@ -609,22 +609,22 @@ class Controller(GObject.GObject): # TODO: may be superfluous treeview.grab_focus() self.destroy_on_idle() + if len(treeview.filter_man.get_model()) == 0: + # TODO: different dialogs for server tab contexts + # TODO: if history/favorites is empty, don't even trigger a call + # TODO: add proper string for this dialog + dialog = ExceptionDialog(self, "API TIMEOUT") + dialog.run() def push_data(self, data: tuple, mode: FilterMode) -> None: - #def cleanup(): - # # TODO: rename signal - # self.mediator.statusbar.emit("server_page_changed", context) - # # TODO: may be superfluous - # treeview.grab_focus() - # self.destroy_on_idle() treeview = self.get_active_treeview() context = self.get_active_context() + manager = treeview.get_filter_man() if data is None: insert = None else: - manager = treeview.get_filter_man() # TODO: consolidate into filter manager if mode == FilterMode.INITIAL: manager.set_control(data) @@ -639,32 +639,6 @@ class Controller(GObject.GObject): def highlight_stale(self) -> None: self.colorize_mods() - # def call_on_thread(self, func: Callable, *args) -> None: - # self.wait_dialog = WaitDialog(self, strings.dialog.fetching) - # self.wait_dialog.show_all() - # thread = threading.Thread(target=func, args=args) - # thread.start() - - #def filter_cleanup(self) -> None: - # tv = self.get_active_treeview() - # m = tv.filter_man.get_model() - # # TODO: model should not be getting updated in this thread - # # delegate to self.push_data() - # # keeping in mind that server refresh button has to wipe control model - # tv.set_model(m) - - #@call_on_thread - #def filter_threaded(self, mode: FilterMode, label: str) -> None: - # tv = self.get_active_treeview() - # tv.filter_man.filter(mode, label) - # self.set_callback(self.filter_cleanup) - # self.destroy_on_idle() - - #def filter_model(self, mode: FilterMode, label: str) -> None: - # tv = self.get_active_treeview() - # tv.set_model(None) - # self.filter_threaded(mode, label) - def get_callback(self) -> Callable | None: return self.callback["func"] diff --git a/dzgui/views/components/filter_panel.py b/dzgui/views/components/filter_panel.py index 86ed068..4a21c22 100644 --- a/dzgui/views/components/filter_panel.py +++ b/dzgui/views/components/filter_panel.py @@ -79,6 +79,7 @@ class FilterPanel(Gtk.Box): self.filters_label = BoldLabel("Filters") self.keyword_entry = Gtk.Entry() + # TODO :strings 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) diff --git a/dzgui/views/dialogs/generic.py b/dzgui/views/dialogs/generic.py index 367b700..b93901b 100644 --- a/dzgui/views/dialogs/generic.py +++ b/dzgui/views/dialogs/generic.py @@ -1,5 +1,5 @@ import textwrap -from typing import Any, Literal, Self, TYPE_CHECKING +from typing import Literal, Self, TYPE_CHECKING from dzgui.const.constants import NO_EXPAND, NO_FILL, EXPAND, FILL from dzgui.const.enum import Popup, ButtonType, NotebookPage @@ -7,6 +7,7 @@ from dzgui.util import strings from dzgui.views.components.buttons import ClipboardButton import gi + gi.require_version("Gtk", "3.0") from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa E402 @@ -29,12 +30,13 @@ if TYPE_CHECKING: class GenericDialog(Gtk.MessageDialog): - def __init__(self, + def __init__( + self, controller: "Controller", text: str, mtype: Gtk.MessageType, buttons: Gtk.ButtonsType, - secondary: str + secondary: str, ) -> None: super().__init__( transient_for=controller.mediator.window, @@ -101,10 +103,10 @@ class WaitDialog(GenericDialog): spinner.start() # FIXME: center on parent window - #self.show_all() + # self.show_all() def _on_dialog_delete( - self, response_id: Gtk.ResponseType, event: Gdk.Event + self, response_id: Gtk.ResponseType, event: Gdk.Event ) -> Literal[True]: """ Prevent manual dialog destruction @@ -143,6 +145,7 @@ class ExceptionDialog(GenericDialog): dialog = ExceptionDialog(Controller, trace) dialog.run() """ + def __init__(self, controller: "Controller", trace: str): super().__init__( controller=controller, @@ -154,15 +157,11 @@ class ExceptionDialog(GenericDialog): # NOTE: box expands to end of content area scrollable = Gtk.ScrolledWindow( - propagate_natural_height=True, - max_content_height=500 + propagate_natural_height=True, max_content_height=500 ) box = Gtk.Box(hexpand=True, vexpand=True, orientation=Gtk.Orientation.VERTICAL) textview = Gtk.TextView( - wrap_mode=Gtk.WrapMode.WORD, - editable=False, - left_margin=10, - right_margin=10 + wrap_mode=Gtk.WrapMode.WORD, editable=False, left_margin=10, right_margin=10 ) textview.set_buffer(Gtk.TextBuffer(text=trace)) box.pack_start(textview, EXPAND, FILL, 10) @@ -176,16 +175,18 @@ class ExceptionDialog(GenericDialog): copy_button = ClipboardButton(controller, trace) self.add_action_widget(copy_button, Gtk.ResponseType.NONE) self.add_button("OK", Gtk.ResponseType.OK) - action_area = self.get_action_area() - self.show_all() + self.show_all() + self.action_area.get_children()[1].grab_focus() self.connect("response", self._on_response) - def _on_response(self, dialog: Self, response: Gtk.ResponseType) -> None: + def _on_response( + self, dialog: Self, response: Gtk.ResponseType + ) -> None | Literal[True]: match response: case Gtk.ResponseType.OK: self.destroy() case Gtk.ResponseType.NONE: - return True # type: ignore + return True case Gtk.ResponseType.DELETE_EVENT: self.destroy() diff --git a/dzgui/views/trees/tree_base.py b/dzgui/views/trees/tree_base.py index ac1f47b..39fdbf5 100644 --- a/dzgui/views/trees/tree_base.py +++ b/dzgui/views/trees/tree_base.py @@ -80,8 +80,7 @@ class TreeView(CursorMixin, Gtk.TreeView): # type: ignore def _on_keypress(self, treeview: Gtk.TreeView, event: Gdk.EventKey) -> None: if is_navkey(event.keyval): - # TODO: if model is None - if len(self.get_model()) < 2: + if self.get_model() is None: return if self.sel_blocked is False: self.controller.suppress_signal( @@ -108,7 +107,6 @@ class TreeView(CursorMixin, Gtk.TreeView): # type: ignore return if len(self.get_model()) < 2: return - if is_navkey(event.keyval): if self.sel_blocked is True: self.controller.suppress_signal( From bb94fbf51b72d57a3f9d5b3718fd053b91b58c7e Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Mon, 2 Feb 2026 16:51:51 +0900 Subject: [PATCH 044/309] feat: add emitter --- dzgui/controllers/mc.py | 46 +++++++++++++++++++++++++ dzgui/views/components/connect_panel.py | 6 ++++ dzgui/views/components/entry.py | 3 ++ dzgui/views/components/filter_panel.py | 4 +++ dzgui/views/trees/tree_servers.py | 12 ++++--- 5 files changed, 66 insertions(+), 5 deletions(-) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 1afbc93..f1f07e1 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -83,6 +83,30 @@ class AppNavigation: logtreeview: "LogTreeView" filters: "FilterPanel" +class Emitter(GObject.GObject): + def __init__(self) -> None: + super().__init__() + + @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object,)) + def widget_changed(self, widget: GObject) -> None: + pass + + @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=()) + def request_keyword_focus(self) -> None: + pass + + @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=()) + def request_maps_focus(self) -> None: + pass + + @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=()) + def request_ip_entry_focus(self) -> None: + pass + + @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=()) + def request_lan_entry_focus(self) -> None: + pass + class Controller(GObject.GObject): def __init__(self) -> None: @@ -95,6 +119,17 @@ class Controller(GObject.GObject): # NOTE: suppress requests until entire UI is loaded self.loaded = False + self.emitter = Emitter() + self.emitter.connect("widget_changed", self.test_emitter) + + def get_emitter(self) -> Emitter: + return self.emitter + + def test_emitter(self, widget: GObject, emitter: Emitter) -> None: + print(type(widget)) + print(type(signal)) + print(f"caught signal from widget: {widget}") + def call_on_thread(func: Callable) -> Callable: def wrapper(*args, **kwargs): self = args[0] @@ -393,8 +428,19 @@ class Controller(GObject.GObject): # TODO: ping column pass parsed = Servers.parse_json(serv) + self.mediator.filters.set_unique_maps(parsed) self.push_data(parsed, FilterMode.INITIAL) + #def set_unique_maps(self, records: list) -> None: + # if len(records) < 1: + # return + # u_maps = set([row[1] for row in records]) + # u_maps = sorted(u_maps) + # for m in u_maps: + # print(m) + # #map_store.append([m]) + # #self.maps_hr.append(m) + def get_help_row(self) -> str: tv = self.mediator.menu model = self.get_help_store() diff --git a/dzgui/views/components/connect_panel.py b/dzgui/views/components/connect_panel.py index dd3b31e..81b206e 100644 --- a/dzgui/views/components/connect_panel.py +++ b/dzgui/views/components/connect_panel.py @@ -20,6 +20,9 @@ class LanPanel(Gtk.Frame): super().__init__(margin_top=10, margin_bottom=5) # TODO: hide lan panel on other tabs + self.controller = controller + self.emitter = controller.get_emitter() + label = BoldLabel(lan_panel.heading) self.set_label_widget(label) @@ -30,6 +33,7 @@ class LanPanel(Gtk.Frame): self.scan = Gtk.Button(label=lan_panel.scan_button) self.entry.connect("string_validated", self._on_port_validated) + self.emitter.connect("request_lan_entry_focus", lambda _: self.entry.grab_focus()) radio1.connect("toggled", self._on_radio_toggled) self.grid = Gtk.Grid(margin=10, vexpand=False, column_spacing=15, row_spacing=5) @@ -91,6 +95,7 @@ class AddPanel(Gtk.Frame): super().__init__(margin_top=10, margin_bottom=5) self.controller = controller + self.emitter = controller.get_emitter() self.classname = "invalid-entry" label = BoldLabel("Connect") @@ -103,6 +108,7 @@ class AddPanel(Gtk.Frame): self.entry = IpEntry(controller) self.entry.connect("string_validated", self._on_ip_validated) + self.emitter.connect("request_ip_entry_focus", lambda _: self.entry.grab_focus()) self.grid = Gtk.Grid(margin=10, vexpand=False, column_spacing=15, row_spacing=5) self.grid.attach(self.entry, 0, 0, COLS, ROWS) diff --git a/dzgui/views/components/entry.py b/dzgui/views/components/entry.py index 37093ea..66ec8a5 100644 --- a/dzgui/views/components/entry.py +++ b/dzgui/views/components/entry.py @@ -47,6 +47,8 @@ class ValidatedEntry(Gtk.Entry): self.set_validation_func(func) self.controller = controller + self.emitter = self.controller.get_emitter() + self.classname = "invalid-entry" self.connect("key-press-event", self._on_entry_keypress) self.connect("changed", self._on_text_changed) @@ -57,6 +59,7 @@ class ValidatedEntry(Gtk.Entry): def mark_valid(self) -> None: self.emit("string_validated", True) + self.emitter.emit("widget_changed", self) remove_class(self, self.classname) def mark_invalid(self) -> None: diff --git a/dzgui/views/components/filter_panel.py b/dzgui/views/components/filter_panel.py index 4a21c22..eacde6c 100644 --- a/dzgui/views/components/filter_panel.py +++ b/dzgui/views/components/filter_panel.py @@ -36,6 +36,7 @@ class FilterPanel(Gtk.Box): self.controller = controller self.controller.register_widget("filters", self) + self.emitter = self.controller.get_emitter() map_store = self.controller.get_map_store() @@ -84,6 +85,9 @@ class FilterPanel(Gtk.Box): self.keyword_entry.connect("activate", self._on_keyword_enter) self.keyword_entry.connect("key-press-event", self._on_keyword_keypress) + self.emitter.connect("request_keyword_focus", lambda _: self.keyword_entry.grab_focus()) + self.emitter.connect("request_maps_focus", lambda _: self.maps_entry.grab_focus()) + completion = Gtk.EntryCompletion(inline_completion=True) completion.set_text_column(0) completion.set_minimum_key_length(1) diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index a518b94..e69b721 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -41,6 +41,7 @@ class ServerTreeView(TreeView): QUEUE_CHECK_DELAY = 200 self.controller = controller + self.emitter = controller.get_emitter() self.enum = enum self.loaded = False @@ -146,6 +147,7 @@ class ServerTreeView(TreeView): def _on_map(self, a) -> None: if self.get_enum() is ServerTab.LAN: + # TODO: use emitter here self.controller.mediator.grid.conpan.lan.set_visible(True) def _on_unmap(self, a) -> None: @@ -248,17 +250,17 @@ class ServerTreeView(TreeView): case Gdk.KEY_r: self.refresh_player_count() case Gdk.KEY_f: - self.controller.mediator.filters.keyword_entry.grab_focus() + self.emitter.emit("request_keyword_focus") case Gdk.KEY_m: - self.controller.mediator.filters.maps_entry.grab_focus() + self.emitter.emit("request_maps_focus") case Gdk.KEY_i: # TODO: - self.controller.mediator.grid.conpan.add_panel.entry.grab_focus() + self.emitter.emit("request_ip_entry_focus") case Gdk.KEY_p: if self.enum is ServerTab.LAN: - self.controller.mediator.grid.conpan.lan.entry.grab_focus() + self.emitter.emit("request_lan_entry_focus") case Gdk.KEY_c: - print("copy to clipboard") + print("UNIMPLEMENTED: copy to clipboard") else: match event.keyval: case Gdk.KEY_l | Gdk.KEY_Right: From 46a215a472c99be1112fcd172f4b9b2debc56b6d Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Mon, 2 Feb 2026 21:35:01 +0900 Subject: [PATCH 045/309] fix: docstrings --- dzgui/controllers/mc.py | 21 ++++++++++----------- dzgui/views/components/right_panel.py | 3 +++ dzgui/views/components/statusbar.py | 1 + dzgui/views/trees/tree_servers.py | 7 +++---- 4 files changed, 17 insertions(+), 15 deletions(-) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index f1f07e1..2f98ca6 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -87,24 +87,29 @@ class Emitter(GObject.GObject): def __init__(self) -> None: super().__init__() - @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object,)) - def widget_changed(self, widget: GObject) -> None: - pass - @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=()) def request_keyword_focus(self) -> None: + """User invoked Ctrl-f keybinding from ServerTreeView""" pass @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=()) def request_maps_focus(self) -> None: + """User invoked Ctrl-m keybinding from ServerTreeView""" pass @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=()) def request_ip_entry_focus(self) -> None: + """User invoked Ctrl-i keybinding from ServerTreeView""" pass @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=()) def request_lan_entry_focus(self) -> None: + """User invoked Ctrl-p keybinding from ServerTreeView""" + pass + + @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=()) + def request_button_box_focus(self) -> None: + """User invoked right movement keybinding from ServerTreeView""" pass @@ -120,16 +125,10 @@ class Controller(GObject.GObject): self.loaded = False self.emitter = Emitter() - self.emitter.connect("widget_changed", self.test_emitter) def get_emitter(self) -> Emitter: return self.emitter - def test_emitter(self, widget: GObject, emitter: Emitter) -> None: - print(type(widget)) - print(type(signal)) - print(f"caught signal from widget: {widget}") - def call_on_thread(func: Callable) -> Callable: def wrapper(*args, **kwargs): self = args[0] @@ -800,7 +799,7 @@ class Controller(GObject.GObject): def populate_model(self) -> None: treeview = self.get_active_treeview() - if treeview.get_loaded() is True: + if treeview.is_loaded() is True: self.mediator.statusbar.emit("server_page_changed", treeview.get_enum()) return diff --git a/dzgui/views/components/right_panel.py b/dzgui/views/components/right_panel.py index 0344e85..de994e2 100644 --- a/dzgui/views/components/right_panel.py +++ b/dzgui/views/components/right_panel.py @@ -21,6 +21,9 @@ class RightPanel(Gtk.Box): self.controller = controller self.controller.register_widget("right_panel", self) + self.emitter = controller.get_emitter() + + emitter.connect("request_button_box_focus", self.focus_button_box) self.button_vbox = ButtonBox(controller) self.filters_vbox = FilterPanel(controller) diff --git a/dzgui/views/components/statusbar.py b/dzgui/views/components/statusbar.py index c66f213..e4a220c 100644 --- a/dzgui/views/components/statusbar.py +++ b/dzgui/views/components/statusbar.py @@ -49,6 +49,7 @@ class Statusbar(Gtk.Grid): self.connect("server_page_changed", self._on_server_page_changed) self.connect("distcalc_ended", self._on_distcalc_ended) + # TODO: move to emitter @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object,)) def server_page_changed(self, tab: ServerTab) -> None: pass diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index e69b721..6b3a5f5 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -254,17 +254,16 @@ class ServerTreeView(TreeView): case Gdk.KEY_m: self.emitter.emit("request_maps_focus") case Gdk.KEY_i: - # TODO: self.emitter.emit("request_ip_entry_focus") case Gdk.KEY_p: if self.enum is ServerTab.LAN: - self.emitter.emit("request_lan_entry_focus") + self.emitter.emit("request_lan_entry_focus", self.enum) case Gdk.KEY_c: print("UNIMPLEMENTED: copy to clipboard") else: match event.keyval: case Gdk.KEY_l | Gdk.KEY_Right: - self.controller.mediator.right_panel.focus_button_box() + self.emitter.emit("request_button_box_focus") case _: self.controller.toggle_check(event) @@ -359,7 +358,7 @@ class ServerTreeView(TreeView): gameport = int(addr.split(":")[1]) return Record(ip, gameport, qport) - def get_loaded(self) -> bool: + def is_loaded(self) -> bool: return self.loaded def set_loaded(self, status: bool) -> None: From 45f1c5d8c6f139fc260300870562c36ab3b2e06d Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Tue, 3 Feb 2026 00:21:52 +0900 Subject: [PATCH 046/309] chore: clear linting errors --- dzgui/api/pefile.py | 10 ++++----- dzgui/api/steam.py | 31 ++++++++++++++------------ dzgui/controllers/mc.py | 16 +++++++++---- dzgui/views/components/filter_panel.py | 15 +++++++++---- dzgui/views/components/right_panel.py | 14 ++++++------ dzgui/views/components/statusbar.py | 12 +++++----- dzgui/views/trees/tree_base.py | 6 ++--- dzgui/views/trees/tree_servers.py | 9 ++++---- 8 files changed, 65 insertions(+), 48 deletions(-) diff --git a/dzgui/api/pefile.py b/dzgui/api/pefile.py index dee55d3..26509ef 100644 --- a/dzgui/api/pefile.py +++ b/dzgui/api/pefile.py @@ -4,7 +4,6 @@ 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 @@ -73,7 +72,7 @@ class PackedData: def unpack(cls, data: BinaryIO): r = [] for key, value in cls.__annotations__.items(): - if value == str: + if type(value) is str: f = data.read(8).rstrip(b"\x00\x00").decode() else: fmt = endian + (value.fmt) @@ -269,6 +268,7 @@ class AppNotInstalledError(Exception): class AppMovedError(Exception): """VDF points to a nonexistent location on disk""" + pass @@ -372,10 +372,7 @@ def get_version(file: Path) -> FileVersion: 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 - ) + total = table.number_of_name_entries + table.number_of_id_entries for entry in range(total): entry = RESOURCE_DIRECTORY_ENTRY.unpack(f) break @@ -423,6 +420,7 @@ def get_pefile_path(steam_path: Path, appid: int) -> Path: 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 diff --git a/dzgui/api/steam.py b/dzgui/api/steam.py index 87691e0..358941d 100644 --- a/dzgui/api/steam.py +++ b/dzgui/api/steam.py @@ -11,12 +11,14 @@ from dzgui.util.bash import concat_bash_args logger = logging.getLogger(__name__) -def query_defunct(): +def query_defunct() -> None: pass + + # TODO: unimplemented -#cf. +# cf. #!/usr/bin/env bash -#query_defunct(){ +# query_defunct(){ # readarray -t modlist <<< "$@" # local max=${#modlist[@]} # concat(){ @@ -41,9 +43,9 @@ def query_defunct(): # | select(.filename|contains("screenshot")|not) # | "\(.file_size) \(.publishedfileid)"') # <<< "$result" awk '{print $2}' -#} +# } # -#query_defunct "3576065083" +# query_defunct "3576065083" def concat_mods(mods: list[str]) -> str: @@ -51,6 +53,7 @@ def concat_mods(mods: list[str]) -> str: 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) -> None: @@ -58,13 +61,13 @@ def connect(addr: str, appid: int, name: str, mods: list) -> None: # TODO: concat_mods(mods): # @;@; params = [ - f"-connect={addr}", - "-nolauncher", - "-nosplash", - "-skipintro", - f"-name={name}", - f"-mod={concat}" - ] + f"-connect={addr}", + "-nolauncher", + "-nosplash", + "-skipintro", + f"-name={name}", + f"-mod={concat}", + ] # TODO: get steam launch command from configs # args = concat_bash_args() # @@ -130,8 +133,8 @@ def gen_shortcut() -> None: show user account name, select outer steam id steam/userdata//config/shortcuts.vdf """ - #STEAMID_MAGIC = 76561197960265728 + # STEAMID_MAGIC = 76561197960265728 # STEAMID_64 - STEAMID_MAGIC = STEAMID32 # or get right-most 32 bits - #STEAMID_64 & 0xFFFFFFFF + # STEAMID_64 & 0xFFFFFFFF pass diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 2f98ca6..bfa3086 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -7,7 +7,7 @@ from warnings import deprecated from concurrent.futures import wait from concurrent.futures import ThreadPoolExecutor from pathlib import Path -from typing import Any, Callable, Literal, TYPE_CHECKING +from typing import Any, Callable, Literal, Union, TYPE_CHECKING import dzgui.api.pefile as PeFile import dzgui.api.servers as Servers @@ -112,6 +112,14 @@ class Emitter(GObject.GObject): """User invoked right movement keybinding from ServerTreeView""" pass + @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=()) + def distcalc_started(self) -> None: + pass + + @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object, object,)) + def distcalc_ended(self, dist: Union[str, None], context: Union["ServerTab", NotebookPage]) -> None: + pass + class Controller(GObject.GObject): def __init__(self) -> None: @@ -400,7 +408,7 @@ class Controller(GObject.GObject): self.mediator.notebook.set_page_by_enum(button.opens) - def dump_api(self): + def dump_api(self) -> None: key = self.query_config(Preferences.STEAM) job = Servers.query_api params = Servers.params @@ -646,7 +654,7 @@ class Controller(GObject.GObject): ) self.push_data(data) - def cleanup(self): + def cleanup(self) -> None: treeview = self.get_active_treeview() context = self.get_active_context() # TODO: rename signal @@ -664,7 +672,7 @@ class Controller(GObject.GObject): def push_data(self, data: tuple, mode: FilterMode) -> None: treeview = self.get_active_treeview() - context = self.get_active_context() + # context = self.get_active_context() manager = treeview.get_filter_man() if data is None: diff --git a/dzgui/views/components/filter_panel.py b/dzgui/views/components/filter_panel.py index eacde6c..c8c8e15 100644 --- a/dzgui/views/components/filter_panel.py +++ b/dzgui/views/components/filter_panel.py @@ -1,5 +1,5 @@ import logging -from typing import Literal +from typing import Literal, TYPE_CHECKING from dzgui.const.enum import FilterMode from dzgui.const.constants import NO_EXPAND, NO_FILL, NO_PADDING @@ -14,9 +14,12 @@ from gi.repository import Gtk, Gdk, Pango, GLib # noqa E402 logger = logging.getLogger(__name__) +if TYPE_CHECKING: + from dzgui.controllers.mc import Controller + class FilterPanel(Gtk.Box): - def __init__(self, controller): + def __init__(self, controller: "Controller") -> None: super().__init__(spacing=6, vexpand=False) self.default_filters = { @@ -85,8 +88,12 @@ class FilterPanel(Gtk.Box): self.keyword_entry.connect("activate", self._on_keyword_enter) self.keyword_entry.connect("key-press-event", self._on_keyword_keypress) - self.emitter.connect("request_keyword_focus", lambda _: self.keyword_entry.grab_focus()) - self.emitter.connect("request_maps_focus", lambda _: self.maps_entry.grab_focus()) + self.emitter.connect( + "request_keyword_focus", lambda _: self.keyword_entry.grab_focus() + ) + self.emitter.connect( + "request_maps_focus", lambda _: self.maps_entry.grab_focus() + ) completion = Gtk.EntryCompletion(inline_completion=True) completion.set_text_column(0) diff --git a/dzgui/views/components/right_panel.py b/dzgui/views/components/right_panel.py index de994e2..9381a8f 100644 --- a/dzgui/views/components/right_panel.py +++ b/dzgui/views/components/right_panel.py @@ -1,19 +1,19 @@ -from typing import Literal, TYPE_CHECKING +from typing import TYPE_CHECKING from dzgui.views.components.buttonbox import ButtonBox from dzgui.views.components.filter_panel import FilterPanel from dzgui.views.components.mod_panel import ModSelectionPanel from dzgui.views.components.buttons import RefreshButton, KeysButton from dzgui.const.constants import NO_EXPAND, NO_FILL, FILL, NO_PADDING -from dzgui.util.strings import atomic_buttons import gi + gi.require_version("Gtk", "3.0") from gi.repository import Gtk, GLib # noqa E402 if TYPE_CHECKING: from dzgui.controllers.mc import Controller - from GLib import SOURCE_REMOVE + class RightPanel(Gtk.Box): def __init__(self, controller: "Controller"): @@ -21,8 +21,8 @@ class RightPanel(Gtk.Box): self.controller = controller self.controller.register_widget("right_panel", self) - self.emitter = controller.get_emitter() + emitter = controller.get_emitter() emitter.connect("request_button_box_focus", self.focus_button_box) self.button_vbox = ButtonBox(controller) @@ -41,13 +41,13 @@ class RightPanel(Gtk.Box): def reinit_maps(self, rows: list) -> None: self.controller.reinit_map_store() # TODO: communicate with controller - #self.controller.clear_map_store() - #map_store.append(["All maps"]) + # self.controller.clear_map_store() + # map_store.append(["All maps"]) self.selected = "All maps" self.filters_vbox.set_unique_maps(rows) # TODO: reference - #def _on_ping_clicked(self, button: Gtk.Button) -> None: + # def _on_ping_clicked(self, button: Gtk.Button) -> None: # block_signals() # def _update_pings(): # # TODO diff --git a/dzgui/views/components/statusbar.py b/dzgui/views/components/statusbar.py index e4a220c..a4e59ec 100644 --- a/dzgui/views/components/statusbar.py +++ b/dzgui/views/components/statusbar.py @@ -20,6 +20,7 @@ class Statusbar(Gtk.Grid): self.controller = controller self.controller.register_widget("statusbar", self) + self.emitter = controller.get_emitter() self.playercount = "" self.statusbar = Gtk.Statusbar() @@ -45,19 +46,17 @@ class Statusbar(Gtk.Grid): controller.mediator.notebook.connect_after( "switch-page", self._on_notebook_page_changed ) - self.connect("server_row_changed", self._on_server_row_changed) - self.connect("server_page_changed", self._on_server_page_changed) + self.emitter.connect("distcalc_started", self._on_distcalc_started) + # TODO: self.connect("distcalc_ended", self._on_distcalc_ended) + self.connect("server_page_changed", self._on_server_page_changed) + # TODO: move to emitter @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object,)) def server_page_changed(self, tab: ServerTab) -> None: pass - @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=()) - def server_row_changed(self) -> None: - pass - @GObject.Signal( flags=GObject.SignalFlags.RUN_LAST, arg_types=( @@ -123,6 +122,7 @@ class Statusbar(Gtk.Grid): self.set_by_context(context, count) tree = self.controller.get_active_treeview() + # TODO: emit page change signal on emitter, treeview catches signal and calls distcalc tree.emit("distcalc_started") def pop(self, context: Union["ServerTab", "NotebookPage"]) -> None: diff --git a/dzgui/views/trees/tree_base.py b/dzgui/views/trees/tree_base.py index 39fdbf5..a39dd91 100644 --- a/dzgui/views/trees/tree_base.py +++ b/dzgui/views/trees/tree_base.py @@ -1,6 +1,6 @@ import logging -from typing import TYPE_CHECKING +from typing import Optional, TYPE_CHECKING from dzgui.const.constants import SEPARATOR from dzgui.util.keys import is_navkey @@ -20,7 +20,7 @@ if TYPE_CHECKING: class TreeView(CursorMixin, Gtk.TreeView): # type: ignore def __init__( - self, controller: "Controller", menu: "ContextMenuGroup" = None + self, controller: "Controller", menu: Optional["ContextMenuGroup"] = None ) -> None: super().__init__( enable_search=False, @@ -147,7 +147,7 @@ class TreeView(CursorMixin, Gtk.TreeView): # type: ignore name = self.get_value_at_index(0) return name - def select_first_row(self): + def select_first_row(self) -> None: sel = self.get_selection() self._on_tree_selection_changed(sel) diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index 6b3a5f5..8543b71 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -3,7 +3,7 @@ import multiprocessing from typing import Any, Self from warnings import deprecated -from dzgui.const.enum import ContextMenu, ContextMenuGroup, FilterMode, ServerTab +from dzgui.const.enum import ContextMenu, ContextMenuGroup, ServerTab from dzgui.api.servers import Record from dzgui.model.filtered_model import FilteredModelManager from dzgui.util.dist import CalcDist @@ -203,13 +203,14 @@ class ServerTreeView(TreeView): self.current_proc.terminate() def _on_distcalc_started(self, treeview: Self): - bar = self.controller.get_statusbar() + # bar = self.controller.get_statusbar() record = self.get_record() if record is None: return - bar.emit("server_row_changed") + self.emitter.emit("distcalc_started") + # bar.emit("server_row_changed") cache = self.controller.get_dist_cache() if record.ip in cache: @@ -297,7 +298,7 @@ class ServerTreeView(TreeView): except AttributeError: pass - mod_context_items = [ContextMenu.OPEN_WORKSHOP, ContextMenu.DELETE_MOD] + # mod_context_items = [ContextMenu.OPEN_WORKSHOP, ContextMenu.DELETE_MOD] # TODO: dynamic menu entries # for row in items: From d361e5d235df80b786eb66f9c6cc4164ecf4f235 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 4 Feb 2026 19:28:21 +0900 Subject: [PATCH 047/309] chore: test commit --- .pre-commit-config.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f46415f..df18b26 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,3 +14,7 @@ repos: name: Copy CHANGELOG.md to package data dir entry: python scripts/copy_changelog.py language: system + - id: untracked + name: Check for untracked files not present in .gitignore + entry: python scripts/untracked.py + language: system From e33ae42526fc7647a881419873b4e5060b84ae64 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 4 Feb 2026 19:30:20 +0900 Subject: [PATCH 048/309] chore: test commit From 6131d66c9a095cb78b5917c870ad7c735b4beed0 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 4 Feb 2026 19:33:13 +0900 Subject: [PATCH 049/309] chore: add untracked crumbs file --- dzgui/views/components/crumbs.py | 52 ++++++++++++++++++++++++++++++++ scripts/untracked.py | 16 ++++++++++ 2 files changed, 68 insertions(+) create mode 100644 dzgui/views/components/crumbs.py create mode 100644 scripts/untracked.py diff --git a/dzgui/views/components/crumbs.py b/dzgui/views/components/crumbs.py new file mode 100644 index 0000000..cd83488 --- /dev/null +++ b/dzgui/views/components/crumbs.py @@ -0,0 +1,52 @@ +from typing import TYPE_CHECKING + +from dzgui.const.enum import NotebookPage +from dzgui.util.strings import label_main_menu +from dzgui.util.format import embolden + +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 + from dzgui.views.base import Notebook + + +class Breadcrumbs(Gtk.Label): + def __init__(self, controller: "Controller") -> None: + super().__init__(halign=Gtk.Align.START) + + crumbs = embolden(label_main_menu) + self.set_markup(crumbs) + + notebook = controller.mediator.notebook + servers = controller.mediator.servers.notebook + notebook.connect_after("switch-page", self._on_notebook_page_changed) + servers.connect_after("switch-page", self._on_server_tab_changed) + + def _on_server_tab_changed( + self, notebook: "Notebook", page: Gtk.Widget, index: int + ) -> None: + label = notebook.get_tab_label_text(page) + self.set_server_crumbs(label) + + def _on_notebook_page_changed( + self, notebook: "Notebook", page: Gtk.Widget, index: int + ) -> None: + enum = notebook.get_page_by_enum() + if enum == NotebookPage.SERVERS: + label = page.get_current_tab_text() + self.set_server_crumbs(label) + else: + crumbs = enum.dict["crumbs"] + self.set_crumbs(crumbs) + + def set_server_crumbs(self, crumbs: str) -> None: + # TODO: strings + self.set_crumbs("Servers > " + crumbs) + + def set_crumbs(self, crumbs: str) -> None: + text = embolden(crumbs) + self.set_markup(text) diff --git a/scripts/untracked.py b/scripts/untracked.py new file mode 100644 index 0000000..89bef95 --- /dev/null +++ b/scripts/untracked.py @@ -0,0 +1,16 @@ +import os +import subprocess +import sys + +cmd = ["git", "ls-files", "--others", "--exclude-standard"] +result = subprocess.Popen( + cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True +) +with open(".gitignore", "r") as f: + ignored = [line.rstrip() for line in f] + +untracked = [os.path.basename(line.rstrip()) for line in result.stdout] +for file in untracked: + if not any(file in item for item in ignored): + print(f"File '{file}' is untracked") + sys.exit(1) From ea841395fc213f72fb8006eb6ab3a5d0d41dbca1 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 4 Feb 2026 19:34:04 +0900 Subject: [PATCH 050/309] chore: add TODOs to readme --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 58c68fc..111e46b 100644 --- a/README.md +++ b/README.md @@ -18,11 +18,11 @@ Refer to the documentation for installation and setup instructions: - [GitHub](https://aclist.github.io/dzgui/index.html) - [Mirror (Codeberg)](https://aclist.codeberg.page) - ![A screenshot of DZGUI](/images/example.png) ## Attribution Geolocation records from [DB-IP](https://db-ip.com) under [CC 4.0 license](https://creativecommons.org/licenses/by/4.0/) -# TODO: mention a2s/dayzquery +# TODO: mention third party libraries +# TODO: mention 'pre-commit install' command From ac7700ec23d92fbdface3f8a1edb695d767c465c Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 7 Feb 2026 16:42:59 +0900 Subject: [PATCH 051/309] feat: dedicated emitter --- dzgui/api/servers.py | 1 - dzgui/const/constants.py | 1 + dzgui/const/enum.py | 8 +-- dzgui/controllers/emitter.py | 61 ++++++++++++++++++++ dzgui/controllers/mc.py | 77 ++------------------------ dzgui/util/strings.py | 5 +- dzgui/views/base.py | 11 +--- dzgui/views/components/buttonbox.py | 6 +- dzgui/views/components/filter_panel.py | 22 ++++++++ dzgui/views/components/right_panel.py | 5 +- dzgui/views/components/statusbar.py | 16 +++++- dzgui/views/mixins/cursor_mixin.py | 3 +- dzgui/views/mixins/help_menu_mixin.py | 13 +++++ dzgui/views/mixins/scrollable_mixin.py | 5 +- dzgui/views/pages/changelog.py | 18 ++++-- dzgui/views/pages/help.py | 6 +- dzgui/views/pages/keys.py | 10 +++- dzgui/views/pages/log.py | 12 +++- dzgui/views/pages/mods.py | 3 + dzgui/views/pages/options.py | 3 + dzgui/views/pages/servers.py | 3 + dzgui/views/pages/thanks.py | 21 ++++++- dzgui/views/trees/tree_base.py | 1 + dzgui/views/trees/tree_servers.py | 8 +-- 24 files changed, 207 insertions(+), 112 deletions(-) create mode 100644 dzgui/controllers/emitter.py create mode 100644 dzgui/views/mixins/help_menu_mixin.py diff --git a/dzgui/api/servers.py b/dzgui/api/servers.py index c31ee5b..f2a3aaf 100644 --- a/dzgui/api/servers.py +++ b/dzgui/api/servers.py @@ -254,7 +254,6 @@ class Record: qport: int -# TODO: refactor this def get_prereqs(ip: str, qport: int) -> Prereqs: try: info = a2s.info((ip, qport)) diff --git a/dzgui/const/constants.py b/dzgui/const/constants.py index 29d72d5..b47a05b 100644 --- a/dzgui/const/constants.py +++ b/dzgui/const/constants.py @@ -58,3 +58,4 @@ LEGACY_IPS_PATH = ".local/share/dzgui/helpers/ips.csv" DEBUG_LOG = f"{APP_NAME}_DEBUG.LOG" SYSTEM_LOG = f"{APP_NAME}_SYSTEM.LOG" +CHANGELOG_PATH = "data/CHANGELOG.md" diff --git a/dzgui/const/enum.py b/dzgui/const/enum.py index 50b1755..490317a 100644 --- a/dzgui/const/enum.py +++ b/dzgui/const/enum.py @@ -110,14 +110,14 @@ class Preferences(EnumWithAttrs): class NotebookPage(EnumWithAttrs): - CHANGELOG = {"crumbs": strings.crumbs.changelog, "statusbar": False} - KEYS = {"crumbs": strings.crumbs.keys, "statusbar": False} - LOG = {"crumbs": strings.crumbs.log, "statusbar": False} + CHANGELOG = {"crumbs": strings.crumbs.changelog, "statusbar": True} + KEYS = {"crumbs": strings.crumbs.keys, "statusbar": True} + LOG = {"crumbs": strings.crumbs.log, "statusbar": True} HELP = {"crumbs": strings.crumbs._help, "statusbar": True} MODS = {"crumbs": strings.crumbs.mods, "statusbar": True} OPTIONS = {"crumbs": strings.crumbs.options, "statusbar": False} SERVERS = {"crumbs": strings.crumbs.servers, "statusbar": True} - THANKS = {"crumbs": strings.crumbs.thanks, "statusbar": False} + THANKS = {"crumbs": strings.crumbs.thanks, "statusbar": True} DEVELOPERS = {"crumbs": strings.crumbs.developers, "statusbar": False} diff --git a/dzgui/controllers/emitter.py b/dzgui/controllers/emitter.py new file mode 100644 index 0000000..6bad77f --- /dev/null +++ b/dzgui/controllers/emitter.py @@ -0,0 +1,61 @@ +from typing import Union, TYPE_CHECKING + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gdk, GObject # noqa E402 + +if TYPE_CHECKING: + from dzgui.const.enum import NotebookPage, ServerTab + +# TODO: rename signals to e.g. maps_keybinding_pressed + +class Emitter(GObject.GObject): + def __init__(self) -> None: + super().__init__() + + @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=()) + def request_keyword_focus(self) -> None: + """User invoked Ctrl-f keybinding from ServerTreeView""" + pass + + @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=()) + def request_maps_focus(self) -> None: + """User invoked Ctrl-m keybinding from ServerTreeView""" + pass + + @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=()) + def request_ip_entry_focus(self) -> None: + """User invoked Ctrl-i keybinding from ServerTreeView""" + pass + + @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=()) + def request_lan_entry_focus(self) -> None: + """User invoked Ctrl-p keybinding from ServerTreeView""" + pass + + @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=()) + def request_button_box_focus(self) -> None: + """User invoked right movement keybinding from ServerTreeView""" + pass + + @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(int,)) + def check_button_pressed(self, keyval: int) -> None: + """User toggled filter panel check button via keyboard""" + pass + + @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=()) + def distcalc_started(self) -> None: + pass + + @GObject.Signal( + flags=GObject.SignalFlags.RUN_LAST, + arg_types=( + object, + object, + ), + ) + def distcalc_ended( + self, dist: Union[str, None], context: Union["ServerTab", "NotebookPage"] + ) -> None: + pass diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index bfa3086..b54fd19 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -12,7 +12,6 @@ from typing import Any, Callable, Literal, Union, TYPE_CHECKING import dzgui.api.pefile as PeFile import dzgui.api.servers as Servers import dzgui.util._json as JSON # noqa -from dzgui.views.dialogs.generic import ExceptionDialog, WaitDialog from dzgui.api.probe import test_steam_api, test_bm_api from dzgui.api.mods import ( @@ -35,6 +34,7 @@ from dzgui.const.constants import ( from dzgui.config import update from dzgui.config.query import lookup from dzgui.config.userprefs import UserPrefs +from dzgui.controllers.emitter import Emitter from dzgui.model.misc_model import ModelManager from dzgui.util import strings from dzgui.util.diag import write_diagnostic @@ -43,7 +43,7 @@ from dzgui.util.localize import number from dzgui.util.open_links import open_workshop_page, open_user_workshop from dzgui.util.format import format_mods, format_player_count from dzgui.util.redact import redact_log - +from dzgui.views.dialogs.generic import ExceptionDialog, WaitDialog from dzgui.views.dialogs.filepicker import FilePicker import gi @@ -83,43 +83,6 @@ class AppNavigation: logtreeview: "LogTreeView" filters: "FilterPanel" -class Emitter(GObject.GObject): - def __init__(self) -> None: - super().__init__() - - @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=()) - def request_keyword_focus(self) -> None: - """User invoked Ctrl-f keybinding from ServerTreeView""" - pass - - @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=()) - def request_maps_focus(self) -> None: - """User invoked Ctrl-m keybinding from ServerTreeView""" - pass - - @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=()) - def request_ip_entry_focus(self) -> None: - """User invoked Ctrl-i keybinding from ServerTreeView""" - pass - - @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=()) - def request_lan_entry_focus(self) -> None: - """User invoked Ctrl-p keybinding from ServerTreeView""" - pass - - @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=()) - def request_button_box_focus(self) -> None: - """User invoked right movement keybinding from ServerTreeView""" - pass - - @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=()) - def distcalc_started(self) -> None: - pass - - @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object, object,)) - def distcalc_ended(self, dist: Union[str, None], context: Union["ServerTab", NotebookPage]) -> None: - pass - class Controller(GObject.GObject): def __init__(self) -> None: @@ -128,11 +91,11 @@ class Controller(GObject.GObject): self.prefs: UserPrefs self.model_man = ModelManager() + self.emitter = Emitter() # NOTE: suppress requests until entire UI is loaded self.loaded = False - self.emitter = Emitter() def get_emitter(self) -> Emitter: return self.emitter @@ -169,8 +132,7 @@ class Controller(GObject.GObject): def terminate_process(self) -> None: # TODO: only used by server table multiprocessing queue - tv = self.get_active_treeview() - tv.terminate_process() + self.get_active_treeview().terminate_process() def get_prefs(self) -> UserPrefs: return self.prefs @@ -626,16 +588,6 @@ class Controller(GObject.GObject): def unselect_all_mods(self) -> None: self.mediator.modtreeview.get_selection().unselect_all() - def dump_test_1(self) -> None: - import time - - time.sleep(1) - data = ( - ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], - ["BAR", "a", "a", "a", 1, 1, 1, "172.111.51.156:2302", 0, 0, "a", False], - ) - self.push_data(data) - def dump_test_2(self) -> None: import time time.sleep(1) @@ -741,6 +693,7 @@ class Controller(GObject.GObject): dialog.run() @call_on_thread + # FIXME: entire process is behind thread, including destroy_on_idle() def update_api_key(self, text: str, key: Preferences) -> None: self.test_api_response(text, key) @@ -825,26 +778,6 @@ class Controller(GObject.GObject): def focus_button_box(self) -> None: self.mediator.right_panel.focus_button_box() - def toggle_check(self, event: Gdk.EventKey) -> None | Literal[False]: - mappings = { - Gdk.KEY_1: 0, - Gdk.KEY_2: 1, - Gdk.KEY_3: 2, - Gdk.KEY_4: 3, - Gdk.KEY_5: 4, - Gdk.KEY_6: 5, - Gdk.KEY_7: 6, - Gdk.KEY_8: 7, - Gdk.KEY_9: 8, - Gdk.KEY_0: 9, - Gdk.KEY_minus: 10, - Gdk.KEY_backslash: 11, - } - if event.keyval not in mappings: - return False - index = mappings[event.keyval] - self.mediator.filters.toggle_check(index) - def get_favorite(self) -> tuple[str, str] | tuple[None, None]: fav = str(self.query_config(Preferences.FAV_LBL)) if len(fav) < 1: diff --git a/dzgui/util/strings.py b/dzgui/util/strings.py index 94529cd..b94eb7c 100644 --- a/dzgui/util/strings.py +++ b/dzgui/util/strings.py @@ -1,5 +1,4 @@ from dataclasses import dataclass - from dzgui.const.constants import SYSTEM_LOG @@ -605,3 +604,7 @@ atomic_buttons = AtomicButton( ) steam_icon_missing="Steam icon not found in IconTheme" + +missing_changelog = "Error: failed to read changelog" +esc_to_return = "Press ESC to return" +question_to_return = "Press ? to return" diff --git a/dzgui/views/base.py b/dzgui/views/base.py index f842202..288407a 100644 --- a/dzgui/views/base.py +++ b/dzgui/views/base.py @@ -191,13 +191,13 @@ class Notebook(ScrollableMixin, Gtk.Notebook): # type: ignore self.help = Help(MainController) self.clog = Changelog(MainController) - self.keys = Keybindings() + self.keys = Keybindings(MainController) self.settings = Options(MainController) self.servers = ServerNotebook(MainController) self.mods = Mods(MainController) - self.thanks = Thanks() + self.thanks = Thanks(MainController) self.log = Log(MainController) self.developers = Developers(MainController) @@ -272,12 +272,7 @@ class Notebook(ScrollableMixin, Gtk.Notebook): # type: ignore if widget is None: return - if widget is self.servers: - MainController.grab_active_treeview() - return - - w = widget.get_children()[0] - w.grab_focus() + widget.grab_content_area() def get_page(self) -> Gtk.Widget | None: ind = self.get_current_page() diff --git a/dzgui/views/components/buttonbox.py b/dzgui/views/components/buttonbox.py index 6b2f99a..e5b2d6f 100644 --- a/dzgui/views/components/buttonbox.py +++ b/dzgui/views/components/buttonbox.py @@ -4,11 +4,13 @@ from dzgui.const.enum import ButtonType from dzgui.const.constants import NO_EXPAND, NO_FILL, NO_PADDING import gi + gi.require_version("Gtk", "3.0") from gi.repository import Gtk, Gdk # noqa E402 logger = logging.getLogger(__name__) + class ContextualButton(Gtk.Button): def __init__(self, label, opens, tooltip, context): super().__init__( @@ -20,6 +22,7 @@ class ContextualButton(Gtk.Button): self.context = context self.opens = opens + class ButtonBox(Gtk.Box): def __init__(self, controller) -> None: super().__init__( @@ -41,7 +44,7 @@ class ButtonBox(Gtk.Box): 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) @@ -52,7 +55,6 @@ class ButtonBox(Gtk.Box): button.connect("clicked", self._on_selection_button_clicked) self.pack_start(button, NO_EXPAND, NO_FILL, NO_PADDING) - def _on_selection_button_clicked(self, button: Gtk.Button) -> None: self.controller.open_page_by_button(button) diff --git a/dzgui/views/components/filter_panel.py b/dzgui/views/components/filter_panel.py index c8c8e15..189db15 100644 --- a/dzgui/views/components/filter_panel.py +++ b/dzgui/views/components/filter_panel.py @@ -16,6 +16,7 @@ logger = logging.getLogger(__name__) if TYPE_CHECKING: from dzgui.controllers.mc import Controller + from dzgui.controllers.emitter import Emitter class FilterPanel(Gtk.Box): @@ -94,6 +95,7 @@ class FilterPanel(Gtk.Box): self.emitter.connect( "request_maps_focus", lambda _: self.maps_entry.grab_focus() ) + self.emitter.connect("check_button_pressed", self.toggle_check_by_key) completion = Gtk.EntryCompletion(inline_completion=True) completion.set_text_column(0) @@ -251,6 +253,26 @@ class FilterPanel(Gtk.Box): def set_active_combo(self, row: int) -> None: self.maps_combo.set_active(row) + def toggle_check_by_key(self, emitter: "Emitter", keyval: int) -> None | Literal[False]: + mappings = { + Gdk.KEY_1: 0, + Gdk.KEY_2: 1, + Gdk.KEY_3: 2, + Gdk.KEY_4: 3, + Gdk.KEY_5: 4, + Gdk.KEY_6: 5, + Gdk.KEY_7: 6, + Gdk.KEY_8: 7, + Gdk.KEY_9: 8, + Gdk.KEY_0: 9, + Gdk.KEY_minus: 10, + Gdk.KEY_backslash: 11, + } + if keyval not in mappings: + return False + index = mappings[keyval] + self.toggle_check(index) + def toggle_check(self, digit: int) -> None: check = self.checks[digit] state = check.get_active() diff --git a/dzgui/views/components/right_panel.py b/dzgui/views/components/right_panel.py index 9381a8f..9e44dba 100644 --- a/dzgui/views/components/right_panel.py +++ b/dzgui/views/components/right_panel.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING +from typing import Optional, TYPE_CHECKING from dzgui.views.components.buttonbox import ButtonBox from dzgui.views.components.filter_panel import FilterPanel @@ -13,6 +13,7 @@ from gi.repository import Gtk, GLib # noqa E402 if TYPE_CHECKING: from dzgui.controllers.mc import Controller + from dzgui.controllers.emitter import Emitter class RightPanel(Gtk.Box): @@ -78,5 +79,5 @@ class RightPanel(Gtk.Box): # thread = threading.Thread(target=_update_pings, args=()) # thread.start() - def focus_button_box(self) -> None: + def focus_button_box(self, emitter: Optional["Emitter"] = None) -> None: self.button_vbox.buttons[0].grab_focus() diff --git a/dzgui/views/components/statusbar.py b/dzgui/views/components/statusbar.py index a4e59ec..95f7130 100644 --- a/dzgui/views/components/statusbar.py +++ b/dzgui/views/components/statusbar.py @@ -1,6 +1,7 @@ from typing import Self, Union, TYPE_CHECKING from dzgui.const.enum import NotebookPage, ServerTab +from dzgui.util.strings import esc_to_return, question_to_return import gi @@ -46,8 +47,8 @@ class Statusbar(Gtk.Grid): controller.mediator.notebook.connect_after( "switch-page", self._on_notebook_page_changed ) - self.emitter.connect("distcalc_started", self._on_distcalc_started) # TODO: + self.emitter.connect("distcalc_started", lambda _: self.spinner.start()) self.connect("distcalc_ended", self._on_distcalc_ended) self.connect("server_page_changed", self._on_server_page_changed) @@ -77,10 +78,19 @@ class Statusbar(Gtk.Grid): enum = notebook.get_page_by_enum() show_statusbar = enum.dict["statusbar"] + if show_statusbar is False: self.set_by_context(enum, "") return + if enum in ( + NotebookPage.THANKS, + NotebookPage.CHANGELOG, + NotebookPage.LOG, + ): + self.set_by_context(enum, esc_to_return) + return + match enum: case NotebookPage.MODS: bar = self.controller.format_mod_statusbar() @@ -89,6 +99,8 @@ class Statusbar(Gtk.Grid): case NotebookPage.SERVERS: self.emit("server_page_changed", ServerTab.BROWSER) return + case NotebookPage.KEYS: + bar = question_to_return self.set_by_context(enum, bar) @@ -122,7 +134,7 @@ class Statusbar(Gtk.Grid): self.set_by_context(context, count) tree = self.controller.get_active_treeview() - # TODO: emit page change signal on emitter, treeview catches signal and calls distcalc + # TODO: emit page change signal on emitter, treeview catches signal and calls distcalc directly tree.emit("distcalc_started") def pop(self, context: Union["ServerTab", "NotebookPage"]) -> None: diff --git a/dzgui/views/mixins/cursor_mixin.py b/dzgui/views/mixins/cursor_mixin.py index ea61d62..dd7d2ac 100644 --- a/dzgui/views/mixins/cursor_mixin.py +++ b/dzgui/views/mixins/cursor_mixin.py @@ -2,6 +2,7 @@ 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 @@ -20,7 +21,7 @@ class CursorMixin: case Gdk.KEY_l | Gdk.KEY_Right: if event.state is Gdk.ModifierType.CONTROL_MASK: return - self.controller.mediator.right_panel.focus_button_box() + self.emitter.emit("request_button_box_focus") case _: return False diff --git a/dzgui/views/mixins/help_menu_mixin.py b/dzgui/views/mixins/help_menu_mixin.py new file mode 100644 index 0000000..c55ca03 --- /dev/null +++ b/dzgui/views/mixins/help_menu_mixin.py @@ -0,0 +1,13 @@ +from typing import Literal + +from dzgui.const.enum import NotebookPage + +import gi +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk, Gdk # noqa + +class HelpMenuMixin: + def _on_esc_keypress(self, widget: Gtk.Widget, event: Gdk.EventKey) -> Literal[True]: + if event.keyval == Gdk.KEY_Escape: + self.controller.open_page(NotebookPage.HELP) + return True diff --git a/dzgui/views/mixins/scrollable_mixin.py b/dzgui/views/mixins/scrollable_mixin.py index 4d9e406..775a522 100644 --- a/dzgui/views/mixins/scrollable_mixin.py +++ b/dzgui/views/mixins/scrollable_mixin.py @@ -2,9 +2,11 @@ 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 Gtk, Gdk # noqa + class ScrollableMixin: def _on_keypress(self, widget: Gtk.Widget, event: Gdk.EventKey) -> None: match event.keyval: @@ -16,6 +18,8 @@ class ScrollableMixin: self._set_adjustment(VAdjustment.TOP) case Gdk.KEY_G: self._set_adjustment(VAdjustment.BOTTOM) + case Gdk.KEY_l | Gdk.KEY_Right: + self.controller.get_emitter().emit("request_button_box_focus") def _set_adjustment(self, adjustment: VAdjustment) -> None: vadj = self.get_vadjustment() @@ -29,4 +33,3 @@ class ScrollableMixin: case VAdjustment.DOWN: adj = vadj.get_value() + SCROLL_INCREMENT vadj.set_value(adj) - diff --git a/dzgui/views/pages/changelog.py b/dzgui/views/pages/changelog.py index 4ba2437..d429ad8 100644 --- a/dzgui/views/pages/changelog.py +++ b/dzgui/views/pages/changelog.py @@ -3,31 +3,35 @@ import logging from typing import TYPE_CHECKING from importlib import resources -from dzgui.const.constants import APP_NAME_LOWER +from dzgui.const.constants import APP_NAME_LOWER, CHANGELOG_PATH +from dzgui.util.strings import missing_changelog from dzgui.util.format import format_pango +from dzgui.views.mixins.help_menu_mixin import HelpMenuMixin from dzgui.views.mixins.scrollable_mixin import ScrollableMixin import gi + gi.require_version("Gtk", "3.0") from gi.repository import Gtk # noqa E402 logger = logging.getLogger(__name__) - if TYPE_CHECKING: from dzgui.controllers.mc import Controller -class Changelog(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore + +class Changelog(HelpMenuMixin, ScrollableMixin, Gtk.ScrolledWindow): # type: ignore def __init__(self, controller: "Controller"): super().__init__(propagate_natural_width=False) try: - changelog = resources.read_text(APP_NAME_LOWER, "data/CHANGELOG.md") + changelog = resources.read_text(APP_NAME_LOWER, CHANGELOG_PATH) except OSError as e: logger.critical(e) - changelog = "Error: Failed to read changelog" + changelog = missing_changelog # TODO: should long text be wrapped? + self.controller = controller formatted = format_pango(changelog) self.changelog_label = Gtk.Label(valign=Gtk.Align.START, margin=15) self.changelog_label.set_markup(formatted) @@ -36,3 +40,7 @@ class Changelog(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore self.add(self.box) self.connect("key-press-event", self._on_keypress) + self.connect("key-press-event", self._on_esc_keypress) + + def grab_content_area(self) -> None: + self.grab_focus() diff --git a/dzgui/views/pages/help.py b/dzgui/views/pages/help.py index 5e4d8a5..531dcc9 100644 --- a/dzgui/views/pages/help.py +++ b/dzgui/views/pages/help.py @@ -18,7 +18,11 @@ class Help(Gtk.ScrolledWindow): self.controller = controller - self.treeview.focus_first_row() + # FIXME: remember focused row + #self.treeview.focus_first_row() def get_treeview(self) -> MenuTreeView: return self.treeview + + def grab_content_area(self) -> None: + self.treeview.grab_focus() diff --git a/dzgui/views/pages/keys.py b/dzgui/views/pages/keys.py index 9fa357b..46ef945 100644 --- a/dzgui/views/pages/keys.py +++ b/dzgui/views/pages/keys.py @@ -1,3 +1,5 @@ +from typing import TYPE_CHECKING + from dzgui.views.mixins.scrollable_mixin import ScrollableMixin from dzgui.util.strings import navigation, servers, vim, key_header, key_contexts from dzgui.util.css import add_class @@ -6,15 +8,18 @@ import gi gi.require_version("Gtk", "3.0") from gi.repository import Gtk, Gdk # noqa E402 +if TYPE_CHECKING: + from dzgui.controllers.mc import Controller class Keybindings(ScrollableMixin, Gtk.ScrolledWindow): """ Notebook page holding a prearranged grid of keybindings and their descriptions """ - def __init__(self) -> None: + def __init__(self, controller: "Controller") -> None: super().__init__() + self.controller = controller self.box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) label = Gtk.Label(label=key_header) @@ -101,3 +106,6 @@ class Keybindings(ScrollableMixin, Gtk.ScrolledWindow): 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 + + def grab_content_area(self) -> None: + self.box.grab_focus() diff --git a/dzgui/views/pages/log.py b/dzgui/views/pages/log.py index 057ab23..b9a4343 100644 --- a/dzgui/views/pages/log.py +++ b/dzgui/views/pages/log.py @@ -1,14 +1,19 @@ from typing import TYPE_CHECKING +from dzgui.views.mixins.cursor_mixin import CursorMixin +from dzgui.views.mixins.help_menu_mixin import HelpMenuMixin from dzgui.views.trees.tree_log import LogTreeView + 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 Log(Gtk.ScrolledWindow): + +class Log(CursorMixin, HelpMenuMixin, Gtk.ScrolledWindow): def __init__(self, controller: "Controller") -> None: super().__init__() self.treeview = LogTreeView(controller) @@ -17,5 +22,10 @@ class Log(Gtk.ScrolledWindow): self.controller = controller self.controller.register_widget("logtreeview", self.treeview) + self.connect("key-press-event", self._on_esc_keypress) + def get_treeview(self) -> LogTreeView: return self.treeview + + def grab_content_area(self) -> None: + self.treeview.grab_focus() diff --git a/dzgui/views/pages/mods.py b/dzgui/views/pages/mods.py index 7947a6d..9ac5767 100644 --- a/dzgui/views/pages/mods.py +++ b/dzgui/views/pages/mods.py @@ -28,3 +28,6 @@ class Mods(Gtk.ScrolledWindow): def _on_map(self, widget: Self) -> None: self.controller.toggle_mod_panel(True) + + def grab_content_area(self) -> None: + self.tree.grab_focus() diff --git a/dzgui/views/pages/options.py b/dzgui/views/pages/options.py index 2183cab..7342e0e 100644 --- a/dzgui/views/pages/options.py +++ b/dzgui/views/pages/options.py @@ -524,3 +524,6 @@ class Options(Gtk.Box): icon, state = VIEW_CONCEAL, True widget.set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, icon) widget.set_visibility(state) + + def grab_content_area(self) -> None: + return diff --git a/dzgui/views/pages/servers.py b/dzgui/views/pages/servers.py index 55589e9..23c739a 100644 --- a/dzgui/views/pages/servers.py +++ b/dzgui/views/pages/servers.py @@ -71,6 +71,9 @@ class ServerNotebook(Gtk.ScrolledWindow): self.notebook.prev_page() case _: return + self.grab_content_area() + + def grab_content_area(self) -> None: self.get_active_treeview().grab_focus() def get_current_tab_text(self) -> None: diff --git a/dzgui/views/pages/thanks.py b/dzgui/views/pages/thanks.py index e59b807..4adc39c 100644 --- a/dzgui/views/pages/thanks.py +++ b/dzgui/views/pages/thanks.py @@ -1,26 +1,37 @@ +from typing import TYPE_CHECKING + from dzgui.util.format import format_pango from dzgui.util.strings import thanks +from dzgui.views.mixins.help_menu_mixin import HelpMenuMixin from dzgui.views.mixins.scrollable_mixin import ScrollableMixin import gi + gi.require_version("Gtk", "3.0") from gi.repository import Gtk # noqa E402 # TODO: wrap and center long values +if TYPE_CHECKING: + from dzgui.controllers.mc import Controller -class Thanks(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore + +class Thanks(HelpMenuMixin, ScrollableMixin, Gtk.ScrolledWindow): # type: ignore """ Special thanks page recognizing contributors to the project in alpha-order """ - def __init__(self) -> None: + + def __init__(self, controller: "Controller") -> None: super().__init__() + self.controller = controller self.box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) label = format_pango(thanks.header) header = Gtk.Label() header.set_markup(label) - description = Gtk.Label(label=thanks.description, justify=Gtk.Justification.CENTER) + description = Gtk.Label( + label=thanks.description, justify=Gtk.Justification.CENTER + ) users = [f"- {user}" for user in sorted(thanks.users, key=str.lower)] pretty_users = "\n".join(users) @@ -32,3 +43,7 @@ class Thanks(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore self.add(self.box) self.connect("key-press-event", self._on_keypress) + self.connect("key-press-event", self._on_esc_keypress) + + def grab_content_area(self) -> None: + self.grab_focus() diff --git a/dzgui/views/trees/tree_base.py b/dzgui/views/trees/tree_base.py index a39dd91..3663157 100644 --- a/dzgui/views/trees/tree_base.py +++ b/dzgui/views/trees/tree_base.py @@ -32,6 +32,7 @@ class TreeView(CursorMixin, Gtk.TreeView): # type: ignore self.controller = controller self.sel_blocked = False self.set_row_separator_func(self._separate) + self.emitter = self.controller.get_emitter() self.selected_row = self.get_selection() self.selected_row.set_mode(Gtk.SelectionMode.SINGLE) diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index 8543b71..b62e8a8 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -203,14 +203,11 @@ class ServerTreeView(TreeView): self.current_proc.terminate() def _on_distcalc_started(self, treeview: Self): - # bar = self.controller.get_statusbar() - record = self.get_record() if record is None: return self.emitter.emit("distcalc_started") - # bar.emit("server_row_changed") cache = self.controller.get_dist_cache() if record.ip in cache: @@ -242,8 +239,6 @@ class ServerTreeView(TreeView): def _on_server_keypress( self, treeview: Gtk.TreeView, event: Gdk.EventKey ) -> bool | None: - # TODO: use mixins - # CONTROL_MASK + KEY_l if event.state is Gdk.ModifierType.CONTROL_MASK: match event.keyval: case Gdk.KEY_l: @@ -266,7 +261,7 @@ class ServerTreeView(TreeView): case Gdk.KEY_l | Gdk.KEY_Right: self.emitter.emit("request_button_box_focus") case _: - self.controller.toggle_check(event) + self.emitter.emit("check_button_pressed", event.keyval) def set_context_menu(self, items: ContextMenuGroup) -> None: # TODO: if debug is on, add raw command copy to context menu @@ -286,7 +281,6 @@ class ServerTreeView(TreeView): # TODO: use ContextMixin if event.type is Gdk.EventType.BUTTON_RELEASE and event.button != 3: return - try: pathinfo = self.get_path_at_pos(int(event.x), int(event.y)) if pathinfo is None: From 56b32a004867648e218fdb57906a1fb6c1595374 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 7 Feb 2026 17:19:44 +0900 Subject: [PATCH 052/309] feat: add navigable context menu to mixins --- dzgui/controllers/mc.py | 17 ++++- dzgui/views/mixins/context_mixin.py | 40 +++++++++-- dzgui/views/trees/tree_mods.py | 30 ++++----- dzgui/views/trees/tree_servers.py | 100 +++++----------------------- 4 files changed, 83 insertions(+), 104 deletions(-) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index b54fd19..38e9ed6 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -434,6 +434,20 @@ class Controller(GObject.GObject): text = "\n".join(final) return text + def copy_ip(self, path: Gtk.TreePath) -> None: + treeview = self.get_active_treeview() + model = treeview.get_model() + if not model: + return None + addr = model[path][7] + if addr is None: + return + qport = model[path][8] + ip = addr.split(":")[0] + gameport = int(addr.split(":")[1]) + record = Servers.Record(ip, gameport, qport) + self.copy_clipboard(f"{record.ip}:{record.qport}") + def copy_clipboard(self, text: str) -> None: self.clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) self.clipboard.set_text(text, -1) @@ -503,8 +517,9 @@ class Controller(GObject.GObject): case ContextMenu.ADD_NOTE: pass case ContextMenu.COPY_CLIPBOARD: - pass + self.copy_ip(path) case ContextMenu.COPY_NAME: + self.copy_name(path) pass case ContextMenu.REFRESH_PLAYERS: pass diff --git a/dzgui/views/mixins/context_mixin.py b/dzgui/views/mixins/context_mixin.py index ca021bd..4e389bc 100644 --- a/dzgui/views/mixins/context_mixin.py +++ b/dzgui/views/mixins/context_mixin.py @@ -1,6 +1,7 @@ import logging -from dzgui.views.trees.tree_base import TreeView from dzgui.const.enum import ContextMenu +from dzgui.util.keys import is_navkey +from dzgui.views.trees.tree_base import TreeView import gi @@ -21,7 +22,7 @@ class ContextMixin(TreeView): if self.is_selection_empty(): return - if event.type is Gdk.EventType.BUTTON_PRESS: + if event.type is Gdk.EventType.BUTTON_PRESS and event.button == 3: try: pathinfo = self.get_path_at_pos(int(event.x), int(event.y)) if pathinfo is None: @@ -41,6 +42,8 @@ class ContextMixin(TreeView): group = self.menu self.context_menu = Gtk.Menu() + self.context_menu.connect("key-press-event", self._on_key) + for row in group.value: item = Gtk.MenuItem(label=row.dict["label"]) item.connect("activate", self._on_menu_click, row) @@ -63,11 +66,11 @@ class ContextMixin(TreeView): 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 + ModTreeView 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. + is used per below. """ if enum == ContextMenu.COPY_LOG_CLIPBOARD: model, records = self.get_selection().get_selected_rows() @@ -76,3 +79,32 @@ class ContextMixin(TreeView): else: path = self.get_focused_row_path() self.controller.menu_action(enum, path) + + def _on_key(self, menu: Gtk.Menu, event: Gdk.EventKey) -> bool | None: + if not is_navkey(event.keyval): + return False + menu = self.context_menu + sel = menu.get_selected_item() + children = menu.get_children() + for i, child in enumerate(children): + if sel is child: + ind = i + break + + match event.keyval: + case Gdk.KEY_j: + if ind == len(children) - 1: + return True + menu.select_item(children[ind + 1]) + case Gdk.KEY_k: + if ind - 1 < 0: + return True + menu.select_item(children[ind - 1]) + case Gdk.KEY_g: + menu.select_item(children[0]) + case Gdk.KEY_G: + ind = len(children) - 1 + menu.select_item(children[ind]) + case _: + return False + return True diff --git a/dzgui/views/trees/tree_mods.py b/dzgui/views/trees/tree_mods.py index 358b9d3..12d7656 100644 --- a/dzgui/views/trees/tree_mods.py +++ b/dzgui/views/trees/tree_mods.py @@ -50,28 +50,28 @@ class ModTreeView(ModsMixin, ContextMixin, TreeView): 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.connect("button-press-event", self.present_menu) + self.connect("key-press-event", self.present_menu) self.connect("map", self._on_map) def _on_map(self, tree: Self) -> None: - # TODO: placeholder logic + # FIXME: placeholder logic # identify root cause of row unfocus on init self.set_cursor(0) - def _on_mods_keypress(self, widget: Gtk.Widget, event: Gdk.EventKey) -> None: - # TODO: multiselect - # if event.keyval is Gdk.KEY_space: - # it = self.get_focused_row_iter() - # self.get_selection().select_iter(it) - # path = self.get_focused_row_path() - # self.set_cursor(path) - # return False - self.present_menu(widget, event) + # def _on_mods_keypress(self, widget: Gtk.Widget, event: Gdk.EventKey) -> None: + # # TODO: multiselect + # # if event.keyval is Gdk.KEY_space: + # # it = self.get_focused_row_iter() + # # self.get_selection().select_iter(it) + # # path = self.get_focused_row_path() + # # self.set_cursor(path) + # # return False + # 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 _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 diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index b62e8a8..77ea3ab 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -3,11 +3,11 @@ import multiprocessing from typing import Any, Self from warnings import deprecated -from dzgui.const.enum import ContextMenu, ContextMenuGroup, ServerTab +from dzgui.views.mixins.context_mixin import ContextMixin +from dzgui.const.enum import ContextMenuGroup, ServerTab from dzgui.api.servers import Record from dzgui.model.filtered_model import FilteredModelManager from dzgui.util.dist import CalcDist -from dzgui.util.keys import is_navkey from dzgui.util import strings from typing import Callable, Literal, TYPE_CHECKING @@ -26,17 +26,11 @@ if TYPE_CHECKING: from dzgui.controllers.mc import Controller -class EnumeratedMenuItem(Gtk.MenuItem): - def __init__(self, enum: ContextMenu): - super().__init__(label=enum.dict["label"]) - self.enum = enum - - -class ServerTreeView(TreeView): +class ServerTreeView(ContextMixin, TreeView): def __init__( self, controller: "Controller", enum: ServerTab, menu: ContextMenuGroup ) -> None: - super().__init__(controller) + super().__init__(controller, menu=ContextMenuGroup.SERVER_BROWSER) QUEUE_CHECK_DELAY = 200 @@ -51,10 +45,6 @@ class ServerTreeView(TreeView): model = self.filter_man.get_model() self.set_model(model) - self.menu = Gtk.Menu() - self.menu.connect("key-press-event", self._on_key) - - self.set_context_menu(menu) self.set_fixed_height_mode(True) self.set_headers_visible(True) @@ -104,13 +94,15 @@ class ServerTreeView(TreeView): self.append_column(column) self.connect("distcalc_started", self._on_distcalc_started) - self.connect("button-release-event", self._on_server_button_release) self.connect("key-press-event", self._on_server_keypress) self.connect("generic_row_activated", self._parent_row_activated) self.connect("generic_treesel_changed", self._parent_selection_changed) self.connect("map", self._on_map) self.connect("unmap", self._on_unmap) + self.connect("key-press-event", self.present_menu) + self.connect("button-press-event", self.present_menu) + GLib.timeout_add(QUEUE_CHECK_DELAY, self._check_result_queue) @GObject.Signal(flags=GObject.SignalFlags.RUN_FIRST, arg_types=()) @@ -154,35 +146,6 @@ class ServerTreeView(TreeView): if self.get_enum() is ServerTab.LAN: self.controller.mediator.grid.conpan.lan.set_visible(False) - def _on_key(self, menu: Gtk.Menu, event: Gdk.EventKey) -> bool | None: - # TODO: abstract as navigable context menu - if not is_navkey(event.keyval): - return False - sel = menu.get_selected_item() - children = menu.get_children() - for i, child in enumerate(children): - if sel is child: - ind = i - break - - match event.keyval: - case Gdk.KEY_j: - if ind == len(children) - 1: - return True - menu.select_item(children[ind + 1]) - case Gdk.KEY_k: - if ind - 1 < 0: - return True - menu.select_item(children[ind - 1]) - case Gdk.KEY_g: - menu.select_item(children[0]) - case Gdk.KEY_G: - ind = len(children) - 1 - menu.select_item(children[ind]) - case _: - return False - return True - def set_query_func(self, func: Callable) -> None: self.query_func = func @@ -241,8 +204,6 @@ class ServerTreeView(TreeView): ) -> bool | None: if event.state is Gdk.ModifierType.CONTROL_MASK: match event.keyval: - case Gdk.KEY_l: - self._on_server_button_release(self, event) case Gdk.KEY_r: self.refresh_player_count() case Gdk.KEY_f: @@ -255,7 +216,8 @@ class ServerTreeView(TreeView): if self.enum is ServerTab.LAN: self.emitter.emit("request_lan_entry_focus", self.enum) case Gdk.KEY_c: - print("UNIMPLEMENTED: copy to clipboard") + record = self.get_record() + self.controller.copy_ip(record) else: match event.keyval: case Gdk.KEY_l | Gdk.KEY_Right: @@ -263,37 +225,7 @@ class ServerTreeView(TreeView): case _: self.emitter.emit("check_button_pressed", event.keyval) - def set_context_menu(self, items: ContextMenuGroup) -> None: - # TODO: if debug is on, add raw command copy to context menu - for item in items.value: - menu_item = EnumeratedMenuItem(item) - menu_item.connect("activate", self._on_menu_click) - self.menu.append(menu_item) - self.menu.show_all() - - def _on_menu_click(self, item) -> None: - print(f"UNIMPLEMENTED: {item.enum}") - pass - - def _on_server_button_release( - self, widget: Gtk.Widget, event: Gdk.EventButton - ) -> None: - # TODO: use ContextMixin - if event.type is Gdk.EventType.BUTTON_RELEASE and event.button != 3: - return - 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 - # mod_context_items = [ContextMenu.OPEN_WORKSHOP, ContextMenu.DELETE_MOD] - # TODO: dynamic menu entries # for row in items: # if row == ContextMenu.ADD_SERVER: @@ -310,13 +242,13 @@ class ServerTreeView(TreeView): # if self.get_record_string() in notes_cache: # item.set_label(strings.edit_note) - if event.type is Gdk.EventType.KEY_PRESS and event.keyval is Gdk.KEY_l: - if self.is_selection_empty(): - return - self.menu.popup_at_widget(widget, Gdk.Gravity.CENTER, Gdk.Gravity.WEST) - else: - self.menu.popup_at_pointer(event) - self.menu.select_first(False) + # if event.type is Gdk.EventType.KEY_PRESS and event.keyval is Gdk.KEY_l: + # if self.is_selection_empty(): + # return + # self.menu.popup_at_widget(widget, Gdk.Gravity.CENTER, Gdk.Gravity.WEST) + # else: + # self.menu.popup_at_pointer(event) + # self.menu.select_first(False) def _parent_row_activated( self, tree: TreeView, path: Gtk.TreePath, column: Gtk.TreeViewColumn From 045f242e09e9c6a95372285795bf25cba75559bb Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 7 Feb 2026 17:42:15 +0900 Subject: [PATCH 053/309] feat: toggle panels on signal --- dzgui/controllers/emitter.py | 4 +++ dzgui/controllers/mc.py | 42 +++++++++++++++++------------ dzgui/views/base.py | 26 +++++++++--------- dzgui/views/mixins/context_mixin.py | 4 ++- dzgui/views/pages/mods.py | 1 + dzgui/views/pages/servers.py | 6 +++-- 6 files changed, 49 insertions(+), 34 deletions(-) diff --git a/dzgui/controllers/emitter.py b/dzgui/controllers/emitter.py index 6bad77f..aa23bef 100644 --- a/dzgui/controllers/emitter.py +++ b/dzgui/controllers/emitter.py @@ -42,6 +42,10 @@ class Emitter(GObject.GObject): @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(int,)) def check_button_pressed(self, keyval: int) -> None: """User toggled filter panel check button via keyboard""" + + @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(bool,)) + def server_page_toggled(self, keyval: int) -> None: + """Triggered on map/unmap signal from NotebookPage.SERVERS. Shows/hides grid panels.""" pass @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=()) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 38e9ed6..210afa4 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -336,13 +336,6 @@ class Controller(GObject.GObject): notebook = self.mediator.grid.notebook notebook.focus_current() - def toggle_server_panels(self, state: bool) -> None: - # TODO: this is going to be signal dependent now, cf. map/unmap - #self.mediator.grid.emit("toggle panels", state) - self.mediator.grid.toggle_filter_panel(state) - self.mediator.grid.toggle_connect_panel(state) - self.mediator.grid.toggle_refresh_button(state) - def toggle_mod_panel(self, state: bool) -> None: self.mediator.grid.right_panel.sel_panel.set_visible(state) @@ -434,18 +427,33 @@ class Controller(GObject.GObject): text = "\n".join(final) return text - def copy_ip(self, path: Gtk.TreePath) -> None: + def get_col_value_by_path_index(self, path: Gtk.TreePath, index: int) -> Any: treeview = self.get_active_treeview() model = treeview.get_model() - if not model: + if model is None: return None - addr = model[path][7] - if addr is None: + value = model[path][index] + return value + + def copy_name(self, path: Gtk.TreePath) -> None: + # TODO: column values are deterministic, perhaps use a col name to index map + name = self.get_col_value_by_path_index(path, 0) + if name is None: return - qport = model[path][8] - ip = addr.split(":")[0] - gameport = int(addr.split(":")[1]) - record = Servers.Record(ip, gameport, qport) + self.copy_clipboard(name) + + def copy_ip(self, path: Gtk.TreePath) -> None: + treeview = self.get_active_treeview() + record = treeview.get_record() + # TODO: maybe delegate to filter man + #addr = self.get_col_value_by_path_index(path, 7) + #if addr is None: + # return + #qport = self.get_col_value_by_path_index(path, 8) + ## TODO: abstract get record function + #ip = addr.split(":")[0] + #gameport = int(addr.split(":")[1]) + #record = Servers.Record(ip, gameport, qport) self.copy_clipboard(f"{record.ip}:{record.qport}") def copy_clipboard(self, text: str) -> None: @@ -520,7 +528,6 @@ class Controller(GObject.GObject): self.copy_ip(path) case ContextMenu.COPY_NAME: self.copy_name(path) - pass case ContextMenu.REFRESH_PLAYERS: pass case ContextMenu.REMOVE_HISTORY: @@ -535,8 +542,9 @@ class Controller(GObject.GObject): # NOTE: manipulates mod store case ContextMenu.DELETE_MOD: self.delete_single_mod(path) - # TODO: hook treeview changed signal automatically + # TODO: connect to emitter automatically # Gtk.TreeModel, row-inserted/row-deleted + # updates statusbar self.update_mod_statusbar() remove_stale_signatures( self.prefs.paths.config, self.prefs.paths.version diff --git a/dzgui/views/base.py b/dzgui/views/base.py index 288407a..9ef205b 100644 --- a/dzgui/views/base.py +++ b/dzgui/views/base.py @@ -4,16 +4,18 @@ import warnings from typing import TYPE_CHECKING, Literal -from dzgui.const.enum import NotebookPage +# TODO: import notebook only and add components there? from dzgui.const.constants import APP_NAME, APP_NAME_LOWER, STEAM_ICON +from dzgui.const.enum import NotebookPage +from dzgui.controllers.emitter import Emitter from dzgui.controllers.mc import Controller from dzgui.util import css, strings - -# NOTEBOOK ITEMS -# TODO: import notebook only and add components there? -from dzgui.views.pages.changelog import Changelog from dzgui.views.components.connect_panel import ConnectPanel from dzgui.views.components.crumbs import Breadcrumbs +from dzgui.views.components.right_panel import RightPanel +from dzgui.views.components.statusbar import Statusbar +from dzgui.views.mixins.scrollable_mixin import ScrollableMixin +from dzgui.views.pages.changelog import Changelog from dzgui.views.pages.devs import Developers from dzgui.views.pages.help import Help from dzgui.views.pages.keys import Keybindings @@ -23,10 +25,6 @@ from dzgui.views.pages.options import Options from dzgui.views.pages.servers import ServerNotebook from dzgui.views.pages.thanks import Thanks -from dzgui.views.components.statusbar import Statusbar -from dzgui.views.components.right_panel import RightPanel -from dzgui.views.mixins.scrollable_mixin import ScrollableMixin - if TYPE_CHECKING: from dzgui.config.userprefs import UserPrefs @@ -309,6 +307,8 @@ class Grid(Gtk.Grid): # self.bu.connect("clicked", self._shrink) # self.crumb_box.add(self.bu) + self.emitter = MainController.get_emitter() + self.notebook = Notebook() self.conpan = ConnectPanel(MainController) @@ -344,17 +344,15 @@ class Grid(Gtk.Grid): self.show_all() + self.emitter.connect("server_page_toggled", self.toggle_filter_panels) + def _shrink(self, button: Gtk.Button) -> None: tv = MainController.get_active_treeview() tv.shrink_to_fit() - def toggle_filter_panel(self, state: bool) -> None: + def toggle_filter_panels(self, emitter: "Emitter", state: bool) -> None: self.right_panel.filters_vbox.set_visible(state) - - def toggle_connect_panel(self, state: bool) -> None: self.conpan.set_visible(state) - - def toggle_refresh_button(self, state: bool) -> None: self.right_panel.refresh_button.set_visible(state) diff --git a/dzgui/views/mixins/context_mixin.py b/dzgui/views/mixins/context_mixin.py index 4e389bc..4cd12b0 100644 --- a/dzgui/views/mixins/context_mixin.py +++ b/dzgui/views/mixins/context_mixin.py @@ -22,7 +22,9 @@ class ContextMixin(TreeView): if self.is_selection_empty(): return - if event.type is Gdk.EventType.BUTTON_PRESS and event.button == 3: + if event.type is Gdk.EventType.BUTTON_PRESS: + if event.button != 3: + return try: pathinfo = self.get_path_at_pos(int(event.x), int(event.y)) if pathinfo is None: diff --git a/dzgui/views/pages/mods.py b/dzgui/views/pages/mods.py index 9ac5767..6d0d4a5 100644 --- a/dzgui/views/pages/mods.py +++ b/dzgui/views/pages/mods.py @@ -23,6 +23,7 @@ class Mods(Gtk.ScrolledWindow): self.connect("map", self._on_map) self.connect("unmap", self._on_unmap) + # TODO: use emitter def _on_unmap(self, widget: Self) -> None: self.controller.toggle_mod_panel(False) diff --git a/dzgui/views/pages/servers.py b/dzgui/views/pages/servers.py index 23c739a..f23ab1a 100644 --- a/dzgui/views/pages/servers.py +++ b/dzgui/views/pages/servers.py @@ -22,6 +22,8 @@ class ServerNotebook(Gtk.ScrolledWindow): super().__init__() self.controller = controller + self.emitter = self.controller.get_emitter() + self.controller.register_widget("servers", self) self.notebook = Gtk.Notebook(show_tabs=True) @@ -56,10 +58,10 @@ class ServerNotebook(Gtk.ScrolledWindow): self.connect("unmap", self._on_unmap) def _on_map(self, widget: Self) -> None: - self.controller.toggle_server_panels(True) + self.emitter.emit("server_page_toggled", True) def _on_unmap(self, widget: Self) -> None: - self.controller.toggle_server_panels(False) + self.emitter.emit("server_page_toggled", False) def _on_keypress(self, widget: Self, event: Gdk.EventKey) -> None: match event.keyval: From 6fc348ea3486f5562220b785c6632510bc865d3f Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 7 Feb 2026 18:49:06 +0900 Subject: [PATCH 054/309] fix: signal emission --- dzgui/controllers/emitter.py | 8 ++++ dzgui/controllers/mc.py | 70 ++++++++++++++++------------- dzgui/init/dayz.py | 1 + dzgui/main.py | 2 +- dzgui/views/components/statusbar.py | 17 +++---- dzgui/views/dialogs/early_alert.py | 18 +++++++- dzgui/views/dialogs/generic.py | 16 ++++++- dzgui/views/trees/tree_servers.py | 13 +++--- 8 files changed, 89 insertions(+), 56 deletions(-) diff --git a/dzgui/controllers/emitter.py b/dzgui/controllers/emitter.py index aa23bef..292f613 100644 --- a/dzgui/controllers/emitter.py +++ b/dzgui/controllers/emitter.py @@ -48,10 +48,18 @@ class Emitter(GObject.GObject): """Triggered on map/unmap signal from NotebookPage.SERVERS. Shows/hides grid panels.""" pass + @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=()) + def statusbar_loaded(self) -> None: + pass + @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=()) def distcalc_started(self) -> None: pass + @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object,)) + def servers_loaded(self, tag: "ServerTab") -> None: + pass + @GObject.Signal( flags=GObject.SignalFlags.RUN_LAST, arg_types=( diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 210afa4..1e4ad79 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -2,6 +2,7 @@ import logging import shutil import threading import traceback +from typing import Optional from warnings import deprecated from concurrent.futures import wait @@ -189,8 +190,6 @@ class Controller(GObject.GObject): widget.handler_block_by_func(func) else: widget.handler_unblock_by_func(func) - # TODO: deprecated? - # self.mediator.menu.sel_blocked = state def toggle_debug_mode(self) -> None: self.toggle_config(Preferences.DEBUG) @@ -377,8 +376,11 @@ class Controller(GObject.GObject): for future in futures: res = future.result() if res.status != 200 or not res.parsed: + # NOTE: this is happening in a thread, need to exit first + # TODO: set internal state and pass failure flag # TODO: pop warning dialog - self.push_data(None, FilterMode.INITIAL) + print("failed to parse/timeout error") + self.push_data(None, success=False) return j = res.json serv += j["response"]["servers"] @@ -391,7 +393,7 @@ class Controller(GObject.GObject): # TODO: ping column pass parsed = Servers.parse_json(serv) self.mediator.filters.set_unique_maps(parsed) - self.push_data(parsed, FilterMode.INITIAL) + self.push_data(parsed, FilterMode.INITIAL, success=True) #def set_unique_maps(self, records: list) -> None: # if len(records) < 1: @@ -615,10 +617,10 @@ class Controller(GObject.GObject): import time time.sleep(1) data = ( - ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], - ["BAR", "a", "a", "a", 1, 1, 1, "172.111.51.156:2302", 0, 0, "a", False], - ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], - ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], + ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 1, 1, "a", False], + ["BAR", "a", "a", "a", 1, 1, 1, "172.111.51.156:2302", 1, 1, "a", False], + ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 1, 1, "a", False], + ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 1, 1, "a", False], ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], @@ -627,39 +629,47 @@ class Controller(GObject.GObject): ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], ) - self.push_data(data) + self.push_data(data, FilterMode.INITIAL, success=True) def cleanup(self) -> None: + # TODO: if success failed, throw popup treeview = self.get_active_treeview() context = self.get_active_context() # TODO: rename signal - self.mediator.statusbar.emit("server_page_changed", context) - # TODO: may be superfluous + self.emitter.emit("servers_loaded", context) treeview.grab_focus() self.destroy_on_idle() - if len(treeview.filter_man.get_model()) == 0: + if treeview.get_filter_man().get_model() is None: + # FIXME: this may not indicate failure, just empty results # TODO: different dialogs for server tab contexts # TODO: if history/favorites is empty, don't even trigger a call # TODO: add proper string for this dialog - dialog = ExceptionDialog(self, "API TIMEOUT") - dialog.run() + # TODO: this is a placeholder string + # TODO: only pop this is success was failure + if self.success is False: + dialog = ExceptionDialog(self, "API TIMEOUT") + dialog.run() - def push_data(self, data: tuple, mode: FilterMode) -> None: + def push_data(self, data: tuple, mode: Optional[FilterMode], success: bool) -> None: treeview = self.get_active_treeview() # context = self.get_active_context() manager = treeview.get_filter_man() + # TODO: + self.success = success - if data is None: - insert = None - else: - # TODO: consolidate into filter manager - if mode == FilterMode.INITIAL: - manager.set_control(data) - # TODO: init maps here - manager.filter(mode) - insert = manager.get_model() - treeview.set_model(insert) + if success: + if data is None: + insert = None + else: + # TODO: consolidate into filter manager + if mode == FilterMode.INITIAL: + manager.set_control(data) + # TODO: init maps here + manager.filter(mode) + # FIXME: inserting none may cause problems if this was a simple refresh action + insert = manager.get_model() + treeview.set_model(insert) treeview.set_loaded(True) GLib.idle_add(self.cleanup) @@ -775,7 +785,7 @@ class Controller(GObject.GObject): def refilter_model(self, mode: FilterMode, label: str) -> None: tv = self.get_active_treeview() # TODO: shouldn't empty model be None? - if len(tv.get_model()) == 0: + if tv.get_model() is None: return tv.set_model(None) self.set_callback(None, None) @@ -784,12 +794,12 @@ class Controller(GObject.GObject): def populate_model(self) -> None: treeview = self.get_active_treeview() if treeview.is_loaded() is True: - self.mediator.statusbar.emit("server_page_changed", treeview.get_enum()) + self.emitter.emit("servers_loaded", treeview.get_enum()) return func = treeview.get_query_func() if func is None: - self.mediator.statusbar.emit("server_page_changed", treeview.get_enum()) + self.emitter.emit("servers_loaded", treeview.get_enum()) return # TODO: on legacy version, model clearing happens in thread treeview.set_model(None) @@ -812,10 +822,6 @@ class Controller(GObject.GObject): def get_dist_cache(self) -> dict[str, "Haversine"]: return self.dist_cache - # TODO: deprecated in favor of map/unmap - def toggle_lan_panel(self, state: bool) -> None: - self.mediator.grid.conpan.set_visible(state) - # TODO: use model manager, map and keyword caches # TODO: model cache that hooks checkbox signal def get_filters(self) -> list: diff --git a/dzgui/init/dayz.py b/dzgui/init/dayz.py index 1816693..7db9e16 100644 --- a/dzgui/init/dayz.py +++ b/dzgui/init/dayz.py @@ -12,6 +12,7 @@ import dzgui.api.pefile as PeFile logger = logging.getLogger(__name__) + def is_dayz_installed(config: Path) -> None: try: path = lookup(config, Preferences.DEFAULT) diff --git a/dzgui/main.py b/dzgui/main.py index 34d3122..8b2d154 100644 --- a/dzgui/main.py +++ b/dzgui/main.py @@ -32,7 +32,7 @@ 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 +from dzgui.views.dialogs.early_alert import EarlyAlertDialog, EarlyIgnoreDialog logger = logging.getLogger(__name__) parser = argparse.ArgumentParser(description=flags.description) diff --git a/dzgui/views/components/statusbar.py b/dzgui/views/components/statusbar.py index 95f7130..09892fe 100644 --- a/dzgui/views/components/statusbar.py +++ b/dzgui/views/components/statusbar.py @@ -49,15 +49,10 @@ class Statusbar(Gtk.Grid): ) # TODO: self.emitter.connect("distcalc_started", lambda _: self.spinner.start()) + self.emitter.connect("servers_loaded", self._on_servers_loaded) + self.connect("distcalc_ended", self._on_distcalc_ended) - self.connect("server_page_changed", self._on_server_page_changed) - - # TODO: move to emitter - @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object,)) - def server_page_changed(self, tab: ServerTab) -> None: - pass - @GObject.Signal( flags=GObject.SignalFlags.RUN_LAST, arg_types=( @@ -97,7 +92,7 @@ class Statusbar(Gtk.Grid): case NotebookPage.HELP: bar = self.controller.get_help_row() case NotebookPage.SERVERS: - self.emit("server_page_changed", ServerTab.BROWSER) + # self.emit("server_page_changed", ServerTab.BROWSER) return case NotebookPage.KEYS: bar = question_to_return @@ -128,14 +123,12 @@ class Statusbar(Gtk.Grid): def append_distance(self, dist: str) -> str: return f"{self.playercount} | Distance: {dist}" - def _on_server_page_changed(self, statusbar: Self, context: "ServerTab") -> None: + def _on_servers_loaded(self, statusbar: Self, context: "ServerTab") -> None: count = self.controller.get_player_count() self.playercount = count self.set_by_context(context, count) - tree = self.controller.get_active_treeview() - # TODO: emit page change signal on emitter, treeview catches signal and calls distcalc directly - tree.emit("distcalc_started") + self.emitter.emit("statusbar_loaded") def pop(self, context: Union["ServerTab", "NotebookPage"]) -> None: cid = self.statusbar.get_context_id(str(context)) diff --git a/dzgui/views/dialogs/early_alert.py b/dzgui/views/dialogs/early_alert.py index f4de721..3d68925 100644 --- a/dzgui/views/dialogs/early_alert.py +++ b/dzgui/views/dialogs/early_alert.py @@ -27,10 +27,24 @@ class EarlyAlertDialog(Gtk.MessageDialog): self.set_default_size(250, 100) - self.connect("response", self._on_response) + abort = self.get_widget_for_response(Gtk.ResponseType.OK) + abort.set_label("Exit") + self.connect("response", self._on_response) self.run() self.destroy() def _on_response(self, dialog: Self, response: Gtk.ResponseType) -> None: - sys.exit(1) + match response: + case Gtk.ResponseType.OK: + sys.exit(1) + case Gtk.ResponseType.CANCEL: + return + +class EarlyIgnoreDialog(EarlyAlertDialog): + def __init__(self, string: str) -> None: + super().__init__(string=string) + + # TODO: reverse order + self.add_button("Ignore", Gtk.ResponseType.CANCEL) + diff --git a/dzgui/views/dialogs/generic.py b/dzgui/views/dialogs/generic.py index b93901b..ef91879 100644 --- a/dzgui/views/dialogs/generic.py +++ b/dzgui/views/dialogs/generic.py @@ -62,6 +62,7 @@ class GenericDialog(Gtk.MessageDialog): def _on_response(self, dialog: Self, response: Gtk.ResponseType) -> None: self.destroy() + return response class ConfirmationDialog(GenericDialog): @@ -74,6 +75,19 @@ class ConfirmationDialog(GenericDialog): secondary=secondary, ) +class IgnoreDialog(GenericDialog): + def __init__(self, controller: "Controller", secondary: str): + super().__init__( + controller=controller, + text=strings.confirm, + mtype=Gtk.MessageType.QUESTION, + buttons=Gtk.ButtonsType.OK_CANCEL, + secondary=secondary, + ) + cancel = self.get_widget_for_response(Gtk.ResponseType.CANCEL) + cancel.set_label("Ignore") + + class NotifyDialog(GenericDialog): def __init__(self, controller: "Controller", secondary: str): @@ -81,7 +95,7 @@ class NotifyDialog(GenericDialog): controller=controller, text=strings.notice, mtype=Gtk.MessageType.INFO, - buttons=Gtk.ButtonsType.OK, + buttons=Gtk.ButtonsType.OK_CANCEL, secondary=secondary, ) diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index 77ea3ab..1cc87a6 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -24,6 +24,7 @@ logger = logging.getLogger(__name__) if TYPE_CHECKING: from dzgui.controllers.mc import Controller + from dzgui.controllers.emitter import Emitter class ServerTreeView(ContextMixin, TreeView): @@ -93,7 +94,6 @@ class ServerTreeView(ContextMixin, TreeView): column.connect("notify::fixed-width", self._on_col_width_changed) self.append_column(column) - self.connect("distcalc_started", self._on_distcalc_started) self.connect("key-press-event", self._on_server_keypress) self.connect("generic_row_activated", self._parent_row_activated) self.connect("generic_treesel_changed", self._parent_selection_changed) @@ -103,12 +103,10 @@ class ServerTreeView(ContextMixin, TreeView): self.connect("key-press-event", self.present_menu) self.connect("button-press-event", self.present_menu) + self.emitter.connect("statusbar_loaded", self._on_distcalc_started) + self.emitter.connect("distcalc_started", self._on_distcalc_started) GLib.timeout_add(QUEUE_CHECK_DELAY, self._check_result_queue) - @GObject.Signal(flags=GObject.SignalFlags.RUN_FIRST, arg_types=()) - def distcalc_started(self) -> None: - pass - def get_filter_man(self) -> FilteredModelManager: return self.filter_man @@ -165,12 +163,11 @@ class ServerTreeView(ContextMixin, TreeView): if self.current_proc and self.current_proc.is_alive(): self.current_proc.terminate() - def _on_distcalc_started(self, treeview: Self): + def _on_distcalc_started(self, emitter: "Emitter"): record = self.get_record() if record is None: return - self.emitter.emit("distcalc_started") cache = self.controller.get_dist_cache() if record.ip in cache: @@ -259,7 +256,7 @@ class ServerTreeView(ContextMixin, TreeView): def _parent_selection_changed(self, base_class: TreeView, sel: Gtk.TreeSelection): self.terminate_process() - self.emit("distcalc_started") + self.emitter.emit("distcalc_started") def get_record_string(self) -> str: addr = self.get_value_at_index(7) From b735995c6536f84889bc945b879f4743f4f49827 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 7 Feb 2026 19:07:51 +0900 Subject: [PATCH 055/309] fix: server threading WIP --- dzgui/controllers/mc.py | 45 +++++++++----------------- dzgui/views/components/filter_panel.py | 7 ++-- 2 files changed, 19 insertions(+), 33 deletions(-) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 1e4ad79..1194be5 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -270,7 +270,7 @@ class Controller(GObject.GObject): if haversine is None: dist = "Unknown" else: - # FIXME: always opens file; cache distance pref + # FIXME: always opens file; cache distance pref at boot and when it changes if self.query_config(Preferences.DIST) is True: raw = round(haversine.as_miles()) separated = number(raw) @@ -357,7 +357,7 @@ class Controller(GObject.GObject): except Exception: return case ButtonType.MODS: - # TODO: reload using refresh button, rather than on demand + # TODO: reload using refresh button, rather than on demand? self.load_mods() self.mediator.notebook.set_page_by_enum(button.opens) @@ -376,9 +376,7 @@ class Controller(GObject.GObject): for future in futures: res = future.result() if res.status != 200 or not res.parsed: - # NOTE: this is happening in a thread, need to exit first - # TODO: set internal state and pass failure flag - # TODO: pop warning dialog + # TODO: pop warning dialog, create enum around various failure states print("failed to parse/timeout error") self.push_data(None, success=False) return @@ -447,15 +445,6 @@ class Controller(GObject.GObject): def copy_ip(self, path: Gtk.TreePath) -> None: treeview = self.get_active_treeview() record = treeview.get_record() - # TODO: maybe delegate to filter man - #addr = self.get_col_value_by_path_index(path, 7) - #if addr is None: - # return - #qport = self.get_col_value_by_path_index(path, 8) - ## TODO: abstract get record function - #ip = addr.split(":")[0] - #gameport = int(addr.split(":")[1]) - #record = Servers.Record(ip, gameport, qport) self.copy_clipboard(f"{record.ip}:{record.qport}") def copy_clipboard(self, text: str) -> None: @@ -635,29 +624,24 @@ class Controller(GObject.GObject): # TODO: if success failed, throw popup treeview = self.get_active_treeview() context = self.get_active_context() - # TODO: rename signal self.emitter.emit("servers_loaded", context) + treeview.grab_focus() self.destroy_on_idle() - if treeview.get_filter_man().get_model() is None: - # FIXME: this may not indicate failure, just empty results - # TODO: different dialogs for server tab contexts + if self.success is False: + dialog = ExceptionDialog(self, "API TIMEOUT") + dialog.run() + #if treeview.get_filter_man().get_model() is None: + # TODO: different dialogs for server tab contexts, e.g. lan timeout # TODO: if history/favorites is empty, don't even trigger a call - # TODO: add proper string for this dialog - # TODO: this is a placeholder string - # TODO: only pop this is success was failure - if self.success is False: - dialog = ExceptionDialog(self, "API TIMEOUT") - dialog.run() def push_data(self, data: tuple, mode: Optional[FilterMode], success: bool) -> None: treeview = self.get_active_treeview() - # context = self.get_active_context() manager = treeview.get_filter_man() + # TODO: self.success = success - if success: if data is None: insert = None @@ -667,7 +651,6 @@ class Controller(GObject.GObject): manager.set_control(data) # TODO: init maps here manager.filter(mode) - # FIXME: inserting none may cause problems if this was a simple refresh action insert = manager.get_model() treeview.set_model(insert) treeview.set_loaded(True) @@ -780,14 +763,15 @@ class Controller(GObject.GObject): def filter_threaded(self, mode: FilterMode, label: str) -> None: tv = self.get_active_treeview() tv.filter_man.filter(mode, label) - self.push_data("", mode) + self.push_data("", mode, success=True) - def refilter_model(self, mode: FilterMode, label: str) -> None: + # FIXME: optional label/map/keyword parameter + def refilter_model(self, mode: FilterMode, label: Optional[str] = None) -> None: tv = self.get_active_treeview() - # TODO: shouldn't empty model be None? if tv.get_model() is None: return tv.set_model(None) + # TODO: deprecated in this context? self.set_callback(None, None) self.filter_threaded(mode, label) @@ -802,6 +786,7 @@ class Controller(GObject.GObject): self.emitter.emit("servers_loaded", treeview.get_enum()) return # TODO: on legacy version, model clearing happens in thread + # (compare) treeview.set_model(None) # manager = treeview.get_filter_man() # manager.clear_model() diff --git a/dzgui/views/components/filter_panel.py b/dzgui/views/components/filter_panel.py index 189db15..b9195ca 100644 --- a/dzgui/views/components/filter_panel.py +++ b/dzgui/views/components/filter_panel.py @@ -253,7 +253,9 @@ class FilterPanel(Gtk.Box): def set_active_combo(self, row: int) -> None: self.maps_combo.set_active(row) - def toggle_check_by_key(self, emitter: "Emitter", keyval: int) -> None | Literal[False]: + def toggle_check_by_key( + self, emitter: "Emitter", keyval: int + ) -> None | Literal[False]: mappings = { Gdk.KEY_1: 0, Gdk.KEY_2: 1, @@ -292,7 +294,6 @@ class FilterPanel(Gtk.Box): self.controller.refilter_model(mode, label) def _on_map_changed(self, combo: Gtk.ComboBox) -> None: - treeview = self.controller.get_active_treeview() old_sel = self.selected_map model = combo.get_model() tree_iter = combo.get_active_iter() @@ -307,4 +308,4 @@ class FilterPanel(Gtk.Box): self.prior_map = self.selected_map self.selected_map = selection self.maps_entry.set_text(selection) - treeview.filter(FilterMode.MAP) + self.controller.refilter_model(FilterMode.MAP) From 960396c7fac5aec0381b8e00acf34975e3fdb1f4 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 7 Feb 2026 19:58:17 +0900 Subject: [PATCH 056/309] chore: add dev install isntructions to docs --- docs/source/conf.py | 6 +- docs/source/development.rst | 7 +++ docs/source/installation.rst | 110 +++++++++++++++++++++++++---------- 3 files changed, 88 insertions(+), 35 deletions(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index 28a28fc..3ffa8d4 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -30,11 +30,11 @@ extensions = [ "sphinx.ext.githubpages" ] html_theme = "sphinx_rtd_theme" -html_static_path = ['_static'] +#html_static_path = ['_static'] html_show_sphinx = False html_show_copyright = False html_show_sourcelink = False today_fmt = "%Y-%m-%d" -release = "5.8.x" -version = "5.8.x" +release = "6.x.x" +version = "6.x.x" diff --git a/docs/source/development.rst b/docs/source/development.rst index 879408b..5c6868c 100644 --- a/docs/source/development.rst +++ b/docs/source/development.rst @@ -10,3 +10,10 @@ file. All contributors are expected to act in accordance with the project's `code of conduct `_, and follow the project's standards and guidelines. + +To install DZGUI and related dependencies for linting, testing, and version control, +invoke the command ``uv pip install .[dev]``. + +After this process, install the applicable pre-commit hooks by invoking the command ``pre-commit install``. + +These pre-commit hooks enforce code checks that prevent making commits unless the relevant tests pass. diff --git a/docs/source/installation.rst b/docs/source/installation.rst index 280e743..cb51ce8 100644 --- a/docs/source/installation.rst +++ b/docs/source/installation.rst @@ -1,8 +1,8 @@ Installation ================== -Dependencies ----------------------- +Basic runtime dependencies +--------------------------------- If not already installed, the below can be found in your system’s package manager. @@ -10,7 +10,6 @@ If any dependencies are missing when the application starts, it will warn you, s - curl - jq -- PyGObject (python-gobject) - steam - wmctrl or xdotool - zenity @@ -18,43 +17,90 @@ If any dependencies are missing when the application starts, it will warn you, s .. note:: All dependencies are installed out of the box on Steam Deck. -Downloading ------------------- +UI dependencies (Python and PyGObject) +--------------------------------------- +.. important:: + As of version 6.0.0, you must have a valid Python 3.13 and matching PyGObject environment. + Depending on your distribution, this may require additional manual intervention. -Automatic installation +Arch Linux (vanilla variants) +^^^^^^^^^^^^^^^^^^^^^^ +You will need the ``python3.13`` package, if it is not already installed. + +Install the following build dependencies: + +.. code:: console + + sudo pacman -S cmake gcc pkgconf python-cairo + +.. include:: venv.rst + +CachyOS (Arch) ^^^^^^^^^^^^^^^^^^^^^^^ -Invoke the command below from a terminal: +Refer to the build instructions for Arch Linux first. +The ``python3.13`` package is not available on CachyOS, so it is recommended to +use ``uv`` to manage Python versions. + +.. code:: console + + sudo pacman -S uv + +.. include:: venv.rst + +Manjaro 26 (Arch) +^^^^^^^^^^^^^^^^^^^^^^^ +If Python 3.13 is not already available out of the box, refer to the build instructions for :ref:`Arch Linux (vanilla variants)`. + +Debian 13 variants +^^^^^^^^^^^^^^^^^^^^^^^ +Python 3.13 and PyGObject are available out of the box. No additional steps are necessary. +You can proceed to the :ref:`Installscript`. + +Fedora 43 variants +^^^^^^^^^^^^^^^^^^^^^^^ +Fedora 43 ships with a newer version of Python (3.14), so you must set up Python 3.13 in its own +virtual environment (venv) to prevent it from conflicting with the system package. + +Install the following build dependencies: + +.. code:: console + + sudo dnf install cairo cairo-devel cairo-gobject-devel gcc python3.13 python3.13-devel + +.. include:: venv.rst + +Ubuntu 24 variants +^^^^^^^^^^^^^^^^^^^^^^ +Ubuntu ships with an older version of Python (3.12). You must install additional dependencies. +Add the PPA below to add Python 3.13 to your sources: + +.. code:: console + + sudo add-apt-repository ppa:deadsnakes/ppa + +Install the following build dependencies: + +.. code:: console + + sudo apt install python3.13 python3.13-venv python3.13-dev build-essential python3-gi-cairo pkg-config libgirepository-2.0-dev libcairo2-dev + +.. include:: venv.rst + +Installscript +----------------------- + +Invoke the command below from a terminal. This script fetches the main DZGUI script from the repository, +makes it executable, and launches DZGUI's first-time setup. After running this installscript, you will +have the file ``dzgui.sh`` wherever you invoked the command. .. code:: console curl -s "https://codeberg.org/aclist/dztui/raw/branch/dzgui/install.sh" | bash - -Manual installation -^^^^^^^^^^^^^^^^^^^^ - -Clone the repository: - -.. code:: console - - git clone https://codeberg.org/aclist/dztui.git - -Set the executable bit on ``dzgui.sh``: - -.. code:: console - - chmod +x dzgui.sh - - -Launch the script: - -.. code:: console - - ./dzgui.sh - nixOS installation -^^^^^^^^^^^^^^^^^^^ -Two packages are independently maintained by other contributors. Each takes a slightly different approach: +---------------------- +Two packages are independently maintained by other contributors. Each takes a slightly different approach. +nixOS packages are standalone and do not require cloning the git repository. - https://github.com/lelgenio/dzgui-nix - https://github.com/jiriks74/dzgui.flake From 6b3257ae83c597b13ecdd9b7b8616f8262047329 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 7 Feb 2026 19:58:47 +0900 Subject: [PATCH 057/309] chore: bump docs version --- docs/source/conf.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index 3ffa8d4..4c746e2 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -36,5 +36,5 @@ html_show_copyright = False html_show_sourcelink = False today_fmt = "%Y-%m-%d" -release = "6.x.x" -version = "6.x.x" +release = "7.x.x" +version = "7.x.x" From 89477f7365163644f176e1ea3b4ba843f78606bb Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 7 Feb 2026 20:05:52 +0900 Subject: [PATCH 058/309] chore: add venv.rst file --- docs/source/venv.rst | 43 +++++++++++++++++++++++++++++++++++++++++++ scripts/untracked.py | 7 +++++-- 2 files changed, 48 insertions(+), 2 deletions(-) create mode 100644 docs/source/venv.rst diff --git a/docs/source/venv.rst b/docs/source/venv.rst new file mode 100644 index 0000000..d23b798 --- /dev/null +++ b/docs/source/venv.rst @@ -0,0 +1,43 @@ +After satisfying the dependencies above, create a virtual environment (venv) and install PyGObject: + +If you have ``uv`` as your Python version manager, invoke the following: + +.. code:: console + + uv venv --python 3.13 $HOME/.virtualenvs/dzgui + source $HOME/.virtualenvs/dzgui/bin/activate + uv pip install PyGObject + +If you installed the ``python3.13`` package directly instead of ``uv``, use the below: + +.. code:: console + + python3.13 -m venv $HOME/.virtualenvs/dzgui + source $HOME/.virtualenvs/dzgui/bin/activate + pip install PyGObject + +You can now proceed to the :ref:`Installscript`. +After installation, refer to the additional remarks below. + +.. important:: + Since PyGObject is localized to a venv, it is advisable to create a wrapper script to ensure + the venv is activated on subsequent invocations of DZGUI. + +.. code:: console + + #!/usr/bin/env bash + source $HOME/.virtualenvs/dzgui/bin/activate + + +Make sure to update the placeholder path on the last line. For example, if you ran the +installscript from your home directory, the wrapper script would look like: + +.. code:: console + + #!/usr/bin/env bash + source $HOME/.virtualenvs/dzgui/bin/activate + $HOME/dzgui.sh + +Save this wrapper script in a location of your choice, e.g., ``mywrapper.sh`` and set the +executable bit: ``chmod +x mywrapper.sh`` This script will be your entrypoint for +future invocations of DZGUI, so you should launch it, not ``dzgui.sh`` diff --git a/scripts/untracked.py b/scripts/untracked.py index 89bef95..c116931 100644 --- a/scripts/untracked.py +++ b/scripts/untracked.py @@ -12,5 +12,8 @@ with open(".gitignore", "r") as f: untracked = [os.path.basename(line.rstrip()) for line in result.stdout] for file in untracked: if not any(file in item for item in ignored): - print(f"File '{file}' is untracked") - sys.exit(1) + res = input(f"File '{file}' is untracked. Proceed anyway? [Y/n] ") + if res.lower() in ("", "y"): + sys.exit(0) + else: + sys.exit(1) From fc294bcede49f1f28ef528e973d1a14f47b4a630 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 7 Feb 2026 20:07:55 +0900 Subject: [PATCH 059/309] chore: drop interactive prompt in hook --- scripts/untracked.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/scripts/untracked.py b/scripts/untracked.py index c116931..f53714d 100644 --- a/scripts/untracked.py +++ b/scripts/untracked.py @@ -12,8 +12,5 @@ with open(".gitignore", "r") as f: untracked = [os.path.basename(line.rstrip()) for line in result.stdout] for file in untracked: if not any(file in item for item in ignored): - res = input(f"File '{file}' is untracked. Proceed anyway? [Y/n] ") - if res.lower() in ("", "y"): - sys.exit(0) - else: - sys.exit(1) + print(f"File '{file}' is untracked. Add to .gitignore file if this is intended.") + sys.exit(1) From 2ef62416c024ad566136a0b44f01256d68ddde3c Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 7 Feb 2026 23:18:25 +0900 Subject: [PATCH 060/309] fix: statusbar signals --- dzgui/controllers/emitter.py | 21 +++++++-- dzgui/controllers/mc.py | 10 +++-- dzgui/views/components/right_panel.py | 10 +++++ dzgui/views/components/statusbar.py | 32 ++++++++------ dzgui/views/mixins/context_mixin.py | 64 +++++++++++++-------------- dzgui/views/pages/servers.py | 2 + dzgui/views/trees/tree_servers.py | 32 +++++++++----- 7 files changed, 108 insertions(+), 63 deletions(-) diff --git a/dzgui/controllers/emitter.py b/dzgui/controllers/emitter.py index 292f613..2ed26d9 100644 --- a/dzgui/controllers/emitter.py +++ b/dzgui/controllers/emitter.py @@ -10,6 +10,7 @@ if TYPE_CHECKING: # TODO: rename signals to e.g. maps_keybinding_pressed + class Emitter(GObject.GObject): def __init__(self) -> None: super().__init__() @@ -56,10 +57,6 @@ class Emitter(GObject.GObject): def distcalc_started(self) -> None: pass - @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object,)) - def servers_loaded(self, tag: "ServerTab") -> None: - pass - @GObject.Signal( flags=GObject.SignalFlags.RUN_LAST, arg_types=( @@ -71,3 +68,19 @@ class Emitter(GObject.GObject): self, dist: Union[str, None], context: Union["ServerTab", "NotebookPage"] ) -> None: pass + + @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object,)) + def servers_loaded(self, tag: "ServerTab") -> None: + pass + + # @GObject.Signal( + # flags=GObject.SignalFlags.RUN_LAST, + # arg_types=( + # object, + # object, + # ), + # ) + # def distcalc_ended( + # self, dist: Union[str, None], context: Union["ServerTab", "NotebookPage"] + # ) -> None: + # pass diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 1194be5..6f81d19 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -253,16 +253,18 @@ class Controller(GObject.GObject): def set_statusbar_dist(self, haversine: "Haversine", enum: "ServerTab") -> None: context = self.get_active_context() + print(context) page = self.mediator.notebook.get_page_by_enum() """ NOTE: prevents race condition when server tab changed, but allows caching the distance in the background """ if page != NotebookPage.SERVERS: - self.mediator.statusbar.spinner.stop() + self.emitter.emit("distcalc_ended" , None, context) + #self.mediator.statusbar.spinner.stop() return if enum != context: - self.mediator.statusbar.emit("distcalc_ended", None, context) + self.emitter.emit("distcalc_ended" , None, context) return # NOTE: user may have changed km/mi toggle, so recalculate @@ -280,7 +282,7 @@ class Controller(GObject.GObject): separated = number(raw) dist = str(separated) + " km" - self.mediator.statusbar.emit("distcalc_ended", dist, context) + self.emitter.emit("distcalc_ended", dist, context) def delete_multiple_mods(self) -> None: sel = self.mediator.modtreeview.get_selection() @@ -781,13 +783,13 @@ class Controller(GObject.GObject): self.emitter.emit("servers_loaded", treeview.get_enum()) return + treeview.set_model(None) func = treeview.get_query_func() if func is None: self.emitter.emit("servers_loaded", treeview.get_enum()) return # TODO: on legacy version, model clearing happens in thread # (compare) - treeview.set_model(None) # manager = treeview.get_filter_man() # manager.clear_model() self.set_callback(None, None) diff --git a/dzgui/views/components/right_panel.py b/dzgui/views/components/right_panel.py index 9e44dba..28f0064 100644 --- a/dzgui/views/components/right_panel.py +++ b/dzgui/views/components/right_panel.py @@ -1,5 +1,6 @@ from typing import Optional, TYPE_CHECKING +from dzgui.const.enum import ServerTab from dzgui.views.components.buttonbox import ButtonBox from dzgui.views.components.filter_panel import FilterPanel from dzgui.views.components.mod_panel import ModSelectionPanel @@ -30,6 +31,7 @@ class RightPanel(Gtk.Box): self.filters_vbox = FilterPanel(controller) self.sel_panel = ModSelectionPanel(controller) + emitter.connect("servers_loaded", self.toggle_refresh_button) self.refresh_button = RefreshButton(controller) self.keys = KeysButton(controller) @@ -38,6 +40,14 @@ class RightPanel(Gtk.Box): self.pack_start(self.sel_panel, NO_EXPAND, NO_FILL, NO_PADDING) + def toggle_refresh_button(self, emitter: "Emitter", context: "ServerTab") -> None: + # TODO: when a row is added, make sure "servers_loaded" signal is emitted + self.refresh_button.set_sensitive(True) + model = self.controller.get_active_treeview().get_model() + if model is None: + if context in (ServerTab.RECENT, ServerTab.SAVED): + self.refresh_button.set_sensitive(False) + # TODO: move to filter panel def reinit_maps(self, rows: list) -> None: self.controller.reinit_map_store() diff --git a/dzgui/views/components/statusbar.py b/dzgui/views/components/statusbar.py index 09892fe..cd4a909 100644 --- a/dzgui/views/components/statusbar.py +++ b/dzgui/views/components/statusbar.py @@ -1,3 +1,4 @@ +from datetime import datetime from typing import Self, Union, TYPE_CHECKING from dzgui.const.enum import NotebookPage, ServerTab @@ -47,23 +48,25 @@ class Statusbar(Gtk.Grid): controller.mediator.notebook.connect_after( "switch-page", self._on_notebook_page_changed ) + # TODO: - self.emitter.connect("distcalc_started", lambda _: self.spinner.start()) + self.emitter.connect("distcalc_started", self._on_distcalc_started) + self.emitter.connect("distcalc_ended", self._on_distcalc_ended) self.emitter.connect("servers_loaded", self._on_servers_loaded) - self.connect("distcalc_ended", self._on_distcalc_ended) + # self.connect("distcalc_ended", self._on_distcalc_ended) - @GObject.Signal( - flags=GObject.SignalFlags.RUN_LAST, - arg_types=( - object, - object, - ), - ) - def distcalc_ended( - self, dist: Union[str, None], context: Union["ServerTab", NotebookPage] - ) -> None: - pass + # @GObject.Signal( + # flags=GObject.SignalFlags.RUN_LAST, + # arg_types=( + # object, + # object, + # ), + # ) + # def distcalc_ended( + # self, dist: Union[str, None], context: Union["ServerTab", NotebookPage] + # ) -> None: + # pass def _on_notebook_page_changed( self, notebook: "Notebook", child: Gtk.Widget, index: int @@ -107,6 +110,9 @@ class Statusbar(Gtk.Grid): def _on_server_row_changed(self, statusbar: Self) -> None: self.spinner.start() + def _on_distcalc_started(self, e) -> None: + self.spinner.start() + def _on_distcalc_ended( self, statusbar: Self, diff --git a/dzgui/views/mixins/context_mixin.py b/dzgui/views/mixins/context_mixin.py index 4cd12b0..c9e5079 100644 --- a/dzgui/views/mixins/context_mixin.py +++ b/dzgui/views/mixins/context_mixin.py @@ -20,27 +20,27 @@ class ContextMixin(TreeView): ) -> None: if self.is_selection_empty(): - return + return False if event.type is Gdk.EventType.BUTTON_PRESS: if event.button != 3: - return + return False try: pathinfo = self.get_path_at_pos(int(event.x), int(event.y)) if pathinfo is None: - return + return True (path, col, cellx, celly) = pathinfo if path is None: - return + return True 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 + return False if event.keyval is not Gdk.KEY_l: - return + return False group = self.menu self.context_menu = Gtk.Menu() @@ -83,30 +83,30 @@ class ContextMixin(TreeView): self.controller.menu_action(enum, path) def _on_key(self, menu: Gtk.Menu, event: Gdk.EventKey) -> bool | None: - if not is_navkey(event.keyval): - return False - menu = self.context_menu - sel = menu.get_selected_item() - children = menu.get_children() - for i, child in enumerate(children): - if sel is child: - ind = i - break + if not is_navkey(event.keyval): + return False + menu = self.context_menu + sel = menu.get_selected_item() + children = menu.get_children() + for i, child in enumerate(children): + if sel is child: + ind = i + break - match event.keyval: - case Gdk.KEY_j: - if ind == len(children) - 1: - return True - menu.select_item(children[ind + 1]) - case Gdk.KEY_k: - if ind - 1 < 0: - return True - menu.select_item(children[ind - 1]) - case Gdk.KEY_g: - menu.select_item(children[0]) - case Gdk.KEY_G: - ind = len(children) - 1 - menu.select_item(children[ind]) - case _: - return False - return True + match event.keyval: + case Gdk.KEY_j: + if ind == len(children) - 1: + return True + menu.select_item(children[ind + 1]) + case Gdk.KEY_k: + if ind - 1 < 0: + return True + menu.select_item(children[ind - 1]) + case Gdk.KEY_g: + menu.select_item(children[0]) + case Gdk.KEY_G: + ind = len(children) - 1 + menu.select_item(children[ind]) + case _: + return False + return True diff --git a/dzgui/views/pages/servers.py b/dzgui/views/pages/servers.py index f23ab1a..d9125f8 100644 --- a/dzgui/views/pages/servers.py +++ b/dzgui/views/pages/servers.py @@ -59,6 +59,8 @@ class ServerNotebook(Gtk.ScrolledWindow): def _on_map(self, widget: Self) -> None: self.emitter.emit("server_page_toggled", True) + print("page changed, starting queue checker") + self.get_active_treeview().start_timeout() def _on_unmap(self, widget: Self) -> None: self.emitter.emit("server_page_toggled", False) diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index 1cc87a6..cb3bba7 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -26,6 +26,8 @@ if TYPE_CHECKING: from dzgui.controllers.mc import Controller from dzgui.controllers.emitter import Emitter +QUEUE_CHECK_DELAY = 200 + class ServerTreeView(ContextMixin, TreeView): def __init__( @@ -33,8 +35,6 @@ class ServerTreeView(ContextMixin, TreeView): ) -> None: super().__init__(controller, menu=ContextMenuGroup.SERVER_BROWSER) - QUEUE_CHECK_DELAY = 200 - self.controller = controller self.emitter = controller.get_emitter() self.enum = enum @@ -49,6 +49,8 @@ class ServerTreeView(ContextMixin, TreeView): self.set_fixed_height_mode(True) self.set_headers_visible(True) + self.queue_id: int = 0 + self.current_proc = None self.queue = multiprocessing.Queue() @@ -103,9 +105,14 @@ class ServerTreeView(ContextMixin, TreeView): self.connect("key-press-event", self.present_menu) self.connect("button-press-event", self.present_menu) - self.emitter.connect("statusbar_loaded", self._on_distcalc_started) - self.emitter.connect("distcalc_started", self._on_distcalc_started) - GLib.timeout_add(QUEUE_CHECK_DELAY, self._check_result_queue) + # TODO: simplify this + self.emitter.connect("statusbar_loaded", self.start_distcalc) + # self.emitter.connect("distcalc_started", self._on_distcalc_started) + + def start_timeout(self) -> None: + if self.queue_id: + GLib.Source.remove(self.queue_id) + self.queue_id = GLib.timeout_add(QUEUE_CHECK_DELAY, self._check_result_queue) def get_filter_man(self) -> FilteredModelManager: return self.filter_man @@ -163,9 +170,15 @@ class ServerTreeView(ContextMixin, TreeView): if self.current_proc and self.current_proc.is_alive(): self.current_proc.terminate() - def _on_distcalc_started(self, emitter: "Emitter"): + from typing import Optional + + def start_distcalc(self, emitter: Optional["Emitter"] = None): + self.terminate_process() + self.emitter.emit("distcalc_started") record = self.get_record() if record is None: + context = self.get_enum() + self.emitter.emit("distcalc_ended", None, context) return cache = self.controller.get_dist_cache() @@ -250,13 +263,12 @@ class ServerTreeView(ContextMixin, TreeView): def _parent_row_activated( self, tree: TreeView, path: Gtk.TreePath, column: Gtk.TreeViewColumn ) -> None: - # TODO: process server connection - # TODO: get record print(self.get_value_at_index(0)) def _parent_selection_changed(self, base_class: TreeView, sel: Gtk.TreeSelection): - self.terminate_process() - self.emitter.emit("distcalc_started") + if self.loaded is False: + return + self.start_distcalc() def get_record_string(self) -> str: addr = self.get_value_at_index(7) From fc6e958c09eb3a75d99f4514f0e802250f8b6a87 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 8 Feb 2026 22:09:45 +0900 Subject: [PATCH 061/309] fix: signal supression on keyup --- dzgui/controllers/mc.py | 6 +++-- dzgui/model/filtered_model.py | 3 +++ dzgui/util/format.py | 10 ++++---- dzgui/views/trees/tree_base.py | 10 ++++++-- dzgui/views/trees/tree_servers.py | 38 ++++++++++++++++++++++++++++++- 5 files changed, 57 insertions(+), 10 deletions(-) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 6f81d19..a71d65c 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -380,7 +380,7 @@ class Controller(GObject.GObject): if res.status != 200 or not res.parsed: # TODO: pop warning dialog, create enum around various failure states print("failed to parse/timeout error") - self.push_data(None, success=False) + self.push_data(None, FilterMode.INITIAL, success=False) return j = res.json serv += j["response"]["servers"] @@ -654,6 +654,7 @@ class Controller(GObject.GObject): # TODO: init maps here manager.filter(mode) insert = manager.get_model() + # TODO: should list store be set outside of this thread? treeview.set_model(insert) treeview.set_loaded(True) GLib.idle_add(self.cleanup) @@ -751,7 +752,8 @@ class Controller(GObject.GObject): def get_player_count(self) -> str: treeview = self.get_active_treeview() model = treeview.get_model() - count = format_player_count(model) + control_model = treeview.filter_man.get_control() + count = format_player_count(model, control_model) return count def get_statusbar(self) -> None: diff --git a/dzgui/model/filtered_model.py b/dzgui/model/filtered_model.py index 0f649fd..57c7967 100644 --- a/dzgui/model/filtered_model.py +++ b/dzgui/model/filtered_model.py @@ -263,6 +263,9 @@ class FilteredModelManager: """ self.control_model = rows + def get_control(self) -> list: + return self.control_model + def set_success(self, result: bool) -> None: self.success = result diff --git a/dzgui/util/format.py b/dzgui/util/format.py index e6e6b3f..3d83d24 100644 --- a/dzgui/util/format.py +++ b/dzgui/util/format.py @@ -4,8 +4,9 @@ from dzgui.util.localize import number from dzgui.util.strings import no_mods, no_servers, distance_suffix import gi + gi.require_version("Gtk", "3.0") -from gi.repository import Gtk # noqa E402 +from gi.repository import Gtk # noqa E402 def format_pango(text: str) -> str: @@ -50,7 +51,7 @@ def format_mods(size: int, mods: int) -> str: return f"Found {mods:n} {plural} taking up {l_size} MiB. {suffix}" -def format_player_count(model: Gtk.TreeModel | None) -> str: +def format_player_count(model: Gtk.TreeModel | None, control: list) -> str: players = 0 hits: int status: str @@ -62,10 +63,9 @@ def format_player_count(model: Gtk.TreeModel | None) -> str: players += row[4] players_pretty = pluralize("players", players) hits_pretty = pluralize("matches", hits) - suffix = distance_suffix - status = f"Found {hits:n} {hits_pretty} with {players:n} {players_pretty}" + control_total = len(control) + status = f"Showing {hits:n}/{control_total:n} {hits_pretty} with {players:n} {players_pretty}" return status - return f"{status} | {suffix}" def embolden(text: str) -> str: diff --git a/dzgui/views/trees/tree_base.py b/dzgui/views/trees/tree_base.py index 3663157..c042804 100644 --- a/dzgui/views/trees/tree_base.py +++ b/dzgui/views/trees/tree_base.py @@ -38,6 +38,9 @@ class TreeView(CursorMixin, Gtk.TreeView): # type: ignore 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("button-press-event", lambda x, y: print(self.sel_blocked)) + self.connect("key-press-event", self._on_keypress) self.connect("key-release-event", self._on_key_release) @@ -84,6 +87,7 @@ class TreeView(CursorMixin, Gtk.TreeView): # type: ignore if self.get_model() is None: return if self.sel_blocked is False: + self.sel_blocked = True self.controller.suppress_signal( self, self.selected_row, @@ -110,9 +114,10 @@ class TreeView(CursorMixin, Gtk.TreeView): # type: ignore return if is_navkey(event.keyval): if self.sel_blocked is True: + self.sel_blocked = False self.controller.suppress_signal( - self.controller.mediator.treeview, - self.controller.mediator.treeview.selected_row, + self, + self.selected_row, "_on_tree_selection_changed", False, ) @@ -120,6 +125,7 @@ class TreeView(CursorMixin, Gtk.TreeView): # type: ignore self._on_tree_selection_changed(selection) def _on_tree_selection_changed(self, selection: Gtk.TreeSelection) -> None: + print("selection changed") self.emit("generic_treesel_changed", selection) def toggle_selection(self, state: bool) -> None: diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index cb3bba7..e8722ac 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -1,6 +1,7 @@ import logging import multiprocessing -from typing import Any, Self +import threading +from typing import Any from warnings import deprecated from dzgui.views.mixins.context_mixin import ContextMixin @@ -71,6 +72,8 @@ class ServerTreeView(ContextMixin, TreeView): column.set_sizing(Gtk.TreeViewColumnSizing.FIXED) column.set_resizable(True) column.set_sort_column_id(i) + if column_title == "Ping": + column.set_cell_data_func(renderer, self._get_ping) if valid_json: try: @@ -109,6 +112,39 @@ class ServerTreeView(ContextMixin, TreeView): self.emitter.connect("statusbar_loaded", self.start_distcalc) # self.emitter.connect("distcalc_started", self._on_distcalc_started) + def _get_ping( + self, + column: Gtk.TreeViewColumn, + cell: Gtk.CellRendererText, + model: Gtk.TreeModel, + it: Gtk.TreeIter, + data: Any, + ): + def ping(model, it, ip: str): + # TODO: use a2s to ping server + # a2s.info -> "ping" key + # Servers.ping() -> this accepts a whole row + # try, if failure just abort + self.res = ip.replace(":", "%") + num = 11111 + GLib.idle_add(lambda: model.set(it, 9, num)) + + addr = model.get_value(it, 7).split(":")[0] + qport = model.get_value(it, 8) + ip = f"{addr}:{qport}" + + thread = threading.Thread( + daemon=True, + target=ping, + args=( + model, + it, + ip, + ), + ) + thread.start() + pass + def start_timeout(self) -> None: if self.queue_id: GLib.Source.remove(self.queue_id) From 4946705a8b1d6f1eb60ce0fdbd631f769b2dec32 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 8 Feb 2026 22:23:36 +0900 Subject: [PATCH 062/309] chore: update changelog --- CHANGELOG.md | 3 +++ dzgui/data/CHANGELOG.md | 3 +++ 2 files changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e1750f..ea2fb9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,9 @@ - Bold labels inside frames - Unsticky keys when clicking sidebar +## Fixed +- Longstanding issue with left clicks not registering as tree selection changes after spamming keyboard input + ## Unreleased - Setup wizard - Copy favorite server IP to clipboard diff --git a/dzgui/data/CHANGELOG.md b/dzgui/data/CHANGELOG.md index 8e1750f..ea2fb9a 100644 --- a/dzgui/data/CHANGELOG.md +++ b/dzgui/data/CHANGELOG.md @@ -29,6 +29,9 @@ - Bold labels inside frames - Unsticky keys when clicking sidebar +## Fixed +- Longstanding issue with left clicks not registering as tree selection changes after spamming keyboard input + ## Unreleased - Setup wizard - Copy favorite server IP to clipboard From 25f19cfd10cceaf435b51b0baac56ce0115e7f0d Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 11 Feb 2026 20:44:07 +0900 Subject: [PATCH 063/309] fix: clean up signal emission --- dzgui/config/userprefs.py | 5 ++- dzgui/const/constants.py | 1 + dzgui/controllers/emitter.py | 9 ++++ dzgui/controllers/mc.py | 20 ++++----- dzgui/main.py | 4 ++ dzgui/views/components/connect_panel.py | 55 ++++++++++++++++++++----- dzgui/views/components/filter_panel.py | 22 ++++++---- dzgui/views/components/mod_panel.py | 7 +++- dzgui/views/components/right_panel.py | 1 + dzgui/views/components/statusbar.py | 18 +------- dzgui/views/pages/devs.py | 2 +- dzgui/views/pages/mods.py | 6 +-- dzgui/views/pages/servers.py | 6 ++- dzgui/views/trees/tree_base.py | 1 - dzgui/views/trees/tree_servers.py | 31 +++++++------- 15 files changed, 116 insertions(+), 72 deletions(-) diff --git a/dzgui/config/userprefs.py b/dzgui/config/userprefs.py index d7c09f9..c2830ad 100644 --- a/dzgui/config/userprefs.py +++ b/dzgui/config/userprefs.py @@ -2,7 +2,9 @@ from dataclasses import dataclass from dzgui.config.xdg import Xdg from dzgui.util.ip import Coords -@dataclass(slots=True, frozen=True) + +# NOTE: mutable dataclass, 'use_miles' key changes on demand +@dataclass(slots=True) class UserPrefs: is_steam_deck: bool is_game_mode: bool @@ -11,3 +13,4 @@ class UserPrefs: version: str allow_updates: bool paths: Xdg + use_miles: bool diff --git a/dzgui/const/constants.py b/dzgui/const/constants.py index b47a05b..ab43f3f 100644 --- a/dzgui/const/constants.py +++ b/dzgui/const/constants.py @@ -34,6 +34,7 @@ CLIPBOARD = "edit-copy-symbolic" LIST_ADD = "list-add-symbolic" STEAM_ICON = "steam_tray_mono" VIEW_CONCEAL = "view-conceal-symbolic" +SEARCH_ICON = "system-search-symbolic" VIEW_REVEAL = "view-reveal-symbolic" INPUT_KEYBOARD = "input-keyboard-symbolic" HELP_BUBBLE = "help-about-symbolic" diff --git a/dzgui/controllers/emitter.py b/dzgui/controllers/emitter.py index 2ed26d9..ee43b00 100644 --- a/dzgui/controllers/emitter.py +++ b/dzgui/controllers/emitter.py @@ -15,6 +15,7 @@ class Emitter(GObject.GObject): def __init__(self) -> None: super().__init__() + # TODO: rename request verbs @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=()) def request_keyword_focus(self) -> None: """User invoked Ctrl-f keybinding from ServerTreeView""" @@ -57,6 +58,14 @@ class Emitter(GObject.GObject): def distcalc_started(self) -> None: pass + @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(bool,)) + def mod_page_toggled(self, state: bool) -> None: + pass + + @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(bool,)) + def lan_tab_toggled(self, state: bool) -> None: + pass + @GObject.Signal( flags=GObject.SignalFlags.RUN_LAST, arg_types=( diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index a71d65c..313b29f 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -104,7 +104,7 @@ class Controller(GObject.GObject): def call_on_thread(func: Callable) -> Callable: def wrapper(*args, **kwargs): self = args[0] - self.wait_dialog = WaitDialog(self, strings.dialog.fetching) + self.wait_dialog = WaitDialog(self, strings.dialog.filtering) self.wait_dialog.show_all() thread = threading.Thread(target=func, args=args) thread.start() @@ -253,7 +253,6 @@ class Controller(GObject.GObject): def set_statusbar_dist(self, haversine: "Haversine", enum: "ServerTab") -> None: context = self.get_active_context() - print(context) page = self.mediator.notebook.get_page_by_enum() """ NOTE: prevents race condition when server tab changed, @@ -261,7 +260,6 @@ class Controller(GObject.GObject): """ if page != NotebookPage.SERVERS: self.emitter.emit("distcalc_ended" , None, context) - #self.mediator.statusbar.spinner.stop() return if enum != context: self.emitter.emit("distcalc_ended" , None, context) @@ -272,8 +270,7 @@ class Controller(GObject.GObject): if haversine is None: dist = "Unknown" else: - # FIXME: always opens file; cache distance pref at boot and when it changes - if self.query_config(Preferences.DIST) is True: + if self.prefs.use_miles: raw = round(haversine.as_miles()) separated = number(raw) dist = str(separated) + " mi" @@ -310,6 +307,9 @@ class Controller(GObject.GObject): config = self.prefs.paths.config try: update.toggle_config(config, context) + # NOTE: 'use_miles' key is updated dynamically for statusbar unit + if context == Preferences.DIST: + self.prefs.use_miles = not self.prefs.use_miles except Exception as e: logger.critical(e) trace = traceback.format_exc() @@ -337,9 +337,6 @@ class Controller(GObject.GObject): notebook = self.mediator.grid.notebook notebook.focus_current() - def toggle_mod_panel(self, state: bool) -> None: - self.mediator.grid.right_panel.sel_panel.set_visible(state) - def show_developers_page(self) -> None: self.open_page(NotebookPage.DEVELOPERS) @@ -631,11 +628,10 @@ class Controller(GObject.GObject): treeview.grab_focus() self.destroy_on_idle() if self.success is False: + # TODO: different dialogs for server tab contexts, e.g. lan timeout + # TODO: if history/favorites is empty, don't even trigger a call to dump data dialog = ExceptionDialog(self, "API TIMEOUT") dialog.run() - #if treeview.get_filter_man().get_model() is None: - # TODO: different dialogs for server tab contexts, e.g. lan timeout - # TODO: if history/favorites is empty, don't even trigger a call def push_data(self, data: tuple, mode: Optional[FilterMode], success: bool) -> None: @@ -772,7 +768,7 @@ class Controller(GObject.GObject): # FIXME: optional label/map/keyword parameter def refilter_model(self, mode: FilterMode, label: Optional[str] = None) -> None: tv = self.get_active_treeview() - if tv.get_model() is None: + if tv.filter_man.get_control() is None: return tv.set_model(None) # TODO: deprecated in this context? diff --git a/dzgui/main.py b/dzgui/main.py index 8b2d154..0b0c29e 100644 --- a/dzgui/main.py +++ b/dzgui/main.py @@ -7,7 +7,9 @@ import warnings from typing import TYPE_CHECKING from dzgui.api.mods import remove_stale_signatures +from dzgui.const.enum import Preferences from dzgui.config.ipdb import get_ipdb +from dzgui.config.query import lookup from dzgui.config.userprefs import UserPrefs from dzgui.config.xdg import get_xdg_paths, parse_filepaths from dzgui.const.update import ALLOW_UPDATES @@ -126,6 +128,7 @@ def main() -> None: # TODO: handle IP DB failure and use coords fallback get_ipdb(XDG.ips) local_coords = get_local_coords(XDG.ips) + use_miles = lookup(XDG.config, Preferences.DIST) prefs = UserPrefs( _is_steam_deck, @@ -135,6 +138,7 @@ def main() -> None: version, allow, XDG, + use_miles, ) print("All OK. Loading UI...") App(prefs) diff --git a/dzgui/views/components/connect_panel.py b/dzgui/views/components/connect_panel.py index 81b206e..4bdcb08 100644 --- a/dzgui/views/components/connect_panel.py +++ b/dzgui/views/components/connect_panel.py @@ -1,20 +1,26 @@ from typing import TYPE_CHECKING from dzgui.util.strings import connect_panel, fav_panel, lan_panel -from dzgui.views.components.buttons import AddButton, ClipboardButton, SteamConnectButton +from dzgui.views.components.buttons import ( + AddButton, + ClipboardButton, + SteamConnectButton, +) from dzgui.views.components.entry import IpEntry, PortEntry from dzgui.views.components.labels import BoldLabel import gi + gi.require_version("Gtk", "3.0") -from gi.repository import Gtk, Gdk # noqa E402 +from gi.repository import Gtk, Gdk # noqa E402 if TYPE_CHECKING: - from dzgui.controllers.mc import Controller + from dzgui.controllers.mc import Controller, Emitter COLS = 1 ROWS = 1 + class LanPanel(Gtk.Frame): def __init__(self, controller: "Controller") -> None: super().__init__(margin_top=10, margin_bottom=5) @@ -27,20 +33,26 @@ class LanPanel(Gtk.Frame): self.set_label_widget(label) radio1 = Gtk.RadioButton.new_with_label(None, lan_panel.default_button) - radio2 = Gtk.RadioButton.new_with_label_from_widget(radio1, lan_panel.custom_button) + radio2 = Gtk.RadioButton.new_with_label_from_widget( + radio1, lan_panel.custom_button + ) self.entry = PortEntry(controller) self.scan = Gtk.Button(label=lan_panel.scan_button) self.entry.connect("string_validated", self._on_port_validated) - self.emitter.connect("request_lan_entry_focus", lambda _: self.entry.grab_focus()) + self.emitter.connect( + "request_lan_entry_focus", lambda _: self.entry.grab_focus() + ) radio1.connect("toggled", self._on_radio_toggled) self.grid = Gtk.Grid(margin=10, vexpand=False, column_spacing=15, row_spacing=5) self.grid.attach(radio1, 0, 0, COLS, ROWS) self.grid.attach_next_to(radio2, radio1, Gtk.PositionType.RIGHT, COLS, ROWS) self.grid.attach_next_to(self.entry, radio2, Gtk.PositionType.RIGHT, COLS, ROWS) - self.grid.attach_next_to(self.scan, self.entry, Gtk.PositionType.RIGHT, COLS, ROWS) + self.grid.attach_next_to( + self.scan, self.entry, Gtk.PositionType.RIGHT, COLS, ROWS + ) self.add(self.grid) @@ -52,6 +64,7 @@ class LanPanel(Gtk.Frame): def _on_port_validated(self, entry: Gtk.Entry, state: bool) -> None: self.scan.set_sensitive(state) + class FavPanel(Gtk.Frame): def __init__(self, controller: "Controller") -> None: super().__init__(margin_top=10, margin_bottom=5) @@ -63,8 +76,17 @@ class FavPanel(Gtk.Frame): # TODO: improve upon this, cache fav ip when changed globally user_fav, self.fav_ip = self.controller.get_favorite() - server_name = f"{user_fav} ({self.fav_ip})" if user_fav is not None else connect_panel.no_fav - self.fav_label = Gtk.Label(label=server_name, track_visited_links=False, halign=Gtk.Align.START, hexpand=True) + server_name = ( + f"{user_fav} ({self.fav_ip})" + if user_fav is not None + else connect_panel.no_fav + ) + self.fav_label = Gtk.Label( + label=server_name, + track_visited_links=False, + halign=Gtk.Align.START, + hexpand=True, + ) # NOTE: disable vscrollbar to prevent layout jumping behavior scrollable_label = Gtk.ScrolledWindow(vscrollbar_policy=Gtk.PolicyType.NEVER) scrollable_label.add(self.fav_label) @@ -108,12 +130,18 @@ class AddPanel(Gtk.Frame): self.entry = IpEntry(controller) self.entry.connect("string_validated", self._on_ip_validated) - self.emitter.connect("request_ip_entry_focus", lambda _: self.entry.grab_focus()) + self.emitter.connect( + "request_ip_entry_focus", lambda _: self.entry.grab_focus() + ) self.grid = Gtk.Grid(margin=10, vexpand=False, column_spacing=15, row_spacing=5) self.grid.attach(self.entry, 0, 0, COLS, ROWS) - self.grid.attach_next_to(self.add_server, self.entry, Gtk.PositionType.RIGHT, COLS, ROWS) - self.grid.attach_next_to(self.conn_server, self.add_server, Gtk.PositionType.RIGHT, COLS, ROWS) + self.grid.attach_next_to( + self.add_server, self.entry, Gtk.PositionType.RIGHT, COLS, ROWS + ) + self.grid.attach_next_to( + self.conn_server, self.add_server, Gtk.PositionType.RIGHT, COLS, ROWS + ) self.add(self.grid) @@ -127,6 +155,8 @@ class ConnectPanel(Gtk.Box): super().__init__(orientation=Gtk.Orientation.VERTICAL) self.controller = controller + emitter = self.controller.get_emitter() + emitter.connect("lan_tab_toggled", self._on_lan_tab_toggled) self.lan = LanPanel(controller) self.fav = FavPanel(controller) @@ -134,3 +164,6 @@ class ConnectPanel(Gtk.Box): for el in self.lan, self.fav, self.add_panel: self.add(el) + + def _on_lan_tab_toggled(self, emitter: "Emitter", state: bool) -> None: + self.lan.set_visible(state) diff --git a/dzgui/views/components/filter_panel.py b/dzgui/views/components/filter_panel.py index b9195ca..8586101 100644 --- a/dzgui/views/components/filter_panel.py +++ b/dzgui/views/components/filter_panel.py @@ -2,7 +2,7 @@ import logging from typing import Literal, TYPE_CHECKING from dzgui.const.enum import FilterMode -from dzgui.const.constants import NO_EXPAND, NO_FILL, NO_PADDING +from dzgui.const.constants import NO_EXPAND, NO_FILL, NO_PADDING, SEARCH_ICON from dzgui.util import strings from dzgui.util.margins import set_surrounding_margins from dzgui.views.components.labels import BoldLabel @@ -42,7 +42,7 @@ class FilterPanel(Gtk.Box): self.controller.register_widget("filters", self) self.emitter = self.controller.get_emitter() - map_store = self.controller.get_map_store() + self.map_store = self.controller.get_map_store() self.checks = [] self.maps_hr = [] @@ -86,8 +86,13 @@ class FilterPanel(Gtk.Box): self.keyword_entry = Gtk.Entry() # TODO :strings self.keyword_entry.set_placeholder_text("Filter by keyword") - self.keyword_entry.connect("activate", self._on_keyword_enter) + self.keyword_entry.connect("activate", self._on_keyword_activated) self.keyword_entry.connect("key-press-event", self._on_keyword_keypress) + self.keyword_entry.set_icon_from_icon_name( + Gtk.EntryIconPosition.SECONDARY, SEARCH_ICON + ) + self.keyword_entry.set_icon_activatable(Gtk.EntryIconPosition.SECONDARY, False) + # entry.connect("icon-release", self._on_icon_release) self.emitter.connect( "request_keyword_focus", lambda _: self.keyword_entry.grab_focus() @@ -103,7 +108,7 @@ class FilterPanel(Gtk.Box): 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 = Gtk.ComboBox.new_with_model_and_entry(self.map_store) self.maps_combo.set_entry_text_column(0) self.maps_entry = self.maps_combo.get_child() @@ -166,7 +171,7 @@ class FilterPanel(Gtk.Box): If entry is exact match for value in liststore, trigger map change function """ - for i in enumerate(map_store): # type: ignore + for i in enumerate(self.map_store): # type: ignore if text == i[1][0]: self.maps_combo.set_active(i[0]) self._on_map_changed(self.maps_combo) @@ -174,7 +179,7 @@ class FilterPanel(Gtk.Box): GLib.idle_add(self.restore_focus_to_treeview) """ This is a workaround for widget.grab_remove() - Sets cursor position to SOL when unfocusing + Sets cursor position to start of line when unfocusing """ text = self.maps_entry.get_text() self.maps_entry.set_position(len(text)) @@ -192,9 +197,8 @@ class FilterPanel(Gtk.Box): 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) + completion.set_model(self.map_store) def restore_focus_to_treeview(self) -> Literal[False]: view = self.controller.get_active_treeview() @@ -232,7 +236,7 @@ class FilterPanel(Gtk.Box): def get_keyword_filter(self) -> str: return self.keyword_filter - def _on_keyword_enter(self, entry: Gtk.Entry) -> None: + def _on_keyword_activated(self, entry: Gtk.Entry) -> None: # TODO: self.controller.mediator.window.set_keep_below(False) keyword = entry.get_text().lower() diff --git a/dzgui/views/components/mod_panel.py b/dzgui/views/components/mod_panel.py index e961759..0704136 100644 --- a/dzgui/views/components/mod_panel.py +++ b/dzgui/views/components/mod_panel.py @@ -11,7 +11,7 @@ gi.require_version("Gtk", "3.0") from gi.repository import Gtk # noqa E402 if TYPE_CHECKING: - from dzgui.controllers.mc import Controller + from dzgui.controllers.mc import Controller, Emitter class EnumeratedModButton(Gtk.Button): @@ -28,6 +28,8 @@ class ModSelectionPanel(Gtk.Box): super().__init__(spacing=6, orientation=Gtk.Orientation.VERTICAL, margin_top=15) self.controller = controller + emitter = controller.get_emitter() + emitter.connect("mod_page_toggled", self._on_mod_page_toggled) header = BoldLabel(mod_panel.header) @@ -58,6 +60,9 @@ class ModSelectionPanel(Gtk.Box): for el in header, self.main_panel, self.stale_panel: self.pack_start(el, NO_EXPAND, FILL, NO_PADDING) + def _on_mod_page_toggled(self, emitter: "Emitter", state: bool) -> None: + self.set_visible(state) + def after_colorize(self) -> None: self.controller.unselect_all_mods() self.swap_sensitive(True) diff --git a/dzgui/views/components/right_panel.py b/dzgui/views/components/right_panel.py index 28f0064..7f47ae3 100644 --- a/dzgui/views/components/right_panel.py +++ b/dzgui/views/components/right_panel.py @@ -29,6 +29,7 @@ class RightPanel(Gtk.Box): self.button_vbox = ButtonBox(controller) self.filters_vbox = FilterPanel(controller) + self.sel_panel = ModSelectionPanel(controller) emitter.connect("servers_loaded", self.toggle_refresh_button) diff --git a/dzgui/views/components/statusbar.py b/dzgui/views/components/statusbar.py index cd4a909..27a1d4d 100644 --- a/dzgui/views/components/statusbar.py +++ b/dzgui/views/components/statusbar.py @@ -49,25 +49,10 @@ class Statusbar(Gtk.Grid): "switch-page", self._on_notebook_page_changed ) - # TODO: self.emitter.connect("distcalc_started", self._on_distcalc_started) self.emitter.connect("distcalc_ended", self._on_distcalc_ended) self.emitter.connect("servers_loaded", self._on_servers_loaded) - # self.connect("distcalc_ended", self._on_distcalc_ended) - - # @GObject.Signal( - # flags=GObject.SignalFlags.RUN_LAST, - # arg_types=( - # object, - # object, - # ), - # ) - # def distcalc_ended( - # self, dist: Union[str, None], context: Union["ServerTab", NotebookPage] - # ) -> None: - # pass - def _on_notebook_page_changed( self, notebook: "Notebook", child: Gtk.Widget, index: int ) -> None: @@ -95,7 +80,8 @@ class Statusbar(Gtk.Grid): case NotebookPage.HELP: bar = self.controller.get_help_row() case NotebookPage.SERVERS: - # self.emit("server_page_changed", ServerTab.BROWSER) + context = self.controller.get_active_context() + self.emitter.emit("servers_loaded", context) return case NotebookPage.KEYS: bar = question_to_return diff --git a/dzgui/views/pages/devs.py b/dzgui/views/pages/devs.py index cd4a1ea..e1a086f 100644 --- a/dzgui/views/pages/devs.py +++ b/dzgui/views/pages/devs.py @@ -5,6 +5,7 @@ from dzgui.const.enum import NotebookPage from dzgui.util.css import add_class from dzgui.util.strings import developers from dzgui.views.components.labels import BoldLabel +from dzgui.views.trees.tree_base import TreeView import gi # noqa E402 gi.require_version("Gtk", "3.0") @@ -61,7 +62,6 @@ class Developers(Gtk.ScrolledWindow): self.controller.open_page(NotebookPage.OPTIONS) def _make_tree(self, prefs: Union["Xdg", "UserPrefs"]) -> Gtk.TreeView: - from dzgui.views.trees.tree_base import TreeView view = TreeView(self.controller) renderer = Gtk.CellRendererText() for i, col in enumerate(developers.columns): diff --git a/dzgui/views/pages/mods.py b/dzgui/views/pages/mods.py index 6d0d4a5..89509d6 100644 --- a/dzgui/views/pages/mods.py +++ b/dzgui/views/pages/mods.py @@ -15,6 +15,7 @@ class Mods(Gtk.ScrolledWindow): super().__init__() self.controller = controller + self.emitter = controller.get_emitter() self.tree = ModTreeView(controller) self.add(self.tree) @@ -23,12 +24,11 @@ class Mods(Gtk.ScrolledWindow): self.connect("map", self._on_map) self.connect("unmap", self._on_unmap) - # TODO: use emitter def _on_unmap(self, widget: Self) -> None: - self.controller.toggle_mod_panel(False) + self.emitter.emit("mod_page_toggled", False) def _on_map(self, widget: Self) -> None: - self.controller.toggle_mod_panel(True) + self.emitter.emit("mod_page_toggled", True) def grab_content_area(self) -> None: self.tree.grab_focus() diff --git a/dzgui/views/pages/servers.py b/dzgui/views/pages/servers.py index d9125f8..ab58bc4 100644 --- a/dzgui/views/pages/servers.py +++ b/dzgui/views/pages/servers.py @@ -59,8 +59,10 @@ class ServerNotebook(Gtk.ScrolledWindow): def _on_map(self, widget: Self) -> None: self.emitter.emit("server_page_toggled", True) - print("page changed, starting queue checker") - self.get_active_treeview().start_timeout() + # FIXME: only applies to server notebook, not atomic page + # this can be delegated to the map/unmap signal of the ServerTreeView itself + # print("page changed, starting queue checker") + # self.get_active_treeview().start_timeout() def _on_unmap(self, widget: Self) -> None: self.emitter.emit("server_page_toggled", False) diff --git a/dzgui/views/trees/tree_base.py b/dzgui/views/trees/tree_base.py index c042804..61dacee 100644 --- a/dzgui/views/trees/tree_base.py +++ b/dzgui/views/trees/tree_base.py @@ -125,7 +125,6 @@ class TreeView(CursorMixin, Gtk.TreeView): # type: ignore self._on_tree_selection_changed(selection) def _on_tree_selection_changed(self, selection: Gtk.TreeSelection) -> None: - print("selection changed") self.emit("generic_treesel_changed", selection) def toggle_selection(self, state: bool) -> None: diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index e8722ac..79bf462 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -1,7 +1,7 @@ import logging import multiprocessing import threading -from typing import Any +from typing import Any, Optional from warnings import deprecated from dzgui.views.mixins.context_mixin import ContextMixin @@ -50,7 +50,8 @@ class ServerTreeView(ContextMixin, TreeView): self.set_fixed_height_mode(True) self.set_headers_visible(True) - self.queue_id: int = 0 + self.queue_id: int + self.handler_id: int self.current_proc = None self.queue = multiprocessing.Queue() @@ -108,10 +109,6 @@ class ServerTreeView(ContextMixin, TreeView): self.connect("key-press-event", self.present_menu) self.connect("button-press-event", self.present_menu) - # TODO: simplify this - self.emitter.connect("statusbar_loaded", self.start_distcalc) - # self.emitter.connect("distcalc_started", self._on_distcalc_started) - def _get_ping( self, column: Gtk.TreeViewColumn, @@ -146,8 +143,6 @@ class ServerTreeView(ContextMixin, TreeView): pass def start_timeout(self) -> None: - if self.queue_id: - GLib.Source.remove(self.queue_id) self.queue_id = GLib.timeout_add(QUEUE_CHECK_DELAY, self._check_result_queue) def get_filter_man(self) -> FilteredModelManager: @@ -180,12 +175,19 @@ class ServerTreeView(ContextMixin, TreeView): def _on_map(self, a) -> None: if self.get_enum() is ServerTab.LAN: - # TODO: use emitter here - self.controller.mediator.grid.conpan.lan.set_visible(True) + self.emitter.emit("lan_tab_toggled", True) + + # FIXME: only if the tab is active + self.handler_id = self.emitter.connect("statusbar_loaded", self.start_distcalc) + self.start_timeout() + self.start_distcalc() def _on_unmap(self, a) -> None: + # NOTE: remove queue checker for this tab + GLib.Source.remove(self.queue_id) + self.emitter.disconnect(self.handler_id) if self.get_enum() is ServerTab.LAN: - self.controller.mediator.grid.conpan.lan.set_visible(False) + self.emitter.emit("lan_tab_toggled", False) def set_query_func(self, func: Callable) -> None: self.query_func = func @@ -206,8 +208,6 @@ class ServerTreeView(ContextMixin, TreeView): if self.current_proc and self.current_proc.is_alive(): self.current_proc.terminate() - from typing import Optional - def start_distcalc(self, emitter: Optional["Emitter"] = None): self.terminate_process() self.emitter.emit("distcalc_started") @@ -230,7 +230,6 @@ class ServerTreeView(ContextMixin, TreeView): self.current_proc.start() def _check_result_queue(self) -> Literal[True]: - # TODO: delegate to controller latest_result = None while not self.queue.empty(): latest_result = self.queue.get() @@ -242,6 +241,7 @@ class ServerTreeView(ContextMixin, TreeView): haversine = latest_result[1] if addr not in cache: cache[addr] = haversine + # TODO: should be emitting a statusbar signal here instead? self.controller.set_statusbar_dist(haversine, self.get_enum()) return True @@ -251,6 +251,7 @@ class ServerTreeView(ContextMixin, TreeView): if event.state is Gdk.ModifierType.CONTROL_MASK: match event.keyval: case Gdk.KEY_r: + # TODO: unimplemented self.refresh_player_count() case Gdk.KEY_f: self.emitter.emit("request_keyword_focus") @@ -260,7 +261,7 @@ class ServerTreeView(ContextMixin, TreeView): self.emitter.emit("request_ip_entry_focus") case Gdk.KEY_p: if self.enum is ServerTab.LAN: - self.emitter.emit("request_lan_entry_focus", self.enum) + self.emitter.emit("request_lan_entry_focus") case Gdk.KEY_c: record = self.get_record() self.controller.copy_ip(record) From 38883626f5053bf640e4607680bb254b10070639 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 11 Feb 2026 21:35:51 +0900 Subject: [PATCH 064/309] feat: restore map selection --- dzgui/controllers/mc.py | 36 ++++++++++++++------------ dzgui/model/filtered_model.py | 28 +++++++++++--------- dzgui/views/components/filter_panel.py | 25 +++++++++++------- dzgui/views/components/right_panel.py | 14 +++++----- 4 files changed, 58 insertions(+), 45 deletions(-) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 313b29f..b84a8db 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -377,6 +377,7 @@ class Controller(GObject.GObject): if res.status != 200 or not res.parsed: # TODO: pop warning dialog, create enum around various failure states print("failed to parse/timeout error") + self.new_maps = None self.push_data(None, FilterMode.INITIAL, success=False) return j = res.json @@ -387,21 +388,12 @@ class Controller(GObject.GObject): j = res.json serv += j["response"]["servers"] - # TODO: ping column pass + # TODO: additional ping column pass, collated parsed = Servers.parse_json(serv) - self.mediator.filters.set_unique_maps(parsed) + self.first_iteration = True + self.new_maps = parsed self.push_data(parsed, FilterMode.INITIAL, success=True) - #def set_unique_maps(self, records: list) -> None: - # if len(records) < 1: - # return - # u_maps = set([row[1] for row in records]) - # u_maps = sorted(u_maps) - # for m in u_maps: - # print(m) - # #map_store.append([m]) - # #self.maps_hr.append(m) - def get_help_row(self) -> str: tv = self.mediator.menu model = self.get_help_store() @@ -620,8 +612,17 @@ class Controller(GObject.GObject): self.push_data(data, FilterMode.INITIAL, success=True) def cleanup(self) -> None: - # TODO: if success failed, throw popup treeview = self.get_active_treeview() + + # TODO: signals or other approach to deferring model insertion + # after thread closes + # cf. servers_loaded signal + treeview.set_model(self.to_insert) + # CHORE: this is placeholder logic + if self.first_iteration: + self.mediator.filters.set_unique_maps(self.new_maps) + self.first_iteration = False + context = self.get_active_context() self.emitter.emit("servers_loaded", context) @@ -638,20 +639,21 @@ class Controller(GObject.GObject): treeview = self.get_active_treeview() manager = treeview.get_filter_man() + self.to_insert = None + # TODO: self.success = success if success: if data is None: - insert = None + self.to_insert = None else: # TODO: consolidate into filter manager if mode == FilterMode.INITIAL: manager.set_control(data) - # TODO: init maps here manager.filter(mode) - insert = manager.get_model() + self.to_insert = manager.get_model() # TODO: should list store be set outside of this thread? - treeview.set_model(insert) + # treeview.set_model(self.to_insert) treeview.set_loaded(True) GLib.idle_add(self.cleanup) diff --git a/dzgui/model/filtered_model.py b/dzgui/model/filtered_model.py index 57c7967..b751dd9 100644 --- a/dzgui/model/filtered_model.py +++ b/dzgui/model/filtered_model.py @@ -49,8 +49,8 @@ class FilteredModelManager: self.ephemeral_model = self.new_model_from_class(ServerColumns) - self.control_model = None - self.filtered = None + self.control_model: list = None + self.filtered: list = None self.success = True def append_row(self, row: list) -> None: @@ -86,7 +86,7 @@ class FilteredModelManager: case FilterMode.MAP: prior_map = self.controller.get_prior_map() - if prior_map == "All maps": + if prior_map == strings.all_maps: rows = self.filter_map(filters) else: rows = self.filter_toggle_on(filters, *args) @@ -138,7 +138,7 @@ class FilteredModelManager: rows = self.filtered sel_map = self.controller.get_map() - if sel_map == "All maps": + if sel_map == strings.all_maps: return rows rows = [row for row in rows if row[1] == sel_map] @@ -234,7 +234,7 @@ class FilteredModelManager: if row[7] == addr and row[8] == qport: self.control_model.remove(row) - # self.wipe_cache() + self.wipe_cache() filters = self.controller.get_filters() refiltered = self.filter_toggle_on(filters) self.set_filtered(refiltered) @@ -272,10 +272,14 @@ class FilteredModelManager: 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 + # NOTE: used when adding/removing rows in-situ in the ephemeral model + # and syncing changes to control model, but ignored for player count/ping updates + # cf. remove_from_history(), remove_server() + # NOTE: this can most likely be simplified for v7 + 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 diff --git a/dzgui/views/components/filter_panel.py b/dzgui/views/components/filter_panel.py index 8586101..d3c15de 100644 --- a/dzgui/views/components/filter_panel.py +++ b/dzgui/views/components/filter_panel.py @@ -127,13 +127,18 @@ class FilterPanel(Gtk.Box): self.pack_start(el, NO_EXPAND, NO_FILL, NO_PADDING) def set_unique_maps(self, maps: list) -> None: + if maps is None: + return if len(maps) < 1: return u_maps = set([row[1] for row in maps]) u_maps = sorted(u_maps) + self.controller.append_map([strings.all_maps]) + self.selected_map = strings.all_maps for m in u_maps: self.controller.append_map([m]) self.maps_hr.append(m) + self.maps_combo.set_active(0) def get_filters(self) -> tuple: filters = [] @@ -144,16 +149,17 @@ class FilterPanel(Gtk.Box): filters.append(k) return tuple(filters) - def reinit_panel(self) -> None: - self.keyword_entry.set_text("") - self.keyword_filter = "" - self.reinit_filters() - self.set_visible(False) - # TODO: - sel_panel = self.controller.mediator.grid.sel_panel - if sel_panel.is_visible(): - sel_panel.set_visible(False) + # TODO: currently unused + # def reinit_panel(self) -> None: + # self.keyword_entry.set_text("") + # self.keyword_filter = "" + # self.reinit_filters() + # self.set_visible(False) + # sel_panel = self.controller.mediator.grid.sel_panel + # if sel_panel.is_visible(): + # sel_panel.set_visible(False) + # TODO: change filters on a per-tab basis def reinit_filters(self) -> None: self.enabled_filters = dict(self.default_filters) for check in self.checks: @@ -162,6 +168,7 @@ class FilterPanel(Gtk.Box): check.set_active(state) def _on_map_entry_keypress(self, entry: Gtk.Entry, event: Gdk.EventKey) -> None: + # TODO: use activated() signal match event.keyval: case Gdk.KEY_Return: text = entry.get_text() diff --git a/dzgui/views/components/right_panel.py b/dzgui/views/components/right_panel.py index 7f47ae3..cb3791d 100644 --- a/dzgui/views/components/right_panel.py +++ b/dzgui/views/components/right_panel.py @@ -50,13 +50,13 @@ class RightPanel(Gtk.Box): self.refresh_button.set_sensitive(False) # TODO: move to filter panel - def reinit_maps(self, rows: list) -> None: - self.controller.reinit_map_store() - # TODO: communicate with controller - # self.controller.clear_map_store() - # map_store.append(["All maps"]) - self.selected = "All maps" - self.filters_vbox.set_unique_maps(rows) + # def reinit_maps(self, rows: list) -> None: + # self.controller.reinit_map_store() + # # TODO: communicate with controller + # # self.controller.clear_map_store() + # # map_store.append(["All maps"]) + # self.selected = "All maps" + # self.filters_vbox.set_unique_maps(rows) # TODO: reference # def _on_ping_clicked(self, button: Gtk.Button) -> None: From e1d84ec7beea88f2bf020f7c04ce390d1308132b Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 12 Feb 2026 19:09:42 +0900 Subject: [PATCH 065/309] fix: evaluate str type --- CHANGELOG.md | 1 + dzgui/api/pefile.py | 2 +- dzgui/controllers/mc.py | 5 +---- dzgui/data/CHANGELOG.md | 1 + dzgui/util/format.py | 6 +++--- dzgui/views/base.py | 2 +- dzgui/views/components/buttonbox.py | 10 ++++++++++ dzgui/views/components/entry.py | 1 - dzgui/views/components/right_panel.py | 19 ++++--------------- dzgui/views/pages/options.py | 3 +-- 10 files changed, 23 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea2fb9a..8e292e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ ## Fixed - Longstanding issue with left clicks not registering as tree selection changes after spamming keyboard input +- Rare segfaults when changing maps (threading) ## Unreleased - Setup wizard diff --git a/dzgui/api/pefile.py b/dzgui/api/pefile.py index 26509ef..479783b 100644 --- a/dzgui/api/pefile.py +++ b/dzgui/api/pefile.py @@ -72,7 +72,7 @@ class PackedData: def unpack(cls, data: BinaryIO): r = [] for key, value in cls.__annotations__.items(): - if type(value) is str: + if value is str: f = data.read(8).rstrip(b"\x00\x00").decode() else: fmt = endian + (value.fmt) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index b84a8db..2dd54e5 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -362,6 +362,7 @@ class Controller(GObject.GObject): self.mediator.notebook.set_page_by_enum(button.opens) def dump_api(self) -> None: + self.first_iteration = True key = self.query_config(Preferences.STEAM) job = Servers.query_api params = Servers.params @@ -390,7 +391,6 @@ class Controller(GObject.GObject): # TODO: additional ping column pass, collated parsed = Servers.parse_json(serv) - self.first_iteration = True self.new_maps = parsed self.push_data(parsed, FilterMode.INITIAL, success=True) @@ -795,9 +795,6 @@ class Controller(GObject.GObject): self.set_callback(None, None) self.thread_data_func(func) - def focus_button_box(self) -> None: - self.mediator.right_panel.focus_button_box() - def get_favorite(self) -> tuple[str, str] | tuple[None, None]: fav = str(self.query_config(Preferences.FAV_LBL)) if len(fav) < 1: diff --git a/dzgui/data/CHANGELOG.md b/dzgui/data/CHANGELOG.md index ea2fb9a..8e292e4 100644 --- a/dzgui/data/CHANGELOG.md +++ b/dzgui/data/CHANGELOG.md @@ -31,6 +31,7 @@ ## Fixed - Longstanding issue with left clicks not registering as tree selection changes after spamming keyboard input +- Rare segfaults when changing maps (threading) ## Unreleased - Setup wizard diff --git a/dzgui/util/format.py b/dzgui/util/format.py index 3d83d24..81db99c 100644 --- a/dzgui/util/format.py +++ b/dzgui/util/format.py @@ -61,10 +61,10 @@ def format_player_count(model: Gtk.TreeModel | None, control: list) -> str: hits = len(model) for row in model: players += row[4] - players_pretty = pluralize("players", players) - hits_pretty = pluralize("matches", hits) control_total = len(control) - status = f"Showing {hits:n}/{control_total:n} {hits_pretty} with {players:n} {players_pretty}" + players_pretty = pluralize("players", players) + control_pretty = pluralize("matches", control_total) + status = f"Showing {hits:n}/{control_total:n} {control_pretty} with {players:n} {players_pretty}" return status diff --git a/dzgui/views/base.py b/dzgui/views/base.py index 9ef205b..7cb7714 100644 --- a/dzgui/views/base.py +++ b/dzgui/views/base.py @@ -231,7 +231,7 @@ class Notebook(ScrollableMixin, Gtk.Notebook): # type: ignore case Gdk.KEY_Right | Gdk.KEY_l: if event.state is Gdk.ModifierType.CONTROL_MASK: return - MainController.focus_button_box() + MainController.get_emitter().emit("request_button_box_focus") case Gdk.KEY_question: self.toggle_keybindings() diff --git a/dzgui/views/components/buttonbox.py b/dzgui/views/components/buttonbox.py index e5b2d6f..cfa73ff 100644 --- a/dzgui/views/components/buttonbox.py +++ b/dzgui/views/components/buttonbox.py @@ -1,4 +1,5 @@ import logging +from typing import TYPE_CHECKING from dzgui.const.enum import ButtonType from dzgui.const.constants import NO_EXPAND, NO_FILL, NO_PADDING @@ -10,6 +11,9 @@ from gi.repository import Gtk, Gdk # noqa E402 logger = logging.getLogger(__name__) +if TYPE_CHECKING: + from dzgui.controllers.emitter import Emitter + class ContextualButton(Gtk.Button): def __init__(self, label, opens, tooltip, context): @@ -34,6 +38,9 @@ class ButtonBox(Gtk.Box): ) self.controller = controller + self.emitter = controller.get_emitter() + self.emitter.connect("request_button_box_focus", self._focus_first_button) + self.buttons = list() self.connect("key-press-event", self._on_keypress) prefs = controller.get_prefs() @@ -55,6 +62,9 @@ class ButtonBox(Gtk.Box): button.connect("clicked", self._on_selection_button_clicked) self.pack_start(button, NO_EXPAND, NO_FILL, NO_PADDING) + def _focus_first_button(self, emitter: "Emitter") -> None: + self.buttons[0].grab_focus() + def _on_selection_button_clicked(self, button: Gtk.Button) -> None: self.controller.open_page_by_button(button) diff --git a/dzgui/views/components/entry.py b/dzgui/views/components/entry.py index 66ec8a5..ac2fcc6 100644 --- a/dzgui/views/components/entry.py +++ b/dzgui/views/components/entry.py @@ -59,7 +59,6 @@ class ValidatedEntry(Gtk.Entry): def mark_valid(self) -> None: self.emit("string_validated", True) - self.emitter.emit("widget_changed", self) remove_class(self, self.classname) def mark_invalid(self) -> None: diff --git a/dzgui/views/components/right_panel.py b/dzgui/views/components/right_panel.py index cb3791d..4a3e9ea 100644 --- a/dzgui/views/components/right_panel.py +++ b/dzgui/views/components/right_panel.py @@ -25,14 +25,13 @@ class RightPanel(Gtk.Box): self.controller.register_widget("right_panel", self) emitter = controller.get_emitter() - emitter.connect("request_button_box_focus", self.focus_button_box) + emitter.connect("servers_loaded", self.toggle_refresh_button) self.button_vbox = ButtonBox(controller) self.filters_vbox = FilterPanel(controller) self.sel_panel = ModSelectionPanel(controller) - emitter.connect("servers_loaded", self.toggle_refresh_button) self.refresh_button = RefreshButton(controller) self.keys = KeysButton(controller) @@ -42,22 +41,15 @@ class RightPanel(Gtk.Box): self.pack_start(self.sel_panel, NO_EXPAND, NO_FILL, NO_PADDING) def toggle_refresh_button(self, emitter: "Emitter", context: "ServerTab") -> None: - # TODO: when a row is added, make sure "servers_loaded" signal is emitted + # TODO: when a row is added to a previously empty table, + # make sure "servers_loaded" signal is emitted self.refresh_button.set_sensitive(True) model = self.controller.get_active_treeview().get_model() + # NOTE: if saved servers or history are empty, there is nothing to refresh if model is None: if context in (ServerTab.RECENT, ServerTab.SAVED): self.refresh_button.set_sensitive(False) - # TODO: move to filter panel - # def reinit_maps(self, rows: list) -> None: - # self.controller.reinit_map_store() - # # TODO: communicate with controller - # # self.controller.clear_map_store() - # # map_store.append(["All maps"]) - # self.selected = "All maps" - # self.filters_vbox.set_unique_maps(rows) - # TODO: reference # def _on_ping_clicked(self, button: Gtk.Button) -> None: # block_signals() @@ -89,6 +81,3 @@ class RightPanel(Gtk.Box): # treeview.wait_dialog.show_all() # thread = threading.Thread(target=_update_pings, args=()) # thread.start() - - def focus_button_box(self, emitter: Optional["Emitter"] = None) -> None: - self.button_vbox.buttons[0].grab_focus() diff --git a/dzgui/views/pages/options.py b/dzgui/views/pages/options.py index 7342e0e..751af20 100644 --- a/dzgui/views/pages/options.py +++ b/dzgui/views/pages/options.py @@ -311,8 +311,7 @@ class Options(Gtk.Box): def _on_branch_changed(self, combo: Gtk.ComboBoxText) -> None: branch = combo.get_active_text() - print("UNIMPLEMENTED") - print(branch) + print("UNIMPLEMENTED: ", branch) ## TODO: needs to trigger download process # self.controller.toggle_branch(branch) # branch = combo.get_active_text().lower() From cf754ee28682e1a1c4a9a97cb963f153a0b72f46 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 12 Feb 2026 19:20:23 +0900 Subject: [PATCH 066/309] feat: connect search icon to activate signal --- dzgui/controllers/mc.py | 5 +++-- dzgui/views/components/filter_panel.py | 12 ++++++++++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 2dd54e5..b047d1f 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -614,10 +614,11 @@ class Controller(GObject.GObject): def cleanup(self) -> None: treeview = self.get_active_treeview() - # TODO: signals or other approach to deferring model insertion - # after thread closes + # TODO: signals or other approach to deferring map + # model insertion after thread closes # cf. servers_loaded signal treeview.set_model(self.to_insert) + # CHORE: this is placeholder logic if self.first_iteration: self.mediator.filters.set_unique_maps(self.new_maps) diff --git a/dzgui/views/components/filter_panel.py b/dzgui/views/components/filter_panel.py index d3c15de..41b05a9 100644 --- a/dzgui/views/components/filter_panel.py +++ b/dzgui/views/components/filter_panel.py @@ -91,8 +91,10 @@ class FilterPanel(Gtk.Box): self.keyword_entry.set_icon_from_icon_name( Gtk.EntryIconPosition.SECONDARY, SEARCH_ICON ) - self.keyword_entry.set_icon_activatable(Gtk.EntryIconPosition.SECONDARY, False) - # entry.connect("icon-release", self._on_icon_release) + self.keyword_entry.set_icon_activatable(Gtk.EntryIconPosition.SECONDARY, True) + self.keyword_entry.connect( + "icon-release", lambda *args: self.keyword_entry.activate() + ) self.emitter.connect( "request_keyword_focus", lambda _: self.keyword_entry.grab_focus() @@ -126,6 +128,12 @@ class FilterPanel(Gtk.Box): for el in self.filters_label, self.keyword_entry, self.maps_combo, button_grid: self.pack_start(el, NO_EXPAND, NO_FILL, NO_PADDING) + # def _on_icon_release(self, widget: Gtk.Entry, icon_pos: Gtk ^ a, b) -> None: + # print(a) + # print(b) + # print(icon) + # self.keyword_entry.activate() + def set_unique_maps(self, maps: list) -> None: if maps is None: return From 502b574085d2f8768413405bb9a3758705568f1b Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 12 Feb 2026 19:23:07 +0900 Subject: [PATCH 067/309] fix: use constant when setting CellLayout params --- dzgui/views/components/filter_panel.py | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/dzgui/views/components/filter_panel.py b/dzgui/views/components/filter_panel.py index 41b05a9..0e12fc1 100644 --- a/dzgui/views/components/filter_panel.py +++ b/dzgui/views/components/filter_panel.py @@ -2,7 +2,7 @@ import logging from typing import Literal, TYPE_CHECKING from dzgui.const.enum import FilterMode -from dzgui.const.constants import NO_EXPAND, NO_FILL, NO_PADDING, SEARCH_ICON +from dzgui.const.constants import EXPAND, NO_EXPAND, NO_FILL, NO_PADDING, SEARCH_ICON from dzgui.util import strings from dzgui.util.margins import set_surrounding_margins from dzgui.views.components.labels import BoldLabel @@ -119,21 +119,13 @@ class FilterPanel(Gtk.Box): 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 - # cf. EXPAND, NO_EXPAND - self.maps_combo.pack_start(renderer_text, True) + self.maps_combo.pack_start(renderer_text, EXPAND) self.maps_combo.connect("changed", self._on_map_changed) self.maps_combo.connect("key-press-event", self._on_combo_keypress) for el in self.filters_label, self.keyword_entry, self.maps_combo, button_grid: self.pack_start(el, NO_EXPAND, NO_FILL, NO_PADDING) - # def _on_icon_release(self, widget: Gtk.Entry, icon_pos: Gtk ^ a, b) -> None: - # print(a) - # print(b) - # print(icon) - # self.keyword_entry.activate() - def set_unique_maps(self, maps: list) -> None: if maps is None: return From 24125a74920b23647ac26297d77ae94cee3b073e Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Fri, 13 Feb 2026 00:59:13 +0900 Subject: [PATCH 068/309] feat: update fav server dynamically --- CHANGELOG.md | 1 + dzgui/api/servers.py | 2 + dzgui/const/enum.py | 30 ++--- dzgui/controllers/emitter.py | 4 + dzgui/controllers/mc.py | 44 ++++--- dzgui/data/CHANGELOG.md | 1 + dzgui/model/misc_model.py | 13 +- dzgui/util/strings.py | 2 + dzgui/views/components/buttons.py | 18 +-- dzgui/views/components/connect_panel.py | 24 ++-- dzgui/views/components/crumbs.py | 7 +- dzgui/views/components/filter_panel.py | 3 +- dzgui/views/components/mod_panel.py | 3 + dzgui/views/dialogs/generic.py | 10 +- dzgui/views/dialogs/lan.py | 153 ------------------------ dzgui/views/mixins/context_mixin.py | 3 +- dzgui/views/pages/servers.py | 1 - dzgui/views/trees/tree_base.py | 31 +++-- dzgui/views/trees/tree_servers.py | 8 +- 19 files changed, 127 insertions(+), 231 deletions(-) delete mode 100644 dzgui/views/dialogs/lan.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e292e4..83ca4b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ - Boldface breadcrumbs - Bold labels inside frames - Unsticky keys when clicking sidebar +- Copy IP copies IP:queryport only instead of IP:gameport:queryport, mimics syntax needed by add by ip method ## Fixed - Longstanding issue with left clicks not registering as tree selection changes after spamming keyboard input diff --git a/dzgui/api/servers.py b/dzgui/api/servers.py index f2a3aaf..a643bea 100644 --- a/dzgui/api/servers.py +++ b/dzgui/api/servers.py @@ -389,6 +389,8 @@ def ping(iteration: int, row: list) -> Ping: def query_api(key: str, appid: int, param: str) -> Res: + import time + LIMIT = 10000 payload: dict[str, Union[int, str]] = { "filter": r"\appid" + rf"\{appid}" + param, diff --git a/dzgui/const/enum.py b/dzgui/const/enum.py index 490317a..d5b50a0 100644 --- a/dzgui/const/enum.py +++ b/dzgui/const/enum.py @@ -209,20 +209,20 @@ class RowType(EnumWithAttrs): # TODO: rename to ContextItem class ContextMenu(EnumWithAttrs): - ADD_SERVER = {"label": strings.add} - ADD_FAV = {"label": strings.add_fav} - 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} + ADD_SERVER = {"label": strings.add} + CONNECT = {"label": strings.connect} + COPY_CLIPBOARD = {"label": strings.copy_ip} + COPY_LOG_CLIPBOARD = {"label": strings.copy_log} + COPY_NAME = {"label": strings.copy_name} + DELETE_MOD = {"label": strings.delete_mod} + OPEN_WORKSHOP = {"label": strings.open_workshop} 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} - CONNECT = {"label": strings.connect} + REMOVE_SERVER = {"label": strings.remove} + SET_FAV = {"label": strings.add_fav} + SHOW_DETAILS = {"label": strings.show_details} + SHOW_MODS = {"label": strings.show_mods} class ContextMenuGroup(Enum): @@ -235,7 +235,7 @@ class ContextMenuGroup(Enum): SERVER_BROWSER = ( ContextMenu.CONNECT, ContextMenu.ADD_SERVER, - ContextMenu.ADD_FAV, + ContextMenu.SET_FAV, ContextMenu.COPY_NAME, ContextMenu.COPY_CLIPBOARD, ContextMenu.ADD_NOTE, @@ -245,7 +245,7 @@ class ContextMenuGroup(Enum): ) SCAN_LAN = ( ContextMenu.CONNECT, - ContextMenu.ADD_FAV, + ContextMenu.SET_FAV, ContextMenu.COPY_NAME, ContextMenu.COPY_CLIPBOARD, ContextMenu.ADD_NOTE, @@ -255,7 +255,7 @@ class ContextMenuGroup(Enum): ) SAVED = ( ContextMenu.CONNECT, - ContextMenu.ADD_FAV, + ContextMenu.SET_FAV, ContextMenu.REMOVE_SERVER, ContextMenu.COPY_NAME, ContextMenu.COPY_CLIPBOARD, @@ -267,7 +267,7 @@ class ContextMenuGroup(Enum): RECENT = ( ContextMenu.CONNECT, ContextMenu.ADD_SERVER, - ContextMenu.ADD_FAV, + ContextMenu.SET_FAV, ContextMenu.REMOVE_HISTORY, ContextMenu.COPY_NAME, ContextMenu.COPY_CLIPBOARD, diff --git a/dzgui/controllers/emitter.py b/dzgui/controllers/emitter.py index ee43b00..8c15495 100644 --- a/dzgui/controllers/emitter.py +++ b/dzgui/controllers/emitter.py @@ -41,6 +41,10 @@ class Emitter(GObject.GObject): """User invoked right movement keybinding from ServerTreeView""" pass + @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(str, str)) + def fav_server_changed(self, name: str, addr: str) -> None: + """Change favorite server via context menu""" + @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(int,)) def check_button_pressed(self, keyval: int) -> None: """User toggled filter panel check button via keyboard""" diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index b047d1f..c2cdcb5 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -359,7 +359,8 @@ class Controller(GObject.GObject): # TODO: reload using refresh button, rather than on demand? self.load_mods() - self.mediator.notebook.set_page_by_enum(button.opens) + self.open_page(button.opens) + #self.mediator.notebook.set_page_by_enum(button.opens) def dump_api(self) -> None: self.first_iteration = True @@ -443,23 +444,22 @@ class Controller(GObject.GObject): self.clipboard.set_text(text, -1) def open_mod_page(self, path: Gtk.TreePath) -> None: - mod, it = self.get_mod_from_tree_path(path) + mod, it = self.model_man.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_man.get_mod_store() - tree_iter = model.get_iter(tree_path) - mod = model.get(tree_iter, 2)[0] - return mod, tree_iter + #def get_mod_from_tree_path( + # self, tree_path: Gtk.TreePath + #) -> tuple[str, Gtk.TreeIter]: + # model = self.model_man.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) + mod, it = self.model_man.get_mod_from_tree_path(tree_path) path = lookup(config, Preferences.DEFAULT) steam_path = Path(path) @@ -502,8 +502,6 @@ class Controller(GObject.GObject): # TODO: unimplemented case ContextMenu.ADD_SERVER: pass - case ContextMenu.ADD_FAV: - pass case ContextMenu.ADD_NOTE: pass case ContextMenu.COPY_CLIPBOARD: @@ -516,6 +514,17 @@ class Controller(GObject.GObject): pass case ContextMenu.REMOVE_SERVER: pass + case ContextMenu.SET_FAV: + treeview = self.get_active_treeview() + name = treeview.get_value_at_index(0) + record = treeview.get_record_string() + + self.update_config(Preferences.FAV_LBL, name) + self.update_config(Preferences.FAV_SRV, record) + # TODO: consider a failure dialog here + + simple_ip = treeview.get_simplified_ip() + self.emitter.emit("fav_server_changed", name, simple_ip) case ContextMenu.SHOW_DETAILS: pass case ContextMenu.SHOW_MODS: @@ -611,6 +620,8 @@ class Controller(GObject.GObject): ) self.push_data(data, FilterMode.INITIAL, success=True) + # TODO: eg cleanup on failure, cleanup on sucess + # separate methods def cleanup(self) -> None: treeview = self.get_active_treeview() @@ -759,7 +770,7 @@ class Controller(GObject.GObject): return self.mediator.statusbar @call_on_thread - def thread_data_func(self, func: Callable) -> None: + def run_query_func(self, func: Callable) -> None: func() @call_on_thread @@ -789,12 +800,11 @@ class Controller(GObject.GObject): if func is None: self.emitter.emit("servers_loaded", treeview.get_enum()) return - # TODO: on legacy version, model clearing happens in thread - # (compare) + # TODO: # manager = treeview.get_filter_man() # manager.clear_model() self.set_callback(None, None) - self.thread_data_func(func) + self.run_query_func(func) def get_favorite(self) -> tuple[str, str] | tuple[None, None]: fav = str(self.query_config(Preferences.FAV_LBL)) diff --git a/dzgui/data/CHANGELOG.md b/dzgui/data/CHANGELOG.md index 8e292e4..83ca4b5 100644 --- a/dzgui/data/CHANGELOG.md +++ b/dzgui/data/CHANGELOG.md @@ -28,6 +28,7 @@ - Boldface breadcrumbs - Bold labels inside frames - Unsticky keys when clicking sidebar +- Copy IP copies IP:queryport only instead of IP:gameport:queryport, mimics syntax needed by add by ip method ## Fixed - Longstanding issue with left clicks not registering as tree selection changes after spamming keyboard input diff --git a/dzgui/model/misc_model.py b/dzgui/model/misc_model.py index 4646514..08b6978 100644 --- a/dzgui/model/misc_model.py +++ b/dzgui/model/misc_model.py @@ -2,9 +2,11 @@ from dataclasses import dataclass from dzgui.const.enum import HELP_MENU_ROWS import gi + gi.require_version("Gtk", "3.0") from gi.repository.Gtk import ListStore # noqa E402 -from gi.repository import GObject, GLib # noqa E402 +from gi.repository import GLib, GObject, Gtk # noqa E402 + @dataclass(slots=True, frozen=True) class ModCols: @@ -40,6 +42,7 @@ class ModelManager: """ Manager for miscellaneous ListStores """ + def __init__(self) -> None: self.map_store = ListStore(str) @@ -77,3 +80,11 @@ class ModelManager: def append_map(self, row: list) -> None: self.map_store.append(row) + + def get_mod_from_tree_path( + self, tree_path: Gtk.TreePath + ) -> tuple[str, Gtk.TreeIter]: + model = self.get_mod_store() + tree_iter = model.get_iter(tree_path) + mod = model.get(tree_iter, 2)[0] + return mod, tree_iter diff --git a/dzgui/util/strings.py b/dzgui/util/strings.py index b94eb7c..f53a695 100644 --- a/dzgui/util/strings.py +++ b/dzgui/util/strings.py @@ -268,6 +268,7 @@ class Crumbs: servers: str thanks: str developers: str + default: str @dataclass(slots=True, frozen=True) @@ -453,6 +454,7 @@ crumbs = Crumbs( servers="Servers > Server browser", thanks="Help > Special thanks", developers="Options > Developers", + default="Servers > ", ) checkmark = "✓" diff --git a/dzgui/views/components/buttons.py b/dzgui/views/components/buttons.py index 47e4803..afd72f2 100644 --- a/dzgui/views/components/buttons.py +++ b/dzgui/views/components/buttons.py @@ -1,4 +1,4 @@ -from typing import Literal, Self, TYPE_CHECKING +from typing import Callable, Literal, Self, TYPE_CHECKING from dzgui.util.strings import atomic_buttons, connect_panel from dzgui.const.constants import ( @@ -11,6 +11,7 @@ from dzgui.const.constants import ( ) import gi + gi.require_version("Gtk", "3.0") from gi.repository import Gtk, GLib # noqa E402 @@ -21,7 +22,8 @@ if TYPE_CHECKING: class Icon(Gtk.Image): def __init__(self, name: str, l_margin: int = 0) -> None: - super().__init__(icon_name=name, + super().__init__( + icon_name=name, icon_size=Gtk.IconSize.BUTTON, margin_start=l_margin, ypad=2, @@ -30,9 +32,8 @@ class Icon(Gtk.Image): class LargeIcon(Gtk.Image): def __init__(self, name: str, l_margin: int = 5) -> None: - super().__init__(icon_name=name, - icon_size=Gtk.IconSize.LARGE_TOOLBAR, - margin_start=l_margin + super().__init__( + icon_name=name, icon_size=Gtk.IconSize.LARGE_TOOLBAR, margin_start=l_margin ) @@ -59,14 +60,15 @@ class LargeIconTextButton(IconButton): class ClipboardButton(IconTextButton): - def __init__(self, controller: "Controller", data: str) -> None: + def __init__(self, controller: "Controller", func: Callable) -> None: super().__init__(CLIPBOARD, atomic_buttons.copy) self.controller = controller - self.connect("clicked", self._on_button_clicked, data) + self.connect("clicked", self._on_button_clicked, func) self.set_tooltip_text("Copy IP to clipboard") - def _on_button_clicked(self, button: Self, data: str) -> None: + def _on_button_clicked(self, button: Self, func: Callable) -> None: + data = func() self.controller.copy_clipboard(data) diff --git a/dzgui/views/components/connect_panel.py b/dzgui/views/components/connect_panel.py index 4bdcb08..10f1db9 100644 --- a/dzgui/views/components/connect_panel.py +++ b/dzgui/views/components/connect_panel.py @@ -73,12 +73,14 @@ class FavPanel(Gtk.Frame): self.set_label_widget(label) self.controller = controller + emitter = self.controller.get_emitter() + emitter.connect("fav_server_changed", self._on_fav_server_changed) # TODO: improve upon this, cache fav ip when changed globally - user_fav, self.fav_ip = self.controller.get_favorite() + self.server_name, self.server_ip = self.controller.get_favorite() server_name = ( - f"{user_fav} ({self.fav_ip})" - if user_fav is not None + f"{self.server_name} ({self.server_ip})" + if self.server_name is not None else connect_panel.no_fav ) self.fav_label = Gtk.Label( @@ -94,9 +96,8 @@ class FavPanel(Gtk.Frame): self.fav_button = SteamConnectButton() if server_name is None: self.fav_button.set_sensitive(False) - copy = ClipboardButton(self.controller, self.get_fav_ip()) + copy = ClipboardButton(self.controller, self.get_fav_ip) - # FIXME: height of connect buttons is not equivalent grid = Gtk.Grid(margin=10, vexpand=False, column_spacing=15, row_spacing=5) grid.attach(scrollable_label, 0, 0, 3, ROWS) grid.attach_next_to(copy, scrollable_label, Gtk.PositionType.RIGHT, COLS, ROWS) @@ -105,11 +106,16 @@ class FavPanel(Gtk.Frame): self.add(grid) def get_fav_ip(self) -> str: - return self.fav_ip + return self.server_ip - def set_fav_label(self, text: str) -> None: - # TODO: called by controller when changing fav - self.fav_label.set_text(text) + from dzgui.api.servers import Record + + def _on_fav_server_changed( + self, emitter: "Emitter", name: str, record: str + ) -> None: + self.server_name = name + self.server_ip = record + self.fav_label.set_text(f"{name} ({record})") class AddPanel(Gtk.Frame): diff --git a/dzgui/views/components/crumbs.py b/dzgui/views/components/crumbs.py index cd83488..63c16f2 100644 --- a/dzgui/views/components/crumbs.py +++ b/dzgui/views/components/crumbs.py @@ -1,7 +1,7 @@ from typing import TYPE_CHECKING from dzgui.const.enum import NotebookPage -from dzgui.util.strings import label_main_menu +from dzgui.util.strings import label_main_menu, crumbs from dzgui.util.format import embolden import gi @@ -43,9 +43,8 @@ class Breadcrumbs(Gtk.Label): crumbs = enum.dict["crumbs"] self.set_crumbs(crumbs) - def set_server_crumbs(self, crumbs: str) -> None: - # TODO: strings - self.set_crumbs("Servers > " + crumbs) + def set_server_crumbs(self, suffix: str) -> None: + self.set_crumbs(crumbs.default + suffix) def set_crumbs(self, crumbs: str) -> None: text = embolden(crumbs) diff --git a/dzgui/views/components/filter_panel.py b/dzgui/views/components/filter_panel.py index 0e12fc1..bd80573 100644 --- a/dzgui/views/components/filter_panel.py +++ b/dzgui/views/components/filter_panel.py @@ -149,7 +149,8 @@ class FilterPanel(Gtk.Box): filters.append(k) return tuple(filters) - # TODO: currently unused + # TODO: should be used when switching ServerTab contexts + # use signals here # def reinit_panel(self) -> None: # self.keyword_entry.set_text("") # self.keyword_filter = "" diff --git a/dzgui/views/components/mod_panel.py b/dzgui/views/components/mod_panel.py index 0704136..9f6fbae 100644 --- a/dzgui/views/components/mod_panel.py +++ b/dzgui/views/components/mod_panel.py @@ -64,6 +64,8 @@ class ModSelectionPanel(Gtk.Box): self.set_visible(state) def after_colorize(self) -> None: + # TODO: split into signal that touches two widgets, + # modtreeview and this widget self.controller.unselect_all_mods() self.swap_sensitive(True) @@ -77,6 +79,7 @@ class ModSelectionPanel(Gtk.Box): def _on_button_clicked(self, button: EnumeratedModButton) -> None: match button.enum: case ModButton.SELECT_ALL: + # TODO: signals self.controller.toggle_mod_selection(True) case ModButton.UNSELECT_ALL: self.controller.toggle_mod_selection(False) diff --git a/dzgui/views/dialogs/generic.py b/dzgui/views/dialogs/generic.py index ef91879..6e704fc 100644 --- a/dzgui/views/dialogs/generic.py +++ b/dzgui/views/dialogs/generic.py @@ -75,6 +75,7 @@ class ConfirmationDialog(GenericDialog): secondary=secondary, ) + class IgnoreDialog(GenericDialog): def __init__(self, controller: "Controller", secondary: str): super().__init__( @@ -88,7 +89,6 @@ class IgnoreDialog(GenericDialog): cancel.set_label("Ignore") - class NotifyDialog(GenericDialog): def __init__(self, controller: "Controller", secondary: str): super().__init__( @@ -169,6 +169,7 @@ class ExceptionDialog(GenericDialog): secondary=strings.something_wrong, ) + self.trace = trace # NOTE: box expands to end of content area scrollable = Gtk.ScrolledWindow( propagate_natural_height=True, max_content_height=500 @@ -177,7 +178,7 @@ class ExceptionDialog(GenericDialog): textview = Gtk.TextView( wrap_mode=Gtk.WrapMode.WORD, editable=False, left_margin=10, right_margin=10 ) - textview.set_buffer(Gtk.TextBuffer(text=trace)) + textview.set_buffer(Gtk.TextBuffer(text=self.trace)) box.pack_start(textview, EXPAND, FILL, 10) scrollable.add(box) @@ -186,7 +187,7 @@ class ExceptionDialog(GenericDialog): # FIXME: padding around top of content area when traceback is long content.add(scrollable) - copy_button = ClipboardButton(controller, trace) + copy_button = ClipboardButton(controller, self.get_trace) self.add_action_widget(copy_button, Gtk.ResponseType.NONE) self.add_button("OK", Gtk.ResponseType.OK) @@ -194,6 +195,9 @@ class ExceptionDialog(GenericDialog): self.action_area.get_children()[1].grab_focus() self.connect("response", self._on_response) + def get_trace(self) -> str: + return self.trace + def _on_response( self, dialog: Self, response: Gtk.ResponseType ) -> None | Literal[True]: diff --git a/dzgui/views/dialogs/lan.py b/dzgui/views/dialogs/lan.py deleted file mode 100644 index dd061e1..0000000 --- a/dzgui/views/dialogs/lan.py +++ /dev/null @@ -1,153 +0,0 @@ -from typing import Self - -import gi -gi.require_version("Gtk", "3.0") -from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa - -from dzgui.const.constants import NO_EXPAND, NO_FILL, NO_PADDING -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, NO_PADDING) - 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) diff --git a/dzgui/views/mixins/context_mixin.py b/dzgui/views/mixins/context_mixin.py index c9e5079..b75515c 100644 --- a/dzgui/views/mixins/context_mixin.py +++ b/dzgui/views/mixins/context_mixin.py @@ -18,7 +18,8 @@ class ContextMixin(TreeView): widget: Gtk.Widget, event: Gdk.EventButton | Gdk.EventKey, ) -> None: - + # FIXME: double click causes issue + # split into present by click and present by key if self.is_selection_empty(): return False diff --git a/dzgui/views/pages/servers.py b/dzgui/views/pages/servers.py index ab58bc4..f5df268 100644 --- a/dzgui/views/pages/servers.py +++ b/dzgui/views/pages/servers.py @@ -61,7 +61,6 @@ class ServerNotebook(Gtk.ScrolledWindow): self.emitter.emit("server_page_toggled", True) # FIXME: only applies to server notebook, not atomic page # this can be delegated to the map/unmap signal of the ServerTreeView itself - # print("page changed, starting queue checker") # self.get_active_treeview().start_timeout() def _on_unmap(self, widget: Self) -> None: diff --git a/dzgui/views/trees/tree_base.py b/dzgui/views/trees/tree_base.py index 61dacee..b2423b5 100644 --- a/dzgui/views/trees/tree_base.py +++ b/dzgui/views/trees/tree_base.py @@ -1,6 +1,7 @@ import logging from typing import Optional, TYPE_CHECKING +from warnings import deprecated from dzgui.const.constants import SEPARATOR from dzgui.util.keys import is_navkey @@ -39,8 +40,6 @@ class TreeView(CursorMixin, Gtk.TreeView): # type: ignore self.selected_row.connect("changed", self._on_tree_selection_changed) self.connect("row-activated", self._on_row_activated) - self.connect("button-press-event", lambda x, y: print(self.sel_blocked)) - self.connect("key-press-event", self._on_keypress) self.connect("key-release-event", self._on_key_release) @@ -56,12 +55,12 @@ class TreeView(CursorMixin, Gtk.TreeView): # type: ignore ) -> None: pass - def _separate(self, model: Gtk.ListStore, iter_: Gtk.TreeIter) -> bool: - if model[iter_][0] == SEPARATOR: + 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: + def get_current_iter(self) -> Optional[Gtk.TreeIter]: it = self.get_selection().get_selected()[1] return it @@ -82,7 +81,6 @@ class TreeView(CursorMixin, Gtk.TreeView): # type: ignore return [model[row] for row in rows] def _on_keypress(self, treeview: Gtk.TreeView, event: Gdk.EventKey) -> None: - if is_navkey(event.keyval): if self.get_model() is None: return @@ -98,9 +96,9 @@ class TreeView(CursorMixin, Gtk.TreeView): # type: ignore return def _on_key_release(self, treeview: Gtk.TreeView, event: Gdk.EventKey) -> None: - # TODO: explain this better """ Suppresses spamming on keydown + TODO: explain this better """ # TODO: multisel # if event.keyval is Gdk.KEY_space: @@ -139,9 +137,7 @@ class TreeView(CursorMixin, Gtk.TreeView): # type: ignore 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 + (model, pathlist) = self.get_model_and_pathlist() if len(pathlist) < 1: return "" path = pathlist[0] @@ -157,16 +153,20 @@ class TreeView(CursorMixin, Gtk.TreeView): # type: ignore sel = self.get_selection() self._on_tree_selection_changed(sel) - def get_mpath(self) -> Gtk.TreePath | None: + def get_model_and_pathlist(self) -> tuple: select = self.get_selection() sels = select.get_selected_rows() (model, pathlist) = sels + return (model, pathlist) + + @deprecated("Currently unused") + def get_mpath(self) -> Optional[Gtk.TreePath]: + (model, pathlist) = self.get_model_and_pathlist() if len(pathlist) < 1: return None path = pathlist[0] return path - # @signal_emission def _on_row_activated( self, treeview: Gtk.TreeView, @@ -176,15 +176,12 @@ class TreeView(CursorMixin, Gtk.TreeView): # type: ignore 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 + (model, pathlist) = self.get_model_and_pathlist() if len(pathlist) < 1: return True return False - def get_selected_row(self) -> Gtk.TreeModelRow | None: + def get_selected_row(self) -> Optional[Gtk.TreeModelRow]: ind = self.get_selected_row_index() model = self.get_model() if model is None: diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index 79bf462..52f0055 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -307,13 +307,19 @@ class ServerTreeView(ContextMixin, TreeView): return self.start_distcalc() + def get_simplified_ip(self) -> str: + addr = self.get_value_at_index(7) + qport = self.get_value_at_index(8) + ip = addr.split(":")[0] + return f"{ip}:{qport}" + def get_record_string(self) -> str: addr = self.get_value_at_index(7) qport = self.get_value_at_index(8) return f"{addr}:{qport}" def get_record(self) -> Record | None: - # TODO: delegate to controller + # TODO: use tree_base.get_selected_row() select = self.get_selection() sels = select.get_selected_rows() (model, pathlist) = sels From 7b06973af36a09e33a7fb4e960c18a1210c58a61 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Fri, 13 Feb 2026 01:07:49 +0900 Subject: [PATCH 069/309] chore: clear linting errors --- README.md | 1 - dzgui/api/servers.py | 2 -- dzgui/controllers/mc.py | 2 +- dzgui/util/diag.py | 9 +++++---- dzgui/util/format.py | 2 +- dzgui/util/strings.py | 4 +++- dzgui/views/components/right_panel.py | 9 ++------- dzgui/views/components/statusbar.py | 1 - dzgui/views/dialogs/generic.py | 2 -- dzgui/views/trees/tree_servers.py | 2 +- 10 files changed, 13 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 111e46b..969add7 100644 --- a/README.md +++ b/README.md @@ -25,4 +25,3 @@ Refer to the documentation for installation and setup instructions: Geolocation records from [DB-IP](https://db-ip.com) under [CC 4.0 license](https://creativecommons.org/licenses/by/4.0/) # TODO: mention third party libraries -# TODO: mention 'pre-commit install' command diff --git a/dzgui/api/servers.py b/dzgui/api/servers.py index a643bea..f2a3aaf 100644 --- a/dzgui/api/servers.py +++ b/dzgui/api/servers.py @@ -389,8 +389,6 @@ def ping(iteration: int, row: list) -> Ping: def query_api(key: str, appid: int, param: str) -> Res: - import time - LIMIT = 10000 payload: dict[str, Union[int, str]] = { "filter": r"\appid" + rf"\{appid}" + param, diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index c2cdcb5..5c18f0b 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -8,7 +8,7 @@ from warnings import deprecated from concurrent.futures import wait from concurrent.futures import ThreadPoolExecutor from pathlib import Path -from typing import Any, Callable, Literal, Union, TYPE_CHECKING +from typing import Any, Callable, TYPE_CHECKING import dzgui.api.pefile as PeFile import dzgui.api.servers as Servers diff --git a/dzgui/util/diag.py b/dzgui/util/diag.py index 8d069c2..d027687 100644 --- a/dzgui/util/diag.py +++ b/dzgui/util/diag.py @@ -26,16 +26,17 @@ def get_cpu_model() -> str: def print_servers(servers: list[str]) -> str: s = "" for server in servers: - s+= server + "\n" + s += server + "\n" return s def print_mods(mods: list[int]) -> str: s = "" for mod in mods: - s+= str(mod) + "\n" + s += str(mod) + "\n" return s + def write_diagnostic(config: Path, outfile: Path) -> None: # TODO: test availability on other distros date = datetime.now().isoformat() @@ -62,7 +63,7 @@ def write_diagnostic(config: Path, outfile: Path) -> None: servers = lookup(config, Preferences.IP_LIST) servers_pretty = print_servers(servers) - # FIXME: extraneous newlines in lists + # FIXME: extraneous newlines in lists of mods template = f"""\ {APP_NAME} version {version} ({branch}) Date: {date} @@ -82,5 +83,5 @@ def write_diagnostic(config: Path, outfile: Path) -> None: {servers_pretty} """ - output = "\n".join([line.lstrip() for line in template.split('\n')]) + output = "\n".join([line.lstrip() for line in template.split("\n")]) outfile.write_text(output) diff --git a/dzgui/util/format.py b/dzgui/util/format.py index 81db99c..9b06e30 100644 --- a/dzgui/util/format.py +++ b/dzgui/util/format.py @@ -1,7 +1,7 @@ import re from dzgui.util.localize import number -from dzgui.util.strings import no_mods, no_servers, distance_suffix +from dzgui.util.strings import no_mods, no_servers import gi diff --git a/dzgui/util/strings.py b/dzgui/util/strings.py index f53a695..4e4ecb2 100644 --- a/dzgui/util/strings.py +++ b/dzgui/util/strings.py @@ -1,6 +1,8 @@ from dataclasses import dataclass from dzgui.const.constants import SYSTEM_LOG +# TODO: move to util.format.py + def build_missing(build: str) -> str: msg = ( @@ -605,7 +607,7 @@ atomic_buttons = AtomicButton( keys_tooltip="Toggles the keybindings dialog", ) -steam_icon_missing="Steam icon not found in IconTheme" +steam_icon_missing = "Steam icon not found in IconTheme" missing_changelog = "Error: failed to read changelog" esc_to_return = "Press ESC to return" diff --git a/dzgui/views/components/right_panel.py b/dzgui/views/components/right_panel.py index 4a3e9ea..e1c95b5 100644 --- a/dzgui/views/components/right_panel.py +++ b/dzgui/views/components/right_panel.py @@ -1,4 +1,4 @@ -from typing import Optional, TYPE_CHECKING +from typing import TYPE_CHECKING from dzgui.const.enum import ServerTab from dzgui.views.components.buttonbox import ButtonBox @@ -50,11 +50,10 @@ class RightPanel(Gtk.Box): if context in (ServerTab.RECENT, ServerTab.SAVED): self.refresh_button.set_sensitive(False) - # TODO: reference + # TODO: reference for ping pass # def _on_ping_clicked(self, button: Gtk.Button) -> None: # block_signals() # def _update_pings(): - # # TODO # rows = ModelManager.get_filtered() # with ThreadPoolExecutor(100) as executor: # futures = [ @@ -67,14 +66,10 @@ class RightPanel(Gtk.Box): # path = Gtk.TreePath.new_from_indices([res.iteration]) # temp_model[path][9] = res.ping # ModelManager.ping_cache[res.addr] = res.ping - # # TODO: drop/rewrite # treeview.set_model(temp_model) # treeview.wait_dialog.destroy() # treeview.grab_focus() - - # # TODO: # unblock_signals() - # temp_model = self.AppNav.treeview.get_model() # treeview.set_model(None) # treeview.wait_dialog = GenericDialog("Pinging servers", Popup.WAIT) diff --git a/dzgui/views/components/statusbar.py b/dzgui/views/components/statusbar.py index 27a1d4d..f442422 100644 --- a/dzgui/views/components/statusbar.py +++ b/dzgui/views/components/statusbar.py @@ -1,4 +1,3 @@ -from datetime import datetime from typing import Self, Union, TYPE_CHECKING from dzgui.const.enum import NotebookPage, ServerTab diff --git a/dzgui/views/dialogs/generic.py b/dzgui/views/dialogs/generic.py index 6e704fc..742a817 100644 --- a/dzgui/views/dialogs/generic.py +++ b/dzgui/views/dialogs/generic.py @@ -116,8 +116,6 @@ class WaitDialog(GenericDialog): content.pack_end(spinner, NO_EXPAND, NO_FILL, 0) spinner.start() - # FIXME: center on parent window - # self.show_all() def _on_dialog_delete( self, response_id: Gtk.ResponseType, event: Gdk.Event diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index 52f0055..85a6a23 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -183,7 +183,7 @@ class ServerTreeView(ContextMixin, TreeView): self.start_distcalc() def _on_unmap(self, a) -> None: - # NOTE: remove queue checker for this tab + # NOTE: removes queue checker for this tab GLib.Source.remove(self.queue_id) self.emitter.disconnect(self.handler_id) if self.get_enum() is ServerTab.LAN: From c786ba2c9ab3e621496ab9f78060815e6fcbed07 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Fri, 13 Feb 2026 02:29:42 +0900 Subject: [PATCH 070/309] fix: isolate API failure control flow --- dzgui/controllers/mc.py | 160 +++++++++++++++++++++++++--------------- 1 file changed, 100 insertions(+), 60 deletions(-) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 5c18f0b..b36dcd2 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -104,6 +104,7 @@ class Controller(GObject.GObject): def call_on_thread(func: Callable) -> Callable: def wrapper(*args, **kwargs): self = args[0] + # TODO: get dialog string from own attribute self.wait_dialog = WaitDialog(self, strings.dialog.filtering) self.wait_dialog.show_all() thread = threading.Thread(target=func, args=args) @@ -116,21 +117,6 @@ class Controller(GObject.GObject): except AttributeError: logger.critical(f"{attr} is not a valid AppNavigation attribute.") - def get_help_store(self) -> Gtk.ListStore: - return self.model_man.get_help_store() - - def get_map_store(self) -> Gtk.ListStore: - return self.model_man.get_map_store() - - def get_modlist_store(self) -> Gtk.ListStore: - return self.model_man.get_modlist_store() - - def get_mod_store(self) -> Gtk.ListStore: - return self.model_man.get_mod_store() - - def get_log_store(self) -> Gtk.ListStore: - return self.model_man.get_log_store() - def terminate_process(self) -> None: # TODO: only used by server table multiprocessing queue self.get_active_treeview().terminate_process() @@ -252,12 +238,12 @@ class Controller(GObject.GObject): return msg_id def set_statusbar_dist(self, haversine: "Haversine", enum: "ServerTab") -> None: - context = self.get_active_context() - page = self.mediator.notebook.get_page_by_enum() """ NOTE: prevents race condition when server tab changed, but allows caching the distance in the background """ + context = self.get_active_context() + page = self.mediator.notebook.get_page_by_enum() if page != NotebookPage.SERVERS: self.emitter.emit("distcalc_ended" , None, context) return @@ -265,7 +251,6 @@ class Controller(GObject.GObject): self.emitter.emit("distcalc_ended" , None, context) return - # NOTE: user may have changed km/mi toggle, so recalculate dist: str if haversine is None: dist = "Unknown" @@ -292,7 +277,7 @@ class Controller(GObject.GObject): self.update_mod_statusbar() def load_mods(self) -> None: - # TODO: threading + # TODO: threading; could get slow with 100s of mods model = self.model_man.get_mod_store() model.clear() path = self.query_config(Preferences.DEFAULT) @@ -360,7 +345,6 @@ class Controller(GObject.GObject): self.load_mods() self.open_page(button.opens) - #self.mediator.notebook.set_page_by_enum(button.opens) def dump_api(self) -> None: self.first_iteration = True @@ -378,9 +362,9 @@ class Controller(GObject.GObject): res = future.result() if res.status != 200 or not res.parsed: # TODO: pop warning dialog, create enum around various failure states - print("failed to parse/timeout error") + # TODO: if first iteration, disable map combo. otherwise, do nothing self.new_maps = None - self.push_data(None, FilterMode.INITIAL, success=False) + self.push_data_failure() #None, FilterMode.INITIAL, success=False) return j = res.json serv += j["response"]["servers"] @@ -496,41 +480,36 @@ class Controller(GObject.GObject): total_size += mod[3] return total_mods, total_size + def set_fav(self) -> None: + treeview = self.get_active_treeview() + name = treeview.get_value_at_index(0) + record = treeview.get_record_string() + + try: + self.update_config(Preferences.FAV_LBL, name) + self.update_config(Preferences.FAV_SRV, record) + except Exception: + # TODO: add a failure dialog here + return + + simple_ip = treeview.get_simplified_ip() + self.emitter.emit("fav_server_changed", name, simple_ip) + def menu_action(self, action: ContextMenu, path: Gtk.TreePath) -> None: match action: - # NOTE: manipulates server stores - # TODO: unimplemented + # NOTE: manipulates server stores and caches + # add to saved servers model verbatim and sort in place + # update tab with ! + # update config file with IP case ContextMenu.ADD_SERVER: pass + # spawn edit dialog and update cache, notes file case ContextMenu.ADD_NOTE: pass case ContextMenu.COPY_CLIPBOARD: self.copy_ip(path) case ContextMenu.COPY_NAME: self.copy_name(path) - case ContextMenu.REFRESH_PLAYERS: - pass - case ContextMenu.REMOVE_HISTORY: - pass - case ContextMenu.REMOVE_SERVER: - pass - case ContextMenu.SET_FAV: - treeview = self.get_active_treeview() - name = treeview.get_value_at_index(0) - record = treeview.get_record_string() - - self.update_config(Preferences.FAV_LBL, name) - self.update_config(Preferences.FAV_SRV, record) - # TODO: consider a failure dialog here - - simple_ip = treeview.get_simplified_ip() - self.emitter.emit("fav_server_changed", name, simple_ip) - case ContextMenu.SHOW_DETAILS: - pass - case ContextMenu.SHOW_MODS: - pass - - # NOTE: manipulates mod store case ContextMenu.DELETE_MOD: self.delete_single_mod(path) # TODO: connect to emitter automatically @@ -540,10 +519,29 @@ class Controller(GObject.GObject): remove_stale_signatures( self.prefs.paths.config, self.prefs.paths.version ) + # call a2s on thread and update ephemeral model in situ + case ContextMenu.REFRESH_PLAYERS: + pass + # update history model, update tab label, pop off of queue, write new list into file + # see dq.py + case ContextMenu.REMOVE_HISTORY: + pass + # reverse of ADD_SERVER + case ContextMenu.REMOVE_SERVER: + pass + case ContextMenu.SET_FAV: + self.set_fav() + + # NOTE: spawns dedicated dialogs + case ContextMenu.SHOW_DETAILS: + pass + case ContextMenu.SHOW_MODS: + pass 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: @@ -556,7 +554,8 @@ class Controller(GObject.GObject): log = self.prefs.paths.debug store = self.model_man.get_log_store() store.clear() - # NOTE: this model is reloaded each time as log changes + # NOTE: this model is reloaded each time as log changes, rather than adding + # atomic events try: with open(log, "r") as f: lines = [ @@ -620,15 +619,17 @@ class Controller(GObject.GObject): ) self.push_data(data, FilterMode.INITIAL, success=True) - # TODO: eg cleanup on failure, cleanup on sucess - # separate methods + # TODO: eg dedicated cleanup on failure, cleanup on sucess + # these can pop their own predefined dialogs, much simpler + def cleanup(self) -> None: treeview = self.get_active_treeview() + treeview.set_loaded(True) + treeview.set_model(self.to_insert) # TODO: signals or other approach to deferring map # model insertion after thread closes # cf. servers_loaded signal - treeview.set_model(self.to_insert) # CHORE: this is placeholder logic if self.first_iteration: @@ -639,12 +640,37 @@ class Controller(GObject.GObject): self.emitter.emit("servers_loaded", context) treeview.grab_focus() + # TODO: chiefly responsible for removing spinner dialog self.destroy_on_idle() - if self.success is False: - # TODO: different dialogs for server tab contexts, e.g. lan timeout - # TODO: if history/favorites is empty, don't even trigger a call to dump data - dialog = ExceptionDialog(self, "API TIMEOUT") - dialog.run() + #if self.success is False: + # # TODO: different dialogs for server tab contexts, e.g. lan timeout + # # TODO: if history/favorites is empty, don't even trigger a call to dump data + # dialog = ExceptionDialog(self, "API TIMEOUT") + # dialog.run() + + def cleanup_on_failure(self) -> None: + # TODO: what if refresh action occurred, and the old model is valid? + treeview = self.get_active_treeview() + treeview.set_model(None) + # TODO: disable map, keyword, and filter widgets if model is None? + # -> signal driven (servers_empty) + # would have to make those unsensitive when changing server tabs + # if model is not None when changing tab, emit other signal + self.mediator.filters.set_unique_maps(None) + + context = self.get_active_context() + self.emitter.emit("servers_loaded", context) + treeview.grab_focus() + self.destroy_on_idle() + dialog = ExceptionDialog(self, "API TIMEOUT") + dialog.run() + + def push_data_failure(self) -> None: + treeview = self.get_active_treeview() + treeview.set_loaded(True) + # TODO: wipe control model on failure or keep old results? + manager = treeview.get_filter_man() + GLib.idle_add(self.cleanup_on_failure) def push_data(self, data: tuple, mode: Optional[FilterMode], success: bool) -> None: @@ -664,8 +690,6 @@ class Controller(GObject.GObject): manager.set_control(data) manager.filter(mode) self.to_insert = manager.get_model() - # TODO: should list store be set outside of this thread? - # treeview.set_model(self.to_insert) treeview.set_loaded(True) GLib.idle_add(self.cleanup) @@ -691,6 +715,7 @@ class Controller(GObject.GObject): func = self.get_callback() self.mediator.window.set_sensitive(True) # TODO: spawn error dialog if API crawl failed + # most likely going to drop this if func is not None: args = self.get_callback_args() GLib.idle_add(func, *args) @@ -784,7 +809,6 @@ class Controller(GObject.GObject): tv = self.get_active_treeview() if tv.filter_man.get_control() is None: return - tv.set_model(None) # TODO: deprecated in this context? self.set_callback(None, None) self.filter_threaded(mode, label) @@ -795,7 +819,8 @@ class Controller(GObject.GObject): self.emitter.emit("servers_loaded", treeview.get_enum()) return - treeview.set_model(None) + # TODO: maybe set tree to none afterwards (swap models) + #treeview.set_model(None) func = treeview.get_query_func() if func is None: self.emitter.emit("servers_loaded", treeview.get_enum()) @@ -819,6 +844,21 @@ class Controller(GObject.GObject): # TODO: use model manager, map and keyword caches # TODO: model cache that hooks checkbox signal + def get_help_store(self) -> Gtk.ListStore: + return self.model_man.get_help_store() + + def get_map_store(self) -> Gtk.ListStore: + return self.model_man.get_map_store() + + def get_modlist_store(self) -> Gtk.ListStore: + return self.model_man.get_modlist_store() + + def get_mod_store(self) -> Gtk.ListStore: + return self.model_man.get_mod_store() + + def get_log_store(self) -> Gtk.ListStore: + return self.model_man.get_log_store() + def get_filters(self) -> list: return self.mediator.filters.get_filters() From 82c585a195f5934ee11cf396b22239d02b471e56 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Fri, 13 Feb 2026 02:43:08 +0900 Subject: [PATCH 071/309] feat: use functools.wraps --- dzgui/api/mods.py | 3 +++ dzgui/controllers/mc.py | 29 ++++++++++++++++------------- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/dzgui/api/mods.py b/dzgui/api/mods.py index c929085..81ce751 100644 --- a/dzgui/api/mods.py +++ b/dzgui/api/mods.py @@ -22,6 +22,7 @@ from typing import Any logger = logging.getLogger(__name__) + @dataclass class ModMeta: protocol: str @@ -41,6 +42,7 @@ def get_local_mod_path(steam_path: Path) -> Path: 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 @@ -148,6 +150,7 @@ def find_stale_mods(config: Path) -> list[int]: steam = lookup(config, Preferences.DEFAULT) steam_path = Path(steam) + # TODO: use concurrency local = get_local_mod_ids(steam_path) servers = lookup(config, Preferences.IP_LIST) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index b36dcd2..c99c943 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -7,6 +7,7 @@ from warnings import deprecated from concurrent.futures import wait from concurrent.futures import ThreadPoolExecutor +from functools import wraps from pathlib import Path from typing import Any, Callable, TYPE_CHECKING @@ -101,15 +102,17 @@ class Controller(GObject.GObject): def get_emitter(self) -> Emitter: return self.emitter - def call_on_thread(func: Callable) -> Callable: - def wrapper(*args, **kwargs): - self = args[0] - # TODO: get dialog string from own attribute - self.wait_dialog = WaitDialog(self, strings.dialog.filtering) - self.wait_dialog.show_all() - thread = threading.Thread(target=func, args=args) - thread.start() - return wrapper + def call_on_thread(dialog_str: str) -> Callable: + def decorator(func: Callable) -> Callable: + @wraps(func) + def wrapper(*args, **kwargs): + self = args[0] + self.wait_dialog = WaitDialog(self, dialog_str) + self.wait_dialog.show_all() + thread = threading.Thread(target=func, args=args) + thread.start() + return wrapper + return decorator def register_widget(self, attr: str, widget: Gtk.Widget) -> None: try: @@ -693,7 +696,7 @@ class Controller(GObject.GObject): treeview.set_loaded(True) GLib.idle_add(self.cleanup) - @call_on_thread + @call_on_thread(strings.dialog.working) def highlight_stale(self) -> None: self.colorize_mods() @@ -746,7 +749,7 @@ class Controller(GObject.GObject): dialog = ExceptionDialog(self, strings.api_error) dialog.run() - @call_on_thread + @call_on_thread(strings.dialog.working) # FIXME: entire process is behind thread, including destroy_on_idle() def update_api_key(self, text: str, key: Preferences) -> None: self.test_api_response(text, key) @@ -794,11 +797,11 @@ class Controller(GObject.GObject): def get_statusbar(self) -> None: return self.mediator.statusbar - @call_on_thread + @call_on_thread(strings.dialog.fetching) def run_query_func(self, func: Callable) -> None: func() - @call_on_thread + @call_on_thread(strings.dialog.filtering) def filter_threaded(self, mode: FilterMode, label: str) -> None: tv = self.get_active_treeview() tv.filter_man.filter(mode, label) From 7e6013bdbd7a6509afe8c1cba2e1f6d3ff1132f8 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Fri, 13 Feb 2026 19:49:15 +0900 Subject: [PATCH 072/309] feat: atomic maps per server context --- CHANGELOG.md | 17 +++-- dzgui/controllers/emitter.py | 23 ++++--- dzgui/controllers/mc.py | 92 ++++++++++++++++++-------- dzgui/data/CHANGELOG.md | 17 +++-- dzgui/model/filtered_model.py | 12 ++-- dzgui/model/misc_model.py | 2 +- dzgui/util/format.py | 8 ++- dzgui/util/strings.py | 2 +- dzgui/views/components/filter_panel.py | 54 ++++++--------- dzgui/views/components/statusbar.py | 2 +- dzgui/views/trees/tree_servers.py | 28 +++++++- 11 files changed, 157 insertions(+), 100 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 83ca4b5..80a91db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,5 @@ ## Added - Add pyproject.toml file -- requests, packaging.version, psutil -- Lockfile - Changelog text wrapping and formatting - Decouple UI components into modules - Choose from kilometer or miles distance display @@ -17,26 +15,31 @@ - Integrate add/connect widgets into main menu - Favorite/connect/LAN panels integrated with server views - Colorized IP/ID validation +- Copy favorite server IP to clipboard +- Set favorite server from tables +- Detailed/copyable trace in critical error dialogs - Visual icons - Integrated server notebook -- Retain position on servers - Propagate width changes to all tables - Remember tree position in menus +- Show hidden server count after filtering +- Atomic map filters per server context ## Changed - Reduce padding on keys button - Boldface breadcrumbs - Bold labels inside frames -- Unsticky keys when clicking sidebar +- Sidebar buttos do not steal focus - Copy IP copies IP:queryport only instead of IP:gameport:queryport, mimics syntax needed by add by ip method +- Load new model into view without flushing ## Fixed - Longstanding issue with left clicks not registering as tree selection changes after spamming keyboard input - Rare segfaults when changing maps (threading) +- Moved dialogs out of threads ## Unreleased - Setup wizard -- Copy favorite server IP to clipboard -- Set favorite server from tables -- Detailed/copyable trace in critical error dialogs - Local documentation +- Offline mods +- Move debug mode to developers only diff --git a/dzgui/controllers/emitter.py b/dzgui/controllers/emitter.py index 8c15495..2be1efe 100644 --- a/dzgui/controllers/emitter.py +++ b/dzgui/controllers/emitter.py @@ -3,6 +3,7 @@ from typing import Union, TYPE_CHECKING import gi gi.require_version("Gtk", "3.0") +from gi.repository.Gtk import ListStore from gi.repository import Gdk, GObject # noqa E402 if TYPE_CHECKING: @@ -86,14 +87,14 @@ class Emitter(GObject.GObject): def servers_loaded(self, tag: "ServerTab") -> None: pass - # @GObject.Signal( - # flags=GObject.SignalFlags.RUN_LAST, - # arg_types=( - # object, - # object, - # ), - # ) - # def distcalc_ended( - # self, dist: Union[str, None], context: Union["ServerTab", "NotebookPage"] - # ) -> None: - # pass + @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=()) + def servers_loaded_init(self) -> None: + pass + + @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(str,)) + def map_selection_changed(self, map: str) -> None: + pass + + @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object,)) + def load_maps(self, store: ListStore) -> None: + pass diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index c99c943..2ea5621 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -94,6 +94,7 @@ class Controller(GObject.GObject): self.model_man = ModelManager() self.emitter = Emitter() + self.emitter.connect("map_selection_changed", self._on_map_selection_changed) # NOTE: suppress requests until entire UI is loaded self.loaded = False @@ -235,6 +236,7 @@ class Controller(GObject.GObject): c = self.mediator.statusbar.statusbar.get_context_id(context) self.mediator.statusbar.statusbar.pop(c) + # TODO: @deprecated("use set_by_context") def set_statusbar(self, text: str, context: str) -> int: msg_id = self.mediator.statusbar.set_text(text, context) @@ -350,7 +352,6 @@ class Controller(GObject.GObject): self.open_page(button.opens) def dump_api(self) -> None: - self.first_iteration = True key = self.query_config(Preferences.STEAM) job = Servers.query_api params = Servers.params @@ -379,7 +380,6 @@ class Controller(GObject.GObject): # TODO: additional ping column pass, collated parsed = Servers.parse_json(serv) - self.new_maps = parsed self.push_data(parsed, FilterMode.INITIAL, success=True) def get_help_row(self) -> str: @@ -608,17 +608,17 @@ class Controller(GObject.GObject): import time time.sleep(1) data = ( - ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 1, 1, "a", False], - ["BAR", "a", "a", "a", 1, 1, 1, "172.111.51.156:2302", 1, 1, "a", False], - ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 1, 1, "a", False], - ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 1, 1, "a", False], - ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], - ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], - ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], - ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], - ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], - ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], - ["BAR", "a", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], + ["BAR", "chernarusplus", "a", "a", 1, 1, 1, "185.207.214.16:2302", 1, 1, "a", False], + ["BAR", "chernarusplus", "a", "a", 1, 1, 1, "172.111.51.156:2302", 1, 1, "a", False], + ["BAR", "chernarusplus", "a", "a", 1, 1, 1, "185.207.214.16:2302", 1, 1, "a", False], + ["BAR", "chernarusplus", "a", "a", 1, 1, 1, "185.207.214.16:2302", 1, 1, "a", False], + ["BAR", "chernarusplus", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], + ["BAR", "chernarusplus", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], + ["BAR", "chernarusplus", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], + ["BAR", "chernarusplus", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], + ["BAR", "chernarusplus", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], + ["BAR", "chernarusplus", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], + ["BAR", "chernarusplus", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], ) self.push_data(data, FilterMode.INITIAL, success=True) @@ -629,21 +629,25 @@ class Controller(GObject.GObject): treeview = self.get_active_treeview() treeview.set_loaded(True) treeview.set_model(self.to_insert) + map_man = treeview.get_map_man() # TODO: signals or other approach to deferring map # model insertion after thread closes # cf. servers_loaded signal - # CHORE: this is placeholder logic - if self.first_iteration: - self.mediator.filters.set_unique_maps(self.new_maps) - self.first_iteration = False context = self.get_active_context() self.emitter.emit("servers_loaded", context) + # CHORE: this is placeholder logic + if self.first_iteration: + map_man.set_unique_maps(self.new_maps) + self.emitter.emit("servers_loaded_init") + self.first_iteration = False + self.new_maps = None + + treeview.grab_focus() - # TODO: chiefly responsible for removing spinner dialog self.destroy_on_idle() #if self.success is False: # # TODO: different dialogs for server tab contexts, e.g. lan timeout @@ -655,13 +659,15 @@ class Controller(GObject.GObject): # TODO: what if refresh action occurred, and the old model is valid? treeview = self.get_active_treeview() treeview.set_model(None) + map_man = treeview.get_map_man() # TODO: disable map, keyword, and filter widgets if model is None? # -> signal driven (servers_empty) # would have to make those unsensitive when changing server tabs # if model is not None when changing tab, emit other signal - self.mediator.filters.set_unique_maps(None) + map_man.set_unique_maps(None) context = self.get_active_context() + # TODO: distinguish signals, e.g. "servers_failed_to_load" self.emitter.emit("servers_loaded", context) treeview.grab_focus() self.destroy_on_idle() @@ -693,6 +699,10 @@ class Controller(GObject.GObject): manager.set_control(data) manager.filter(mode) self.to_insert = manager.get_model() + # TODO: pre parse maps + u_maps = set([row[1] for row in data]) + self.new_maps = sorted(u_maps) + treeview.set_loaded(True) GLib.idle_add(self.cleanup) @@ -714,6 +724,9 @@ class Controller(GObject.GObject): self.callback = {"func": callback, "args": args} def destroy_on_idle(self) -> None: + """ + TODO: chiefly responsible for removing spinner dialog + """ self.wait_dialog.destroy() func = self.get_callback() self.mediator.window.set_sensitive(True) @@ -790,7 +803,8 @@ class Controller(GObject.GObject): def get_player_count(self) -> str: treeview = self.get_active_treeview() model = treeview.get_model() - control_model = treeview.filter_man.get_control() + filter_man = treeview.get_filter_man() + control_model = filter_man.get_control() count = format_player_count(model, control_model) return count @@ -804,13 +818,16 @@ class Controller(GObject.GObject): @call_on_thread(strings.dialog.filtering) def filter_threaded(self, mode: FilterMode, label: str) -> None: tv = self.get_active_treeview() - tv.filter_man.filter(mode, label) + filter_man = tv.get_filter_man() + filter_man.filter(mode, label) self.push_data("", mode, success=True) # FIXME: optional label/map/keyword parameter + # FIXME: drop set_callback def refilter_model(self, mode: FilterMode, label: Optional[str] = None) -> None: tv = self.get_active_treeview() - if tv.filter_man.get_control() is None: + filter_man = tv.get_filter_man() + if filter_man.get_control() is None: return # TODO: deprecated in this context? self.set_callback(None, None) @@ -827,10 +844,12 @@ class Controller(GObject.GObject): func = treeview.get_query_func() if func is None: self.emitter.emit("servers_loaded", treeview.get_enum()) + treeview.set_model(None) return # TODO: # manager = treeview.get_filter_man() # manager.clear_model() + self.first_iteration = True self.set_callback(None, None) self.run_query_func(func) @@ -850,9 +869,6 @@ class Controller(GObject.GObject): def get_help_store(self) -> Gtk.ListStore: return self.model_man.get_help_store() - def get_map_store(self) -> Gtk.ListStore: - return self.model_man.get_map_store() - def get_modlist_store(self) -> Gtk.ListStore: return self.model_man.get_modlist_store() @@ -865,11 +881,31 @@ class Controller(GObject.GObject): def get_filters(self) -> list: return self.mediator.filters.get_filters() + # TODO: filterman calls back to here, gets convoluted def get_keyword(self) -> str: return self.mediator.filters.get_keyword_filter() - def get_map(self) -> str: - return self.mediator.filters.get_selected_map() + def get_map_store(self) -> Gtk.ListStore: + treeview = self.get_active_treeview() + map_man = treeview.get_map_man() + return map_man.get_map_store() + + def get_selected_map(self) -> str: + treeview = self.get_active_treeview() + map_man = treeview.get_map_man() + return map_man.get_selected_map() def get_prior_map(self) -> str: - return self.mediator.filters.get_prior_map() + treeview = self.get_active_treeview() + map_man = treeview.get_map_man() + return map_man.get_prior_map() + + def _on_map_selection_changed(self, emitter: Emitter, selection: str) -> None: + treeview = self.get_active_treeview() + map_man = treeview.get_map_man() + map_man.set_selected_map(selection) + self.refilter_model(FilterMode.MAP) + + def has_server_model(self) -> bool: + treeview = self.get_active_treeview() + return treeview.get_model() is not None diff --git a/dzgui/data/CHANGELOG.md b/dzgui/data/CHANGELOG.md index 83ca4b5..80a91db 100644 --- a/dzgui/data/CHANGELOG.md +++ b/dzgui/data/CHANGELOG.md @@ -1,7 +1,5 @@ ## Added - Add pyproject.toml file -- requests, packaging.version, psutil -- Lockfile - Changelog text wrapping and formatting - Decouple UI components into modules - Choose from kilometer or miles distance display @@ -17,26 +15,31 @@ - Integrate add/connect widgets into main menu - Favorite/connect/LAN panels integrated with server views - Colorized IP/ID validation +- Copy favorite server IP to clipboard +- Set favorite server from tables +- Detailed/copyable trace in critical error dialogs - Visual icons - Integrated server notebook -- Retain position on servers - Propagate width changes to all tables - Remember tree position in menus +- Show hidden server count after filtering +- Atomic map filters per server context ## Changed - Reduce padding on keys button - Boldface breadcrumbs - Bold labels inside frames -- Unsticky keys when clicking sidebar +- Sidebar buttos do not steal focus - Copy IP copies IP:queryport only instead of IP:gameport:queryport, mimics syntax needed by add by ip method +- Load new model into view without flushing ## Fixed - Longstanding issue with left clicks not registering as tree selection changes after spamming keyboard input - Rare segfaults when changing maps (threading) +- Moved dialogs out of threads ## Unreleased - Setup wizard -- Copy favorite server IP to clipboard -- Set favorite server from tables -- Detailed/copyable trace in critical error dialogs - Local documentation +- Offline mods +- Move debug mode to developers only diff --git a/dzgui/model/filtered_model.py b/dzgui/model/filtered_model.py index b751dd9..2e2b6d6 100644 --- a/dzgui/model/filtered_model.py +++ b/dzgui/model/filtered_model.py @@ -47,7 +47,8 @@ class FilteredModelManager: self.filter_cache = {} self.ping_cache: dict[str, int] = {} - self.ephemeral_model = self.new_model_from_class(ServerColumns) + self.ephemeral_model: ListStore = None + #= self.new_model_from_class(ServerColumns) self.control_model: list = None self.filtered: list = None @@ -107,13 +108,14 @@ class FilteredModelManager: if row[7] in self.ping_cache: row[9] = self.ping_cache[row[7]] + clone = self.new_model_from_class(ServerColumns) if len(rows) > 0: - clone = self.new_model_from_class(ServerColumns) rows = self.sort_rows(rows) for row in rows: clone.append(row) - else: - clone = None + #else: + # print("nothing to filter, sending none") + # clone = None self.set_cache(filters, clone, rows) self.set_model(clone) @@ -136,7 +138,7 @@ class FilteredModelManager: Multi-filtration for any context starts by narrowing by map """ rows = self.filtered - sel_map = self.controller.get_map() + sel_map = self.controller.get_selected_map() if sel_map == strings.all_maps: return rows diff --git a/dzgui/model/misc_model.py b/dzgui/model/misc_model.py index 08b6978..0fe97c9 100644 --- a/dzgui/model/misc_model.py +++ b/dzgui/model/misc_model.py @@ -5,7 +5,7 @@ import gi gi.require_version("Gtk", "3.0") from gi.repository.Gtk import ListStore # noqa E402 -from gi.repository import GLib, GObject, Gtk # noqa E402 +from gi.repository import GObject, Gtk # noqa E402 @dataclass(slots=True, frozen=True) diff --git a/dzgui/util/format.py b/dzgui/util/format.py index 9b06e30..3d462f5 100644 --- a/dzgui/util/format.py +++ b/dzgui/util/format.py @@ -55,16 +55,18 @@ def format_player_count(model: Gtk.TreeModel | None, control: list) -> str: players = 0 hits: int status: str - if model is None or len(model) == 0: + if model is None: return no_servers else: hits = len(model) for row in model: players += row[4] control_total = len(control) + hits_pretty = pluralize("matches", hits) players_pretty = pluralize("players", players) - control_pretty = pluralize("matches", control_total) - status = f"Showing {hits:n}/{control_total:n} {control_pretty} with {players:n} {players_pretty}" + hidden = control_total - hits + hidden_pretty = f" ({hidden:n} hidden)" if hidden > 0 else "" + status = f"Showing {hits:n} {hits_pretty}{hidden_pretty}, {players:n} {players_pretty}." return status diff --git a/dzgui/util/strings.py b/dzgui/util/strings.py index 4e4ecb2..0e43df0 100644 --- a/dzgui/util/strings.py +++ b/dzgui/util/strings.py @@ -167,7 +167,7 @@ ping_tooltip = ( ) # statusbar_helptext = "Select a row to see its detailed description" -statusbar_helptext = "No server metadata to list." +#statusbar_helptext = "No server metadata to list." # use e.g. filters.1pp filter_1pp = "1PP" diff --git a/dzgui/views/components/filter_panel.py b/dzgui/views/components/filter_panel.py index bd80573..ad89a34 100644 --- a/dzgui/views/components/filter_panel.py +++ b/dzgui/views/components/filter_panel.py @@ -17,6 +17,7 @@ logger = logging.getLogger(__name__) if TYPE_CHECKING: from dzgui.controllers.mc import Controller from dzgui.controllers.emitter import Emitter + from dzgui.const.enum import ServerTab class FilterPanel(Gtk.Box): @@ -49,10 +50,10 @@ class FilterPanel(Gtk.Box): self.enabled_filters = dict(self.default_filters) self.keyword_filter = "" - self.selected_map: str = strings.all_maps - self.prior_map: str = strings.all_maps + #self.selected_map: str = strings.all_maps + #self.prior_map: str = strings.all_maps - button_grid = Gtk.Grid( + self.button_grid = Gtk.Grid( halign=Gtk.Align.CENTER, column_spacing=5, column_homogeneous=True ) row = 1 @@ -70,7 +71,7 @@ class FilterPanel(Gtk.Box): if col > 3: row += 1 col = 1 - button_grid.attach(checkbox, col, row, 1, 1) + self.button_grid.attach(checkbox, col, row, 1, 1) checkbox.connect("toggled", self._on_check_toggled) self.checks.append(checkbox) @@ -103,6 +104,8 @@ class FilterPanel(Gtk.Box): "request_maps_focus", lambda _: self.maps_entry.grab_focus() ) self.emitter.connect("check_button_pressed", self.toggle_check_by_key) + self.emitter.connect("servers_loaded", self._on_servers_loaded) + self.emitter.connect("load_maps", self._on_maps_loaded) completion = Gtk.EntryCompletion(inline_completion=True) completion.set_text_column(0) @@ -123,26 +126,22 @@ class FilterPanel(Gtk.Box): self.maps_combo.connect("changed", self._on_map_changed) self.maps_combo.connect("key-press-event", self._on_combo_keypress) - for el in self.filters_label, self.keyword_entry, self.maps_combo, button_grid: + for el in self.filters_label, self.keyword_entry, self.maps_combo, self.button_grid: self.pack_start(el, NO_EXPAND, NO_FILL, NO_PADDING) - def set_unique_maps(self, maps: list) -> None: - if maps is None: - return - if len(maps) < 1: - return - u_maps = set([row[1] for row in maps]) - u_maps = sorted(u_maps) - self.controller.append_map([strings.all_maps]) - self.selected_map = strings.all_maps - for m in u_maps: - self.controller.append_map([m]) - self.maps_hr.append(m) + def _on_servers_loaded(self, emitter: "Emitter", tab: "ServerTab") -> None: + state = self.controller.has_server_model() + for widget in (self.keyword_entry, self.maps_combo, self.button_grid): + widget.set_sensitive(state) + + def _on_maps_loaded(self, emitter: "Emitter", store: Gtk.ListStore) -> None: + self.maps_combo.set_model(store) self.maps_combo.set_active(0) def get_filters(self) -> tuple: + selected = self.controller.get_selected_map() filters = [] - filters.append(self.selected_map) + filters.append(selected) filters.append(self.keyword_filter) for k in self.enabled_filters: if not self.enabled_filters[k]: @@ -205,8 +204,9 @@ class FilterPanel(Gtk.Box): def _on_map_completion(self, entry, editable): text = entry.get_text() completion = entry.get_completion() + store = self.controller.get_map_store() if len(text) >= completion.get_minimum_key_length(): - completion.set_model(self.map_store) + completion.set_model(store) def restore_focus_to_treeview(self) -> Literal[False]: view = self.controller.get_active_treeview() @@ -232,15 +232,6 @@ class FilterPanel(Gtk.Box): 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 @@ -293,7 +284,6 @@ class FilterPanel(Gtk.Box): check.set_active(not state) def _on_check_toggled(self, button: Gtk.CheckButton) -> None: - # treeview = self.controller.get_active_treeview() label = button.get_label() state = button.get_active() logger.info(f"User toggled button '{label}' to {state}") @@ -306,7 +296,7 @@ class FilterPanel(Gtk.Box): self.controller.refilter_model(mode, label) def _on_map_changed(self, combo: Gtk.ComboBox) -> None: - old_sel = self.selected_map + old_sel = self.controller.get_selected_map() model = combo.get_model() tree_iter = combo.get_active_iter() if tree_iter is None: @@ -317,7 +307,5 @@ class FilterPanel(Gtk.Box): 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.controller.refilter_model(FilterMode.MAP) + self.emitter.emit("map_selection_changed", selection) diff --git a/dzgui/views/components/statusbar.py b/dzgui/views/components/statusbar.py index f442422..9f4a465 100644 --- a/dzgui/views/components/statusbar.py +++ b/dzgui/views/components/statusbar.py @@ -112,7 +112,7 @@ class Statusbar(Gtk.Grid): self.set_by_context(context, pretty) def append_distance(self, dist: str) -> str: - return f"{self.playercount} | Distance: {dist}" + return f"{self.playercount} Distance: {dist}" def _on_servers_loaded(self, statusbar: Self, context: "ServerTab") -> None: count = self.controller.get_player_count() diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index 85a6a23..460d104 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -7,6 +7,7 @@ from warnings import deprecated from dzgui.views.mixins.context_mixin import ContextMixin from dzgui.const.enum import ContextMenuGroup, ServerTab from dzgui.api.servers import Record +from dzgui.model.map_model import MapManager from dzgui.model.filtered_model import FilteredModelManager from dzgui.util.dist import CalcDist from dzgui.util import strings @@ -47,6 +48,9 @@ class ServerTreeView(ContextMixin, TreeView): model = self.filter_man.get_model() self.set_model(model) + # NOTE: each tab context has its own unique maps + self.map_man = MapManager() + self.set_fixed_height_mode(True) self.set_headers_visible(True) @@ -100,14 +104,26 @@ class ServerTreeView(ContextMixin, TreeView): column.connect("notify::fixed-width", self._on_col_width_changed) self.append_column(column) - self.connect("key-press-event", self._on_server_keypress) + self.connect("button-press-event", self.present_menu) self.connect("generic_row_activated", self._parent_row_activated) self.connect("generic_treesel_changed", self._parent_selection_changed) + self.connect("key-press-event", self._on_server_keypress) + self.connect("key-press-event", self.present_menu) self.connect("map", self._on_map) self.connect("unmap", self._on_unmap) - self.connect("key-press-event", self.present_menu) - self.connect("button-press-event", self.present_menu) + self.emitter.connect("servers_loaded_init", self._on_servers_loaded_init) + self.emitter.connect("servers_loaded", self._on_servers_loaded) + + def _on_servers_loaded(self, emitter: "Emitter", tab: "ServerTab") -> None: + state = self.controller.has_server_model() + self.set_headers_clickable(state) + + def _on_servers_loaded_init(self, emitter: "Emitter") -> None: + if self.loaded is False: + return + store = self.map_man.get_map_store() + self.emitter.emit("load_maps", store) def _get_ping( self, @@ -145,6 +161,9 @@ class ServerTreeView(ContextMixin, TreeView): def start_timeout(self) -> None: self.queue_id = GLib.timeout_add(QUEUE_CHECK_DELAY, self._check_result_queue) + def get_map_man(self) -> MapManager: + return self.map_man + def get_filter_man(self) -> FilteredModelManager: return self.filter_man @@ -174,10 +193,13 @@ class ServerTreeView(ContextMixin, TreeView): return self.enum def _on_map(self, a) -> None: + # TODO: disable filter panel if current model is None if self.get_enum() is ServerTab.LAN: self.emitter.emit("lan_tab_toggled", True) # FIXME: only if the tab is active + store = self.map_man.get_map_store() + self.emitter.emit("load_maps", store) self.handler_id = self.emitter.connect("statusbar_loaded", self.start_distcalc) self.start_timeout() self.start_distcalc() From 85cbd2d183106c2626e84d1ce4f55ec9c406ebea Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Fri, 13 Feb 2026 19:52:16 +0900 Subject: [PATCH 073/309] chore: update comments --- dzgui/model/filtered_model.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/dzgui/model/filtered_model.py b/dzgui/model/filtered_model.py index 2e2b6d6..9a6d07e 100644 --- a/dzgui/model/filtered_model.py +++ b/dzgui/model/filtered_model.py @@ -1,6 +1,7 @@ import re from dataclasses import dataclass from typing import TYPE_CHECKING +from warnings import deprecated from dzgui.const.enum import FilterMode from dzgui.util import strings @@ -209,6 +210,7 @@ class FilteredModelManager: 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] + # FIXME: can create logically opposed contexts like "official + modded" case strings.filter_modded: rows = [row for row in rows if not row[11]] return rows @@ -229,7 +231,7 @@ class FilteredModelManager: def resync_model(self, addr: str, qport: int) -> None: """ Handle in-situ updates to model during - row deletion actions. Skipped for ephemeral + row deletion/insertion actions. Skipped for ephemeral actions like player count/ping updates """ for row in self.control_model: @@ -240,7 +242,7 @@ class FilteredModelManager: filters = self.controller.get_filters() refiltered = self.filter_toggle_on(filters) self.set_filtered(refiltered) - self.set_success(True) + #self.set_success(True) def convert_model_to_list(self, model: ListStore) -> list: return [[el for el in row] for row in model] @@ -261,16 +263,18 @@ class FilteredModelManager: def set_control(self, rows: list) -> None: """ - Raw representation of the model, no transformations + Raw data model prior to filtration """ self.control_model = rows def get_control(self) -> list: return self.control_model + @deprecated("Legacy code") def set_success(self, result: bool) -> None: self.success = result + @deprecated("Legacy code") def get_success(self) -> bool: return self.success @@ -279,7 +283,7 @@ class FilteredModelManager: # cf. remove_from_history(), remove_server() # NOTE: this can most likely be simplified for v7 def wipe_cache(self, full=False) -> None: - self.success = True + self.set_success(True) self.filtered = None self.filter_cache = {} self.ping_cache = {} From b14c63b23ca40d9d791590ca99cbfeb6ddd55bd8 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Fri, 13 Feb 2026 20:41:09 +0900 Subject: [PATCH 074/309] feat: use concurrency for stale mods --- CHANGELOG.md | 1 + dzgui/api/mods.py | 34 ++++++++++++++++++++-------------- dzgui/controllers/mc.py | 3 ++- dzgui/data/CHANGELOG.md | 1 + 4 files changed, 24 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 80a91db..e51e053 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ - Remember tree position in menus - Show hidden server count after filtering - Atomic map filters per server context +- Use concurrency when checking stale mods (performance uplift) ## Changed - Reduce padding on keys button diff --git a/dzgui/api/mods.py b/dzgui/api/mods.py index 81ce751..9010079 100644 --- a/dzgui/api/mods.py +++ b/dzgui/api/mods.py @@ -3,6 +3,8 @@ import hashlib import logging import shlex +from concurrent.futures import wait +from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass from pathlib import Path @@ -145,26 +147,30 @@ def remove_stale_signatures(config: Path, versions: Path) -> None: for line in lines: f.write(line) - def find_stale_mods(config: Path) -> list[int]: + def push_record(rec: str) -> list: + add = rec.split(":") + ip = add[0] + qport = add[2] + return get_rules(ip, qport) + steam = lookup(config, Preferences.DEFAULT) steam_path = Path(steam) - # TODO: use concurrency local = get_local_mod_ids(steam_path) - servers = lookup(config, Preferences.IP_LIST) + records = lookup(config, Preferences.IP_LIST) - all_mods = [] - for server in servers: - split = server.split(":") - ip = split[0] - qport = split[2] + remote_mods = [] - mods = get_rules(ip, qport) - all_mods += mods + with ThreadPoolExecutor() as executor: + futures = [ + executor.submit(push_record, record) + for record in records + ] + wait(futures) + for future in futures: + res = future.result() + remote_mods += res - stale = [] - for mod in local: - if mod not in all_mods: - stale.append(mod) + stale = [mod for mod in local if mod not in remote_mods] return stale diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 2ea5621..510ccba 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -596,6 +596,7 @@ class Controller(GObject.GObject): it = mod.iter path = model.get_path(it) # TODO: consider storing in ListStore as int + # TODO: clone existing model and set outside of thread if int(mod[2]) in stale: model[path][4] = HEX_RED @@ -731,7 +732,7 @@ class Controller(GObject.GObject): func = self.get_callback() self.mediator.window.set_sensitive(True) # TODO: spawn error dialog if API crawl failed - # most likely going to drop this + # most likely going to drop callback method if func is not None: args = self.get_callback_args() GLib.idle_add(func, *args) diff --git a/dzgui/data/CHANGELOG.md b/dzgui/data/CHANGELOG.md index 80a91db..e51e053 100644 --- a/dzgui/data/CHANGELOG.md +++ b/dzgui/data/CHANGELOG.md @@ -24,6 +24,7 @@ - Remember tree position in menus - Show hidden server count after filtering - Atomic map filters per server context +- Use concurrency when checking stale mods (performance uplift) ## Changed - Reduce padding on keys button From d4b346006d769333a797e32c119b28783e2085bf Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Fri, 13 Feb 2026 22:58:09 +0900 Subject: [PATCH 075/309] chore: abstract filter panel components --- dzgui/controllers/emitter.py | 4 + dzgui/controllers/mc.py | 234 ++++++++++++++++---- dzgui/util/strings.py | 2 +- dzgui/views/components/filter_panel.py | 288 ++++++++++++++----------- dzgui/views/dialogs/link_dialog.py | 7 +- dzgui/views/trees/tree_servers.py | 1 + 6 files changed, 370 insertions(+), 166 deletions(-) diff --git a/dzgui/controllers/emitter.py b/dzgui/controllers/emitter.py index 2be1efe..6c2481c 100644 --- a/dzgui/controllers/emitter.py +++ b/dzgui/controllers/emitter.py @@ -83,6 +83,10 @@ class Emitter(GObject.GObject): ) -> None: pass + @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(str,bool,)) + def check_toggled(self, label: str, state: bool) -> None: + pass + @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object,)) def servers_loaded(self, tag: "ServerTab") -> None: pass diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 510ccba..3cfc62d 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -23,7 +23,13 @@ from dzgui.api.mods import ( _hash, remove_stale_signatures, ) -from dzgui.const.enum import FilterMode, Preferences, NotebookPage, ButtonType, ContextMenu +from dzgui.const.enum import ( + FilterMode, + Preferences, + NotebookPage, + ButtonType, + ContextMenu, +) from dzgui.const.constants import ( APPID_DAYZ, @@ -56,6 +62,7 @@ from gi.repository import Gtk, Gdk, GLib, GObject # noqa E402 logger = logging.getLogger(__name__) if TYPE_CHECKING: + from dzgui.model.map_model import MapManager from dzgui.const.enum import ServerTab from dzgui.util.dist import Haversine from dzgui.views.base import Notebook, Grid, OuterWindow @@ -95,11 +102,11 @@ class Controller(GObject.GObject): self.model_man = ModelManager() self.emitter = Emitter() self.emitter.connect("map_selection_changed", self._on_map_selection_changed) + self.emitter.connect("check_toggled", self._on_check_toggled) # NOTE: suppress requests until entire UI is loaded self.loaded = False - def get_emitter(self) -> Emitter: return self.emitter @@ -112,7 +119,9 @@ class Controller(GObject.GObject): self.wait_dialog.show_all() thread = threading.Thread(target=func, args=args) thread.start() + return wrapper + return decorator def register_widget(self, attr: str, widget: Gtk.Widget) -> None: @@ -250,10 +259,10 @@ class Controller(GObject.GObject): context = self.get_active_context() page = self.mediator.notebook.get_page_by_enum() if page != NotebookPage.SERVERS: - self.emitter.emit("distcalc_ended" , None, context) + self.emitter.emit("distcalc_ended", None, context) return if enum != context: - self.emitter.emit("distcalc_ended" , None, context) + self.emitter.emit("distcalc_ended", None, context) return dist: str @@ -357,10 +366,7 @@ class Controller(GObject.GObject): params = Servers.params serv = [] with ThreadPoolExecutor() as executor: - futures = [ - executor.submit(job, key, APPID_DAYZ, param) - for param in params - ] + futures = [executor.submit(job, key, APPID_DAYZ, param) for param in params] wait(futures) for future in futures: res = future.result() @@ -368,7 +374,7 @@ class Controller(GObject.GObject): # TODO: pop warning dialog, create enum around various failure states # TODO: if first iteration, disable map combo. otherwise, do nothing self.new_maps = None - self.push_data_failure() #None, FilterMode.INITIAL, success=False) + self.push_data_failure() # None, FilterMode.INITIAL, success=False) return j = res.json serv += j["response"]["servers"] @@ -436,9 +442,9 @@ class Controller(GObject.GObject): open_workshop_page(mod, cmd) # TODO: put in model manager (dedicated manager for mod store) - #def get_mod_from_tree_path( + # def get_mod_from_tree_path( # self, tree_path: Gtk.TreePath - #) -> tuple[str, Gtk.TreeIter]: + # ) -> tuple[str, Gtk.TreeIter]: # model = self.model_man.get_mod_store() # tree_iter = model.get_iter(tree_path) # mod = model.get(tree_iter, 2)[0] @@ -544,7 +550,6 @@ class Controller(GObject.GObject): 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: @@ -596,7 +601,7 @@ class Controller(GObject.GObject): it = mod.iter path = model.get_path(it) # TODO: consider storing in ListStore as int - # TODO: clone existing model and set outside of thread + # TODO: clone existing model and set outside of threai if int(mod[2]) in stale: model[path][4] = HEX_RED @@ -607,25 +612,172 @@ class Controller(GObject.GObject): def dump_test_2(self) -> None: import time + time.sleep(1) data = ( - ["BAR", "chernarusplus", "a", "a", 1, 1, 1, "185.207.214.16:2302", 1, 1, "a", False], - ["BAR", "chernarusplus", "a", "a", 1, 1, 1, "172.111.51.156:2302", 1, 1, "a", False], - ["BAR", "chernarusplus", "a", "a", 1, 1, 1, "185.207.214.16:2302", 1, 1, "a", False], - ["BAR", "chernarusplus", "a", "a", 1, 1, 1, "185.207.214.16:2302", 1, 1, "a", False], - ["BAR", "chernarusplus", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], - ["BAR", "chernarusplus", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], - ["BAR", "chernarusplus", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], - ["BAR", "chernarusplus", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], - ["BAR", "chernarusplus", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], - ["BAR", "chernarusplus", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], - ["BAR", "chernarusplus", "a", "a", 1, 1, 1, "185.207.214.16:2302", 0, 0, "a", False], + [ + "BAR", + "chernarusplus", + "a", + "a", + 1, + 1, + 1, + "185.207.214.16:2302", + 1, + 1, + "a", + False, + ], + [ + "BAR", + "chernarusplus", + "a", + "a", + 1, + 1, + 1, + "172.111.51.156:2302", + 1, + 1, + "a", + False, + ], + [ + "BAR", + "chernarusplus", + "a", + "a", + 1, + 1, + 1, + "185.207.214.16:2302", + 1, + 1, + "a", + False, + ], + [ + "BAR", + "chernarusplus", + "a", + "a", + 1, + 1, + 1, + "185.207.214.16:2302", + 1, + 1, + "a", + False, + ], + [ + "BAR", + "chernarusplus", + "a", + "a", + 1, + 1, + 1, + "185.207.214.16:2302", + 0, + 0, + "a", + False, + ], + [ + "BAR", + "chernarusplus", + "a", + "a", + 1, + 1, + 1, + "185.207.214.16:2302", + 0, + 0, + "a", + False, + ], + [ + "BAR", + "chernarusplus", + "a", + "a", + 1, + 1, + 1, + "185.207.214.16:2302", + 0, + 0, + "a", + False, + ], + [ + "BAR", + "chernarusplus", + "a", + "a", + 1, + 1, + 1, + "185.207.214.16:2302", + 0, + 0, + "a", + False, + ], + [ + "BAR", + "chernarusplus", + "a", + "a", + 1, + 1, + 1, + "185.207.214.16:2302", + 0, + 0, + "a", + False, + ], + [ + "BAR", + "chernarusplus", + "a", + "a", + 1, + 1, + 1, + "185.207.214.16:2302", + 0, + 0, + "a", + False, + ], + [ + "BAR", + "chernarusplus", + "a", + "a", + 1, + 1, + 1, + "185.207.214.16:2302", + 0, + 0, + "a", + False, + ], ) self.push_data(data, FilterMode.INITIAL, success=True) # TODO: eg dedicated cleanup on failure, cleanup on sucess # these can pop their own predefined dialogs, much simpler + def get_map_man(self) -> "MapManager": + return self.get_active_treeview().get_map_man() + def cleanup(self) -> None: treeview = self.get_active_treeview() treeview.set_loaded(True) @@ -636,7 +788,6 @@ class Controller(GObject.GObject): # model insertion after thread closes # cf. servers_loaded signal - context = self.get_active_context() self.emitter.emit("servers_loaded", context) @@ -647,10 +798,9 @@ class Controller(GObject.GObject): self.first_iteration = False self.new_maps = None - treeview.grab_focus() self.destroy_on_idle() - #if self.success is False: + # if self.success is False: # # TODO: different dialogs for server tab contexts, e.g. lan timeout # # TODO: if history/favorites is empty, don't even trigger a call to dump data # dialog = ExceptionDialog(self, "API TIMEOUT") @@ -679,7 +829,7 @@ class Controller(GObject.GObject): treeview = self.get_active_treeview() treeview.set_loaded(True) # TODO: wipe control model on failure or keep old results? - manager = treeview.get_filter_man() + # manager = treeview.get_filter_man() GLib.idle_add(self.cleanup_on_failure) def push_data(self, data: tuple, mode: Optional[FilterMode], success: bool) -> None: @@ -841,7 +991,7 @@ class Controller(GObject.GObject): return # TODO: maybe set tree to none afterwards (swap models) - #treeview.set_model(None) + # treeview.set_model(None) func = treeview.get_query_func() if func is None: self.emitter.emit("servers_loaded", treeview.get_enum()) @@ -887,23 +1037,33 @@ class Controller(GObject.GObject): return self.mediator.filters.get_keyword_filter() def get_map_store(self) -> Gtk.ListStore: - treeview = self.get_active_treeview() - map_man = treeview.get_map_man() + map_man = self.get_map_man() return map_man.get_map_store() def get_selected_map(self) -> str: - treeview = self.get_active_treeview() - map_man = treeview.get_map_man() + map_man = self.get_map_man() return map_man.get_selected_map() + def get_enabled_filters(self) -> dict: + map_man = self.get_map_man() + return map_man.get_filters() + def get_prior_map(self) -> str: - treeview = self.get_active_treeview() - map_man = treeview.get_map_man() + map_man = self.get_map_man() return map_man.get_prior_map() + def _on_check_toggled(self, emitter: Emitter, label: str, state: bool) -> None: + map_man = self.get_map_man() + map_man.set_filter(label, state) + + if state: + mode = FilterMode.TOGGLE_ON + else: + mode = FilterMode.TOGGLE_OFF + self.refilter_model(mode, label) + def _on_map_selection_changed(self, emitter: Emitter, selection: str) -> None: - treeview = self.get_active_treeview() - map_man = treeview.get_map_man() + map_man = self.get_map_man() map_man.set_selected_map(selection) self.refilter_model(FilterMode.MAP) diff --git a/dzgui/util/strings.py b/dzgui/util/strings.py index 0e43df0..0d37986 100644 --- a/dzgui/util/strings.py +++ b/dzgui/util/strings.py @@ -417,7 +417,7 @@ options = Options( 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' +Open your Profile, then 'Workshop Items' and select 'Unsubscribe from all' on the right-hand side. """, ) diff --git a/dzgui/views/components/filter_panel.py b/dzgui/views/components/filter_panel.py index ad89a34..92eb9ab 100644 --- a/dzgui/views/components/filter_panel.py +++ b/dzgui/views/components/filter_panel.py @@ -20,83 +20,151 @@ if TYPE_CHECKING: from dzgui.const.enum import ServerTab -class FilterPanel(Gtk.Box): - def __init__(self, controller: "Controller") -> None: - super().__init__(spacing=6, vexpand=False) - - 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.controller = controller - self.controller.register_widget("filters", self) - self.emitter = self.controller.get_emitter() - - self.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: str = strings.all_maps - #self.prior_map: str = strings.all_maps - - self.button_grid = Gtk.Grid( +class ButtonGrid(Gtk.Grid): + def __init__(self, controller: "Controller", defaults: dict) -> None: + super().__init__( halign=Gtk.Align.CENTER, column_spacing=5, column_homogeneous=True ) row = 1 col = 0 + + self.controller = controller + self.emitter = controller.get_emitter() + + self.checks: list[Gtk.CheckButton] = [] + # TODO: use enumerated checks - for check in self.default_filters.keys(): + for check in defaults.keys(): checkbox = Gtk.CheckButton(label=check) label = checkbox.get_children() label[0].set_ellipsize(Pango.EllipsizeMode.END) - if self.default_filters[check]: + if defaults[check]: checkbox.set_active(True) col = col + 1 if col > 3: row += 1 col = 1 - self.button_grid.attach(checkbox, col, row, 1, 1) - + self.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) + def block_toggles(self, state: bool) -> None: + for check in self.checks: + self.controller.suppress_signal( + self, + check, + "_on_check_toggled", + state, + ) + + def reload_filters(self) -> None: + checkboxes = self.checks + self.block_toggles(True) + filters = self.controller.get_enabled_filters() + for check in checkboxes: + label = check.get_label() + state = filters[label] + check.set_active(state) + self.block_toggles(False) + + def _on_check_toggled(self, button: Gtk.CheckButton) -> None: + label = button.get_label() + state = button.get_active() + logger.info(f"User toggled button '{label}' to {state}") + self.emitter.emit("check_toggled", label, state) + + def get_checkboxes(self) -> list: + return self.checks + + +class KeywordEntry(Gtk.Entry): + def __init__(self, controller: "Controller") -> None: + # TODO :strings + super().__init__(placeholder_text="Filter by keyword") + + self.keyword_filter = "" + self.controller = controller + self.connect("activate", self._on_activated) + self.connect("key-press-event", self._on_keypress) + self.connect("icon-release", lambda *args: self.activate()) + + self.set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, SEARCH_ICON) + self.set_icon_activatable(Gtk.EntryIconPosition.SECONDARY, True) + + def get_keyword(self) -> str: + return self.keyword_filter + + def _on_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_activated(self, entry: Gtk.Entry) -> None: + # TODO: investigate this method + self.controller.mediator.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.controller.refilter_model(FilterMode.KEYWORD, keyword) + + +class FilterPanel(Gtk.Box): + def __init__(self, controller: "Controller") -> None: + super().__init__(spacing=6, vexpand=False, orientation=Gtk.Orientation.VERTICAL) + + # TODO: get these from metamanager + # 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.controller = controller + self.controller.register_widget("filters", self) + self.emitter = self.controller.get_emitter() + + # self.selected_map: str = strings.all_maps + # self.prior_map: str = strings.all_maps + # self.set_orientation(Gtk.Orientation.VERTICAL) + + # TODO: initialize to empty + map_man = self.controller.get_map_man() + defaults = map_man.get_default_filters() + self.map_store = map_man.get_map_store() + self.enabled_filters = defaults + + self.keyword_entry = KeywordEntry(self.controller) + self.button_grid = ButtonGrid(self.controller, defaults) + + # TODO: unintended legacy behavior? + self.connect("button-release-event", lambda *args: True) set_surrounding_margins(self, 10) self.set_margin_top(1) # TODO: strings self.filters_label = BoldLabel("Filters") - self.keyword_entry = Gtk.Entry() - # TODO :strings - self.keyword_entry.set_placeholder_text("Filter by keyword") - self.keyword_entry.connect("activate", self._on_keyword_activated) - self.keyword_entry.connect("key-press-event", self._on_keyword_keypress) - self.keyword_entry.set_icon_from_icon_name( - Gtk.EntryIconPosition.SECONDARY, SEARCH_ICON - ) - self.keyword_entry.set_icon_activatable(Gtk.EntryIconPosition.SECONDARY, True) - self.keyword_entry.connect( - "icon-release", lambda *args: self.keyword_entry.activate() - ) - self.emitter.connect( "request_keyword_focus", lambda _: self.keyword_entry.grab_focus() ) @@ -107,6 +175,7 @@ class FilterPanel(Gtk.Box): self.emitter.connect("servers_loaded", self._on_servers_loaded) self.emitter.connect("load_maps", self._on_maps_loaded) + # TODO: break into MapsCombo class completion = Gtk.EntryCompletion(inline_completion=True) completion.set_text_column(0) completion.set_minimum_key_length(1) @@ -121,14 +190,30 @@ class FilterPanel(Gtk.Box): 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) + self.maps_entry.connect("activate", self._on_maps_activated) self.maps_combo.pack_start(renderer_text, EXPAND) self.maps_combo.connect("changed", self._on_map_changed) self.maps_combo.connect("key-press-event", self._on_combo_keypress) - for el in self.filters_label, self.keyword_entry, self.maps_combo, self.button_grid: + for el in ( + self.filters_label, + self.keyword_entry, + self.maps_combo, + self.button_grid, + ): self.pack_start(el, NO_EXPAND, NO_FILL, NO_PADDING) + def _on_maps_activated(self, entry: Gtk.Entry) -> None: + text = entry.get_text() + model = self.maps_combo.get_model() + if text is None: + return + for i, row in enumerate(model): + if text == row[0]: + self.maps_combo.set_active(i) + self._on_map_changed(self.maps_combo) + def _on_servers_loaded(self, emitter: "Emitter", tab: "ServerTab") -> None: state = self.controller.has_server_model() for widget in (self.keyword_entry, self.maps_combo, self.button_grid): @@ -137,17 +222,23 @@ class FilterPanel(Gtk.Box): def _on_maps_loaded(self, emitter: "Emitter", store: Gtk.ListStore) -> None: self.maps_combo.set_model(store) self.maps_combo.set_active(0) + self.button_grid.reload_filters() + # TODO: move into metamanager def get_filters(self) -> tuple: selected = self.controller.get_selected_map() + enabled = self.controller.get_enabled_filters() filters = [] filters.append(selected) - filters.append(self.keyword_filter) - for k in self.enabled_filters: - if not self.enabled_filters[k]: - filters.append(k) + filters.append(self.get_keyword_filter()) + for filt in enabled: + if enabled[filt] is False: + filters.append(filt) return tuple(filters) + def get_keyword_filter(self) -> str: + return self.keyword_entry.get_keyword() + # TODO: should be used when switching ServerTab contexts # use signals here # def reinit_panel(self) -> None: @@ -159,29 +250,17 @@ class FilterPanel(Gtk.Box): # if sel_panel.is_visible(): # sel_panel.set_visible(False) - # TODO: change filters on a per-tab basis - 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) + # TODO: this chiefly applies when clicking refresh button, etc. + # and setting filters to default state + # 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: - # TODO: use activated() signal 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(self.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) """ @@ -191,7 +270,7 @@ class FilterPanel(Gtk.Box): text = self.maps_entry.get_text() self.maps_entry.set_position(len(text)) case _: - return + return False def _on_completer_match( self, @@ -213,17 +292,6 @@ class FilterPanel(Gtk.Box): view.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: @@ -232,33 +300,13 @@ class FilterPanel(Gtk.Box): case _: return False - def get_keyword_filter(self) -> str: - return self.keyword_filter - - def _on_keyword_activated(self, entry: Gtk.Entry) -> None: - # TODO: - self.controller.mediator.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.controller.refilter_model(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_by_key( - self, emitter: "Emitter", keyval: int - ) -> None | Literal[False]: + def toggle_check_by_key(self, emitter: "Emitter", keyval: int) -> bool: mappings = { Gdk.KEY_1: 0, Gdk.KEY_2: 1, @@ -277,24 +325,14 @@ class FilterPanel(Gtk.Box): return False index = mappings[keyval] self.toggle_check(index) + return True def toggle_check(self, digit: int) -> None: - check = self.checks[digit] + checks = self.button_grid.get_checkboxes() + check = checks[digit] state = check.get_active() check.set_active(not state) - def _on_check_toggled(self, button: Gtk.CheckButton) -> None: - 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.controller.refilter_model(mode, label) - def _on_map_changed(self, combo: Gtk.ComboBox) -> None: old_sel = self.controller.get_selected_map() model = combo.get_model() @@ -306,6 +344,6 @@ class FilterPanel(Gtk.Box): return if not selection: return - logger.info(f"User selected map '{selection}'") self.maps_entry.set_text(selection) + logger.info(f"User selected map '{selection}'") self.emitter.emit("map_selection_changed", selection) diff --git a/dzgui/views/dialogs/link_dialog.py b/dzgui/views/dialogs/link_dialog.py index ba7cded..57e6c09 100644 --- a/dzgui/views/dialogs/link_dialog.py +++ b/dzgui/views/dialogs/link_dialog.py @@ -4,6 +4,7 @@ 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.util.strings import notice from dzgui.views.dialogs.generic import GenericDialog from dzgui.views.components.buttons import SteamTextButton, WebButton @@ -18,9 +19,9 @@ if TYPE_CHECKING: 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) + text = textwrap.dedent(text).replace("\n", " ") + super().__init__(controller, text=notice, mtype=Gtk.MessageType.INFO, buttons=Gtk.ButtonsType.OK, secondary=text) + self.controller = controller self.dialogBox = self.get_content_area() self.set_default_response(Gtk.ResponseType.OK) self.set_size_request(500, 0) diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index 460d104..f84ebf6 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -116,6 +116,7 @@ class ServerTreeView(ContextMixin, TreeView): self.emitter.connect("servers_loaded", self._on_servers_loaded) def _on_servers_loaded(self, emitter: "Emitter", tab: "ServerTab") -> None: + # FIXME: gets called on every tab, should be localized only to that context state = self.controller.has_server_model() self.set_headers_clickable(state) From 2c8e722d8a8cfa069888fa1842289769ae635ad5 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 14 Feb 2026 16:46:44 +0900 Subject: [PATCH 076/309] fix: lan panel sensitivity --- dzgui/controllers/mc.py | 13 ++++++++++++- dzgui/views/components/connect_panel.py | 11 +++++++---- dzgui/views/components/filter_panel.py | 7 +------ dzgui/views/components/mod_panel.py | 4 ++-- dzgui/views/components/right_panel.py | 17 +++++++---------- dzgui/views/dialogs/generic.py | 11 ++++++++--- dzgui/views/pages/servers.py | 1 + dzgui/views/trees/tree_servers.py | 3 +++ 8 files changed, 41 insertions(+), 26 deletions(-) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 3cfc62d..5a30cee 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -379,12 +379,23 @@ class Controller(GObject.GObject): j = res.json serv += j["response"]["servers"] + """ + TODO: e.g. class method like 'total' + GLib.idle_add(lambda: self.wait_dialog.format_secondary_text("Pass 2")) + GLib.idle_add(lambda: self.wait_dialog.prog.set_fraction(0.5)) subclass or wrap add fraction and increment, then + calculate off of total + """ res = Servers.query_api(key, APPID_DAYZ_EXP, "") if res.status == 200 and res.parsed is True: j = res.json serv += j["response"]["servers"] + GLib.idle_add( + lambda: self.wait_dialog.format_secondary_text("Unpacking servers") + ) + # TODO: additional ping column pass, collated + # GLib.idle_add(lambda: self.wait_dialog.prog.set_fraction(0.75)) parsed = Servers.parse_json(serv) self.push_data(parsed, FilterMode.INITIAL, success=True) @@ -839,7 +850,7 @@ class Controller(GObject.GObject): self.to_insert = None - # TODO: + # TODO: drop self.success = success if success: if data is None: diff --git a/dzgui/views/components/connect_panel.py b/dzgui/views/components/connect_panel.py index 10f1db9..daf641e 100644 --- a/dzgui/views/components/connect_panel.py +++ b/dzgui/views/components/connect_panel.py @@ -25,7 +25,6 @@ class LanPanel(Gtk.Frame): def __init__(self, controller: "Controller") -> None: super().__init__(margin_top=10, margin_bottom=5) - # TODO: hide lan panel on other tabs self.controller = controller self.emitter = controller.get_emitter() @@ -56,9 +55,14 @@ class LanPanel(Gtk.Frame): self.add(self.grid) + self.entry.set_sensitive(False) + def _on_radio_toggled(self, button: Gtk.RadioButton) -> None: - self.entry.set_sensitive(not button.get_active()) - if len(self.entry.get_text()) < 1: + state = button.get_active() + self.entry.set_sensitive(not state) + if state: + self.scan.set_sensitive(True) + elif len(self.entry.get_text()) < 1: self.scan.set_sensitive(False) def _on_port_validated(self, entry: Gtk.Entry, state: bool) -> None: @@ -76,7 +80,6 @@ class FavPanel(Gtk.Frame): emitter = self.controller.get_emitter() emitter.connect("fav_server_changed", self._on_fav_server_changed) - # TODO: improve upon this, cache fav ip when changed globally self.server_name, self.server_ip = self.controller.get_favorite() server_name = ( f"{self.server_name} ({self.server_ip})" diff --git a/dzgui/views/components/filter_panel.py b/dzgui/views/components/filter_panel.py index 92eb9ab..e59fb86 100644 --- a/dzgui/views/components/filter_panel.py +++ b/dzgui/views/components/filter_panel.py @@ -172,7 +172,7 @@ class FilterPanel(Gtk.Box): "request_maps_focus", lambda _: self.maps_entry.grab_focus() ) self.emitter.connect("check_button_pressed", self.toggle_check_by_key) - self.emitter.connect("servers_loaded", self._on_servers_loaded) + # self.emitter.connect("servers_loaded", self._on_servers_loaded) self.emitter.connect("load_maps", self._on_maps_loaded) # TODO: break into MapsCombo class @@ -214,11 +214,6 @@ class FilterPanel(Gtk.Box): self.maps_combo.set_active(i) self._on_map_changed(self.maps_combo) - def _on_servers_loaded(self, emitter: "Emitter", tab: "ServerTab") -> None: - state = self.controller.has_server_model() - for widget in (self.keyword_entry, self.maps_combo, self.button_grid): - widget.set_sensitive(state) - def _on_maps_loaded(self, emitter: "Emitter", store: Gtk.ListStore) -> None: self.maps_combo.set_model(store) self.maps_combo.set_active(0) diff --git a/dzgui/views/components/mod_panel.py b/dzgui/views/components/mod_panel.py index 9f6fbae..fe9d3b3 100644 --- a/dzgui/views/components/mod_panel.py +++ b/dzgui/views/components/mod_panel.py @@ -71,10 +71,10 @@ class ModSelectionPanel(Gtk.Box): def swap_sensitive(self, state: bool) -> None: for child in self.stale_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) + else: + child.set_sensitive(state) def _on_button_clicked(self, button: EnumeratedModButton) -> None: match button.enum: diff --git a/dzgui/views/components/right_panel.py b/dzgui/views/components/right_panel.py index e1c95b5..84a2881 100644 --- a/dzgui/views/components/right_panel.py +++ b/dzgui/views/components/right_panel.py @@ -25,7 +25,7 @@ class RightPanel(Gtk.Box): self.controller.register_widget("right_panel", self) emitter = controller.get_emitter() - emitter.connect("servers_loaded", self.toggle_refresh_button) + emitter.connect("servers_loaded", self._on_servers_loaded) self.button_vbox = ButtonBox(controller) self.filters_vbox = FilterPanel(controller) @@ -40,15 +40,12 @@ class RightPanel(Gtk.Box): self.pack_start(self.sel_panel, NO_EXPAND, NO_FILL, NO_PADDING) - def toggle_refresh_button(self, emitter: "Emitter", context: "ServerTab") -> None: - # TODO: when a row is added to a previously empty table, - # make sure "servers_loaded" signal is emitted - self.refresh_button.set_sensitive(True) - model = self.controller.get_active_treeview().get_model() - # NOTE: if saved servers or history are empty, there is nothing to refresh - if model is None: - if context in (ServerTab.RECENT, ServerTab.SAVED): - self.refresh_button.set_sensitive(False) + def _on_servers_loaded(self, emitter: "Emitter", context: "ServerTab") -> None: + state = self.controller.has_server_model() + for widget in (self.refresh_button, self.filters_vbox): + widget.set_sensitive(state) + if context in (ServerTab.RECENT, ServerTab.SAVED): + self.refresh_button.set_sensitive(False) # TODO: reference for ping pass # def _on_ping_clicked(self, button: Gtk.Button) -> None: diff --git a/dzgui/views/dialogs/generic.py b/dzgui/views/dialogs/generic.py index 742a817..738fdd3 100644 --- a/dzgui/views/dialogs/generic.py +++ b/dzgui/views/dialogs/generic.py @@ -101,7 +101,9 @@ class NotifyDialog(GenericDialog): class WaitDialog(GenericDialog): - def __init__(self, controller: "Controller", secondary: str): + def __init__( + self, controller: "Controller", secondary: str, show_progress: bool = False + ): super().__init__( controller=controller, text=strings.wait, @@ -113,9 +115,12 @@ class WaitDialog(GenericDialog): self.connect("delete-event", self._on_dialog_delete) content = self.get_content_area() spinner = Gtk.Spinner() + self.prog = Gtk.ProgressBar() content.pack_end(spinner, NO_EXPAND, NO_FILL, 0) - - spinner.start() + if show_progress: + content.pack_end(self.prog, NO_EXPAND, NO_FILL, 0) + else: + spinner.start() def _on_dialog_delete( self, response_id: Gtk.ResponseType, event: Gdk.Event diff --git a/dzgui/views/pages/servers.py b/dzgui/views/pages/servers.py index f5df268..5cdfeaf 100644 --- a/dzgui/views/pages/servers.py +++ b/dzgui/views/pages/servers.py @@ -36,6 +36,7 @@ class ServerNotebook(Gtk.ScrolledWindow): ) self.lan = ServerTreeView(controller, ServerTab.LAN, ContextMenuGroup.SCAN_LAN) + self.recent.set_query_func(self.controller.dump_test_2) self.lan.set_query_func(self.controller.dump_test_2) self.browser.set_query_func(self.controller.dump_api) diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index f84ebf6..a8df4fd 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -117,7 +117,10 @@ class ServerTreeView(ContextMixin, TreeView): def _on_servers_loaded(self, emitter: "Emitter", tab: "ServerTab") -> None: # FIXME: gets called on every tab, should be localized only to that context + # hook signal from main controller state = self.controller.has_server_model() + # NOTE: workaround for GTK bug where fullscreen causes headers to vanish when model is None + self.set_headers_visible(state) self.set_headers_clickable(state) def _on_servers_loaded_init(self, emitter: "Emitter") -> None: From 96244a4560f746a36795c6e1278a15cc48f39c8f Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 14 Feb 2026 20:16:28 +0900 Subject: [PATCH 077/309] feat: wrap callback function --- dzgui/controllers/emitter.py | 30 +++++- dzgui/controllers/mc.py | 137 ++++++++++++-------------- dzgui/model/misc_model.py | 6 +- dzgui/views/components/mod_panel.py | 19 ++-- dzgui/views/components/right_panel.py | 1 + dzgui/views/components/statusbar.py | 12 ++- dzgui/views/pages/options.py | 13 ++- dzgui/views/trees/tree_mods.py | 40 ++++---- 8 files changed, 150 insertions(+), 108 deletions(-) diff --git a/dzgui/controllers/emitter.py b/dzgui/controllers/emitter.py index 6c2481c..55a5e5f 100644 --- a/dzgui/controllers/emitter.py +++ b/dzgui/controllers/emitter.py @@ -83,7 +83,23 @@ class Emitter(GObject.GObject): ) -> None: pass - @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(str,bool,)) + @GObject.Signal( + flags=GObject.SignalFlags.RUN_LAST, + arg_types=( + str, + bool, + ), + ) + def check_toggled(self, label: str, state: bool) -> None: + pass + + @GObject.Signal( + flags=GObject.SignalFlags.RUN_LAST, + arg_types=( + str, + bool, + ), + ) def check_toggled(self, label: str, state: bool) -> None: pass @@ -102,3 +118,15 @@ class Emitter(GObject.GObject): @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object,)) def load_maps(self, store: ListStore) -> None: pass + + @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=()) + def mod_page_loaded(self) -> None: + pass + + @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=()) + def mods_highlighted(self) -> None: + pass + + @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=()) + def api_change_failed(self) -> None: + pass diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 5a30cee..bda2b74 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -1,3 +1,4 @@ +import inspect import logging import shutil import threading @@ -93,11 +94,22 @@ class AppNavigation: filters: "FilterPanel" +class CleanupFunc: + def __init__(self, func: Callable, *args, **kwargs) -> None: + sig = inspect.signature(func) + self.func = func + self.bindings = sig.bind(*args, **kwargs) + + def call(self) -> None: + self.func(*self.bindings.args, *self.bindings.kwargs) + + class Controller(GObject.GObject): def __init__(self) -> None: self.dist_cache: dict[str, "Haversine", "ServerTab"] = {} self.mediator = AppNavigation() self.prefs: UserPrefs + self.cleanup_func: CleanupFunc = None self.model_man = ModelManager() self.emitter = Emitter() @@ -114,10 +126,14 @@ class Controller(GObject.GObject): def decorator(func: Callable) -> Callable: @wraps(func) def wrapper(*args, **kwargs): + def callback() -> None: + func(*args, **kwargs) + GLib.idle_add(self._destroy_on_idle) + self = args[0] self.wait_dialog = WaitDialog(self, dialog_str) self.wait_dialog.show_all() - thread = threading.Thread(target=func, args=args) + thread = threading.Thread(target=callback) thread.start() return wrapper @@ -156,6 +172,7 @@ class Controller(GObject.GObject): def unblock_signals(self) -> None: self.block_signals(False) + @deprecated("Currently unused") def block_signals(self, state: bool = True) -> None: self.suppress_signal( self.mediator.filters, @@ -280,6 +297,7 @@ class Controller(GObject.GObject): self.emitter.emit("distcalc_ended", dist, context) + # TODO: threading def delete_multiple_mods(self) -> None: sel = self.mediator.modtreeview.get_selection() model, pathlist = sel.get_selected_rows() @@ -290,10 +308,13 @@ class Controller(GObject.GObject): total_mods, total_size = self.calc_mod_size() self.update_mod_statusbar() + def load_mods_cleanup(self, model: Gtk.ListStore) -> None: + self.mediator.modtreeview.set_model(model) + self.emitter.emit("mod_page_loaded") + + @call_on_thread(strings.dialog.modlist) def load_mods(self) -> None: - # TODO: threading; could get slow with 100s of mods - model = self.model_man.get_mod_store() - model.clear() + model = self.model_man.new_mod_store() path = self.query_config(Preferences.DEFAULT) mods = get_delimited_mods(Path(path)) @@ -302,6 +323,8 @@ class Controller(GObject.GObject): mod.append(None) model.append(mod) + self.cleanup_func = CleanupFunc(self.load_mods_cleanup, model) + def toggle_config(self, context: Preferences) -> None: config = self.prefs.paths.config try: @@ -484,16 +507,21 @@ class Controller(GObject.GObject): except PeFile.AppNotInstalledError: pass - model = self.model_man.get_mod_store() + model = self.get_mod_store() model.remove(it) + def get_mod_store(self) -> Gtk.ListStore: + return self.mediator.modtreeview.get_model() + def format_mod_statusbar(self) -> None: total_mods, total_size = self.calc_mod_size() msg = format_mods(total_size, total_mods) return msg def calc_mod_size(self) -> tuple[int, int]: - model = self.model_man.get_mod_store() + model = self.get_mod_store() + if model is None: + return 0, 0 total_mods = len(model) total_size = 0 for mod in model: @@ -590,7 +618,7 @@ class Controller(GObject.GObject): self.open_page(NotebookPage.LOG) def select_colorized(self) -> None: - model = self.model_man.get_mod_store() + model = self.get_mod_store() sel = self.mediator.modtreeview.get_selection() for mod in model: it = mod.iter @@ -598,29 +626,14 @@ class Controller(GObject.GObject): if mod[4] == HEX_RED: sel.select_path(path) + # TODO: make as method of tree? def uncolorize_mods(self) -> None: - model = self.model_man.get_mod_store() + model = self.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_man.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 - # TODO: clone existing model and set outside of threai - 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 dump_test_2(self) -> None: import time @@ -810,7 +823,6 @@ class Controller(GObject.GObject): self.new_maps = None treeview.grab_focus() - self.destroy_on_idle() # if self.success is False: # # TODO: different dialogs for server tab contexts, e.g. lan timeout # # TODO: if history/favorites is empty, don't even trigger a call to dump data @@ -818,10 +830,10 @@ class Controller(GObject.GObject): # dialog.run() def cleanup_on_failure(self) -> None: - # TODO: what if refresh action occurred, and the old model is valid? treeview = self.get_active_treeview() treeview.set_model(None) map_man = treeview.get_map_man() + # TODO: what if refresh action occurred, and the old model is still valid? # TODO: disable map, keyword, and filter widgets if model is None? # -> signal driven (servers_empty) # would have to make those unsensitive when changing server tabs @@ -832,7 +844,6 @@ class Controller(GObject.GObject): # TODO: distinguish signals, e.g. "servers_failed_to_load" self.emitter.emit("servers_loaded", context) treeview.grab_focus() - self.destroy_on_idle() dialog = ExceptionDialog(self, "API TIMEOUT") dialog.run() @@ -841,7 +852,7 @@ class Controller(GObject.GObject): treeview.set_loaded(True) # TODO: wipe control model on failure or keep old results? # manager = treeview.get_filter_man() - GLib.idle_add(self.cleanup_on_failure) + self.cleanup_func = CleanupFunc(self.cleanup_on_failure) def push_data(self, data: tuple, mode: Optional[FilterMode], success: bool) -> None: @@ -866,38 +877,31 @@ class Controller(GObject.GObject): self.new_maps = sorted(u_maps) treeview.set_loaded(True) - GLib.idle_add(self.cleanup) + self.cleanup_func = CleanupFunc(self.cleanup) @call_on_thread(strings.dialog.working) def highlight_stale(self) -> None: - self.colorize_mods() + model = self.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 + # FIXME: deep copy existing model and set outside of thread + if int(mod[2]) in stale: + model[path][4] = HEX_RED + self.cleanup_func = CleanupFunc(lambda: self.emitter.emit("mods_highlighted")) - def get_callback(self) -> Callable | None: - return self.callback["func"] + def get_cleanup_func(self) -> CleanupFunc: + return self.cleanup_func - def get_callback_args(self) -> Any: - return self.callback["args"] - - 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 destroy_on_idle(self) -> None: - """ - TODO: chiefly responsible for removing spinner dialog - """ + def _destroy_on_idle(self) -> None: self.wait_dialog.destroy() - func = self.get_callback() - self.mediator.window.set_sensitive(True) - # TODO: spawn error dialog if API crawl failed - # most likely going to drop callback method + func = self.get_cleanup_func() if func is not None: - args = self.get_callback_args() - GLib.idle_add(func, *args) - self.set_callback(None, None) + func.call() + self.cleanup_func = None + self.mediator.window.set_sensitive(True) def dump_diagnostics(self) -> None: picker = FilePicker(self.mediator.window) @@ -909,7 +913,8 @@ class Controller(GObject.GObject): dialog = ExceptionDialog(self, str(e)) dialog.run() - def test_api_response(self, text: str, key: Preferences) -> None: + @call_on_thread(strings.dialog.working) + def update_api_key(self, text: str, key: Preferences) -> None: if key is Preferences.STEAM: res = test_steam_api(text) else: @@ -917,17 +922,9 @@ class Controller(GObject.GObject): if res is True: self.update_config(key, text) - self.set_callback(None, None) - self.destroy_on_idle() else: - self.destroy_on_idle() - dialog = ExceptionDialog(self, strings.api_error) - dialog.run() - - @call_on_thread(strings.dialog.working) - # FIXME: entire process is behind thread, including destroy_on_idle() - def update_api_key(self, text: str, key: Preferences) -> None: - self.test_api_response(text, key) + # FIXME: dialog spawning in thread + self.cleanup_func = CleanupFunc(self.emitter.emit("api_change_failed")) def set_resolution(self, window: "OuterWindow") -> None: if self.prefs.is_game_mode: @@ -985,14 +982,12 @@ class Controller(GObject.GObject): self.push_data("", mode, success=True) # FIXME: optional label/map/keyword parameter - # FIXME: drop set_callback + # TODO: consolidate with method above def refilter_model(self, mode: FilterMode, label: Optional[str] = None) -> None: tv = self.get_active_treeview() filter_man = tv.get_filter_man() if filter_man.get_control() is None: return - # TODO: deprecated in this context? - self.set_callback(None, None) self.filter_threaded(mode, label) def populate_model(self) -> None: @@ -1001,18 +996,15 @@ class Controller(GObject.GObject): self.emitter.emit("servers_loaded", treeview.get_enum()) return - # TODO: maybe set tree to none afterwards (swap models) - # treeview.set_model(None) func = treeview.get_query_func() if func is None: self.emitter.emit("servers_loaded", treeview.get_enum()) treeview.set_model(None) return - # TODO: + # TODO: clear ephemeral model if necessary # manager = treeview.get_filter_man() # manager.clear_model() self.first_iteration = True - self.set_callback(None, None) self.run_query_func(func) def get_favorite(self) -> tuple[str, str] | tuple[None, None]: @@ -1034,9 +1026,6 @@ class Controller(GObject.GObject): def get_modlist_store(self) -> Gtk.ListStore: return self.model_man.get_modlist_store() - def get_mod_store(self) -> Gtk.ListStore: - return self.model_man.get_mod_store() - def get_log_store(self) -> Gtk.ListStore: return self.model_man.get_log_store() diff --git a/dzgui/model/misc_model.py b/dzgui/model/misc_model.py index 0fe97c9..39c93eb 100644 --- a/dzgui/model/misc_model.py +++ b/dzgui/model/misc_model.py @@ -47,8 +47,8 @@ class ModelManager: self.map_store = ListStore(str) self.help_store = self.new_model_from_class(MenuCols) - self.mod_store = self.new_model_from_class(ModCols) self.log_store = self.new_model_from_class(LogCols) + # FIXME: needs to be generated on demand self.modlist_store = self.new_model_from_class(ServerModCols) for row in HELP_MENU_ROWS: @@ -65,8 +65,8 @@ class ModelManager: def get_help_store(self) -> ListStore: return self.help_store - def get_mod_store(self) -> ListStore: - return self.mod_store + def new_mod_store(self) -> ListStore: + return self.new_model_from_class(ModCols) def get_log_store(self) -> ListStore: return self.log_store diff --git a/dzgui/views/components/mod_panel.py b/dzgui/views/components/mod_panel.py index fe9d3b3..c92e2de 100644 --- a/dzgui/views/components/mod_panel.py +++ b/dzgui/views/components/mod_panel.py @@ -16,7 +16,7 @@ if TYPE_CHECKING: class EnumeratedModButton(Gtk.Button): def __init__(self, enum: ModButton) -> None: - super().__init__(margin_start=10, margin_end=10) + super().__init__(margin_start=10, margin_end=10, focus_on_click=False) self.enum = enum self.set_label(enum.dict["label"]) @@ -30,6 +30,7 @@ class ModSelectionPanel(Gtk.Box): self.controller = controller emitter = controller.get_emitter() emitter.connect("mod_page_toggled", self._on_mod_page_toggled) + emitter.connect("mods_highlighted", self._on_mods_highlighted) header = BoldLabel(mod_panel.header) @@ -60,14 +61,17 @@ class ModSelectionPanel(Gtk.Box): for el in header, self.main_panel, self.stale_panel: self.pack_start(el, NO_EXPAND, FILL, NO_PADDING) + def _on_mods_highlighted(self, emitter: "Emitter") -> None: + self.swap_sensitive(True) + def _on_mod_page_toggled(self, emitter: "Emitter", state: bool) -> None: self.set_visible(state) - def after_colorize(self) -> None: - # TODO: split into signal that touches two widgets, - # modtreeview and this widget - self.controller.unselect_all_mods() - self.swap_sensitive(True) + #def after_colorize(self) -> None: + # # TODO: split into signal that touches two widgets, + # # modtreeview and this widget + # #self.controller.unselect_all_mods() + # self.swap_sensitive(True) def swap_sensitive(self, state: bool) -> None: for child in self.stale_panel.get_children(): @@ -79,15 +83,12 @@ class ModSelectionPanel(Gtk.Box): def _on_button_clicked(self, button: EnumeratedModButton) -> None: match button.enum: case ModButton.SELECT_ALL: - # TODO: signals 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() diff --git a/dzgui/views/components/right_panel.py b/dzgui/views/components/right_panel.py index 84a2881..a8f7232 100644 --- a/dzgui/views/components/right_panel.py +++ b/dzgui/views/components/right_panel.py @@ -44,6 +44,7 @@ class RightPanel(Gtk.Box): state = self.controller.has_server_model() for widget in (self.refresh_button, self.filters_vbox): widget.set_sensitive(state) + # FIXME: may wish to reload these in place to show new data if context in (ServerTab.RECENT, ServerTab.SAVED): self.refresh_button.set_sensitive(False) diff --git a/dzgui/views/components/statusbar.py b/dzgui/views/components/statusbar.py index 9f4a465..20f69a7 100644 --- a/dzgui/views/components/statusbar.py +++ b/dzgui/views/components/statusbar.py @@ -51,6 +51,11 @@ class Statusbar(Gtk.Grid): self.emitter.connect("distcalc_started", self._on_distcalc_started) self.emitter.connect("distcalc_ended", self._on_distcalc_ended) self.emitter.connect("servers_loaded", self._on_servers_loaded) + self.emitter.connect("mod_page_loaded", self._on_mod_page_loaded) + + def _on_mod_page_loaded(self, emitter: "Emitter") -> None: + msg = self.controller.format_mod_statusbar() + self.set_by_context(NotebookPage.MODS, msg) def _on_notebook_page_changed( self, notebook: "Notebook", child: Gtk.Widget, index: int @@ -75,12 +80,15 @@ class Statusbar(Gtk.Grid): match enum: case NotebookPage.MODS: - bar = self.controller.format_mod_statusbar() + return + # FIXME: format on callback after mods load + #bar = self.controller.format_mod_statusbar() case NotebookPage.HELP: bar = self.controller.get_help_row() case NotebookPage.SERVERS: context = self.controller.get_active_context() - self.emitter.emit("servers_loaded", context) + # FIXME: may be superfluous + #self.emitter.emit("servers_loaded", context) return case NotebookPage.KEYS: bar = question_to_return diff --git a/dzgui/views/pages/options.py b/dzgui/views/pages/options.py index 751af20..05ed658 100644 --- a/dzgui/views/pages/options.py +++ b/dzgui/views/pages/options.py @@ -37,6 +37,7 @@ from gi.repository import Gtk, Gdk # noqa if TYPE_CHECKING: from dzgui.controllers.mc import Controller + from dzgui.controllers.emitter import Emitter class Options(Gtk.Box): @@ -48,6 +49,8 @@ class Options(Gtk.Box): ) self.controller = controller + emitter = controller.get_emitter() + emitter.connect("api_change_failed", self._on_api_change_failed) self.DEFAULT_WIDTH = 1 self.DEFAULT_HEIGHT = 1 @@ -270,6 +273,9 @@ class Options(Gtk.Box): self, button: Gtk.Button, entry: Gtk.Entry, enum: Preferences ) -> None: old_text = self.controller.query_config(enum) + self.old_text = old_text + self.old_entry = entry + button.set_sensitive(False) match enum: case Preferences.NAME: @@ -277,9 +283,14 @@ class Options(Gtk.Box): 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.set_callback(self.restore_api_text, old_text, entry) self.controller.update_api_key(text, enum) + def _on_api_change_failed(self, emitter: "Emitter") -> None: + self.old_entry.set_text(self.old_text) + dialog = ExceptionDialog(self.controller, strings.api_error) + dialog.run() + def restore_api_text(self, text: str, entry: Gtk.Entry) -> None: entry.set_text(text) diff --git a/dzgui/views/trees/tree_mods.py b/dzgui/views/trees/tree_mods.py index 12d7656..0425649 100644 --- a/dzgui/views/trees/tree_mods.py +++ b/dzgui/views/trees/tree_mods.py @@ -15,6 +15,7 @@ from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa if TYPE_CHECKING: from dzgui.controllers.mc import Controller + from dzgui.controllers.emitter import Emitter logger = logging.getLogger(__name__) @@ -23,12 +24,14 @@ class ModTreeView(ModsMixin, ContextMixin, TreeView): def __init__(self, controller: "Controller") -> None: super().__init__(controller, menu=ContextMenuGroup.MOD) self.controller = controller + emitter = self.controller.get_emitter() + emitter.connect("mod_page_loaded", self._on_mod_page_loaded) + emitter.connect("mods_highlighted", self._on_mods_highlighted) self.set_fixed_height_mode(True) self.set_headers_visible(True) - mod_store = self.controller.get_mod_store() - self.set_model(mod_store) + self.set_model(None) for i, column_title in enumerate(strings.mod_cols): renderer = Gtk.CellRendererText() @@ -52,26 +55,27 @@ class ModTreeView(ModsMixin, ContextMixin, TreeView): self.connect("generic_treesel_changed", self._parent_selection_changed) self.connect("button-press-event", self.present_menu) self.connect("key-press-event", self.present_menu) - self.connect("map", self._on_map) - def _on_map(self, tree: Self) -> None: - # FIXME: placeholder logic - # identify root cause of row unfocus on init + def _on_mods_highlighted(self, emitter: "Emitter") -> None: + self.get_selection().unselect_all() + + # TODO: test loading with no mods + def _on_mod_page_loaded(self, emitter: "Emitter") -> None: self.set_cursor(0) - # def _on_mods_keypress(self, widget: Gtk.Widget, event: Gdk.EventKey) -> None: - # # TODO: multiselect - # # if event.keyval is Gdk.KEY_space: - # # it = self.get_focused_row_iter() - # # self.get_selection().select_iter(it) - # # path = self.get_focused_row_path() - # # self.set_cursor(path) - # # return False - # self.present_menu(widget, event) + # def _on_mods_keypress(self, widget: Gtk.Widget, event: Gdk.EventKey) -> None: + # # TODO: multiselect + # # if event.keyval is Gdk.KEY_space: + # # it = self.get_focused_row_iter() + # # self.get_selection().select_iter(it) + # # path = self.get_focused_row_path() + # # self.set_cursor(path) + # # return False + # 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 _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 From b703af897f884ed8d852e1d36a8c288b8b4ef14b Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 14 Feb 2026 20:16:56 +0900 Subject: [PATCH 078/309] feat: add map model --- dzgui/model/map_model.py | 83 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 dzgui/model/map_model.py diff --git a/dzgui/model/map_model.py b/dzgui/model/map_model.py new file mode 100644 index 0000000..fe08b1f --- /dev/null +++ b/dzgui/model/map_model.py @@ -0,0 +1,83 @@ +import gi + +from dzgui.util import strings +from dzgui.util.strings import all_maps + +gi.require_version("Gtk", "3.0") +from gi.repository.Gtk import ListStore # noqa E402 +from gi.repository import Gtk # noqa E402 + + +# TODO: rename class to MetaManager +class MapManager: + def __init__(self) -> None: + + self.map_store = ListStore(str) + self.prior_map: str + self.selected_map = all_maps + + # TODO: namespace + 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.filters: list + self.enabled_filters = dict(self.default_filters) + + def reinit_filters(self) -> None: + self.enabled_filters = dict(self.default_filters) + + def get_default_filters(self) -> dict: + """Deep copy of defaults""" + return dict(self.default_filters) + + def get_filters(self) -> dict: + return self.enabled_filters + + def set_filter(self, label: str, state: bool) -> None: + self.enabled_filters[label] = state + + def get_map_store(self) -> ListStore: + return self.map_store + + def reinit_map_store(self) -> None: + model = ListStore(str) + model.append([all_maps]) + self.map_store = model + self.selected_map = all_maps + + def get_prior_map(self) -> str: + return self.prior_map + + def get_selected_map(self) -> str: + return self.selected_map + + def set_selected_map(self, selection: str) -> str: + self.prior_map = self.selected_map + self.selected_map = selection + + def append_map(self, row: list[str]) -> None: + self.map_store.append(row) + + def clear_map_store(self) -> None: + self.map_store.clear() + + def set_unique_maps(self, maps: list) -> None: + if maps is None: + return + if len(maps) < 1: + return + + self.reinit_map_store() + for m in maps: + self.append_map([m]) From b9fe87cf901291932a677fa4678462b8a91b0554 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 15 Feb 2026 02:04:54 +0900 Subject: [PATCH 079/309] feat: threading with GLib WIP --- dzgui/controllers/emitter.py | 4 + dzgui/controllers/mc.py | 167 +++++++++++++++---------- dzgui/model/filtered_model.py | 14 ++- dzgui/model/misc_model.py | 8 -- dzgui/views/components/filter_panel.py | 34 +---- dzgui/views/components/mod_panel.py | 6 - dzgui/views/components/statusbar.py | 19 +-- dzgui/views/pages/options.py | 3 - dzgui/views/trees/tree_mods.py | 2 +- dzgui/views/trees/tree_servers.py | 4 +- 10 files changed, 134 insertions(+), 127 deletions(-) diff --git a/dzgui/controllers/emitter.py b/dzgui/controllers/emitter.py index 55a5e5f..c055e6c 100644 --- a/dzgui/controllers/emitter.py +++ b/dzgui/controllers/emitter.py @@ -130,3 +130,7 @@ class Emitter(GObject.GObject): @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=()) def api_change_failed(self) -> None: pass + + @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(str,)) + def keyword_set(self, keyword: str) -> None: + pass diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index bda2b74..d205a03 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -6,7 +6,7 @@ import traceback from typing import Optional from warnings import deprecated -from concurrent.futures import wait +from concurrent.futures import wait, as_completed from concurrent.futures import ThreadPoolExecutor from functools import wraps from pathlib import Path @@ -131,7 +131,7 @@ class Controller(GObject.GObject): GLib.idle_add(self._destroy_on_idle) self = args[0] - self.wait_dialog = WaitDialog(self, dialog_str) + self.wait_dialog = WaitDialog(self, dialog_str, True) self.wait_dialog.show_all() thread = threading.Thread(target=callback) thread.start() @@ -388,25 +388,38 @@ class Controller(GObject.GObject): job = Servers.query_api params = Servers.params serv = [] + i = 0 + total = 10 with ThreadPoolExecutor() as executor: futures = [executor.submit(job, key, APPID_DAYZ, param) for param in params] - wait(futures) - for future in futures: - res = future.result() - if res.status != 200 or not res.parsed: - # TODO: pop warning dialog, create enum around various failure states - # TODO: if first iteration, disable map combo. otherwise, do nothing - self.new_maps = None - self.push_data_failure() # None, FilterMode.INITIAL, success=False) - return - j = res.json - serv += j["response"]["servers"] + #wait(futures) + for future in as_completed(futures): + try: + i += 1 + GLib.idle_add(lambda: self.wait_dialog.prog.set_fraction(i/total)) + res = future.result(timeout=3) + if res.status != 200 or not res.parsed: + # TODO: pop warning dialog, create enum around various failure states + # set failure type here + self.cleanup_func = CleanupFunc(self.cleanup_on_failure) + return + j = res.json + serv += j["response"]["servers"] + except Exception as e: + # TODO: save exception text + print(e) + self.cleanup_func = CleanupFunc(self.cleanup_on_failure) + """ TODO: e.g. class method like 'total' + total of 10 params for this call + @call_on_thread(msg, jobs=10) + if total > 1, pop progress bar GLib.idle_add(lambda: self.wait_dialog.format_secondary_text("Pass 2")) - GLib.idle_add(lambda: self.wait_dialog.prog.set_fraction(0.5)) subclass or wrap add fraction and increment, then - calculate off of total + GLib.idle_add(lambda: self.wait_dialog.prog.set_fraction(0.5)) + subclass or wrap add fraction and increment, then calculate off of total + This next step is allowed to fail, since this metadata is incidental """ res = Servers.query_api(key, APPID_DAYZ_EXP, "") if res.status == 200 and res.parsed is True: @@ -416,11 +429,11 @@ class Controller(GObject.GObject): GLib.idle_add( lambda: self.wait_dialog.format_secondary_text("Unpacking servers") ) + GLib.idle_add(lambda: self.wait_dialog.prog.set_fraction(10/total)) # TODO: additional ping column pass, collated - # GLib.idle_add(lambda: self.wait_dialog.prog.set_fraction(0.75)) parsed = Servers.parse_json(serv) - self.push_data(parsed, FilterMode.INITIAL, success=True) + self.push_data_success(parsed, FilterMode.INITIAL) def get_help_row(self) -> str: tv = self.mediator.menu @@ -484,9 +497,22 @@ class Controller(GObject.GObject): # mod = model.get(tree_iter, 2)[0] # return mod, tree_iter + def get_mod_from_tree_path( + self, tree_path: Gtk.TreePath + ) -> tuple[str, Gtk.TreeIter]: + model = self.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_cleanup(self, _iter: Gtk.TreeIter) -> None: + self.get_mod_store().remove(_iter) + + # TODO: strings + @call_on_thread("deleting mod") def delete_single_mod(self, tree_path: Gtk.TreePath) -> None: config = self.prefs.paths.config - mod, it = self.model_man.get_mod_from_tree_path(tree_path) + mod, _iter = self.get_mod_from_tree_path(tree_path) path = lookup(config, Preferences.DEFAULT) steam_path = Path(path) @@ -507,8 +533,7 @@ class Controller(GObject.GObject): except PeFile.AppNotInstalledError: pass - model = self.get_mod_store() - model.remove(it) + self.cleanup_func = CleanupFunc(self.delete_single_mod_cleanup, _iter) def get_mod_store(self) -> Gtk.ListStore: return self.mediator.modtreeview.get_model() @@ -551,29 +576,37 @@ class Controller(GObject.GObject): # update config file with IP case ContextMenu.ADD_SERVER: pass + # spawn edit dialog and update cache, notes file case ContextMenu.ADD_NOTE: pass + case ContextMenu.COPY_CLIPBOARD: self.copy_ip(path) + case ContextMenu.COPY_NAME: self.copy_name(path) + case ContextMenu.DELETE_MOD: self.delete_single_mod(path) # TODO: connect to emitter automatically # Gtk.TreeModel, row-inserted/row-deleted # updates statusbar - self.update_mod_statusbar() + # FIXME: signal should instead be emitted off of treeview when rows added/inserted + #self.update_mod_statusbar() remove_stale_signatures( self.prefs.paths.config, self.prefs.paths.version ) + # call a2s on thread and update ephemeral model in situ case ContextMenu.REFRESH_PLAYERS: pass + # update history model, update tab label, pop off of queue, write new list into file # see dq.py case ContextMenu.REMOVE_HISTORY: pass + # reverse of ADD_SERVER case ContextMenu.REMOVE_SERVER: pass @@ -627,6 +660,7 @@ class Controller(GObject.GObject): sel.select_path(path) # TODO: make as method of tree? + # FIXME: move cursor when finished def uncolorize_mods(self) -> None: model = self.get_mod_store() for mod in model: @@ -794,15 +828,12 @@ class Controller(GObject.GObject): False, ], ) - self.push_data(data, FilterMode.INITIAL, success=True) - - # TODO: eg dedicated cleanup on failure, cleanup on sucess - # these can pop their own predefined dialogs, much simpler + self.push_data_success(data, FilterMode.INITIAL) def get_map_man(self) -> "MapManager": return self.get_active_treeview().get_map_man() - def cleanup(self) -> None: + def cleanup_on_success(self) -> None: treeview = self.get_active_treeview() treeview.set_loaded(True) treeview.set_model(self.to_insert) @@ -823,21 +854,15 @@ class Controller(GObject.GObject): self.new_maps = None treeview.grab_focus() - # if self.success is False: - # # TODO: different dialogs for server tab contexts, e.g. lan timeout - # # TODO: if history/favorites is empty, don't even trigger a call to dump data - # dialog = ExceptionDialog(self, "API TIMEOUT") - # dialog.run() def cleanup_on_failure(self) -> None: treeview = self.get_active_treeview() treeview.set_model(None) + treeview.set_loaded(True) map_man = treeview.get_map_man() # TODO: what if refresh action occurred, and the old model is still valid? - # TODO: disable map, keyword, and filter widgets if model is None? + # TODO: disable map, keyword, and filter widgets if model is None # -> signal driven (servers_empty) - # would have to make those unsensitive when changing server tabs - # if model is not None when changing tab, emit other signal map_man.set_unique_maps(None) context = self.get_active_context() @@ -847,50 +872,49 @@ class Controller(GObject.GObject): dialog = ExceptionDialog(self, "API TIMEOUT") dialog.run() - def push_data_failure(self) -> None: - treeview = self.get_active_treeview() - treeview.set_loaded(True) - # TODO: wipe control model on failure or keep old results? - # manager = treeview.get_filter_man() - self.cleanup_func = CleanupFunc(self.cleanup_on_failure) - - def push_data(self, data: tuple, mode: Optional[FilterMode], success: bool) -> None: + #def push_data_failure(self) -> None: + # #treeview = self.get_active_treeview() + # #treeview.set_loaded(True) + # # TODO: wipe control model on failure or keep old results? + # # manager = treeview.get_filter_man() + # self.cleanup_func = CleanupFunc(self.cleanup_on_failure) + def push_data_success(self, data: tuple, mode: Optional[FilterMode]) -> None: + # FIXME: set outside of thread treeview = self.get_active_treeview() manager = treeview.get_filter_man() self.to_insert = None - # TODO: drop - self.success = success - if success: - if data is None: - self.to_insert = None - else: - # TODO: consolidate into filter manager - if mode == FilterMode.INITIAL: - manager.set_control(data) - manager.filter(mode) - self.to_insert = manager.get_model() - # TODO: pre parse maps - u_maps = set([row[1] for row in data]) - self.new_maps = sorted(u_maps) + if data is None: + self.to_insert = None + else: + # TODO: consolidate into filter manager + if mode == FilterMode.INITIAL: + manager.set_control(data) + manager.filter(mode) + self.to_insert = manager.get_model() + # TODO: pre parse maps + u_maps = set([row[1] for row in data]) + self.new_maps = sorted(u_maps) treeview.set_loaded(True) - self.cleanup_func = CleanupFunc(self.cleanup) + self.cleanup_func = CleanupFunc(self.cleanup_on_success) - @call_on_thread(strings.dialog.working) - def highlight_stale(self) -> None: + def highlight_stale_cleanup(self, stale_mods: list) -> None: model = self.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 - # FIXME: deep copy existing model and set outside of thread - if int(mod[2]) in stale: + if int(mod[2]) in stale_mods: model[path][4] = HEX_RED - self.cleanup_func = CleanupFunc(lambda: self.emitter.emit("mods_highlighted")) + self.emitter.emit("mods_highlighted") + + + @call_on_thread(strings.dialog.working) + def highlight_stale(self) -> None: + stale = find_stale_mods(self.prefs.paths.config) + self.cleanup_func = CleanupFunc(self.highlight_stale_cleanup, stale) def get_cleanup_func(self) -> CleanupFunc: return self.cleanup_func @@ -923,8 +947,7 @@ class Controller(GObject.GObject): if res is True: self.update_config(key, text) else: - # FIXME: dialog spawning in thread - self.cleanup_func = CleanupFunc(self.emitter.emit("api_change_failed")) + self.cleanup_func = CleanupFunc(lambda: self.emitter.emit("api_change_failed")) def set_resolution(self, window: "OuterWindow") -> None: if self.prefs.is_game_mode: @@ -976,13 +999,15 @@ class Controller(GObject.GObject): @call_on_thread(strings.dialog.filtering) def filter_threaded(self, mode: FilterMode, label: str) -> None: + # FIXME: call outside of thread tv = self.get_active_treeview() filter_man = tv.get_filter_man() filter_man.filter(mode, label) - self.push_data("", mode, success=True) + # TODO: why pushing empty data? + self.push_data_success("", mode) - # FIXME: optional label/map/keyword parameter # TODO: consolidate with method above + # TODO: call filter_man methods directly def refilter_model(self, mode: FilterMode, label: Optional[str] = None) -> None: tv = self.get_active_treeview() filter_man = tv.get_filter_man() @@ -1001,9 +1026,11 @@ class Controller(GObject.GObject): self.emitter.emit("servers_loaded", treeview.get_enum()) treeview.set_model(None) return + # TODO: clear ephemeral model if necessary # manager = treeview.get_filter_man() # manager.clear_model() + self.first_iteration = True self.run_query_func(func) @@ -1034,7 +1061,9 @@ class Controller(GObject.GObject): # TODO: filterman calls back to here, gets convoluted def get_keyword(self) -> str: - return self.mediator.filters.get_keyword_filter() + tv = self.get_active_treeview() + filter_man = tv.get_filter_man() + return filter_man.get_keyword_filter() def get_map_store(self) -> Gtk.ListStore: map_man = self.get_map_man() diff --git a/dzgui/model/filtered_model.py b/dzgui/model/filtered_model.py index 9a6d07e..84be7df 100644 --- a/dzgui/model/filtered_model.py +++ b/dzgui/model/filtered_model.py @@ -14,6 +14,7 @@ from gi.repository import GObject, GLib # noqa E402 if TYPE_CHECKING: from dzgui.controllers.mc import Controller + from dzgui.controllers.emitter import Emitter @dataclass(slots=True, frozen=True) @@ -44,6 +45,8 @@ class FilteredModelManager: def __init__(self, controller: "Controller") -> None: self.controller = controller + self.emitter = controller.get_emitter() + self.emitter.connect("keyword_set", self._on_keyword_set) self.filter_cache = {} self.ping_cache: dict[str, int] = {} @@ -55,6 +58,14 @@ class FilteredModelManager: self.filtered: list = None self.success = True + self.keyword_filter = "" + + def get_keyword_filter(self) -> str: + return self.keyword_filter + + def _on_keyword_set(self, emitter: "Emitter", keyword: str) -> None: + self.keyword_filter = keyword + def append_row(self, row: list) -> None: self.ephemeral_model.append(row) @@ -120,6 +131,7 @@ class FilteredModelManager: self.set_cache(filters, clone, rows) self.set_model(clone) + return clone def sort_rows(self, rows: list) -> list: rows.sort(key=lambda x: re.sub(r"[^A-Za-z0-9]+", "", x[0].lower())) @@ -148,7 +160,7 @@ class FilteredModelManager: return rows def filter_keyword(self, filters: tuple) -> list: - keyword = self.controller.get_keyword() + keyword = self.keyword_filter rows = self.filtered if keyword == "": diff --git a/dzgui/model/misc_model.py b/dzgui/model/misc_model.py index 39c93eb..340a156 100644 --- a/dzgui/model/misc_model.py +++ b/dzgui/model/misc_model.py @@ -80,11 +80,3 @@ class ModelManager: def append_map(self, row: list) -> None: self.map_store.append(row) - - def get_mod_from_tree_path( - self, tree_path: Gtk.TreePath - ) -> tuple[str, Gtk.TreeIter]: - model = self.get_mod_store() - tree_iter = model.get_iter(tree_path) - mod = model.get(tree_iter, 2)[0] - return mod, tree_iter diff --git a/dzgui/views/components/filter_panel.py b/dzgui/views/components/filter_panel.py index e59fb86..86ed56f 100644 --- a/dzgui/views/components/filter_panel.py +++ b/dzgui/views/components/filter_panel.py @@ -3,7 +3,6 @@ from typing import Literal, TYPE_CHECKING from dzgui.const.enum import FilterMode from dzgui.const.constants import EXPAND, NO_EXPAND, NO_FILL, NO_PADDING, SEARCH_ICON -from dzgui.util import strings from dzgui.util.margins import set_surrounding_margins from dzgui.views.components.labels import BoldLabel @@ -17,7 +16,6 @@ logger = logging.getLogger(__name__) if TYPE_CHECKING: from dzgui.controllers.mc import Controller from dzgui.controllers.emitter import Emitter - from dzgui.const.enum import ServerTab class ButtonGrid(Gtk.Grid): @@ -84,8 +82,8 @@ class KeywordEntry(Gtk.Entry): # TODO :strings super().__init__(placeholder_text="Filter by keyword") - self.keyword_filter = "" self.controller = controller + self.emitter = controller.get_emitter() self.connect("activate", self._on_activated) self.connect("key-press-event", self._on_keypress) self.connect("icon-release", lambda *args: self.activate()) @@ -93,9 +91,6 @@ class KeywordEntry(Gtk.Entry): self.set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, SEARCH_ICON) self.set_icon_activatable(Gtk.EntryIconPosition.SECONDARY, True) - def get_keyword(self) -> str: - return self.keyword_filter - def _on_keypress(self, entry: Gtk.Entry, event: Gdk.EventKey) -> bool: match event.keyval: case Gdk.KEY_Up: @@ -110,13 +105,15 @@ class KeywordEntry(Gtk.Entry): def _on_activated(self, entry: Gtk.Entry) -> None: # TODO: investigate this method self.controller.mediator.window.set_keep_below(False) + keyword = entry.get_text().lower() - if keyword == self.keyword_filter: + if keyword == self.controller.get_keyword(): return if keyword.isspace(): return + logger.info(f"User filtered by keyword '{keyword}'") - self.keyword_filter = keyword + self.emitter.emit("keyword_set", keyword) self.controller.refilter_model(FilterMode.KEYWORD, keyword) @@ -124,22 +121,6 @@ class FilterPanel(Gtk.Box): def __init__(self, controller: "Controller") -> None: super().__init__(spacing=6, vexpand=False, orientation=Gtk.Orientation.VERTICAL) - # TODO: get these from metamanager - # 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.controller = controller self.controller.register_widget("filters", self) self.emitter = self.controller.get_emitter() @@ -225,15 +206,12 @@ class FilterPanel(Gtk.Box): enabled = self.controller.get_enabled_filters() filters = [] filters.append(selected) - filters.append(self.get_keyword_filter()) + filters.append(self.controller.get_keyword()) for filt in enabled: if enabled[filt] is False: filters.append(filt) return tuple(filters) - def get_keyword_filter(self) -> str: - return self.keyword_entry.get_keyword() - # TODO: should be used when switching ServerTab contexts # use signals here # def reinit_panel(self) -> None: diff --git a/dzgui/views/components/mod_panel.py b/dzgui/views/components/mod_panel.py index c92e2de..5bc4552 100644 --- a/dzgui/views/components/mod_panel.py +++ b/dzgui/views/components/mod_panel.py @@ -67,12 +67,6 @@ class ModSelectionPanel(Gtk.Box): def _on_mod_page_toggled(self, emitter: "Emitter", state: bool) -> None: self.set_visible(state) - #def after_colorize(self) -> None: - # # TODO: split into signal that touches two widgets, - # # modtreeview and this widget - # #self.controller.unselect_all_mods() - # self.swap_sensitive(True) - def swap_sensitive(self, state: bool) -> None: for child in self.stale_panel.get_children(): if child.enum == ModButton.HIGHLIGHT_STALE: diff --git a/dzgui/views/components/statusbar.py b/dzgui/views/components/statusbar.py index 20f69a7..8b1bd6c 100644 --- a/dzgui/views/components/statusbar.py +++ b/dzgui/views/components/statusbar.py @@ -11,6 +11,7 @@ from gi.repository import Gtk, GObject # noqa E402 if TYPE_CHECKING: from dzgui.const.enum import ServerTab from dzgui.controllers.mc import Controller + from dzgui.controllers.emitter import Emitter from dzgui.views.trees.tree_base import TreeView from dzgui.views.base import Notebook @@ -79,19 +80,19 @@ class Statusbar(Gtk.Grid): return match enum: - case NotebookPage.MODS: - return - # FIXME: format on callback after mods load - #bar = self.controller.format_mod_statusbar() case NotebookPage.HELP: bar = self.controller.get_help_row() - case NotebookPage.SERVERS: - context = self.controller.get_active_context() - # FIXME: may be superfluous - #self.emitter.emit("servers_loaded", context) - return + # TODO: drop + #case NotebookPage.MODS: + # return + #case NotebookPage.SERVERS: + # context = self.controller.get_active_context() + # # self.emitter.emit("servers_loaded", context) + # return case NotebookPage.KEYS: bar = question_to_return + case _: + return self.set_by_context(enum, bar) diff --git a/dzgui/views/pages/options.py b/dzgui/views/pages/options.py index 05ed658..270b82e 100644 --- a/dzgui/views/pages/options.py +++ b/dzgui/views/pages/options.py @@ -283,7 +283,6 @@ class Options(Gtk.Box): 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 _on_api_change_failed(self, emitter: "Emitter") -> None: @@ -305,8 +304,6 @@ class Options(Gtk.Box): # 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: # TODO: use two columns or constants here, not strings diff --git a/dzgui/views/trees/tree_mods.py b/dzgui/views/trees/tree_mods.py index 0425649..bb09334 100644 --- a/dzgui/views/trees/tree_mods.py +++ b/dzgui/views/trees/tree_mods.py @@ -1,5 +1,5 @@ import logging -from typing import Any, Self, TYPE_CHECKING +from typing import Any, TYPE_CHECKING from dzgui.util import strings, localize from dzgui.views.trees.tree_base import TreeView diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index a8df4fd..8423ef2 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -77,8 +77,8 @@ class ServerTreeView(ContextMixin, TreeView): column.set_sizing(Gtk.TreeViewColumnSizing.FIXED) column.set_resizable(True) column.set_sort_column_id(i) - if column_title == "Ping": - column.set_cell_data_func(renderer, self._get_ping) + #if column_title == "Ping": + # column.set_cell_data_func(renderer, self._get_ping) if valid_json: try: From 4d0bb2f4f8a1337cf536480a00d24e5268e494c2 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 15 Feb 2026 03:05:32 +0900 Subject: [PATCH 080/309] feat: progress bar WIP --- CHANGELOG.md | 6 ++ dzgui/controllers/mc.py | 34 ++++---- dzgui/data/CHANGELOG.md | 6 ++ dzgui/model/filtered_model.py | 5 +- dzgui/views/dialogs/entry.py | 2 + dzgui/views/pages/devs.py | 20 +++-- dzgui/views/trees/tree_servers.py | 137 ++++++++++++++++-------------- 7 files changed, 116 insertions(+), 94 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e51e053..197922c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,3 +44,9 @@ - Local documentation - Offline mods - Move debug mode to developers only + +## Devs +- Show deprecation warnings +- Options -> Dev page +- Raw debug command +- Moved debug log to this mode diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index d205a03..f9fdfe8 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -262,7 +262,7 @@ class Controller(GObject.GObject): c = self.mediator.statusbar.statusbar.get_context_id(context) self.mediator.statusbar.statusbar.pop(c) - # TODO: + # TODO: refactor any modules using this @deprecated("use set_by_context") def set_statusbar(self, text: str, context: str) -> int: msg_id = self.mediator.statusbar.set_text(text, context) @@ -297,7 +297,6 @@ class Controller(GObject.GObject): self.emitter.emit("distcalc_ended", dist, context) - # TODO: threading def delete_multiple_mods(self) -> None: sel = self.mediator.modtreeview.get_selection() model, pathlist = sel.get_selected_rows() @@ -392,11 +391,11 @@ class Controller(GObject.GObject): total = 10 with ThreadPoolExecutor() as executor: futures = [executor.submit(job, key, APPID_DAYZ, param) for param in params] - #wait(futures) + # wait(futures) for future in as_completed(futures): try: i += 1 - GLib.idle_add(lambda: self.wait_dialog.prog.set_fraction(i/total)) + GLib.idle_add(lambda: self.wait_dialog.prog.set_fraction(i / total)) res = future.result(timeout=3) if res.status != 200 or not res.parsed: # TODO: pop warning dialog, create enum around various failure states @@ -410,7 +409,6 @@ class Controller(GObject.GObject): print(e) self.cleanup_func = CleanupFunc(self.cleanup_on_failure) - """ TODO: e.g. class method like 'total' total of 10 params for this call @@ -429,7 +427,7 @@ class Controller(GObject.GObject): GLib.idle_add( lambda: self.wait_dialog.format_secondary_text("Unpacking servers") ) - GLib.idle_add(lambda: self.wait_dialog.prog.set_fraction(10/total)) + GLib.idle_add(lambda: self.wait_dialog.prog.set_fraction(10 / total)) # TODO: additional ping column pass, collated parsed = Servers.parse_json(serv) @@ -507,6 +505,7 @@ class Controller(GObject.GObject): def delete_single_mod_cleanup(self, _iter: Gtk.TreeIter) -> None: self.get_mod_store().remove(_iter) + remove_stale_signatures(self.prefs.paths.config, self.prefs.paths.version) # TODO: strings @call_on_thread("deleting mod") @@ -574,6 +573,7 @@ class Controller(GObject.GObject): # add to saved servers model verbatim and sort in place # update tab with ! # update config file with IP + # TODO: needs threading case ContextMenu.ADD_SERVER: pass @@ -593,10 +593,7 @@ class Controller(GObject.GObject): # Gtk.TreeModel, row-inserted/row-deleted # updates statusbar # FIXME: signal should instead be emitted off of treeview when rows added/inserted - #self.update_mod_statusbar() - remove_stale_signatures( - self.prefs.paths.config, self.prefs.paths.version - ) + # self.update_mod_statusbar() # call a2s on thread and update ephemeral model in situ case ContextMenu.REFRESH_PLAYERS: @@ -610,12 +607,15 @@ class Controller(GObject.GObject): # reverse of ADD_SERVER case ContextMenu.REMOVE_SERVER: pass + case ContextMenu.SET_FAV: self.set_fav() # NOTE: spawns dedicated dialogs + # TODO: needs threading case ContextMenu.SHOW_DETAILS: pass + # TODO: needs threading case ContextMenu.SHOW_MODS: pass @@ -634,8 +634,7 @@ class Controller(GObject.GObject): log = self.prefs.paths.debug store = self.model_man.get_log_store() store.clear() - # NOTE: this model is reloaded each time as log changes, rather than adding - # atomic events + # NOTE: this model is reloaded each time as log changes try: with open(log, "r") as f: lines = [ @@ -872,7 +871,7 @@ class Controller(GObject.GObject): dialog = ExceptionDialog(self, "API TIMEOUT") dialog.run() - #def push_data_failure(self) -> None: + # def push_data_failure(self) -> None: # #treeview = self.get_active_treeview() # #treeview.set_loaded(True) # # TODO: wipe control model on failure or keep old results? @@ -910,7 +909,6 @@ class Controller(GObject.GObject): model[path][4] = HEX_RED self.emitter.emit("mods_highlighted") - @call_on_thread(strings.dialog.working) def highlight_stale(self) -> None: stale = find_stale_mods(self.prefs.paths.config) @@ -947,7 +945,9 @@ class Controller(GObject.GObject): if res is True: self.update_config(key, text) else: - self.cleanup_func = CleanupFunc(lambda: self.emitter.emit("api_change_failed")) + self.cleanup_func = CleanupFunc( + lambda: self.emitter.emit("api_change_failed") + ) def set_resolution(self, window: "OuterWindow") -> None: if self.prefs.is_game_mode: @@ -1016,6 +1016,7 @@ class Controller(GObject.GObject): self.filter_threaded(mode, label) def populate_model(self) -> None: + # NOTE: prepare GTK objects outside of thread treeview = self.get_active_treeview() if treeview.is_loaded() is True: self.emitter.emit("servers_loaded", treeview.get_enum()) @@ -1045,8 +1046,6 @@ class Controller(GObject.GObject): def get_dist_cache(self) -> dict[str, "Haversine"]: return self.dist_cache - # TODO: use model manager, map and keyword caches - # TODO: model cache that hooks checkbox signal def get_help_store(self) -> Gtk.ListStore: return self.model_man.get_help_store() @@ -1091,6 +1090,7 @@ class Controller(GObject.GObject): mode = FilterMode.TOGGLE_OFF self.refilter_model(mode, label) + # TODO: map man should be consolidated into filter man def _on_map_selection_changed(self, emitter: Emitter, selection: str) -> None: map_man = self.get_map_man() map_man.set_selected_map(selection) diff --git a/dzgui/data/CHANGELOG.md b/dzgui/data/CHANGELOG.md index e51e053..197922c 100644 --- a/dzgui/data/CHANGELOG.md +++ b/dzgui/data/CHANGELOG.md @@ -44,3 +44,9 @@ - Local documentation - Offline mods - Move debug mode to developers only + +## Devs +- Show deprecation warnings +- Options -> Dev page +- Raw debug command +- Moved debug log to this mode diff --git a/dzgui/model/filtered_model.py b/dzgui/model/filtered_model.py index 84be7df..dffd952 100644 --- a/dzgui/model/filtered_model.py +++ b/dzgui/model/filtered_model.py @@ -52,7 +52,6 @@ class FilteredModelManager: self.ping_cache: dict[str, int] = {} self.ephemeral_model: ListStore = None - #= self.new_model_from_class(ServerColumns) self.control_model: list = None self.filtered: list = None @@ -125,7 +124,7 @@ class FilteredModelManager: rows = self.sort_rows(rows) for row in rows: clone.append(row) - #else: + # else: # print("nothing to filter, sending none") # clone = None @@ -222,7 +221,6 @@ class FilteredModelManager: 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] - # FIXME: can create logically opposed contexts like "official + modded" case strings.filter_modded: rows = [row for row in rows if not row[11]] return rows @@ -254,7 +252,6 @@ class FilteredModelManager: filters = self.controller.get_filters() refiltered = self.filter_toggle_on(filters) self.set_filtered(refiltered) - #self.set_success(True) def convert_model_to_list(self, model: ListStore) -> list: return [[el for el in row] for row in model] diff --git a/dzgui/views/dialogs/entry.py b/dzgui/views/dialogs/entry.py index 4a520d1..64d57a8 100644 --- a/dzgui/views/dialogs/entry.py +++ b/dzgui/views/dialogs/entry.py @@ -1,10 +1,12 @@ import gi + gi.require_version("Gtk", "3.0") from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa from dzgui.const.constants import NO_EXPAND, NO_FILL, NO_PADDING 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) diff --git a/dzgui/views/pages/devs.py b/dzgui/views/pages/devs.py index e1a086f..b453829 100644 --- a/dzgui/views/pages/devs.py +++ b/dzgui/views/pages/devs.py @@ -8,6 +8,7 @@ from dzgui.views.components.labels import BoldLabel from dzgui.views.trees.tree_base import TreeView import gi # noqa E402 + gi.require_version("Gtk", "3.0") from gi.repository import Gtk # noqa E402 @@ -16,20 +17,19 @@ if TYPE_CHECKING: from dzgui.config.userprefs import UserPrefs from dzgui.config.xdg import Xdg + class Developers(Gtk.ScrolledWindow): """ Shows TreeViews displaying contents of parsed XDG paths and user preferences """ + def __init__(self, controller: "Controller") -> None: - super().__init__( - ) + super().__init__() self.controller = controller self.box = Gtk.Box( - orientation=Gtk.Orientation.VERTICAL, - margin_start=10, - margin_end=10 + orientation=Gtk.Orientation.VERTICAL, margin_start=10, margin_end=10 ) heading = Gtk.Label(label=developers.header) @@ -42,8 +42,9 @@ class Developers(Gtk.ScrolledWindow): paths_label = BoldLabel(developers.paths_label) prefs_label = BoldLabel(developers.prefs_label) - paths_tree = self._make_tree(self.controller.prefs.paths) - prefs_tree = self._make_tree(self.controller.prefs) + prefs = self.controller.get_prefs() + paths_tree = self._make_tree(prefs.paths) + prefs_tree = self._make_tree(prefs) trees_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) path = Gtk.TreePath.new_from_indices([0]) @@ -72,9 +73,12 @@ class Developers(Gtk.ScrolledWindow): store = Gtk.ListStore(str, str) for field in fields(prefs): if field.name == "paths": - break + continue k, v = field.name, getattr(prefs, field.name) store.append((k, str(v))) view.set_model(store) return view + + def grab_content_area(self) -> None: + return diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index 8423ef2..fdcaf6b 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -69,7 +69,14 @@ class ServerTreeView(ContextMixin, TreeView): logger.critical(e) valid_json = False + width_map = { + "Name": 800, + "Map": 300, + "IP": 240, + } + # TODO: abstract + # FIXME: resize col width func causes snapping behavior browser_cols = strings.browser_cols for i, column_title in enumerate(browser_cols): renderer = Gtk.CellRendererText() @@ -77,8 +84,6 @@ class ServerTreeView(ContextMixin, TreeView): column.set_sizing(Gtk.TreeViewColumnSizing.FIXED) column.set_resizable(True) column.set_sort_column_id(i) - #if column_title == "Ping": - # column.set_cell_data_func(renderer, self._get_ping) if valid_json: try: @@ -88,18 +93,20 @@ class ServerTreeView(ContextMixin, TreeView): column.set_fixed_width(saved_size) column.set_expand(True) else: - if column_title == "Name": - column.set_fixed_width(800) - if column_title == "Map": - column.set_fixed_width(300) + w = width_map[column_title] + column.set_fixed_width(w) + #if column_title == "Name": + # column.set_fixed_width(800) + #if column_title == "Map": + # column.set_fixed_width(300) + # TODO: standardize widths based on column title and longest content - # TODO: resize col width func causes snapping behavior - if column_title == "Name": - column.set_fixed_width(500) - if column_title == "Map": - column.set_fixed_width(200) - if column_title == "IP": - column.set_fixed_width(240) + #if column_title == "Name": + # column.set_fixed_width(500) + #if column_title == "Map": + # column.set_fixed_width(200) + #if column_title == "IP": + # column.set_fixed_width(240) column.connect("notify::fixed-width", self._on_col_width_changed) self.append_column(column) @@ -129,38 +136,38 @@ class ServerTreeView(ContextMixin, TreeView): store = self.map_man.get_map_store() self.emitter.emit("load_maps", store) - def _get_ping( - self, - column: Gtk.TreeViewColumn, - cell: Gtk.CellRendererText, - model: Gtk.TreeModel, - it: Gtk.TreeIter, - data: Any, - ): - def ping(model, it, ip: str): - # TODO: use a2s to ping server - # a2s.info -> "ping" key - # Servers.ping() -> this accepts a whole row - # try, if failure just abort - self.res = ip.replace(":", "%") - num = 11111 - GLib.idle_add(lambda: model.set(it, 9, num)) + #def _get_ping( + # self, + # column: Gtk.TreeViewColumn, + # cell: Gtk.CellRendererText, + # model: Gtk.TreeModel, + # it: Gtk.TreeIter, + # data: Any, + #): + # def ping(model, it, ip: str): + # # TODO: use a2s to ping server + # # a2s.info -> "ping" key + # # Servers.ping() -> this accepts a whole row + # # try, if failure just abort + # self.res = ip.replace(":", "%") + # num = 11111 + # GLib.idle_add(lambda: model.set(it, 9, num)) - addr = model.get_value(it, 7).split(":")[0] - qport = model.get_value(it, 8) - ip = f"{addr}:{qport}" + # addr = model.get_value(it, 7).split(":")[0] + # qport = model.get_value(it, 8) + # ip = f"{addr}:{qport}" - thread = threading.Thread( - daemon=True, - target=ping, - args=( - model, - it, - ip, - ), - ) - thread.start() - pass + # thread = threading.Thread( + # daemon=True, + # target=ping, + # args=( + # model, + # it, + # ip, + # ), + # ) + # thread.start() + # pass def start_timeout(self) -> None: self.queue_id = GLib.timeout_add(QUEUE_CHECK_DELAY, self._check_result_queue) @@ -171,27 +178,27 @@ class ServerTreeView(ContextMixin, TreeView): def get_filter_man(self) -> FilteredModelManager: return self.filter_man - def shrink_to_fit(self) -> None: - cols = self.get_columns() - # TODO: run on only one treeview and propagate results - # TODO: does not shrink name, map, ip fields to fit - # TODO: col width changed signal is buggy on current treeview - for col in cols: - title = col.get_title() - if title == "Name": - continue - if title == "Map": - continue - if title == "IP": - continue - label = Gtk.Label(label=title) - pango = label.get_layout() - size = pango.get_pixel_size() - if size.width > 50: - width = size.width * 1.30 - else: - width = size.width * 1.65 - col.set_fixed_width(width) + #def shrink_to_fit(self) -> None: + # cols = self.get_columns() + # # TODO: run on only one treeview and propagate results + # # TODO: does not shrink name, map, ip fields to fit + # # TODO: col width changed signal is buggy on current treeview + # for col in cols: + # title = col.get_title() + # if title == "Name": + # continue + # if title == "Map": + # continue + # if title == "IP": + # continue + # label = Gtk.Label(label=title) + # pango = label.get_layout() + # size = pango.get_pixel_size() + # if size.width > 50: + # width = size.width * 1.30 + # else: + # width = size.width * 1.65 + # col.set_fixed_width(width) def get_enum(self) -> None: return self.enum @@ -277,7 +284,7 @@ class ServerTreeView(ContextMixin, TreeView): if event.state is Gdk.ModifierType.CONTROL_MASK: match event.keyval: case Gdk.KEY_r: - # TODO: unimplemented + # TODO: unimplemented, threading self.refresh_player_count() case Gdk.KEY_f: self.emitter.emit("request_keyword_focus") From c3e2f6e4b424e7f8a0e31a5ca7467852d09f1394 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 15 Feb 2026 23:58:48 +0900 Subject: [PATCH 081/309] fix: update active maps combo on tab change --- dzgui/api/servers.py | 10 +-- dzgui/controllers/mc.py | 97 +++++++++++++--------- dzgui/model/filtered_model.py | 15 ++-- dzgui/model/map_model.py | 7 ++ dzgui/util/strings.py | 8 +- dzgui/views/components/filter_panel.py | 10 ++- dzgui/views/components/right_panel.py | 5 +- dzgui/views/dialogs/entry.py | 83 ------------------- dzgui/views/dialogs/generic.py | 16 +++- dzgui/views/dialogs/link_dialog.py | 25 +++--- dzgui/views/mixins/context_mixin.py | 42 ++++++---- dzgui/views/pages/servers.py | 3 +- dzgui/views/trees/tree_servers.py | 110 +++++++++++++------------ 13 files changed, 200 insertions(+), 231 deletions(-) delete mode 100644 dzgui/views/dialogs/entry.py diff --git a/dzgui/api/servers.py b/dzgui/api/servers.py index f2a3aaf..728bbda 100644 --- a/dzgui/api/servers.py +++ b/dzgui/api/servers.py @@ -361,14 +361,14 @@ def details(ip: str, qport: int) -> Details: return Details(rows, description, True) -def ping(iteration: int, row: list) -> Ping: - addr = row[7] - qport = row[8] +def ping(iteration: int, addr: list, qport: int, ping: int) -> Ping: + # addr = row[7] + # qport = row[8] res = None - if row[9] != 9999: - return Ping(addr, iteration, row[9]) + if ping != 9999: + return Ping(addr, iteration, ping) try: ip = addr.split(":")[0] diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index f9fdfe8..501e392 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -115,9 +115,12 @@ class Controller(GObject.GObject): self.emitter = Emitter() self.emitter.connect("map_selection_changed", self._on_map_selection_changed) self.emitter.connect("check_toggled", self._on_check_toggled) + self.emitter.connect("servers_loaded", self._on_servers_loaded) + self.emitter.connect("servers_loaded_init", self._on_servers_loaded_init) # NOTE: suppress requests until entire UI is loaded self.loaded = False + self.pending_jobs = 1 def get_emitter(self) -> Emitter: return self.emitter @@ -131,7 +134,7 @@ class Controller(GObject.GObject): GLib.idle_add(self._destroy_on_idle) self = args[0] - self.wait_dialog = WaitDialog(self, dialog_str, True) + self.wait_dialog = WaitDialog(self, dialog_str, jobs=self.pending_jobs) self.wait_dialog.show_all() thread = threading.Thread(target=callback) thread.start() @@ -388,48 +391,29 @@ class Controller(GObject.GObject): params = Servers.params serv = [] i = 0 - total = 10 with ThreadPoolExecutor() as executor: futures = [executor.submit(job, key, APPID_DAYZ, param) for param in params] - # wait(futures) for future in as_completed(futures): try: i += 1 - GLib.idle_add(lambda: self.wait_dialog.prog.set_fraction(i / total)) + GLib.idle_add(lambda: self.wait_dialog.increment()) res = future.result(timeout=3) if res.status != 200 or not res.parsed: - # TODO: pop warning dialog, create enum around various failure states - # set failure type here self.cleanup_func = CleanupFunc(self.cleanup_on_failure) return j = res.json serv += j["response"]["servers"] except Exception as e: - # TODO: save exception text - print(e) + logger.critical(e) self.cleanup_func = CleanupFunc(self.cleanup_on_failure) - """ - TODO: e.g. class method like 'total' - total of 10 params for this call - @call_on_thread(msg, jobs=10) - if total > 1, pop progress bar - GLib.idle_add(lambda: self.wait_dialog.format_secondary_text("Pass 2")) - GLib.idle_add(lambda: self.wait_dialog.prog.set_fraction(0.5)) - subclass or wrap add fraction and increment, then calculate off of total - This next step is allowed to fail, since this metadata is incidental - """ + # NOTE: This step is allowed to fail, since this metadata is incidental res = Servers.query_api(key, APPID_DAYZ_EXP, "") if res.status == 200 and res.parsed is True: j = res.json serv += j["response"]["servers"] - GLib.idle_add( - lambda: self.wait_dialog.format_secondary_text("Unpacking servers") - ) - GLib.idle_add(lambda: self.wait_dialog.prog.set_fraction(10 / total)) - - # TODO: additional ping column pass, collated + GLib.idle_add(lambda: self.wait_dialog.increment("Unpacking servers")) parsed = Servers.parse_json(serv) self.push_data_success(parsed, FilterMode.INITIAL) @@ -466,7 +450,6 @@ class Controller(GObject.GObject): return value def copy_name(self, path: Gtk.TreePath) -> None: - # TODO: column values are deterministic, perhaps use a col name to index map name = self.get_col_value_by_path_index(path, 0) if name is None: return @@ -659,13 +642,13 @@ class Controller(GObject.GObject): sel.select_path(path) # TODO: make as method of tree? - # FIXME: move cursor when finished def uncolorize_mods(self) -> None: model = self.get_mod_store() for mod in model: it = mod.iter path = model.get_path(it) model[path][4] = None + self.mediator.modtreeview.set_cursor(0) def dump_test_2(self) -> None: import time @@ -833,8 +816,10 @@ class Controller(GObject.GObject): return self.get_active_treeview().get_map_man() def cleanup_on_success(self) -> None: + self.pending_jobs = 1 treeview = self.get_active_treeview() treeview.set_loaded(True) + treeview.set_model(self.to_insert) map_man = treeview.get_map_man() @@ -842,6 +827,7 @@ class Controller(GObject.GObject): # model insertion after thread closes # cf. servers_loaded signal + # TODO: servers_loaded vs servers_reloaded context = self.get_active_context() self.emitter.emit("servers_loaded", context) @@ -856,27 +842,29 @@ class Controller(GObject.GObject): def cleanup_on_failure(self) -> None: treeview = self.get_active_treeview() - treeview.set_model(None) treeview.set_loaded(True) map_man = treeview.get_map_man() - # TODO: what if refresh action occurred, and the old model is still valid? + # TODO: disable map, keyword, and filter widgets if model is None # -> signal driven (servers_empty) - map_man.set_unique_maps(None) + # TODO: what if refresh action occurred and failed, and the old model is still valid? + # skip the step below if refresh action failed + # do not wipe control model in this case + # e.g. if treeview.is_refresh(): + # revert old model + # wipe refresh state to False - context = self.get_active_context() - # TODO: distinguish signals, e.g. "servers_failed_to_load" - self.emitter.emit("servers_loaded", context) + treeview.set_model(None) treeview.grab_focus() - dialog = ExceptionDialog(self, "API TIMEOUT") - dialog.run() - # def push_data_failure(self) -> None: - # #treeview = self.get_active_treeview() - # #treeview.set_loaded(True) - # # TODO: wipe control model on failure or keep old results? - # # manager = treeview.get_filter_man() - # self.cleanup_func = CleanupFunc(self.cleanup_on_failure) + map_man.set_unique_maps(None) + context = self.get_active_context() + + # TODO: distinguish signals, e.g. "servers_failed_to_load" + # may trigger different behavior + self.emitter.emit("servers_loaded", context) + dialog = ExceptionDialog(self, strings.api_warn_msg) + dialog.run() def push_data_success(self, data: tuple, mode: Optional[FilterMode]) -> None: # FIXME: set outside of thread @@ -901,6 +889,7 @@ class Controller(GObject.GObject): self.cleanup_func = CleanupFunc(self.cleanup_on_success) def highlight_stale_cleanup(self, stale_mods: list) -> None: + """Manipulates attached ListStore in the main event loop""" model = self.get_mod_store() for mod in model: it = mod.iter @@ -911,6 +900,7 @@ class Controller(GObject.GObject): @call_on_thread(strings.dialog.working) def highlight_stale(self) -> None: + # TODO: set progress bar for number of mods stale = find_stale_mods(self.prefs.paths.config) self.cleanup_func = CleanupFunc(self.highlight_stale_cleanup, stale) @@ -995,6 +985,7 @@ class Controller(GObject.GObject): @call_on_thread(strings.dialog.fetching) def run_query_func(self, func: Callable) -> None: + # TODO: use CleanupFunc -> StoredFunc func() @call_on_thread(strings.dialog.filtering) @@ -1010,6 +1001,7 @@ class Controller(GObject.GObject): # TODO: call filter_man methods directly def refilter_model(self, mode: FilterMode, label: Optional[str] = None) -> None: tv = self.get_active_treeview() + #tv.freeze_child_notify() filter_man = tv.get_filter_man() if filter_man.get_control() is None: return @@ -1022,7 +1014,7 @@ class Controller(GObject.GObject): self.emitter.emit("servers_loaded", treeview.get_enum()) return - func = treeview.get_query_func() + func, jobs = treeview.get_query_func() if func is None: self.emitter.emit("servers_loaded", treeview.get_enum()) treeview.set_model(None) @@ -1031,8 +1023,10 @@ class Controller(GObject.GObject): # TODO: clear ephemeral model if necessary # manager = treeview.get_filter_man() # manager.clear_model() + #treeview.fancy_col.set_cell_data_func(treeview.fancy_rend, None) self.first_iteration = True + self.pending_jobs = jobs self.run_query_func(func) def get_favorite(self) -> tuple[str, str] | tuple[None, None]: @@ -1080,6 +1074,27 @@ class Controller(GObject.GObject): map_man = self.get_map_man() return map_man.get_prior_map() + def get_active_map(self) -> None: + return self.get_map_man().get_active_map() + + def set_active_map(self, ind: int) -> None: + self.get_map_man().set_active_map(ind) + + def _on_servers_loaded_init(self, emitter: "Emitter") -> None: + # FIXME: wipe maps store when changing tabs if model is none + tv = self.get_active_treeview() + if tv.loaded is False: + return + store = self.get_map_store() + self.emitter.emit("load_maps", store) + + def _on_servers_loaded(self, emitter: "Emitter", tab: "ServerTab") -> None: + # NOTE: workaround for GTK bug where fullscreen causes headers to vanish when model is None + state = self.has_server_model() + tv = self.get_active_treeview() + tv.set_headers_visible(state) + tv.set_headers_clickable(state) + def _on_check_toggled(self, emitter: Emitter, label: str, state: bool) -> None: map_man = self.get_map_man() map_man.set_filter(label, state) diff --git a/dzgui/model/filtered_model.py b/dzgui/model/filtered_model.py index dffd952..18bd750 100644 --- a/dzgui/model/filtered_model.py +++ b/dzgui/model/filtered_model.py @@ -1,6 +1,7 @@ +import datetime import re from dataclasses import dataclass -from typing import TYPE_CHECKING +from typing import Optional, TYPE_CHECKING from warnings import deprecated from dzgui.const.enum import FilterMode @@ -114,16 +115,18 @@ class FilteredModelManager: case FilterMode.TOGGLE_ON: 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 mode is not FilterMode.INITIAL: + # for row in rows: + # if row[7] in self.ping_cache: + # row[9] = self.ping_cache[row[7]] clone = self.new_model_from_class(ServerColumns) if len(rows) > 0: + # TODO: considering exposing an option to not sort rows rows = self.sort_rows(rows) for row in rows: clone.append(row) + # else: # print("nothing to filter, sending none") # clone = None @@ -256,7 +259,7 @@ class FilteredModelManager: 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: + def set_filtered(self, rows: Optional[list]) -> None: if rows is None: rows = [] self.filtered = rows diff --git a/dzgui/model/map_model.py b/dzgui/model/map_model.py index fe08b1f..9af925b 100644 --- a/dzgui/model/map_model.py +++ b/dzgui/model/map_model.py @@ -31,12 +31,19 @@ class MapManager: strings.filter_unofficial: True, strings.filter_modded: True, } + self.active_map = 0 self.filters: list self.enabled_filters = dict(self.default_filters) def reinit_filters(self) -> None: self.enabled_filters = dict(self.default_filters) + def get_active_map(self) -> int: + return self.active_map + + def set_active_map(self, ind: int) -> None: + self.active_map = ind + def get_default_filters(self) -> dict: """Deep copy of defaults""" return dict(self.default_filters) diff --git a/dzgui/util/strings.py b/dzgui/util/strings.py index 0d37986..5a39119 100644 --- a/dzgui/util/strings.py +++ b/dzgui/util/strings.py @@ -99,10 +99,8 @@ steam_missing = "Local Steam installation is not set, possibly malformed config 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. +api_warn_msg = """No servers returned. Please wait and try again. +If this issue persists, your API key may be defunct or your network is blocking requests. """ server_timeout = "Timed out when querying server, check IP or try again later" server_error = ( @@ -167,7 +165,7 @@ ping_tooltip = ( ) # statusbar_helptext = "Select a row to see its detailed description" -#statusbar_helptext = "No server metadata to list." +# statusbar_helptext = "No server metadata to list." # use e.g. filters.1pp filter_1pp = "1PP" diff --git a/dzgui/views/components/filter_panel.py b/dzgui/views/components/filter_panel.py index 86ed56f..a8ea9bb 100644 --- a/dzgui/views/components/filter_panel.py +++ b/dzgui/views/components/filter_panel.py @@ -153,7 +153,6 @@ class FilterPanel(Gtk.Box): "request_maps_focus", lambda _: self.maps_entry.grab_focus() ) self.emitter.connect("check_button_pressed", self.toggle_check_by_key) - # self.emitter.connect("servers_loaded", self._on_servers_loaded) self.emitter.connect("load_maps", self._on_maps_loaded) # TODO: break into MapsCombo class @@ -177,6 +176,9 @@ class FilterPanel(Gtk.Box): self.maps_combo.connect("changed", self._on_map_changed) self.maps_combo.connect("key-press-event", self._on_combo_keypress) + # FIXME: should be a property of treeview's meta manager + self.active_map = 0 + for el in ( self.filters_label, self.keyword_entry, @@ -194,10 +196,12 @@ class FilterPanel(Gtk.Box): if text == row[0]: self.maps_combo.set_active(i) self._on_map_changed(self.maps_combo) + self.controller.set_active_map(i) def _on_maps_loaded(self, emitter: "Emitter", store: Gtk.ListStore) -> None: self.maps_combo.set_model(store) - self.maps_combo.set_active(0) + ind = self.controller.get_active_map() + self.maps_combo.set_active(ind) self.button_grid.reload_filters() # TODO: move into metamanager @@ -259,6 +263,8 @@ class FilterPanel(Gtk.Box): store = self.controller.get_map_store() if len(text) >= completion.get_minimum_key_length(): completion.set_model(store) + ind = self.get_active_combo() + self.controller.set_active_map(ind) def restore_focus_to_treeview(self) -> Literal[False]: view = self.controller.get_active_treeview() diff --git a/dzgui/views/components/right_panel.py b/dzgui/views/components/right_panel.py index a8f7232..fc26b61 100644 --- a/dzgui/views/components/right_panel.py +++ b/dzgui/views/components/right_panel.py @@ -44,9 +44,10 @@ class RightPanel(Gtk.Box): state = self.controller.has_server_model() for widget in (self.refresh_button, self.filters_vbox): widget.set_sensitive(state) + # FIXME: may wish to reload these in place to show new data - if context in (ServerTab.RECENT, ServerTab.SAVED): - self.refresh_button.set_sensitive(False) + # if context in (ServerTab.RECENT, ServerTab.SAVED): + # self.refresh_button.set_sensitive(False) # TODO: reference for ping pass # def _on_ping_clicked(self, button: Gtk.Button) -> None: diff --git a/dzgui/views/dialogs/entry.py b/dzgui/views/dialogs/entry.py deleted file mode 100644 index 64d57a8..0000000 --- a/dzgui/views/dialogs/entry.py +++ /dev/null @@ -1,83 +0,0 @@ -import gi - -gi.require_version("Gtk", "3.0") -from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa - -from dzgui.const.constants import NO_EXPAND, NO_FILL, NO_PADDING -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, NO_PADDING) - - 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, NO_PADDING) - - 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 diff --git a/dzgui/views/dialogs/generic.py b/dzgui/views/dialogs/generic.py index 738fdd3..fd41306 100644 --- a/dzgui/views/dialogs/generic.py +++ b/dzgui/views/dialogs/generic.py @@ -101,9 +101,7 @@ class NotifyDialog(GenericDialog): class WaitDialog(GenericDialog): - def __init__( - self, controller: "Controller", secondary: str, show_progress: bool = False - ): + def __init__(self, controller: "Controller", secondary: str, jobs: int = 1): super().__init__( controller=controller, text=strings.wait, @@ -112,16 +110,26 @@ class WaitDialog(GenericDialog): secondary=secondary, ) + self.jobs = jobs + self.cur_job = 1 + self.connect("delete-event", self._on_dialog_delete) content = self.get_content_area() spinner = Gtk.Spinner() self.prog = Gtk.ProgressBar() content.pack_end(spinner, NO_EXPAND, NO_FILL, 0) - if show_progress: + if self.jobs > 1: content.pack_end(self.prog, NO_EXPAND, NO_FILL, 0) else: spinner.start() + def increment(self, msg: str = "") -> None: + if msg != "": + self.format_secondary_text(msg) + fraction = self.cur_job / self.jobs + self.prog.set_fraction(fraction) + self.cur_job += 1 + def _on_dialog_delete( self, response_id: Gtk.ResponseType, event: Gdk.Event ) -> Literal[True]: diff --git a/dzgui/views/dialogs/link_dialog.py b/dzgui/views/dialogs/link_dialog.py index 57e6c09..fa36460 100644 --- a/dzgui/views/dialogs/link_dialog.py +++ b/dzgui/views/dialogs/link_dialog.py @@ -1,14 +1,13 @@ 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.util.strings import notice from dzgui.views.dialogs.generic import GenericDialog -from dzgui.views.components.buttons import SteamTextButton, WebButton +from dzgui.views.components.buttons import SteamTextButton import gi + gi.require_version("Gtk", "3.0") from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa E402 @@ -18,9 +17,17 @@ if TYPE_CHECKING: class WorkshopLinkDialog(GenericDialog): - def __init__(self, controller: "Controller", text: str, button_label: str, uid: str): + def __init__( + self, controller: "Controller", text: str, button_label: str, uid: str + ): text = textwrap.dedent(text).replace("\n", " ") - super().__init__(controller, text=notice, mtype=Gtk.MessageType.INFO, buttons=Gtk.ButtonsType.OK, secondary=text) + super().__init__( + controller, + text=notice, + mtype=Gtk.MessageType.INFO, + buttons=Gtk.ButtonsType.OK, + secondary=text, + ) self.controller = controller self.dialogBox = self.get_content_area() self.set_default_response(Gtk.ResponseType.OK) @@ -43,12 +50,10 @@ class WorkshopLinkDialog(GenericDialog): def _on_button_clicked(self, button: Gtk.Button, uid: str) -> None: # TODO: currently checks user id on instantiation self.controller.open_user_workshop(uid) - #client = self.controller.query_config(Preferences.CLIENT) - #open_user_workshop(uid, client) + # client = self.controller.query_config(Preferences.CLIENT) + # open_user_workshop(uid, client) - def _on_dialog_response( - self, dialog: Self, resp: Gtk.ResponseType - ) -> None: + def _on_dialog_response(self, dialog: Self, resp: Gtk.ResponseType) -> None: match resp: case Gtk.ResponseType.DELETE_EVENT: return diff --git a/dzgui/views/mixins/context_mixin.py b/dzgui/views/mixins/context_mixin.py index b75515c..753e2b3 100644 --- a/dzgui/views/mixins/context_mixin.py +++ b/dzgui/views/mixins/context_mixin.py @@ -20,27 +20,21 @@ class ContextMixin(TreeView): ) -> None: # FIXME: double click causes issue # split into present by click and present by key + if self.is_selection_empty(): return False - if event.type is Gdk.EventType.BUTTON_PRESS: - if event.button != 3: - return False - try: - pathinfo = self.get_path_at_pos(int(event.x), int(event.y)) - if pathinfo is None: - return True - (path, col, cellx, celly) = pathinfo - if path is None: - return True - 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 False - if event.keyval is not Gdk.KEY_l: + match event.type: + case Gdk.EventType.BUTTON_PRESS: + if event.button != 3: + return False + self._process_button_event(event) + case Gdk.EventType.KEY_PRESS: + if event.state is not Gdk.ModifierType.CONTROL_MASK: + return False + if event.keyval is not Gdk.KEY_l: + return False + case _: return False group = self.menu @@ -64,6 +58,18 @@ class ContextMixin(TreeView): self.context_menu.select_first(False) + def _process_button_event(self, event: Gdk.EventButton) -> None: + try: + pathinfo = self.get_path_at_pos(int(event.x), int(event.y)) + if pathinfo is None: + return True + (path, col, cellx, celly) = pathinfo + if path is None: + return True + self.set_cursor(path, col, False) + except AttributeError: + pass + def _on_menu_click(self, widget: Gtk.MenuItem, enum: ContextMenu) -> None: """ Local mods page allows multi selection, so ensure that only focused row diff --git a/dzgui/views/pages/servers.py b/dzgui/views/pages/servers.py index 5cdfeaf..f891f67 100644 --- a/dzgui/views/pages/servers.py +++ b/dzgui/views/pages/servers.py @@ -37,8 +37,9 @@ class ServerNotebook(Gtk.ScrolledWindow): self.lan = ServerTreeView(controller, ServerTab.LAN, ContextMenuGroup.SCAN_LAN) self.recent.set_query_func(self.controller.dump_test_2) + # TODO: number of servers will not be known a priori until reading file during query phase self.lan.set_query_func(self.controller.dump_test_2) - self.browser.set_query_func(self.controller.dump_api) + self.browser.set_query_func(self.controller.dump_api, jobs=11) tabs = [ (self.browser, server_labels.browser), diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index fdcaf6b..4ccf9de 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -7,12 +7,14 @@ from warnings import deprecated from dzgui.views.mixins.context_mixin import ContextMixin from dzgui.const.enum import ContextMenuGroup, ServerTab from dzgui.api.servers import Record +from dzgui.api.servers import ping as Ping from dzgui.model.map_model import MapManager from dzgui.model.filtered_model import FilteredModelManager from dzgui.util.dist import CalcDist from dzgui.util import strings from typing import Callable, Literal, TYPE_CHECKING + from dzgui.views.trees.tree_base import TreeView import dzgui.util._json as JSON # noqa @@ -56,6 +58,9 @@ class ServerTreeView(ContextMixin, TreeView): self.queue_id: int self.handler_id: int + self.query_func_jobs = 1 + + self.seen_cache = [] self.current_proc = None self.queue = multiprocessing.Queue() @@ -95,17 +100,21 @@ class ServerTreeView(ContextMixin, TreeView): else: w = width_map[column_title] column.set_fixed_width(w) - #if column_title == "Name": + #if column_title == "Ping": + #self.fancy_col = column + #self.fancy_rend = renderer + #column.set_cell_data_func(renderer, self._get_ping) + # if column_title == "Name": # column.set_fixed_width(800) - #if column_title == "Map": + # if column_title == "Map": # column.set_fixed_width(300) # TODO: standardize widths based on column title and longest content - #if column_title == "Name": + # if column_title == "Name": # column.set_fixed_width(500) - #if column_title == "Map": + # if column_title == "Map": # column.set_fixed_width(200) - #if column_title == "IP": + # if column_title == "IP": # column.set_fixed_width(240) column.connect("notify::fixed-width", self._on_col_width_changed) @@ -119,55 +128,47 @@ class ServerTreeView(ContextMixin, TreeView): self.connect("map", self._on_map) self.connect("unmap", self._on_unmap) - self.emitter.connect("servers_loaded_init", self._on_servers_loaded_init) - self.emitter.connect("servers_loaded", self._on_servers_loaded) + def _get_ping( + self, + column: Gtk.TreeViewColumn, + cell: Gtk.CellRendererText, + model: Gtk.TreeModel, + it: Gtk.TreeIter, + data: Any, + ): + def ping_server(model, _iter, ip: str, qport: int, ping: int): + #res = Ping(0, model[_iter]) + res = Ping(0, ip, qport, ping) + ping = res.ping + print(res) + GLib.idle_add(lambda: model.set(_iter, ping_column, ping)) - def _on_servers_loaded(self, emitter: "Emitter", tab: "ServerTab") -> None: - # FIXME: gets called on every tab, should be localized only to that context - # hook signal from main controller - state = self.controller.has_server_model() - # NOTE: workaround for GTK bug where fullscreen causes headers to vanish when model is None - self.set_headers_visible(state) - self.set_headers_clickable(state) + addr_column = 7 + qport_column = 8 + ping_column = 9 + # TODO: unpack model before entering thread + # test 40k servers with unpacked model + addr = model.get_value(it, addr_column).split(":")[0] + qport = model.get_value(it, qport_column) + ping = model.get_value(it, ping_column) + ip = f"{addr}:{qport}" - def _on_servers_loaded_init(self, emitter: "Emitter") -> None: - if self.loaded is False: + if ip in self.seen_cache: return - store = self.map_man.get_map_store() - self.emitter.emit("load_maps", store) + self.seen_cache.append(ip) - #def _get_ping( - # self, - # column: Gtk.TreeViewColumn, - # cell: Gtk.CellRendererText, - # model: Gtk.TreeModel, - # it: Gtk.TreeIter, - # data: Any, - #): - # def ping(model, it, ip: str): - # # TODO: use a2s to ping server - # # a2s.info -> "ping" key - # # Servers.ping() -> this accepts a whole row - # # try, if failure just abort - # self.res = ip.replace(":", "%") - # num = 11111 - # GLib.idle_add(lambda: model.set(it, 9, num)) - - # addr = model.get_value(it, 7).split(":")[0] - # qport = model.get_value(it, 8) - # ip = f"{addr}:{qport}" - - # thread = threading.Thread( - # daemon=True, - # target=ping, - # args=( - # model, - # it, - # ip, - # ), - # ) - # thread.start() - # pass + thread = threading.Thread( + daemon=True, + target=ping_server, + args=( + model, + it, + ip, + qport, + ping + ), + ) + thread.start() def start_timeout(self) -> None: self.queue_id = GLib.timeout_add(QUEUE_CHECK_DELAY, self._check_result_queue) @@ -178,7 +179,7 @@ class ServerTreeView(ContextMixin, TreeView): def get_filter_man(self) -> FilteredModelManager: return self.filter_man - #def shrink_to_fit(self) -> None: + # def shrink_to_fit(self) -> None: # cols = self.get_columns() # # TODO: run on only one treeview and propagate results # # TODO: does not shrink name, map, ip fields to fit @@ -208,8 +209,8 @@ class ServerTreeView(ContextMixin, TreeView): if self.get_enum() is ServerTab.LAN: self.emitter.emit("lan_tab_toggled", True) - # FIXME: only if the tab is active store = self.map_man.get_map_store() + # FIXME: if model is none, wipe maps self.emitter.emit("load_maps", store) self.handler_id = self.emitter.connect("statusbar_loaded", self.start_distcalc) self.start_timeout() @@ -222,11 +223,12 @@ class ServerTreeView(ContextMixin, TreeView): if self.get_enum() is ServerTab.LAN: self.emitter.emit("lan_tab_toggled", False) - def set_query_func(self, func: Callable) -> None: + def set_query_func(self, func: Callable, jobs: int = 1) -> None: self.query_func = func + self.query_func_jobs = jobs def get_query_func(self) -> Callable | None: - return self.query_func + return self.query_func, self.query_func_jobs def _on_col_width_changed( self, col: Gtk.TreeViewColumn, width: GObject.ParamSpecInt From 4decd16345113b7fc907e9c455e9647068b4fa1c Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Mon, 16 Feb 2026 00:13:41 +0900 Subject: [PATCH 082/309] feat: skip first server query in debug mode --- dzgui/controllers/mc.py | 9 --------- dzgui/views/base.py | 4 ++++ dzgui/views/pages/devs.py | 32 +++++++++++++++++++++++-------- dzgui/views/trees/tree_servers.py | 4 ++-- 4 files changed, 30 insertions(+), 19 deletions(-) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 501e392..8ce05d7 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -469,15 +469,6 @@ class Controller(GObject.GObject): 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_man.get_mod_store() - # tree_iter = model.get_iter(tree_path) - # mod = model.get(tree_iter, 2)[0] - # return mod, tree_iter - def get_mod_from_tree_path( self, tree_path: Gtk.TreePath ) -> tuple[str, Gtk.TreeIter]: diff --git a/dzgui/views/base.py b/dzgui/views/base.py index 7cb7714..9f7ffc7 100644 --- a/dzgui/views/base.py +++ b/dzgui/views/base.py @@ -158,8 +158,12 @@ class OuterWindow(Gtk.Window): MainController.open_page(NotebookPage.SERVERS) MainController.mediator.grid.conpan.lan.set_visible(False) self.grid.right_panel.sel_panel.hide() + # TODO: POC, trigger page change here MainController.loaded = True + + if MainController.get_prefs().is_developer: + return MainController.populate_model() def _on_delete_event(self, window: "OuterWindow", event: Gdk.EventKey) -> None: diff --git a/dzgui/views/pages/devs.py b/dzgui/views/pages/devs.py index b453829..a58adcf 100644 --- a/dzgui/views/pages/devs.py +++ b/dzgui/views/pages/devs.py @@ -1,5 +1,5 @@ from dataclasses import fields -from typing import Union, TYPE_CHECKING +from typing import Self, Union, TYPE_CHECKING from dzgui.const.enum import NotebookPage from dzgui.util.css import add_class @@ -44,41 +44,57 @@ class Developers(Gtk.ScrolledWindow): prefs = self.controller.get_prefs() paths_tree = self._make_tree(prefs.paths) - prefs_tree = self._make_tree(prefs) + self.prefs_tree = self._make_tree(prefs) trees_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) path = Gtk.TreePath.new_from_indices([0]) paths_tree.set_cursor(path) - prefs_tree.set_cursor(path) + self.prefs_tree.set_cursor(path) - for el in [paths_label, paths_tree, prefs_label, prefs_tree]: + for el in [paths_label, paths_tree, prefs_label, self.prefs_tree]: trees_box.add(el) for el in [heading, back_button, trees_box]: self.box.add(el) self.add(self.box) + self.connect("map", self._on_map) def _on_back_clicked(self, button: Gtk.Button) -> None: self.controller.open_page(NotebookPage.OPTIONS) def _make_tree(self, prefs: Union["Xdg", "UserPrefs"]) -> Gtk.TreeView: - view = TreeView(self.controller) + tree = TreeView(self.controller) renderer = Gtk.CellRendererText() for i, col in enumerate(developers.columns): column = Gtk.TreeViewColumn(col, renderer, text=i) column.set_sizing(Gtk.TreeViewColumnSizing.FIXED) - view.append_column(column) + tree.append_column(column) + store = self._make_store(prefs) + tree.set_model(store) + # store = Gtk.ListStore(str, str) + # for field in fields(prefs): + # if field.name == "paths": + # continue + # k, v = field.name, getattr(prefs, field.name) + # store.append((k, str(v))) + + return tree + + def _make_store(self, prefs: Union["Xdg", "UserPrefs"]) -> Gtk.ListStore: store = Gtk.ListStore(str, str) for field in fields(prefs): if field.name == "paths": continue k, v = field.name, getattr(prefs, field.name) store.append((k, str(v))) + return store - view.set_model(store) - return view + def _on_map(self, widget: Self) -> None: + prefs = self.controller.get_prefs() + store = self._make_store(prefs) + self.prefs_tree.set_model(store) def grab_content_area(self) -> None: return diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index 4ccf9de..cbf7fef 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -1,7 +1,7 @@ import logging import multiprocessing import threading -from typing import Any, Optional +from typing import Any, Optional, Self from warnings import deprecated from dzgui.views.mixins.context_mixin import ContextMixin @@ -204,7 +204,7 @@ class ServerTreeView(ContextMixin, TreeView): def get_enum(self) -> None: return self.enum - def _on_map(self, a) -> None: + def _on_map(self, widget: Self) -> None: # TODO: disable filter panel if current model is None if self.get_enum() is ServerTab.LAN: self.emitter.emit("lan_tab_toggled", True) From 987a4f51abc79fba540def6256a84c6febf4425d Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Mon, 16 Feb 2026 00:18:24 +0900 Subject: [PATCH 083/309] fix: change developers to debug --- dzgui/config/userprefs.py | 2 +- dzgui/main.py | 6 +++--- dzgui/util/strings.py | 4 ++-- dzgui/views/base.py | 2 +- dzgui/views/pages/options.py | 4 +--- 5 files changed, 8 insertions(+), 10 deletions(-) diff --git a/dzgui/config/userprefs.py b/dzgui/config/userprefs.py index c2830ad..c28b63c 100644 --- a/dzgui/config/userprefs.py +++ b/dzgui/config/userprefs.py @@ -8,7 +8,7 @@ from dzgui.util.ip import Coords class UserPrefs: is_steam_deck: bool is_game_mode: bool - is_developer: bool + is_debug: bool coords: Coords | None version: str allow_updates: bool diff --git a/dzgui/main.py b/dzgui/main.py index 0b0c29e..41b284b 100644 --- a/dzgui/main.py +++ b/dzgui/main.py @@ -41,7 +41,7 @@ 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) -parser.add_argument("-d", "--developers", action="store_true", help=flags.developers) +parser.add_argument("-d", "--debug", action="store_true", help=flags.debug) args = parser.parse_args() if TYPE_CHECKING: @@ -73,7 +73,7 @@ def main() -> None: uninstall() sys.exit(0) - if args.developers is True: + if args.debug is True: warnings.filterwarnings("default", category=DeprecationWarning) version = get_version() set_locale() @@ -133,7 +133,7 @@ def main() -> None: prefs = UserPrefs( _is_steam_deck, _is_game_mode, - args.developers, + args.debug, local_coords, version, allow, diff --git a/dzgui/util/strings.py b/dzgui/util/strings.py index 5a39119..febd83d 100644 --- a/dzgui/util/strings.py +++ b/dzgui/util/strings.py @@ -467,14 +467,14 @@ class Flags: description: str version: str uninstall: str - developers: str + debug: str flags = Flags( description="DayZ server browser and mod manager", version="Print version information", uninstall="Uninstall data files (use prior to 'pip uninstall')", - developers="Enables developer debugging features", + debug="Enables developer debugging features", ) diff --git a/dzgui/views/base.py b/dzgui/views/base.py index 9f7ffc7..60708c3 100644 --- a/dzgui/views/base.py +++ b/dzgui/views/base.py @@ -162,7 +162,7 @@ class OuterWindow(Gtk.Window): # TODO: POC, trigger page change here MainController.loaded = True - if MainController.get_prefs().is_developer: + if MainController.get_prefs().is_debug: return MainController.populate_model() diff --git a/dzgui/views/pages/options.py b/dzgui/views/pages/options.py index 270b82e..ae9ec6c 100644 --- a/dzgui/views/pages/options.py +++ b/dzgui/views/pages/options.py @@ -55,7 +55,6 @@ class Options(Gtk.Box): 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") @@ -176,8 +175,7 @@ class Options(Gtk.Box): developers.connect("clicked", self._on_developers_clicked) prefs = self.controller.get_prefs() - is_developer = prefs.is_developer - if is_developer: + if prefs.is_debug: grid.attach(developers, 1, 0, self.DEFAULT_WIDTH, self.DEFAULT_HEIGHT) for frame in [ From 45700e7a4a26710459637979c1284ee0556620be Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Mon, 16 Feb 2026 00:34:07 +0900 Subject: [PATCH 084/309] feat: use StoredFunc for query funcs --- dzgui/controllers/mc.py | 25 +++++++++-------- dzgui/views/components/buttons.py | 8 +++--- dzgui/views/pages/servers.py | 7 ++--- dzgui/views/trees/tree_servers.py | 45 ++++++++++++++----------------- 4 files changed, 41 insertions(+), 44 deletions(-) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 8ce05d7..e458cb6 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -94,7 +94,7 @@ class AppNavigation: filters: "FilterPanel" -class CleanupFunc: +class StoredFunc: def __init__(self, func: Callable, *args, **kwargs) -> None: sig = inspect.signature(func) self.func = func @@ -109,7 +109,7 @@ class Controller(GObject.GObject): self.dist_cache: dict[str, "Haversine", "ServerTab"] = {} self.mediator = AppNavigation() self.prefs: UserPrefs - self.cleanup_func: CleanupFunc = None + self.cleanup_func: StoredFunc = None self.model_man = ModelManager() self.emitter = Emitter() @@ -325,7 +325,7 @@ class Controller(GObject.GObject): mod.append(None) model.append(mod) - self.cleanup_func = CleanupFunc(self.load_mods_cleanup, model) + self.cleanup_func = StoredFunc(self.load_mods_cleanup, model) def toggle_config(self, context: Preferences) -> None: config = self.prefs.paths.config @@ -399,13 +399,13 @@ class Controller(GObject.GObject): GLib.idle_add(lambda: self.wait_dialog.increment()) res = future.result(timeout=3) if res.status != 200 or not res.parsed: - self.cleanup_func = CleanupFunc(self.cleanup_on_failure) + self.cleanup_func = StoredFunc(self.cleanup_on_failure) return j = res.json serv += j["response"]["servers"] except Exception as e: logger.critical(e) - self.cleanup_func = CleanupFunc(self.cleanup_on_failure) + self.cleanup_func = StoredFunc(self.cleanup_on_failure) # NOTE: This step is allowed to fail, since this metadata is incidental res = Servers.query_api(key, APPID_DAYZ_EXP, "") @@ -506,7 +506,7 @@ class Controller(GObject.GObject): except PeFile.AppNotInstalledError: pass - self.cleanup_func = CleanupFunc(self.delete_single_mod_cleanup, _iter) + self.cleanup_func = StoredFunc(self.delete_single_mod_cleanup, _iter) def get_mod_store(self) -> Gtk.ListStore: return self.mediator.modtreeview.get_model() @@ -877,7 +877,7 @@ class Controller(GObject.GObject): self.new_maps = sorted(u_maps) treeview.set_loaded(True) - self.cleanup_func = CleanupFunc(self.cleanup_on_success) + self.cleanup_func = StoredFunc(self.cleanup_on_success) def highlight_stale_cleanup(self, stale_mods: list) -> None: """Manipulates attached ListStore in the main event loop""" @@ -893,9 +893,9 @@ class Controller(GObject.GObject): def highlight_stale(self) -> None: # TODO: set progress bar for number of mods stale = find_stale_mods(self.prefs.paths.config) - self.cleanup_func = CleanupFunc(self.highlight_stale_cleanup, stale) + self.cleanup_func = StoredFunc(self.highlight_stale_cleanup, stale) - def get_cleanup_func(self) -> CleanupFunc: + def get_cleanup_func(self) -> StoredFunc: return self.cleanup_func def _destroy_on_idle(self) -> None: @@ -926,7 +926,7 @@ class Controller(GObject.GObject): if res is True: self.update_config(key, text) else: - self.cleanup_func = CleanupFunc( + self.cleanup_func = StoredFunc( lambda: self.emitter.emit("api_change_failed") ) @@ -975,9 +975,8 @@ class Controller(GObject.GObject): return self.mediator.statusbar @call_on_thread(strings.dialog.fetching) - def run_query_func(self, func: Callable) -> None: - # TODO: use CleanupFunc -> StoredFunc - func() + def run_query_func(self, func: StoredFunc) -> None: + func.call() @call_on_thread(strings.dialog.filtering) def filter_threaded(self, mode: FilterMode, label: str) -> None: diff --git a/dzgui/views/components/buttons.py b/dzgui/views/components/buttons.py index afd72f2..de6141a 100644 --- a/dzgui/views/components/buttons.py +++ b/dzgui/views/components/buttons.py @@ -95,11 +95,13 @@ class RefreshButton(IconTextButton): self.connect("clicked", self._on_refresh_clicked) def _on_refresh_clicked(self, button: Self) -> None: - # TODO: get page enum - print(self.controller.mediator.notebook.get_page_by_enum()) - # if .MODS, .LOG, .OPTIONS, reload in place + # TODO: get server tab enum + # if LAN tab, reload existing entries in place self.set_sensitive(False) self.show_time(True) + # FIXME: do this after threaded call fully finishes + # FIXME: other signals cause refresh button to become sensitive + # after model is loaded GLib.timeout_add_seconds(1, self.decrement) self.controller.refresh_tree() diff --git a/dzgui/views/pages/servers.py b/dzgui/views/pages/servers.py index f891f67..9d3d2a3 100644 --- a/dzgui/views/pages/servers.py +++ b/dzgui/views/pages/servers.py @@ -2,6 +2,7 @@ import logging from typing import Self, TYPE_CHECKING +from dzgui.controllers.mc import StoredFunc from dzgui.const.enum import ContextMenuGroup, ServerTab from dzgui.views.trees.tree_servers import ServerTreeView from dzgui.util.strings import server_labels @@ -36,10 +37,10 @@ class ServerNotebook(Gtk.ScrolledWindow): ) self.lan = ServerTreeView(controller, ServerTab.LAN, ContextMenuGroup.SCAN_LAN) - self.recent.set_query_func(self.controller.dump_test_2) + self.recent.set_query_func(StoredFunc(self.controller.dump_test_2)) # TODO: number of servers will not be known a priori until reading file during query phase - self.lan.set_query_func(self.controller.dump_test_2) - self.browser.set_query_func(self.controller.dump_api, jobs=11) + self.lan.set_query_func(StoredFunc(self.controller.dump_test_2)) + self.browser.set_query_func(StoredFunc(self.controller.dump_api), jobs=11) tabs = [ (self.browser, server_labels.browser), diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index cbf7fef..11167b3 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -27,7 +27,7 @@ from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa logger = logging.getLogger(__name__) if TYPE_CHECKING: - from dzgui.controllers.mc import Controller + from dzgui.controllers.mc import Controller, StoredFunc from dzgui.controllers.emitter import Emitter QUEUE_CHECK_DELAY = 200 @@ -100,14 +100,14 @@ class ServerTreeView(ContextMixin, TreeView): else: w = width_map[column_title] column.set_fixed_width(w) - #if column_title == "Ping": - #self.fancy_col = column - #self.fancy_rend = renderer - #column.set_cell_data_func(renderer, self._get_ping) - # if column_title == "Name": - # column.set_fixed_width(800) - # if column_title == "Map": - # column.set_fixed_width(300) + # if column_title == "Ping": + # self.fancy_col = column + # self.fancy_rend = renderer + # column.set_cell_data_func(renderer, self._get_ping) + # if column_title == "Name": + # column.set_fixed_width(800) + # if column_title == "Map": + # column.set_fixed_width(300) # TODO: standardize widths based on column title and longest content # if column_title == "Name": @@ -129,15 +129,15 @@ class ServerTreeView(ContextMixin, TreeView): self.connect("unmap", self._on_unmap) def _get_ping( - self, - column: Gtk.TreeViewColumn, - cell: Gtk.CellRendererText, - model: Gtk.TreeModel, - it: Gtk.TreeIter, - data: Any, + self, + column: Gtk.TreeViewColumn, + cell: Gtk.CellRendererText, + model: Gtk.TreeModel, + it: Gtk.TreeIter, + data: Any, ): def ping_server(model, _iter, ip: str, qport: int, ping: int): - #res = Ping(0, model[_iter]) + # res = Ping(0, model[_iter]) res = Ping(0, ip, qport, ping) ping = res.ping print(res) @@ -160,13 +160,7 @@ class ServerTreeView(ContextMixin, TreeView): thread = threading.Thread( daemon=True, target=ping_server, - args=( - model, - it, - ip, - qport, - ping - ), + args=(model, it, ip, qport, ping), ) thread.start() @@ -223,11 +217,12 @@ class ServerTreeView(ContextMixin, TreeView): if self.get_enum() is ServerTab.LAN: self.emitter.emit("lan_tab_toggled", False) - def set_query_func(self, func: Callable, jobs: int = 1) -> None: + def set_query_func(self, func: "StoredFunc", jobs: int = 1) -> None: self.query_func = func self.query_func_jobs = jobs - def get_query_func(self) -> Callable | None: + # TODO: possibly split this up + def get_query_func(self) -> tuple["StoredFunc", int]: return self.query_func, self.query_func_jobs def _on_col_width_changed( From c479051c2fd5b4d80d445e4e031813ed7b6418be Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Mon, 16 Feb 2026 01:57:51 +0900 Subject: [PATCH 085/309] fix: improve sort algo --- dzgui/controllers/mc.py | 172 +++----------------------- dzgui/model/filtered_model.py | 15 +-- dzgui/model/map_model.py | 2 +- dzgui/model/misc_model.py | 2 +- dzgui/views/base.py | 1 + dzgui/views/mixins/help_menu_mixin.py | 8 +- dzgui/views/pages/log.py | 4 +- dzgui/views/pages/options.py | 4 +- dzgui/views/pages/servers.py | 2 +- 9 files changed, 36 insertions(+), 174 deletions(-) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index e458cb6..63ad28a 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -3,14 +3,13 @@ import logging import shutil import threading import traceback -from typing import Optional -from warnings import deprecated from concurrent.futures import wait, as_completed from concurrent.futures import ThreadPoolExecutor from functools import wraps from pathlib import Path -from typing import Any, Callable, TYPE_CHECKING +from typing import Any, Callable, Optional, TYPE_CHECKING +from warnings import deprecated import dzgui.api.pefile as PeFile import dzgui.api.servers as Servers @@ -46,14 +45,14 @@ from dzgui.config.userprefs import UserPrefs from dzgui.controllers.emitter import Emitter from dzgui.model.misc_model import ModelManager from dzgui.util import strings -from dzgui.util.diag import write_diagnostic from dzgui.util._json import read_json, write_json +from dzgui.util.diag import write_diagnostic +from dzgui.util.format import format_mods, format_player_count from dzgui.util.localize import number from dzgui.util.open_links import open_workshop_page, open_user_workshop -from dzgui.util.format import format_mods, format_player_count from dzgui.util.redact import redact_log -from dzgui.views.dialogs.generic import ExceptionDialog, WaitDialog from dzgui.views.dialogs.filepicker import FilePicker +from dzgui.views.dialogs.generic import ExceptionDialog, WaitDialog import gi @@ -63,8 +62,8 @@ from gi.repository import Gtk, Gdk, GLib, GObject # noqa E402 logger = logging.getLogger(__name__) if TYPE_CHECKING: - from dzgui.model.map_model import MapManager from dzgui.const.enum import ServerTab + from dzgui.model.map_model import MapManager from dzgui.util.dist import Haversine from dzgui.views.base import Notebook, Grid, OuterWindow from dzgui.views.components.buttonbox import ContextualButton @@ -511,7 +510,7 @@ class Controller(GObject.GObject): def get_mod_store(self) -> Gtk.ListStore: return self.mediator.modtreeview.get_model() - def format_mod_statusbar(self) -> None: + def format_mod_statusbar(self) -> str: total_mods, total_size = self.calc_mod_size() msg = format_mods(total_size, total_mods) return msg @@ -534,7 +533,8 @@ class Controller(GObject.GObject): try: self.update_config(Preferences.FAV_LBL, name) self.update_config(Preferences.FAV_SRV, record) - except Exception: + except Exception as e: + logger.critical(e) # TODO: add a failure dialog here return @@ -642,9 +642,6 @@ class Controller(GObject.GObject): self.mediator.modtreeview.set_cursor(0) def dump_test_2(self) -> None: - import time - - time.sleep(1) data = ( [ "BAR", @@ -660,146 +657,6 @@ class Controller(GObject.GObject): "a", False, ], - [ - "BAR", - "chernarusplus", - "a", - "a", - 1, - 1, - 1, - "172.111.51.156:2302", - 1, - 1, - "a", - False, - ], - [ - "BAR", - "chernarusplus", - "a", - "a", - 1, - 1, - 1, - "185.207.214.16:2302", - 1, - 1, - "a", - False, - ], - [ - "BAR", - "chernarusplus", - "a", - "a", - 1, - 1, - 1, - "185.207.214.16:2302", - 1, - 1, - "a", - False, - ], - [ - "BAR", - "chernarusplus", - "a", - "a", - 1, - 1, - 1, - "185.207.214.16:2302", - 0, - 0, - "a", - False, - ], - [ - "BAR", - "chernarusplus", - "a", - "a", - 1, - 1, - 1, - "185.207.214.16:2302", - 0, - 0, - "a", - False, - ], - [ - "BAR", - "chernarusplus", - "a", - "a", - 1, - 1, - 1, - "185.207.214.16:2302", - 0, - 0, - "a", - False, - ], - [ - "BAR", - "chernarusplus", - "a", - "a", - 1, - 1, - 1, - "185.207.214.16:2302", - 0, - 0, - "a", - False, - ], - [ - "BAR", - "chernarusplus", - "a", - "a", - 1, - 1, - 1, - "185.207.214.16:2302", - 0, - 0, - "a", - False, - ], - [ - "BAR", - "chernarusplus", - "a", - "a", - 1, - 1, - 1, - "185.207.214.16:2302", - 0, - 0, - "a", - False, - ], - [ - "BAR", - "chernarusplus", - "a", - "a", - 1, - 1, - 1, - "185.207.214.16:2302", - 0, - 0, - "a", - False, - ], ) self.push_data_success(data, FilterMode.INITIAL) @@ -812,6 +669,11 @@ class Controller(GObject.GObject): treeview.set_loaded(True) treeview.set_model(self.to_insert) + # TODO: this will allow history and saved tab to emit signals to statusbar + # CHORE: test if treeview's sort method inserts row at the correct index + # inserting a row serializes file on disk, updates control model for that tab, and + # reapplies filters to ephemeral model; since filters are applied, in-situ insertion might not be necessary + self.to_insert.connect("row-inserted", lambda: print("row inserted into model")) map_man = treeview.get_map_man() # TODO: signals or other approach to deferring map @@ -862,8 +724,6 @@ class Controller(GObject.GObject): treeview = self.get_active_treeview() manager = treeview.get_filter_man() - self.to_insert = None - if data is None: self.to_insert = None else: @@ -991,7 +851,7 @@ class Controller(GObject.GObject): # TODO: call filter_man methods directly def refilter_model(self, mode: FilterMode, label: Optional[str] = None) -> None: tv = self.get_active_treeview() - #tv.freeze_child_notify() + # tv.freeze_child_notify() filter_man = tv.get_filter_man() if filter_man.get_control() is None: return @@ -1013,7 +873,7 @@ class Controller(GObject.GObject): # TODO: clear ephemeral model if necessary # manager = treeview.get_filter_man() # manager.clear_model() - #treeview.fancy_col.set_cell_data_func(treeview.fancy_rend, None) + # treeview.fancy_col.set_cell_data_func(treeview.fancy_rend, None) self.first_iteration = True self.pending_jobs = jobs diff --git a/dzgui/model/filtered_model.py b/dzgui/model/filtered_model.py index 18bd750..1dd5013 100644 --- a/dzgui/model/filtered_model.py +++ b/dzgui/model/filtered_model.py @@ -1,5 +1,6 @@ import datetime import re + from dataclasses import dataclass from typing import Optional, TYPE_CHECKING from warnings import deprecated @@ -115,6 +116,7 @@ class FilteredModelManager: case FilterMode.TOGGLE_ON: rows = self.filter_toggle_on(filters, *args) + # TODO: unimplemented # if mode is not FilterMode.INITIAL: # for row in rows: # if row[7] in self.ping_cache: @@ -127,10 +129,6 @@ class FilteredModelManager: for row in rows: clone.append(row) - # else: - # print("nothing to filter, sending none") - # clone = None - self.set_cache(filters, clone, rows) self.set_model(clone) return clone @@ -206,13 +204,12 @@ class FilteredModelManager: case strings.filter_full: rows = [row for row in rows if row[4] != row[5]] case strings.filter_duplicate: - seen = [] + seen = set() final = [] for row in rows: - if row[0] in seen: - continue - seen.append(row[0]) - final.append(row) + if row[0] not in seen: + seen.add(row[0]) + final.append(row) rows = final case strings.filter_day: reg = r"([0][0-9]|[1][0-6])" diff --git a/dzgui/model/map_model.py b/dzgui/model/map_model.py index 9af925b..2083f76 100644 --- a/dzgui/model/map_model.py +++ b/dzgui/model/map_model.py @@ -16,7 +16,7 @@ class MapManager: self.prior_map: str self.selected_map = all_maps - # TODO: namespace + # TODO: namespace under strings.filters self.default_filters = { strings.filter_1pp: True, strings.filter_day: True, diff --git a/dzgui/model/misc_model.py b/dzgui/model/misc_model.py index 340a156..6431e1e 100644 --- a/dzgui/model/misc_model.py +++ b/dzgui/model/misc_model.py @@ -48,7 +48,7 @@ class ModelManager: self.map_store = ListStore(str) self.help_store = self.new_model_from_class(MenuCols) self.log_store = self.new_model_from_class(LogCols) - # FIXME: needs to be generated on demand + # FIXME: needs to be generated on demand for servers self.modlist_store = self.new_model_from_class(ServerModCols) for row in HELP_MENU_ROWS: diff --git a/dzgui/views/base.py b/dzgui/views/base.py index 60708c3..32753a6 100644 --- a/dzgui/views/base.py +++ b/dzgui/views/base.py @@ -274,6 +274,7 @@ class Notebook(ScrollableMixin, Gtk.Notebook): # type: ignore if widget is None: return + # FIXME: deprecated widget.grab_content_area() def get_page(self) -> Gtk.Widget | None: diff --git a/dzgui/views/mixins/help_menu_mixin.py b/dzgui/views/mixins/help_menu_mixin.py index c55ca03..9fa3225 100644 --- a/dzgui/views/mixins/help_menu_mixin.py +++ b/dzgui/views/mixins/help_menu_mixin.py @@ -3,11 +3,15 @@ from typing import Literal from dzgui.const.enum import NotebookPage import gi + gi.require_version("Gtk", "3.0") from gi.repository import Gtk, Gdk # noqa + class HelpMenuMixin: - def _on_esc_keypress(self, widget: Gtk.Widget, event: Gdk.EventKey) -> Literal[True]: + def _on_esc_keypress( + self, widget: Gtk.Widget, event: Gdk.EventKey + ) -> Literal[True]: if event.keyval == Gdk.KEY_Escape: - self.controller.open_page(NotebookPage.HELP) + self.controller.open_page(NotebookPage.HELP) # type: ignore return True diff --git a/dzgui/views/pages/log.py b/dzgui/views/pages/log.py index b9a4343..f216327 100644 --- a/dzgui/views/pages/log.py +++ b/dzgui/views/pages/log.py @@ -13,7 +13,7 @@ if TYPE_CHECKING: from dzgui.controllers.mc import Controller -class Log(CursorMixin, HelpMenuMixin, Gtk.ScrolledWindow): +class Log(CursorMixin, HelpMenuMixin, Gtk.ScrolledWindow): # type: ignore def __init__(self, controller: "Controller") -> None: super().__init__() self.treeview = LogTreeView(controller) @@ -26,6 +26,6 @@ class Log(CursorMixin, HelpMenuMixin, Gtk.ScrolledWindow): def get_treeview(self) -> LogTreeView: return self.treeview - + def grab_content_area(self) -> None: self.treeview.grab_focus() diff --git a/dzgui/views/pages/options.py b/dzgui/views/pages/options.py index ae9ec6c..19327df 100644 --- a/dzgui/views/pages/options.py +++ b/dzgui/views/pages/options.py @@ -60,8 +60,8 @@ class Options(Gtk.Box): css.add_class(label, "page-heading") self.add(label) - self.steam_entry = None - self.bm_entry = None + self.steam_entry: Gtk.Entry = None + self.bm_entry: Gtk.Entry = None self.steam = WebButton(label=strings.options.steam_web) self.steam.connect("clicked", self._on_link_button_clicked, STEAM_API_SETUP) diff --git a/dzgui/views/pages/servers.py b/dzgui/views/pages/servers.py index 9d3d2a3..31bb2ed 100644 --- a/dzgui/views/pages/servers.py +++ b/dzgui/views/pages/servers.py @@ -84,7 +84,7 @@ class ServerNotebook(Gtk.ScrolledWindow): def grab_content_area(self) -> None: self.get_active_treeview().grab_focus() - def get_current_tab_text(self) -> None: + def get_current_tab_text(self) -> str: ind = self.notebook.get_current_page() child = self.notebook.get_nth_page(ind) return self.notebook.get_tab_label_text(child) From 78be3508634480aaa7362d7a2af4a8d584332c4a Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Mon, 16 Feb 2026 02:13:48 +0900 Subject: [PATCH 086/309] feat: push data into row immediately --- dzgui/model/filtered_model.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/dzgui/model/filtered_model.py b/dzgui/model/filtered_model.py index 1dd5013..a618e24 100644 --- a/dzgui/model/filtered_model.py +++ b/dzgui/model/filtered_model.py @@ -122,12 +122,14 @@ class FilteredModelManager: # if row[7] in self.ping_cache: # row[9] = self.ping_cache[row[7]] + # NOTE: this ListStore manipulation must remain local to the thread clone = self.new_model_from_class(ServerColumns) + n_cols = clone.get_n_columns() if len(rows) > 0: - # TODO: considering exposing an option to not sort rows rows = self.sort_rows(rows) - for row in rows: - clone.append(row) + for i, row in enumerate(rows): + clone.insert_with_values(i, tuple(range(0, n_cols)), row) + #clone.append(row) self.set_cache(filters, clone, rows) self.set_model(clone) From 93a4e231acc5500a6bbbd99af5cc1b1e7178814c Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Mon, 16 Feb 2026 04:16:57 +0900 Subject: [PATCH 087/309] feat: load history and saved --- dzgui/controllers/mc.py | 63 ++++++++++++++++++++++++++++--- dzgui/views/base.py | 1 + dzgui/views/dialogs/generic.py | 4 +- dzgui/views/pages/servers.py | 5 ++- dzgui/views/trees/tree_servers.py | 1 + 5 files changed, 65 insertions(+), 9 deletions(-) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 63ad28a..c547b45 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -384,6 +384,57 @@ class Controller(GObject.GObject): self.open_page(button.opens) + def dump_favorites(self) -> None: + # TODO: if list is empty, return failure + ips = self.query_config(Preferences.IP_LIST) + if len(ips) == 0: + # FIXME: this is not a failure, just a quiet exit with custom statusbar + # TODO: add custom statusbar parameter + self.cleanup_func = StoredFunc(self.cleanup_on_failure, False) + return + self.dump_ips(ips) + + def dump_history(self) -> None: + history = self.prefs.paths.history + # TODO: customize statusbar to mention how records are added + try: + with open(history, "r") as f: + rows = [row.rstrip("\n") for row in f] + except OSError: + self.cleanup_func = StoredFunc(self.cleanup_on_failure, False) + return + if len(rows) == 0: + self.cleanup_func = StoredFunc(self.cleanup_on_failure, False) + return + self.dump_ips(rows) + + def dump_ips(self, ips: list) -> None: + # NOTE: block malformed records (TODO: add github issue no.) + ips = [ip for ip in ips if len(ip.split(":")) == 3 and ip.split(":")[2] != "" ] + with ThreadPoolExecutor() as executor: + futures = [ + executor.submit( + Servers.query_direct, + ip.split(":")[0], + int(ip.split(":")[2]), + ) + for ip in ips + ] + # TODO: update dialog in main loop + #wait(futures) + serv = [] + for future in as_completed(futures): + res = future.result() + # NOTE: discard failing entries + if res is None: + continue + serv.append(res) + if len(serv) == 0: + self.cleanup_func = StoredFunc(self.cleanup_on_failure) + return + parsed = Servers.parse_json(serv) + self.push_data_success(parsed, FilterMode.INITIAL) + def dump_api(self) -> None: key = self.query_config(Preferences.STEAM) job = Servers.query_api @@ -405,6 +456,7 @@ class Controller(GObject.GObject): except Exception as e: logger.critical(e) self.cleanup_func = StoredFunc(self.cleanup_on_failure) + return # NOTE: This step is allowed to fail, since this metadata is incidental res = Servers.query_api(key, APPID_DAYZ_EXP, "") @@ -693,7 +745,7 @@ class Controller(GObject.GObject): treeview.grab_focus() - def cleanup_on_failure(self) -> None: + def cleanup_on_failure(self, show_dialog=True) -> None: treeview = self.get_active_treeview() treeview.set_loaded(True) map_man = treeview.get_map_man() @@ -713,11 +765,12 @@ class Controller(GObject.GObject): map_man.set_unique_maps(None) context = self.get_active_context() - # TODO: distinguish signals, e.g. "servers_failed_to_load" - # may trigger different behavior + # TODO: distinguish signals, e.g. "servers_failed_to_load", "servers_loaded_empty" + # customize statusbar accordingly self.emitter.emit("servers_loaded", context) - dialog = ExceptionDialog(self, strings.api_warn_msg) - dialog.run() + if show_dialog: + dialog = ExceptionDialog(self, strings.api_warn_msg) + dialog.run() def push_data_success(self, data: tuple, mode: Optional[FilterMode]) -> None: # FIXME: set outside of thread diff --git a/dzgui/views/base.py b/dzgui/views/base.py index 32753a6..ae6e930 100644 --- a/dzgui/views/base.py +++ b/dzgui/views/base.py @@ -37,6 +37,7 @@ 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 # kilometer cache; note, user may change measurement it partway through, flush cache # cache: dict[str, int] = {} diff --git a/dzgui/views/dialogs/generic.py b/dzgui/views/dialogs/generic.py index fd41306..82c3a48 100644 --- a/dzgui/views/dialogs/generic.py +++ b/dzgui/views/dialogs/generic.py @@ -54,8 +54,8 @@ class GenericDialog(Gtk.MessageDialog): self.set_default_response(Gtk.ResponseType.OK) self.connect("response", self._on_response) - self.action_area.set_layout(Gtk.ButtonBoxStyle.CENTER) - self.action_area.set_margin_bottom(20) + self.get_action_area().set_layout(Gtk.ButtonBoxStyle.CENTER) + self.get_action_area().set_margin_bottom(20) self.outer = self.get_content_area() self.outer.set_margin_start(30) self.outer.set_margin_end(30) diff --git a/dzgui/views/pages/servers.py b/dzgui/views/pages/servers.py index 31bb2ed..1b0bb14 100644 --- a/dzgui/views/pages/servers.py +++ b/dzgui/views/pages/servers.py @@ -37,8 +37,9 @@ class ServerNotebook(Gtk.ScrolledWindow): ) self.lan = ServerTreeView(controller, ServerTab.LAN, ContextMenuGroup.SCAN_LAN) - self.recent.set_query_func(StoredFunc(self.controller.dump_test_2)) - # TODO: number of servers will not be known a priori until reading file during query phase + # TODO: number of jobs will not be known a priori unless files are read + self.recent.set_query_func(StoredFunc(self.controller.dump_history)) + self.saved.set_query_func(StoredFunc(self.controller.dump_favorites)) self.lan.set_query_func(StoredFunc(self.controller.dump_test_2)) self.browser.set_query_func(StoredFunc(self.controller.dump_api), jobs=11) diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index 11167b3..bc5085f 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -4,6 +4,7 @@ import threading from typing import Any, Optional, Self from warnings import deprecated + from dzgui.views.mixins.context_mixin import ContextMixin from dzgui.const.enum import ContextMenuGroup, ServerTab from dzgui.api.servers import Record From 7766b4c5c2b02cab4c658c0fa44c9943d547484c Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Mon, 16 Feb 2026 22:36:44 +0900 Subject: [PATCH 088/309] feat: queue pool in thread --- dzgui/controllers/mc.py | 69 +++++++++++++++++++++---------- dzgui/util/dist.py | 9 ++-- dzgui/views/base.py | 2 +- dzgui/views/trees/tree_servers.py | 35 ++++++++++++---- 4 files changed, 79 insertions(+), 36 deletions(-) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index c547b45..93fcdc0 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -43,6 +43,7 @@ from dzgui.config import update from dzgui.config.query import lookup from dzgui.config.userprefs import UserPrefs from dzgui.controllers.emitter import Emitter +from dzgui.model.filtered_model import FilteredModelManager from dzgui.model.misc_model import ModelManager from dzgui.util import strings from dzgui.util._json import read_json, write_json @@ -148,9 +149,9 @@ class Controller(GObject.GObject): except AttributeError: logger.critical(f"{attr} is not a valid AppNavigation attribute.") - def terminate_process(self) -> None: - # TODO: only used by server table multiprocessing queue - self.get_active_treeview().terminate_process() + # def terminate_process(self) -> None: + # # TODO: only used by server table multiprocessing queue + # self.get_active_treeview().terminate_process() def get_prefs(self) -> UserPrefs: return self.prefs @@ -410,7 +411,7 @@ class Controller(GObject.GObject): def dump_ips(self, ips: list) -> None: # NOTE: block malformed records (TODO: add github issue no.) - ips = [ip for ip in ips if len(ip.split(":")) == 3 and ip.split(":")[2] != "" ] + ips = [ip for ip in ips if len(ip.split(":")) == 3 and ip.split(":")[2] != ""] with ThreadPoolExecutor() as executor: futures = [ executor.submit( @@ -421,7 +422,7 @@ class Controller(GObject.GObject): for ip in ips ] # TODO: update dialog in main loop - #wait(futures) + # wait(futures) serv = [] for future in as_completed(futures): res = future.result() @@ -435,17 +436,34 @@ class Controller(GObject.GObject): parsed = Servers.parse_json(serv) self.push_data_success(parsed, FilterMode.INITIAL) + def dump_lan(self, port: int) -> None: + serv = [] + with ThreadPoolExecutor() as executor: + futures = [executor.submit(Servers.test_ip, i, port) for i in range(1, 256)] + for future in as_completed(futures): + try: + res = future.result(timeout=3) + if res is None: + continue + serv.panned(res) + if len(serv) == 0: + self.cleanup_func = StoredFunc(self.cleanup_on_failure) + return + parsed = Servers.parse_json(serv) + self.push_data_success(parsed, FilterMode.INITIAL) + + def dump_api(self) -> None: key = self.query_config(Preferences.STEAM) job = Servers.query_api params = Servers.params serv = [] - i = 0 + #i = 0 with ThreadPoolExecutor() as executor: futures = [executor.submit(job, key, APPID_DAYZ, param) for param in params] for future in as_completed(futures): try: - i += 1 + #i += 1 GLib.idle_add(lambda: self.wait_dialog.increment()) res = future.result(timeout=3) if res.status != 200 or not res.parsed: @@ -621,8 +639,9 @@ class Controller(GObject.GObject): # FIXME: signal should instead be emitted off of treeview when rows added/inserted # self.update_mod_statusbar() - # call a2s on thread and update ephemeral model in situ case ContextMenu.REFRESH_PLAYERS: + # get record + # call a2s on thread pass # update history model, update tab label, pop off of queue, write new list into file @@ -719,14 +738,13 @@ class Controller(GObject.GObject): self.pending_jobs = 1 treeview = self.get_active_treeview() treeview.set_loaded(True) - treeview.set_model(self.to_insert) + # TODO: this will allow history and saved tab to emit signals to statusbar # CHORE: test if treeview's sort method inserts row at the correct index # inserting a row serializes file on disk, updates control model for that tab, and # reapplies filters to ephemeral model; since filters are applied, in-situ insertion might not be necessary self.to_insert.connect("row-inserted", lambda: print("row inserted into model")) - map_man = treeview.get_map_man() # TODO: signals or other approach to deferring map # model insertion after thread closes @@ -738,6 +756,7 @@ class Controller(GObject.GObject): # CHORE: this is placeholder logic if self.first_iteration: + map_man = treeview.get_map_man() map_man.set_unique_maps(self.new_maps) self.emitter.emit("servers_loaded_init") self.first_iteration = False @@ -773,14 +792,14 @@ class Controller(GObject.GObject): dialog.run() def push_data_success(self, data: tuple, mode: Optional[FilterMode]) -> None: - # FIXME: set outside of thread - treeview = self.get_active_treeview() - manager = treeview.get_filter_man() + # FIXME: calls treeview read methods in thread + # treeview = self.get_active_treeview() + # manager = treeview.get_filter_man() + manager = self.get_filter_man() if data is None: self.to_insert = None else: - # TODO: consolidate into filter manager if mode == FilterMode.INITIAL: manager.set_control(data) manager.filter(mode) @@ -789,7 +808,6 @@ class Controller(GObject.GObject): u_maps = set([row[1] for row in data]) self.new_maps = sorted(u_maps) - treeview.set_loaded(True) self.cleanup_func = StoredFunc(self.cleanup_on_success) def highlight_stale_cleanup(self, stale_mods: list) -> None: @@ -892,23 +910,27 @@ class Controller(GObject.GObject): func.call() @call_on_thread(strings.dialog.filtering) - def filter_threaded(self, mode: FilterMode, label: str) -> None: - # FIXME: call outside of thread - tv = self.get_active_treeview() - filter_man = tv.get_filter_man() - filter_man.filter(mode, label) + def filter_threaded( + self, filter_man: "FilteredModelManager", mode: FilterMode, label: str + ) -> None: # TODO: why pushing empty data? + filter_man.filter(mode, label) self.push_data_success("", mode) # TODO: consolidate with method above # TODO: call filter_man methods directly def refilter_model(self, mode: FilterMode, label: Optional[str] = None) -> None: tv = self.get_active_treeview() - # tv.freeze_child_notify() filter_man = tv.get_filter_man() if filter_man.get_control() is None: return - self.filter_threaded(mode, label) + self.filter_threaded(filter_man, mode, label) + + def get_filter_man(self) -> "FilteredModelManager": + return self.filter_man + + def set_filter_man(self, filter_man: "FilteredModelManager") -> None: + self.filter_man = filter_man def populate_model(self) -> None: # NOTE: prepare GTK objects outside of thread @@ -930,6 +952,9 @@ class Controller(GObject.GObject): self.first_iteration = True self.pending_jobs = jobs + # TODO: get filter manager a priori and set it so that it can be accessed out of thread + filter_man = treeview.get_filter_man() + self.set_filter_man(filter_man) self.run_query_func(func) def get_favorite(self) -> tuple[str, str] | tuple[None, None]: diff --git a/dzgui/util/dist.py b/dzgui/util/dist.py index 34472ba..d886e54 100644 --- a/dzgui/util/dist.py +++ b/dzgui/util/dist.py @@ -1,5 +1,4 @@ import logging -import multiprocessing from math import radians, cos, sin, asin, sqrt from typing import TYPE_CHECKING @@ -40,12 +39,12 @@ class Haversine: return self.dist / 1609.344 -class CalcDist(multiprocessing.Process): +class CalcDist: def __init__( self, addr: str, enum: "ServerTab", - result_queue: multiprocessing.Queue, + result_queue: "Queue", controller: "Controller", ) -> None: super().__init__() @@ -54,9 +53,9 @@ class CalcDist(multiprocessing.Process): self.controller = controller self.result_queue = result_queue self.addr = addr - self.ip = addr.split(":")[0] + self.ip = self.addr #.split(":")[0] - def run(self) -> None: + #def run(self) -> None: cache = self.controller.get_dist_cache() if self.addr in cache: logger.info(f"Address '{self.addr}' already in cache") diff --git a/dzgui/views/base.py b/dzgui/views/base.py index ae6e930..7700394 100644 --- a/dzgui/views/base.py +++ b/dzgui/views/base.py @@ -171,7 +171,7 @@ class OuterWindow(Gtk.Window): self.halt_proc_and_quit() def halt_proc_and_quit(self) -> None: - MainController.terminate_process() + #MainController.terminate_process() MainController.save_res_and_quit() diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index bc5085f..ffdb383 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -64,7 +64,9 @@ class ServerTreeView(ContextMixin, TreeView): self.seen_cache = [] self.current_proc = None - self.queue = multiprocessing.Queue() + from queue import Queue + self.queue = Queue() + #self.queue = multiprocessing.Queue() prefs = self.controller.get_prefs() columns = prefs.paths.columns @@ -129,6 +131,8 @@ class ServerTreeView(ContextMixin, TreeView): self.connect("map", self._on_map) self.connect("unmap", self._on_unmap) + self.thread = None + def _get_ping( self, column: Gtk.TreeViewColumn, @@ -235,12 +239,15 @@ class ServerTreeView(ContextMixin, TreeView): # NOTE: get final width after drag action completes GLib.idle_add(self.controller.propagate_column_width, col) - def terminate_process(self) -> None: - if self.current_proc and self.current_proc.is_alive(): - self.current_proc.terminate() + #def terminate_process(self) -> None: + # if self.thread and self.thread.is_alive(): + # print("thread still exists") + # if self.current_proc and self.current_proc.is_alive(): + # self.current_proc.terminate() def start_distcalc(self, emitter: Optional["Emitter"] = None): - self.terminate_process() + #self.terminate_process() + #self.queue_id = GLib.timeout_add(QUEUE_CHECK_DELAY, self._check_result_queue) self.emitter.emit("distcalc_started") record = self.get_record() if record is None: @@ -255,10 +262,22 @@ class ServerTreeView(ContextMixin, TreeView): self.controller.set_statusbar_dist(haversine, self.get_enum()) return - self.current_proc = CalcDist( - record.ip, self.get_enum(), self.queue, self.controller + #def t(ip, enum, queue) -> None: + # if queue.empty(): + # queue.put(["LONG DISTANCE", 0]) + + + enum = self.get_enum() + self.thread = threading.Thread( + daemon=True, + target=CalcDist, + args=(record.ip, enum, self.queue, self.controller), ) - self.current_proc.start() + self.thread.start() + #self.current_proc = CalcDist( + # record.ip, self.get_enum(), self.queue, self.controller + #) + #self.current_proc.start() def _check_result_queue(self) -> Literal[True]: latest_result = None From 59120825dca06e9e0877469a85edb21d287cc4f6 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Tue, 17 Feb 2026 00:35:33 +0900 Subject: [PATCH 089/309] feat: early abort on LAN --- dzgui/api/servers.py | 6 ++++- dzgui/controllers/mc.py | 30 +++++++++++++++++-------- dzgui/views/components/connect_panel.py | 30 ++++++++++++++++++++----- 3 files changed, 51 insertions(+), 15 deletions(-) diff --git a/dzgui/api/servers.py b/dzgui/api/servers.py index 728bbda..06e0b05 100644 --- a/dzgui/api/servers.py +++ b/dzgui/api/servers.py @@ -6,6 +6,7 @@ import re import requests import socket import subprocess +import threading import typing # noqa from dataclasses import dataclass @@ -56,7 +57,10 @@ def get_netmask() -> str: return netmask -def test_ip(suffix: int, port: int) -> dict | None: +# TODO: rename +def test_ip(suffix: int, port: int, event: threading.Event) -> dict | None: + if event.is_set(): + return netmask = get_netmask() hostname = f"{netmask}.{str(suffix)}" ping = ["ping", "-c1", "-i", "0.1", "-w", "1"] diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 93fcdc0..b18e9e0 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -436,34 +436,45 @@ class Controller(GObject.GObject): parsed = Servers.parse_json(serv) self.push_data_success(parsed, FilterMode.INITIAL) - def dump_lan(self, port: int) -> None: + # TODO: strings + @call_on_thread("scanning LAN ports") + def dump_lan(self, port: int, early_abort: bool) -> None: serv = [] + event = threading.Event() with ThreadPoolExecutor() as executor: - futures = [executor.submit(Servers.test_ip, i, port) for i in range(1, 256)] + futures = [ + executor.submit(Servers.test_ip, i, port, event) for i in range(1, 256) + ] for future in as_completed(futures): try: - res = future.result(timeout=3) + res = future.result(timeout=0.5) + if res is not None and early_abort is True: + event.set() + self.cleanup_func = StoredFunc(self.cleanup_on_failure) + return if res is None: continue - serv.panned(res) - if len(serv) == 0: + serv.append(res) + except Exception as e: + logger.critical(e) self.cleanup_func = StoredFunc(self.cleanup_on_failure) - return + if len(serv) == 0: + self.cleanup_func = StoredFunc(self.cleanup_on_failure) + return parsed = Servers.parse_json(serv) self.push_data_success(parsed, FilterMode.INITIAL) - def dump_api(self) -> None: key = self.query_config(Preferences.STEAM) job = Servers.query_api params = Servers.params serv = [] - #i = 0 + # i = 0 with ThreadPoolExecutor() as executor: futures = [executor.submit(job, key, APPID_DAYZ, param) for param in params] for future in as_completed(futures): try: - #i += 1 + # i += 1 GLib.idle_add(lambda: self.wait_dialog.increment()) res = future.result(timeout=3) if res.status != 200 or not res.parsed: @@ -477,6 +488,7 @@ class Controller(GObject.GObject): return # NOTE: This step is allowed to fail, since this metadata is incidental + print("now checking exp servers") res = Servers.query_api(key, APPID_DAYZ_EXP, "") if res.status == 200 and res.parsed is True: j = res.json diff --git a/dzgui/views/components/connect_panel.py b/dzgui/views/components/connect_panel.py index daf641e..a5d0077 100644 --- a/dzgui/views/components/connect_panel.py +++ b/dzgui/views/components/connect_panel.py @@ -31,32 +31,52 @@ class LanPanel(Gtk.Frame): label = BoldLabel(lan_panel.heading) self.set_label_widget(label) - radio1 = Gtk.RadioButton.new_with_label(None, lan_panel.default_button) + self.radio1 = Gtk.RadioButton.new_with_label(None, lan_panel.default_button) radio2 = Gtk.RadioButton.new_with_label_from_widget( - radio1, lan_panel.custom_button + self.radio1, lan_panel.custom_button ) self.entry = PortEntry(controller) self.scan = Gtk.Button(label=lan_panel.scan_button) + self.scan.connect("clicked", self._on_scan_clicked) + # TODO: strings + self.early_abort = Gtk.CheckButton("Stop scanning on first hit") + self.early_abort.set_active(True) + self.early_abort.set_tooltip_text( + "Unless you have multiple DayZ servers on your LAN,\nleave this checked to get results faster" + ) self.entry.connect("string_validated", self._on_port_validated) self.emitter.connect( "request_lan_entry_focus", lambda _: self.entry.grab_focus() ) - radio1.connect("toggled", self._on_radio_toggled) + self.radio1.connect("toggled", self._on_radio_toggled) self.grid = Gtk.Grid(margin=10, vexpand=False, column_spacing=15, row_spacing=5) - self.grid.attach(radio1, 0, 0, COLS, ROWS) - self.grid.attach_next_to(radio2, radio1, Gtk.PositionType.RIGHT, COLS, ROWS) + self.grid.attach(self.radio1, 0, 0, COLS, ROWS) + self.grid.attach_next_to( + radio2, self.radio1, Gtk.PositionType.RIGHT, COLS, ROWS + ) self.grid.attach_next_to(self.entry, radio2, Gtk.PositionType.RIGHT, COLS, ROWS) self.grid.attach_next_to( self.scan, self.entry, Gtk.PositionType.RIGHT, COLS, ROWS ) + self.grid.attach_next_to( + self.early_abort, self.scan, Gtk.PositionType.RIGHT, COLS, ROWS + ) self.add(self.grid) self.entry.set_sensitive(False) + def _on_scan_clicked(self, button: Gtk.Button) -> None: + if self.radio1.get_active(): + port = 27016 + else: + port = self.entry.get_text() + abort = self.early_abort.get_active() + self.controller.dump_lan(int(port), abort) + def _on_radio_toggled(self, button: Gtk.RadioButton) -> None: state = button.get_active() self.entry.set_sensitive(not state) From decc1f636c9755dac3fce3693b70c7221c08bb18 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Tue, 17 Feb 2026 00:49:32 +0900 Subject: [PATCH 090/309] fix: add keypress signal to KeywordEntry --- dzgui/controllers/mc.py | 15 ++++----------- dzgui/views/components/filter_panel.py | 5 +++++ 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index b18e9e0..8924c37 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -149,10 +149,6 @@ class Controller(GObject.GObject): except AttributeError: logger.critical(f"{attr} is not a valid AppNavigation attribute.") - # def terminate_process(self) -> None: - # # TODO: only used by server table multiprocessing queue - # self.get_active_treeview().terminate_process() - def get_prefs(self) -> UserPrefs: return self.prefs @@ -469,12 +465,10 @@ class Controller(GObject.GObject): job = Servers.query_api params = Servers.params serv = [] - # i = 0 with ThreadPoolExecutor() as executor: futures = [executor.submit(job, key, APPID_DAYZ, param) for param in params] for future in as_completed(futures): try: - # i += 1 GLib.idle_add(lambda: self.wait_dialog.increment()) res = future.result(timeout=3) if res.status != 200 or not res.parsed: @@ -488,7 +482,6 @@ class Controller(GObject.GObject): return # NOTE: This step is allowed to fail, since this metadata is incidental - print("now checking exp servers") res = Servers.query_api(key, APPID_DAYZ_EXP, "") if res.status == 200 and res.parsed is True: j = res.json @@ -803,6 +796,8 @@ class Controller(GObject.GObject): dialog = ExceptionDialog(self, strings.api_warn_msg) dialog.run() + # TODO: break into initial dump and refilter modes, can drop filtermode kwarg + # and stop pushing empty data def push_data_success(self, data: tuple, mode: Optional[FilterMode]) -> None: # FIXME: calls treeview read methods in thread # treeview = self.get_active_treeview() @@ -925,11 +920,10 @@ class Controller(GObject.GObject): def filter_threaded( self, filter_man: "FilteredModelManager", mode: FilterMode, label: str ) -> None: - # TODO: why pushing empty data? filter_man.filter(mode, label) - self.push_data_success("", mode) + self.to_insert = filter_man.get_model() + self.cleanup_func = StoredFunc(self.cleanup_on_success) - # TODO: consolidate with method above # TODO: call filter_man methods directly def refilter_model(self, mode: FilterMode, label: Optional[str] = None) -> None: tv = self.get_active_treeview() @@ -960,7 +954,6 @@ class Controller(GObject.GObject): # TODO: clear ephemeral model if necessary # manager = treeview.get_filter_man() # manager.clear_model() - # treeview.fancy_col.set_cell_data_func(treeview.fancy_rend, None) self.first_iteration = True self.pending_jobs = jobs diff --git a/dzgui/views/components/filter_panel.py b/dzgui/views/components/filter_panel.py index a8ea9bb..8681331 100644 --- a/dzgui/views/components/filter_panel.py +++ b/dzgui/views/components/filter_panel.py @@ -102,6 +102,11 @@ class KeywordEntry(Gtk.Entry): return True return False + def restore_focus_to_treeview(self) -> Literal[False]: + view = self.controller.get_active_treeview() + view.grab_focus() + return False + def _on_activated(self, entry: Gtk.Entry) -> None: # TODO: investigate this method self.controller.mediator.window.set_keep_below(False) From 42544c6dccaed2ff4f2c0c396be5bdea7211e004 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 19 Feb 2026 23:08:10 +0900 Subject: [PATCH 091/309] feat: add by id or ip WIP --- dzgui/controllers/mc.py | 63 ++++++++++++++++++++----- dzgui/views/components/connect_panel.py | 16 ++++++- 2 files changed, 67 insertions(+), 12 deletions(-) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 8924c37..50df3e2 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -15,7 +15,7 @@ import dzgui.api.pefile as PeFile import dzgui.api.servers as Servers import dzgui.util._json as JSON # noqa -from dzgui.api.probe import test_steam_api, test_bm_api +from dzgui.api.bm import map_id_to_record from dzgui.api.mods import ( get_delimited_mods, get_local_mod_path, @@ -23,14 +23,7 @@ from dzgui.api.mods import ( _hash, remove_stale_signatures, ) -from dzgui.const.enum import ( - FilterMode, - Preferences, - NotebookPage, - ButtonType, - ContextMenu, -) - +from dzgui.api.probe import test_steam_api, test_bm_api from dzgui.const.constants import ( APPID_DAYZ, APPID_DAYZ_EXP, @@ -38,6 +31,13 @@ from dzgui.const.constants import ( WINDOW_DEFAULT_X, WINDOW_DEFAULT_Y, ) +from dzgui.const.enum import ( + FilterMode, + Preferences, + NotebookPage, + ButtonType, + ContextMenu, +) from dzgui.config import update from dzgui.config.query import lookup @@ -460,6 +460,43 @@ class Controller(GObject.GObject): parsed = Servers.parse_json(serv) self.push_data_success(parsed, FilterMode.INITIAL) + def query_ip_id(self, addr: str) -> None: + # NOTE: Battlemetrics + if addr.isdigit(): + config = self.prefs.paths.config + resolved = map_id_to_record(config, addr) + res = Servers.query_direct(resolved.ip, resolved.qport) + else: + record = addr.split(":") + ip, qport = record[0], record[1] + res = Servers.query_direct(ip, int(qport)) + return res + + # TODO: strings + @call_on_thread("querying address") + def connect_by_id_or_ip(self, addr: str) -> None: + res = self.query_ip_id(addr) + + def cleanup_on_insert(self) -> None: + print(self.get_cleanup_func()) + treeview = self.get_active_treeview() + # TODO; update control model in filtered_model + model = treeview.get_model().append(self.insert_record[0]) + + @call_on_thread("querying address") + def add_by_id_or_ip(self, addr: str) -> None: + res = self.query_ip_id(addr) + self.insert_record = Servers.parse_json([res]) + #if res is None: + # # TODO: set cleanupfunc with dialog + # return + # TODO: add into saved servers file + # TODO: update saved servers model + # if current focus is not save servers, update label + # TODO: add ip here + self.set_cleanup_func(StoredFunc(lambda: self.cleanup_on_insert())) + + def dump_api(self) -> None: key = self.query_config(Preferences.STEAM) job = Servers.query_api @@ -749,7 +786,7 @@ class Controller(GObject.GObject): # CHORE: test if treeview's sort method inserts row at the correct index # inserting a row serializes file on disk, updates control model for that tab, and # reapplies filters to ephemeral model; since filters are applied, in-situ insertion might not be necessary - self.to_insert.connect("row-inserted", lambda: print("row inserted into model")) + self.to_insert.connect("row-inserted", lambda *args: print("row inserted into model")) # TODO: signals or other approach to deferring map # model insertion after thread closes @@ -833,6 +870,9 @@ class Controller(GObject.GObject): stale = find_stale_mods(self.prefs.paths.config) self.cleanup_func = StoredFunc(self.highlight_stale_cleanup, stale) + def set_cleanup_func(self, func: StoredFunc) -> None: + self.cleanup_func = func + def get_cleanup_func(self) -> StoredFunc: return self.cleanup_func @@ -841,7 +881,7 @@ class Controller(GObject.GObject): func = self.get_cleanup_func() if func is not None: func.call() - self.cleanup_func = None + self.set_cleanup_func(None) self.mediator.window.set_sensitive(True) def dump_diagnostics(self) -> None: @@ -922,6 +962,7 @@ class Controller(GObject.GObject): ) -> None: filter_man.filter(mode, label) self.to_insert = filter_man.get_model() + print("filtering threaded") self.cleanup_func = StoredFunc(self.cleanup_on_success) # TODO: call filter_man methods directly diff --git a/dzgui/views/components/connect_panel.py b/dzgui/views/components/connect_panel.py index a5d0077..0cc17f1 100644 --- a/dzgui/views/components/connect_panel.py +++ b/dzgui/views/components/connect_panel.py @@ -40,7 +40,7 @@ class LanPanel(Gtk.Frame): self.scan = Gtk.Button(label=lan_panel.scan_button) self.scan.connect("clicked", self._on_scan_clicked) # TODO: strings - self.early_abort = Gtk.CheckButton("Stop scanning on first hit") + self.early_abort = Gtk.CheckButton(label="Stop scanning on first hit") self.early_abort.set_active(True) self.early_abort.set_tooltip_text( "Unless you have multiple DayZ servers on your LAN,\nleave this checked to get results faster" @@ -153,11 +153,14 @@ class AddPanel(Gtk.Frame): self.set_label_widget(label) self.add_server = AddButton() + self.add_server.connect("clicked", self._on_add_clicked) + self.conn_server = SteamConnectButton() self.conn_server.set_sensitive(False) self.add_server.set_sensitive(False) self.entry = IpEntry(controller) + self.entry.connect("activate", self._on_activate) self.entry.connect("string_validated", self._on_ip_validated) self.emitter.connect( "request_ip_entry_focus", lambda _: self.entry.grab_focus() @@ -174,6 +177,17 @@ class AddPanel(Gtk.Frame): self.add(self.grid) + def _submit_query(self) -> None: + text = self.entry.get_text() + self.controller.add_by_id_or_ip(text) + + def _on_activate(self, entry: Gtk.Entry) -> None: + self._submit_query() + + def _on_add_clicked(self, button: Gtk.Button) -> None: + self._submit_query() + + def _on_ip_validated(self, entry: Gtk.Entry, state: bool) -> None: self.conn_server.set_sensitive(state) self.add_server.set_sensitive(state) From 62da94946545388b4c933bfaf6d8b4300d6e8025 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 21 Feb 2026 17:44:11 +0900 Subject: [PATCH 092/309] chore: abstract ThreadManager class (WIP) --- dzgui/const/enum.py | 20 +-- dzgui/controllers/mc.py | 226 ++++++++++++++---------- dzgui/views/base.py | 6 +- dzgui/views/components/connect_panel.py | 6 +- dzgui/views/components/crumbs.py | 4 +- dzgui/views/components/filter_panel.py | 2 +- dzgui/views/components/right_panel.py | 2 - dzgui/views/components/statusbar.py | 10 +- dzgui/views/dialogs/generic.py | 4 +- dzgui/views/mixins/context_mixin.py | 5 +- dzgui/views/trees/tree_base.py | 9 +- dzgui/views/trees/tree_mods.py | 9 +- dzgui/views/trees/tree_servers.py | 1 - 13 files changed, 177 insertions(+), 127 deletions(-) diff --git a/dzgui/const/enum.py b/dzgui/const/enum.py index d5b50a0..1162a95 100644 --- a/dzgui/const/enum.py +++ b/dzgui/const/enum.py @@ -212,9 +212,9 @@ class ContextMenu(EnumWithAttrs): ADD_NOTE = {"label": strings.add_note} ADD_SERVER = {"label": strings.add} CONNECT = {"label": strings.connect} - COPY_CLIPBOARD = {"label": strings.copy_ip} COPY_LOG_CLIPBOARD = {"label": strings.copy_log} - COPY_NAME = {"label": strings.copy_name} + COPY_SERVER_IP = {"label": strings.copy_ip} + COPY_SERVER_NAME = {"label": strings.copy_name} DELETE_MOD = {"label": strings.delete_mod} OPEN_WORKSHOP = {"label": strings.open_workshop} REFRESH_PLAYERS = {"label": strings.refresh_players} @@ -236,8 +236,8 @@ class ContextMenuGroup(Enum): ContextMenu.CONNECT, ContextMenu.ADD_SERVER, ContextMenu.SET_FAV, - ContextMenu.COPY_NAME, - ContextMenu.COPY_CLIPBOARD, + ContextMenu.COPY_SERVER_NAME, + ContextMenu.COPY_SERVER_IP, ContextMenu.ADD_NOTE, ContextMenu.SHOW_MODS, ContextMenu.SHOW_DETAILS, @@ -246,8 +246,8 @@ class ContextMenuGroup(Enum): SCAN_LAN = ( ContextMenu.CONNECT, ContextMenu.SET_FAV, - ContextMenu.COPY_NAME, - ContextMenu.COPY_CLIPBOARD, + ContextMenu.COPY_SERVER_NAME, + ContextMenu.COPY_SERVER_IP, ContextMenu.ADD_NOTE, ContextMenu.SHOW_MODS, ContextMenu.SHOW_DETAILS, @@ -257,8 +257,8 @@ class ContextMenuGroup(Enum): ContextMenu.CONNECT, ContextMenu.SET_FAV, ContextMenu.REMOVE_SERVER, - ContextMenu.COPY_NAME, - ContextMenu.COPY_CLIPBOARD, + ContextMenu.COPY_SERVER_NAME, + ContextMenu.COPY_SERVER_IP, ContextMenu.ADD_NOTE, ContextMenu.SHOW_MODS, ContextMenu.SHOW_DETAILS, @@ -269,8 +269,8 @@ class ContextMenuGroup(Enum): ContextMenu.ADD_SERVER, ContextMenu.SET_FAV, ContextMenu.REMOVE_HISTORY, - ContextMenu.COPY_NAME, - ContextMenu.COPY_CLIPBOARD, + ContextMenu.COPY_SERVER_NAME, + ContextMenu.COPY_SERVER_IP, ContextMenu.ADD_NOTE, ContextMenu.SHOW_MODS, ContextMenu.SHOW_DETAILS, diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 50df3e2..3b2326a 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -43,6 +43,7 @@ from dzgui.config import update from dzgui.config.query import lookup from dzgui.config.userprefs import UserPrefs from dzgui.controllers.emitter import Emitter +from dzgui.model.contextmenu import ContextMenuManager from dzgui.model.filtered_model import FilteredModelManager from dzgui.model.misc_model import ModelManager from dzgui.util import strings @@ -462,6 +463,7 @@ class Controller(GObject.GObject): def query_ip_id(self, addr: str) -> None: # NOTE: Battlemetrics + # TODO: stores full ip, gameport, qport in file as canonical record if addr.isdigit(): config = self.prefs.paths.config resolved = map_id_to_record(config, addr) @@ -478,24 +480,36 @@ class Controller(GObject.GObject): res = self.query_ip_id(addr) def cleanup_on_insert(self) -> None: - print(self.get_cleanup_func()) - treeview = self.get_active_treeview() - # TODO; update control model in filtered_model - model = treeview.get_model().append(self.insert_record[0]) + # FIXME: grab filterman outside of thread + treeview = self.mediator.servers.saved + filter_man = treeview.get_filter_man() + model = filter_man.get_control() + print(len(model)) + # FIXME: refiltration should occur in thread + model.append(self.insert_record[0]) + filtered = filter_man.filter(FilterMode.INITIAL) + treeview.set_model(filter_man.get_model()) + # TODO: update statusbar + context = self.get_active_context() + # TODO: adding a row may update available maps + # TODO: if all filters are already applied, strange behavior may occur + # need to insert and update all according logic per current filters + self.emitter.emit("servers_loaded", context) + # model = treeview.get_model().append(self.insert_record[0]) @call_on_thread("querying address") def add_by_id_or_ip(self, addr: str) -> None: res = self.query_ip_id(addr) self.insert_record = Servers.parse_json([res]) - #if res is None: + # if res is None: # # TODO: set cleanupfunc with dialog # return # TODO: add into saved servers file # TODO: update saved servers model # if current focus is not save servers, update label - # TODO: add ip here - self.set_cleanup_func(StoredFunc(lambda: self.cleanup_on_insert())) - + # TODO: saved servers might not be loaded yet, in which case + # should just add to server file + self.set_cleanup_func(StoredFunc(self.cleanup_on_insert)) def dump_api(self) -> None: key = self.query_config(Preferences.STEAM) @@ -540,53 +554,53 @@ class Controller(GObject.GObject): client = self.query_config(Preferences.CLIENT) open_user_workshop(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_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 get_col_value_by_path_index(self, path: Gtk.TreePath, index: int) -> Any: - treeview = self.get_active_treeview() - model = treeview.get_model() - if model is None: - return None - value = model[path][index] - return value + # def get_col_value_by_path_index(self, path: Gtk.TreePath, index: int) -> Any: + # treeview = self.get_active_treeview() + # model = treeview.get_model() + # if model is None: + # return None + # value = model[path][index] + # return value - def copy_name(self, path: Gtk.TreePath) -> None: - name = self.get_col_value_by_path_index(path, 0) - if name is None: - return - self.copy_clipboard(name) + # def copy_name(self, path: Gtk.TreePath) -> None: + # name = self.get_col_value_by_path_index(path, 0) + # if name is None: + # return + # self.copy_clipboard(name) - def copy_ip(self, path: Gtk.TreePath) -> None: - treeview = self.get_active_treeview() - record = treeview.get_record() - self.copy_clipboard(f"{record.ip}:{record.qport}") + # def copy_ip(self, path: Gtk.TreePath) -> None: + # treeview = self.get_active_treeview() + # record = treeview.get_record() + # self.copy_clipboard(f"{record.ip}:{record.qport}") - def copy_clipboard(self, text: str) -> None: - self.clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) - self.clipboard.set_text(text, -1) + # 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.model_man.get_mod_from_tree_path(path) - cmd = self.query_config(Preferences.CLIENT) - open_workshop_page(mod, cmd) + # 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) - def get_mod_from_tree_path( - self, tree_path: Gtk.TreePath - ) -> tuple[str, Gtk.TreeIter]: - model = self.get_mod_store() - tree_iter = model.get_iter(tree_path) - mod = model.get(tree_iter, 2)[0] - return mod, tree_iter + # def get_mod_from_tree_path( + # self, tree_path: Gtk.TreePath + # ) -> tuple[str, Gtk.TreeIter]: + # model = self.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_cleanup(self, _iter: Gtk.TreeIter) -> None: self.get_mod_store().remove(_iter) @@ -653,61 +667,65 @@ class Controller(GObject.GObject): simple_ip = treeview.get_simplified_ip() self.emitter.emit("fav_server_changed", name, simple_ip) - def menu_action(self, action: ContextMenu, path: Gtk.TreePath) -> None: - match action: - # NOTE: manipulates server stores and caches - # add to saved servers model verbatim and sort in place - # update tab with ! - # update config file with IP - # TODO: needs threading - case ContextMenu.ADD_SERVER: - pass + def menu_action(self, action: ContextMenu, tree: Gtk.TreeView) -> None: + # TODO: pass prefs into contextman + context_man = ContextMenuManager(tree, self) + context_man.process(action) + # context_man.process(action) + # match action: + # # NOTE: manipulates server stores and caches + # # add to saved servers model verbatim and sort in place + # # update tab with ! + # # update config file with IP + # # TODO: needs threading + # case ContextMenu.ADD_SERVER: + # pass - # spawn edit dialog and update cache, notes file - case ContextMenu.ADD_NOTE: - pass + # # spawn edit dialog and update cache, notes file + # case ContextMenu.ADD_NOTE: + # pass - case ContextMenu.COPY_CLIPBOARD: - self.copy_ip(path) + # case ContextMenu.COPY_CLIPBOARD: + # self.copy_ip(path) - case ContextMenu.COPY_NAME: - self.copy_name(path) + # case ContextMenu.COPY_NAME: + # self.copy_name(path) - case ContextMenu.DELETE_MOD: - self.delete_single_mod(path) - # TODO: connect to emitter automatically - # Gtk.TreeModel, row-inserted/row-deleted - # updates statusbar - # FIXME: signal should instead be emitted off of treeview when rows added/inserted - # self.update_mod_statusbar() + # case ContextMenu.DELETE_MOD: + # self.delete_single_mod(path) + # # TODO: connect to emitter automatically + # # Gtk.TreeModel, row-inserted/row-deleted + # # updates statusbar + # # FIXME: signal should instead be emitted off of treeview when rows added/inserted + # # self.update_mod_statusbar() - case ContextMenu.REFRESH_PLAYERS: - # get record - # call a2s on thread - pass + # case ContextMenu.REFRESH_PLAYERS: + # # get record + # # call a2s on thread + # pass - # update history model, update tab label, pop off of queue, write new list into file - # see dq.py - case ContextMenu.REMOVE_HISTORY: - pass + # # update history model, update tab label, pop off of queue, write new list into file + # # see dq.py + # case ContextMenu.REMOVE_HISTORY: + # pass - # reverse of ADD_SERVER - case ContextMenu.REMOVE_SERVER: - pass + # # reverse of ADD_SERVER + # case ContextMenu.REMOVE_SERVER: + # pass - case ContextMenu.SET_FAV: - self.set_fav() + # case ContextMenu.SET_FAV: + # self.set_fav() - # NOTE: spawns dedicated dialogs - # TODO: needs threading - case ContextMenu.SHOW_DETAILS: - pass - # TODO: needs threading - case ContextMenu.SHOW_MODS: - pass + # # NOTE: spawns dedicated dialogs + # # TODO: needs threading + # case ContextMenu.SHOW_DETAILS: + # pass + # # TODO: needs threading + # case ContextMenu.SHOW_MODS: + # pass - case ContextMenu.OPEN_WORKSHOP: - self.open_mod_page(path) + # case ContextMenu.OPEN_WORKSHOP: + # self.open_mod_page(path) def toggle_mod_selection(self, state: bool) -> None: sel = self.mediator.modtreeview.get_selection() @@ -715,6 +733,7 @@ class Controller(GObject.GObject): sel.select_all() else: sel.unselect_all() + # TODO: signal self.mediator.modtreeview.grab_focus() def populate_log(self) -> None: @@ -786,7 +805,8 @@ class Controller(GObject.GObject): # CHORE: test if treeview's sort method inserts row at the correct index # inserting a row serializes file on disk, updates control model for that tab, and # reapplies filters to ephemeral model; since filters are applied, in-situ insertion might not be necessary - self.to_insert.connect("row-inserted", lambda *args: print("row inserted into model")) + # TODO: will be inserted out of order + # self.to_insert.connect("row-inserted", lambda *args: print("row inserted into model")) # TODO: signals or other approach to deferring map # model insertion after thread closes @@ -1088,3 +1108,19 @@ class Controller(GObject.GObject): def has_server_model(self) -> bool: treeview = self.get_active_treeview() return treeview.get_model() is not None + + def hide_widgets_on_init(self) -> None: + self.mediator.grid.conpan.set_lan_visible(False) + self.mediator.grid.right_panel.sel_panel.hide() + + def get_notebook(self) -> "Notebook": + return self.mediator.notebook + + def get_servers(self) -> "Notebook": + return self.mediator.servers.notebook + + def get_window(self) -> "OuterWindow": + return self.mediator.window + + def get_menu(self) -> "MenuTreeView": + return self.mediator.menu diff --git a/dzgui/views/base.py b/dzgui/views/base.py index 7700394..2f0d81b 100644 --- a/dzgui/views/base.py +++ b/dzgui/views/base.py @@ -157,12 +157,12 @@ class OuterWindow(Gtk.Window): # TODO: first run MainController.open_page(NotebookPage.SERVERS) - MainController.mediator.grid.conpan.lan.set_visible(False) - self.grid.right_panel.sel_panel.hide() + MainController.hide_widgets_on_init() # TODO: POC, trigger page change here MainController.loaded = True + # TODO: debug flag if MainController.get_prefs().is_debug: return MainController.populate_model() @@ -171,7 +171,7 @@ class OuterWindow(Gtk.Window): self.halt_proc_and_quit() def halt_proc_and_quit(self) -> None: - #MainController.terminate_process() + # MainController.terminate_process() MainController.save_res_and_quit() diff --git a/dzgui/views/components/connect_panel.py b/dzgui/views/components/connect_panel.py index 0cc17f1..93d3b59 100644 --- a/dzgui/views/components/connect_panel.py +++ b/dzgui/views/components/connect_panel.py @@ -187,7 +187,6 @@ class AddPanel(Gtk.Frame): def _on_add_clicked(self, button: Gtk.Button) -> None: self._submit_query() - def _on_ip_validated(self, entry: Gtk.Entry, state: bool) -> None: self.conn_server.set_sensitive(state) self.add_server.set_sensitive(state) @@ -208,5 +207,8 @@ class ConnectPanel(Gtk.Box): for el in self.lan, self.fav, self.add_panel: self.add(el) - def _on_lan_tab_toggled(self, emitter: "Emitter", state: bool) -> None: + def set_lan_visible(self, state: bool) -> None: self.lan.set_visible(state) + + def _on_lan_tab_toggled(self, emitter: "Emitter", state: bool) -> None: + self.set_lan_visible(state) diff --git a/dzgui/views/components/crumbs.py b/dzgui/views/components/crumbs.py index 63c16f2..4405b28 100644 --- a/dzgui/views/components/crumbs.py +++ b/dzgui/views/components/crumbs.py @@ -21,8 +21,8 @@ class Breadcrumbs(Gtk.Label): crumbs = embolden(label_main_menu) self.set_markup(crumbs) - notebook = controller.mediator.notebook - servers = controller.mediator.servers.notebook + notebook = controller.get_notebook() + servers = controller.get_servers() notebook.connect_after("switch-page", self._on_notebook_page_changed) servers.connect_after("switch-page", self._on_server_tab_changed) diff --git a/dzgui/views/components/filter_panel.py b/dzgui/views/components/filter_panel.py index 8681331..e3c9198 100644 --- a/dzgui/views/components/filter_panel.py +++ b/dzgui/views/components/filter_panel.py @@ -109,7 +109,7 @@ class KeywordEntry(Gtk.Entry): def _on_activated(self, entry: Gtk.Entry) -> None: # TODO: investigate this method - self.controller.mediator.window.set_keep_below(False) + # self.controller.mediator.window.set_keep_below(False) keyword = entry.get_text().lower() if keyword == self.controller.get_keyword(): diff --git a/dzgui/views/components/right_panel.py b/dzgui/views/components/right_panel.py index fc26b61..3a1cdda 100644 --- a/dzgui/views/components/right_panel.py +++ b/dzgui/views/components/right_panel.py @@ -71,7 +71,5 @@ class RightPanel(Gtk.Box): # unblock_signals() # temp_model = self.AppNav.treeview.get_model() # treeview.set_model(None) - # treeview.wait_dialog = GenericDialog("Pinging servers", Popup.WAIT) - # treeview.wait_dialog.show_all() # thread = threading.Thread(target=_update_pings, args=()) # thread.start() diff --git a/dzgui/views/components/statusbar.py b/dzgui/views/components/statusbar.py index 8b1bd6c..18716c1 100644 --- a/dzgui/views/components/statusbar.py +++ b/dzgui/views/components/statusbar.py @@ -42,10 +42,8 @@ class Statusbar(Gtk.Grid): self.players = "" - controller.mediator.menu.connect( - "generic_treesel_changed", self._help_row_changed - ) - controller.mediator.notebook.connect_after( + controller.get_menu().connect("generic_treesel_changed", self._help_row_changed) + controller.get_notebook().connect_after( "switch-page", self._on_notebook_page_changed ) @@ -83,9 +81,9 @@ class Statusbar(Gtk.Grid): case NotebookPage.HELP: bar = self.controller.get_help_row() # TODO: drop - #case NotebookPage.MODS: + # case NotebookPage.MODS: # return - #case NotebookPage.SERVERS: + # case NotebookPage.SERVERS: # context = self.controller.get_active_context() # # self.emitter.emit("servers_loaded", context) # return diff --git a/dzgui/views/dialogs/generic.py b/dzgui/views/dialogs/generic.py index 82c3a48..708b124 100644 --- a/dzgui/views/dialogs/generic.py +++ b/dzgui/views/dialogs/generic.py @@ -39,7 +39,7 @@ class GenericDialog(Gtk.MessageDialog): secondary: str, ) -> None: super().__init__( - transient_for=controller.mediator.window, + transient_for=controller.get_window(), message_type=mtype, buttons=buttons, text=text, @@ -141,6 +141,7 @@ class WaitDialog(GenericDialog): class QuitDialog(GenericDialog): def __init__(self, controller: "Controller", secondary: str): + # FIXME: do not pass controller to every dialog super().__init__( controller=controller, text=strings.wait, @@ -156,6 +157,7 @@ class QuitDialog(GenericDialog): self.connect("delete-event", self._on_response) def _on_response(self, dialog: Self, response: Gtk.ResponseType) -> None: + # TODO: use a signal? self.controller.save_res_and_quit() diff --git a/dzgui/views/mixins/context_mixin.py b/dzgui/views/mixins/context_mixin.py index 753e2b3..01b4e61 100644 --- a/dzgui/views/mixins/context_mixin.py +++ b/dzgui/views/mixins/context_mixin.py @@ -86,8 +86,9 @@ class ContextMixin(TreeView): clipboard = self.controller.copy_log(records) self.controller.copy_clipboard(clipboard) else: - path = self.get_focused_row_path() - self.controller.menu_action(enum, path) + #path = self.get_focused_row_path() + #self.controller.menu_action(enum, path) + self.controller.menu_action(enum, self) def _on_key(self, menu: Gtk.Menu, event: Gdk.EventKey) -> bool | None: if not is_navkey(event.keyval): diff --git a/dzgui/views/trees/tree_base.py b/dzgui/views/trees/tree_base.py index b2423b5..9fc7187 100644 --- a/dzgui/views/trees/tree_base.py +++ b/dzgui/views/trees/tree_base.py @@ -1,6 +1,6 @@ import logging -from typing import Optional, TYPE_CHECKING +from typing import Any, Optional, TYPE_CHECKING from warnings import deprecated from dzgui.const.constants import SEPARATOR @@ -188,3 +188,10 @@ class TreeView(CursorMixin, Gtk.TreeView): # type: ignore return None row = model[ind] return row + + def get_col_value_by_path_index(self, path: Gtk.TreePath, index: int) -> Any: + model = self.get_model() + if model is None: + return None + value = model[path][index] + return value diff --git a/dzgui/views/trees/tree_mods.py b/dzgui/views/trees/tree_mods.py index bb09334..f86a8ee 100644 --- a/dzgui/views/trees/tree_mods.py +++ b/dzgui/views/trees/tree_mods.py @@ -63,6 +63,14 @@ class ModTreeView(ModsMixin, ContextMixin, TreeView): def _on_mod_page_loaded(self, emitter: "Emitter") -> None: self.set_cursor(0) + def get_selected_mod(self) -> str: + path = self.get_focused_row_path() + model = self.get_model() + tree_iter = model.get_iter(path) + mod = model.get(tree_iter, 2)[0] + return mod + # return mod, tree_iter + # def _on_mods_keypress(self, widget: Gtk.Widget, event: Gdk.EventKey) -> None: # # TODO: multiselect # # if event.keyval is Gdk.KEY_space: @@ -88,7 +96,6 @@ class ModTreeView(ModsMixin, ContextMixin, TreeView): 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) diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index ffdb383..0522e67 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -1,5 +1,4 @@ import logging -import multiprocessing import threading from typing import Any, Optional, Self from warnings import deprecated From dec2bf7e6ffd9a82774b197f64108d99cfd28d61 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 21 Feb 2026 18:35:06 +0900 Subject: [PATCH 093/309] chore: clean up context menu mixin --- dzgui/controllers/mc.py | 152 +++++----------------------- dzgui/views/mixins/context_mixin.py | 41 ++++---- dzgui/views/trees/tree_log.py | 32 +----- dzgui/views/trees/tree_servers.py | 149 +++++++++++---------------- 4 files changed, 108 insertions(+), 266 deletions(-) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 3b2326a..710effa 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -172,30 +172,30 @@ class Controller(GObject.GObject): def unblock_signals(self) -> None: self.block_signals(False) - @deprecated("Currently unused") - def block_signals(self, state: bool = True) -> None: - self.suppress_signal( - self.mediator.filters, - self.mediator.filters.maps_combo, - "_on_map_changed", - state, - ) - self.suppress_signal( - self.mediator.menu, - self.mediator.menu.selected_row, - "_on_tree_selection_changed", - state, - ) - self.suppress_signal( - self.mediator.menu, self.mediator.menu, "_on_keypress", state - ) - for check in self.mediator.filters.checks: - self.suppress_signal( - self.mediator.filters, - check, - "_on_check_toggled", - state, - ) + # @deprecated("Currently unused") + # def block_signals(self, state: bool = True) -> None: + # self.suppress_signal( + # self.mediator.filters, + # self.mediator.filters.maps_combo, + # "_on_map_changed", + # state, + # ) + # self.suppress_signal( + # self.mediator.menu, + # self.mediator.menu.selected_row, + # "_on_tree_selection_changed", + # state, + # ) + # self.suppress_signal( + # self.mediator.menu, self.mediator.menu, "_on_keypress", state + # ) + # for check in self.mediator.filters.checks: + # self.suppress_signal( + # self.mediator.filters, + # check, + # "_on_check_toggled", + # state, + # ) def suppress_signal( self, owner: Gtk.Widget, widget: Gtk.Widget, func_name: str, state: bool @@ -554,54 +554,6 @@ class Controller(GObject.GObject): client = self.query_config(Preferences.CLIENT) open_user_workshop(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 get_col_value_by_path_index(self, path: Gtk.TreePath, index: int) -> Any: - # treeview = self.get_active_treeview() - # model = treeview.get_model() - # if model is None: - # return None - # value = model[path][index] - # return value - - # def copy_name(self, path: Gtk.TreePath) -> None: - # name = self.get_col_value_by_path_index(path, 0) - # if name is None: - # return - # self.copy_clipboard(name) - - # def copy_ip(self, path: Gtk.TreePath) -> None: - # treeview = self.get_active_treeview() - # record = treeview.get_record() - # self.copy_clipboard(f"{record.ip}:{record.qport}") - - # 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) - - # def get_mod_from_tree_path( - # self, tree_path: Gtk.TreePath - # ) -> tuple[str, Gtk.TreeIter]: - # model = self.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_cleanup(self, _iter: Gtk.TreeIter) -> None: self.get_mod_store().remove(_iter) remove_stale_signatures(self.prefs.paths.config, self.prefs.paths.version) @@ -668,64 +620,8 @@ class Controller(GObject.GObject): self.emitter.emit("fav_server_changed", name, simple_ip) def menu_action(self, action: ContextMenu, tree: Gtk.TreeView) -> None: - # TODO: pass prefs into contextman context_man = ContextMenuManager(tree, self) context_man.process(action) - # context_man.process(action) - # match action: - # # NOTE: manipulates server stores and caches - # # add to saved servers model verbatim and sort in place - # # update tab with ! - # # update config file with IP - # # TODO: needs threading - # case ContextMenu.ADD_SERVER: - # pass - - # # spawn edit dialog and update cache, notes file - # case ContextMenu.ADD_NOTE: - # pass - - # case ContextMenu.COPY_CLIPBOARD: - # self.copy_ip(path) - - # case ContextMenu.COPY_NAME: - # self.copy_name(path) - - # case ContextMenu.DELETE_MOD: - # self.delete_single_mod(path) - # # TODO: connect to emitter automatically - # # Gtk.TreeModel, row-inserted/row-deleted - # # updates statusbar - # # FIXME: signal should instead be emitted off of treeview when rows added/inserted - # # self.update_mod_statusbar() - - # case ContextMenu.REFRESH_PLAYERS: - # # get record - # # call a2s on thread - # pass - - # # update history model, update tab label, pop off of queue, write new list into file - # # see dq.py - # case ContextMenu.REMOVE_HISTORY: - # pass - - # # reverse of ADD_SERVER - # case ContextMenu.REMOVE_SERVER: - # pass - - # case ContextMenu.SET_FAV: - # self.set_fav() - - # # NOTE: spawns dedicated dialogs - # # TODO: needs threading - # case ContextMenu.SHOW_DETAILS: - # pass - # # TODO: needs threading - # case ContextMenu.SHOW_MODS: - # pass - - # case ContextMenu.OPEN_WORKSHOP: - # self.open_mod_page(path) def toggle_mod_selection(self, state: bool) -> None: sel = self.mediator.modtreeview.get_selection() diff --git a/dzgui/views/mixins/context_mixin.py b/dzgui/views/mixins/context_mixin.py index 01b4e61..85fae0a 100644 --- a/dzgui/views/mixins/context_mixin.py +++ b/dzgui/views/mixins/context_mixin.py @@ -18,9 +18,8 @@ class ContextMixin(TreeView): widget: Gtk.Widget, event: Gdk.EventButton | Gdk.EventKey, ) -> None: - # FIXME: double click causes issue - # split into present by click and present by key + # FIXME: start debug log focused if self.is_selection_empty(): return False @@ -42,8 +41,7 @@ class ContextMixin(TreeView): self.context_menu.connect("key-press-event", self._on_key) for row in group.value: - item = Gtk.MenuItem(label=row.dict["label"]) - item.connect("activate", self._on_menu_click, row) + item = self._process_dynamic_row(row) self.context_menu.append(item) self.context_menu.show_all() @@ -58,6 +56,22 @@ class ContextMixin(TreeView): self.context_menu.select_first(False) + def _process_dynamic_row(self, row: ContextMenu) -> None: + if row == ContextMenu.ADD_SERVER and self.is_in_favs(): + row = ContextMenu.REMOVE_SERVER + + item = Gtk.MenuItem(label=row.dict["label"]) + item.connect("activate", self._on_menu_click, row) + + if row == ContextMenu.SHOW_MODS: + item.set_sensitive(self.is_modded()) + + return item + # TODO: notes cache + # if row == ContextMenu.ADD_NOTE: + # if self.get_record_string() in notes_cache: + # item.set_label(strings.edit_note) + def _process_button_event(self, event: Gdk.EventButton) -> None: try: pathinfo = self.get_path_at_pos(int(event.x), int(event.y)) @@ -71,24 +85,7 @@ class ContextMixin(TreeView): pass 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. - - ModTreeView 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 per below. - """ - 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) - self.controller.menu_action(enum, self) + self.controller.menu_action(enum, self) def _on_key(self, menu: Gtk.Menu, event: Gdk.EventKey) -> bool | None: if not is_navkey(event.keyval): diff --git a/dzgui/views/trees/tree_log.py b/dzgui/views/trees/tree_log.py index 535ae97..b5ec90a 100644 --- a/dzgui/views/trees/tree_log.py +++ b/dzgui/views/trees/tree_log.py @@ -8,6 +8,7 @@ from dzgui.views.trees.tree_base import TreeView from dzgui.views.mixins.context_mixin import ContextMixin import gi + gi.require_version("Gtk", "3.0") from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa E402 @@ -16,6 +17,7 @@ if TYPE_CHECKING: logger = logging.getLogger(__name__) + class LogTreeView(ContextMixin, TreeView): def __init__(self, controller: "Controller") -> None: super().__init__(controller, menu=ContextMenuGroup.LOG) @@ -33,42 +35,16 @@ class LogTreeView(ContextMixin, TreeView): for i, column_title in enumerate(strings.log_cols): renderer = Gtk.CellRendererText() - column = Gtk.TreeViewColumn( - column_title, renderer, text=i - ) + 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.connect("button-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) diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index 0522e67..972a94f 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -1,11 +1,12 @@ import logging import threading + +from queue import Queue from typing import Any, Optional, Self from warnings import deprecated - from dzgui.views.mixins.context_mixin import ContextMixin -from dzgui.const.enum import ContextMenuGroup, ServerTab +from dzgui.const.enum import ContextMenu, ContextMenuGroup, ServerTab from dzgui.api.servers import Record from dzgui.api.servers import ping as Ping from dzgui.model.map_model import MapManager @@ -60,12 +61,10 @@ class ServerTreeView(ContextMixin, TreeView): self.handler_id: int self.query_func_jobs = 1 - self.seen_cache = [] - self.current_proc = None - from queue import Queue + self.queue = Queue() - #self.queue = multiprocessing.Queue() + # self.queue = multiprocessing.Queue() prefs = self.controller.get_prefs() columns = prefs.paths.columns @@ -132,42 +131,6 @@ class ServerTreeView(ContextMixin, TreeView): self.thread = None - def _get_ping( - self, - column: Gtk.TreeViewColumn, - cell: Gtk.CellRendererText, - model: Gtk.TreeModel, - it: Gtk.TreeIter, - data: Any, - ): - def ping_server(model, _iter, ip: str, qport: int, ping: int): - # res = Ping(0, model[_iter]) - res = Ping(0, ip, qport, ping) - ping = res.ping - print(res) - GLib.idle_add(lambda: model.set(_iter, ping_column, ping)) - - addr_column = 7 - qport_column = 8 - ping_column = 9 - # TODO: unpack model before entering thread - # test 40k servers with unpacked model - addr = model.get_value(it, addr_column).split(":")[0] - qport = model.get_value(it, qport_column) - ping = model.get_value(it, ping_column) - ip = f"{addr}:{qport}" - - if ip in self.seen_cache: - return - self.seen_cache.append(ip) - - thread = threading.Thread( - daemon=True, - target=ping_server, - args=(model, it, ip, qport, ping), - ) - thread.start() - def start_timeout(self) -> None: self.queue_id = GLib.timeout_add(QUEUE_CHECK_DELAY, self._check_result_queue) @@ -238,15 +201,7 @@ class ServerTreeView(ContextMixin, TreeView): # NOTE: get final width after drag action completes GLib.idle_add(self.controller.propagate_column_width, col) - #def terminate_process(self) -> None: - # if self.thread and self.thread.is_alive(): - # print("thread still exists") - # if self.current_proc and self.current_proc.is_alive(): - # self.current_proc.terminate() - def start_distcalc(self, emitter: Optional["Emitter"] = None): - #self.terminate_process() - #self.queue_id = GLib.timeout_add(QUEUE_CHECK_DELAY, self._check_result_queue) self.emitter.emit("distcalc_started") record = self.get_record() if record is None: @@ -261,11 +216,6 @@ class ServerTreeView(ContextMixin, TreeView): self.controller.set_statusbar_dist(haversine, self.get_enum()) return - #def t(ip, enum, queue) -> None: - # if queue.empty(): - # queue.put(["LONG DISTANCE", 0]) - - enum = self.get_enum() self.thread = threading.Thread( daemon=True, @@ -273,16 +223,13 @@ class ServerTreeView(ContextMixin, TreeView): args=(record.ip, enum, self.queue, self.controller), ) self.thread.start() - #self.current_proc = CalcDist( - # record.ip, self.get_enum(), self.queue, self.controller - #) - #self.current_proc.start() def _check_result_queue(self) -> Literal[True]: latest_result = None while not self.queue.empty(): latest_result = self.queue.get() + # FIXME: cache is being checked at two intervals cache = self.controller.get_dist_cache() if latest_result: @@ -299,9 +246,6 @@ class ServerTreeView(ContextMixin, TreeView): ) -> bool | None: if event.state is Gdk.ModifierType.CONTROL_MASK: match event.keyval: - case Gdk.KEY_r: - # TODO: unimplemented, threading - self.refresh_player_count() case Gdk.KEY_f: self.emitter.emit("request_keyword_focus") case Gdk.KEY_m: @@ -312,8 +256,10 @@ class ServerTreeView(ContextMixin, TreeView): if self.enum is ServerTab.LAN: self.emitter.emit("request_lan_entry_focus") case Gdk.KEY_c: - record = self.get_record() - self.controller.copy_ip(record) + self.controller.menu_action(ContextMenu.COPY_SERVER_IP, self) + case Gdk.KEY_r: + # TODO: unimplemented, needs threading + self.controller.menu_action(ContextMenu.REFRESH_PLAYERS, self) else: match event.keyval: case Gdk.KEY_l | Gdk.KEY_Right: @@ -321,30 +267,23 @@ class ServerTreeView(ContextMixin, TreeView): case _: self.emitter.emit("check_button_pressed", event.keyval) - # mod_context_items = [ContextMenu.OPEN_WORKSHOP, ContextMenu.DELETE_MOD] - # TODO: dynamic menu entries - # for row in items: - # if row == ContextMenu.ADD_SERVER: - # if self.is_in_favs(): - # row = ContextMenu.REMOVE_SERVER - # item = Gtk.MenuItem(label=row.dict["label"]) - # item.type = row - # item.action = row.dict["action"] - # self.menu.append(item) - # if row == ContextMenu.SHOW_MODS: - # if not self.has_mods(): - # item.set_sensitive(False) - # if row == ContextMenu.ADD_NOTE: - # if self.get_record_string() in notes_cache: - # item.set_label(strings.edit_note) + # TODO: unimplemented + # def is_modded(self) -> bool: + # select = self.get_selection() + # sels = select.get_selected_rows() + # (model, pathlist) = sels + # path = pathlist[0] + # tree_iter = model.get_iter(path) + # mods = model.get_value(tree_iter, 11) + # return mods - # if event.type is Gdk.EventType.KEY_PRESS and event.keyval is Gdk.KEY_l: - # if self.is_selection_empty(): - # return - # self.menu.popup_at_widget(widget, Gdk.Gravity.CENTER, Gdk.Gravity.WEST) - # else: - # self.menu.popup_at_pointer(event) - # self.menu.select_first(False) + # TODO: unimplemented + # def is_in_favs(self) -> bool: + # record = self.get_record_string() + # proc = call_out("is_in_favs", record) + # if proc.returncode == 0: + # return True + # return False def _parent_row_activated( self, tree: TreeView, path: Gtk.TreePath, column: Gtk.TreeViewColumn @@ -368,7 +307,7 @@ class ServerTreeView(ContextMixin, TreeView): return f"{addr}:{qport}" def get_record(self) -> Record | None: - # TODO: use tree_base.get_selected_row() + # TODO: clean up, use tree_base.get_selected_row() select = self.get_selection() sels = select.get_selected_rows() (model, pathlist) = sels @@ -392,6 +331,40 @@ class ServerTreeView(ContextMixin, TreeView): def set_loaded(self, status: bool) -> None: self.loaded = status + # @deprecated("currently unused") + # def _get_ping( + # self, + # column: Gtk.TreeViewColumn, + # cell: Gtk.CellRendererText, + # model: Gtk.TreeModel, + # it: Gtk.TreeIter, + # data: Any, + # ): + # def ping_server(model, _iter, ip: str, qport: int, ping: int): + # res = Ping(0, ip, qport, ping) + # ping = res.ping + # GLib.idle_add(lambda: model.set(_iter, ping_column, ping)) + + # addr_column = 7 + # qport_column = 8 + # ping_column = 9 + + # addr = model.get_value(it, addr_column).split(":")[0] + # qport = model.get_value(it, qport_column) + # ping = model.get_value(it, ping_column) + # ip = f"{addr}:{qport}" + + # if ip in self.seen_cache: + # return + # self.seen_cache.append(ip) + + # thread = threading.Thread( + # daemon=True, + # target=ping_server, + # args=(model, it, ip, qport, ping), + # ) + # thread.start() + @deprecated("Currently unused") def _lazy_load( self, From 85c1d1862235bf6ebd59e7b0a85d9f19b818b67b Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 21 Feb 2026 18:35:37 +0900 Subject: [PATCH 094/309] feat: add standalone managers --- dzgui/model/contextmenu.py | 104 +++++++++++++++++++++++++++++++++++++ dzgui/model/thread_man.py | 77 +++++++++++++++++++++++++++ 2 files changed, 181 insertions(+) create mode 100644 dzgui/model/contextmenu.py create mode 100644 dzgui/model/thread_man.py diff --git a/dzgui/model/contextmenu.py b/dzgui/model/contextmenu.py new file mode 100644 index 0000000..728b7b6 --- /dev/null +++ b/dzgui/model/contextmenu.py @@ -0,0 +1,104 @@ +import gi +import logging +from typing import TYPE_CHECKING + +from dzgui.const.enum import ContextMenu, Preferences +from dzgui.model.thread_man import ThreadingManager +from dzgui.util import strings +from dzgui.util.open_links import open_workshop_page + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk, Gdk, GLib, GObject # noqa E402 + + +logger = logging.getLogger(__name__) + +if TYPE_CHECKING: + from dzgui.controllers.mc import Controller + + +class ContextMenuManager: + def __init__(self, treeview: Gtk.TreeView, controller: "Controller") -> None: + self.controller = controller + self.treeview = treeview + self.thread_man = ThreadingManager(parent=controller) + + def process(self, action: ContextMenu) -> None: + match action: + # NON THREADED + case ContextMenu.ADD_NOTE: + # spawn edit dialog and update cache, notes file + pass + case ContextMenu.COPY_LOG_CLIPBOARD: + self.copy_log() + case ContextMenu.COPY_SERVER_IP: + self.copy_server_ip() + case ContextMenu.COPY_SERVER_NAME: + self.copy_server_name() + case ContextMenu.DELETE_MOD: + # self.delete_single_mod() + # TODO: connect to emitter automatically + # Gtk.TreeModel, row-inserted/row-deleted + # updates statusbar + # FIXME: signal should instead be emitted off of treeview when rows added/inserted + # self.update_mod_statusbar() + pass + case ContextMenu.OPEN_WORKSHOP: + self.open_mod_page() + case ContextMenu.SET_FAV: + self.controller.set_fav() + + # THREADED + case ContextMenu.ADD_SERVER: + # add to saved servers model verbatim and sort in place + # update tab with ! + # update config file with IP + pass + case ContextMenu.REFRESH_PLAYERS: + # get record + # call a2s on thread + pass + case ContextMenu.REMOVE_HISTORY: + # update history model, update tab label, pop off of queue, write new list into file + # see dq.py + pass + case ContextMenu.REMOVE_SERVER: + # reverse of ADD_SERVER + pass + case ContextMenu.SHOW_DETAILS: + pass + case ContextMenu.SHOW_MODS: + pass + + def copy_server_ip(self) -> None: + record = self.treeview.get_simplified_ip() + self.copy_clipboard(record) + + def copy_clipboard(self, text: str) -> None: + self.clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) + self.clipboard.set_text(text, -1) + + def copy_server_name(self) -> None: + name = self.treeview.get_value_at_index(0) + if name is None: + return + self.copy_clipboard(name) + + def open_mod_page(self) -> None: + mod = self.treeview.get_selected_mod() + cmd = self.controller.query_config(Preferences.CLIENT) + open_workshop_page(mod, cmd) + + def copy_log(self) -> str: + # NOTE: ModTreeView uses Gtk.SelectionMode.MULTIPLE + model, records = self.treeview.get_selection().get_selected_rows() + if len(records) < 1: + return + final = [] + for record in records: + record = self.treeview.get_model()[record] + r = [el for el in record] + concat = strings.delimiter.join(r) + final.append(concat) + text = "\n".join(final) + self.copy_clipboard(text) diff --git a/dzgui/model/thread_man.py b/dzgui/model/thread_man.py new file mode 100644 index 0000000..dc10d28 --- /dev/null +++ b/dzgui/model/thread_man.py @@ -0,0 +1,77 @@ +import inspect +import logging +import threading + +from functools import wraps +from typing import Callable +from dzgui.views.dialogs.generic import WaitDialog + + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk, Gdk, GLib, GObject # noqa E402 + +logger = logging.getLogger(__name__) + + +def call_on_thread(dialog_str: str) -> Callable: + def decorator(func: Callable) -> Callable: + @wraps(func) + def wrapper(*args, **kwargs): + self = args[0] + stored = StoredFunc(func, *args, **kwargs) + if not hasattr(self, "thread_man"): + raise AttributeError + if type(self.thread_man) is not ThreadingManager: + raise TypeError( + "Attribute 'thread_man' must be of type 'ThreadingManager'" + ) + self.thread_man.call_on_thread(dialog_str, stored) + + return wrapper + + return decorator + + +class StoredFunc: + def __init__(self, func: Callable, *args, **kwargs) -> None: + sig = inspect.signature(func) + self.func = func + self.bindings = sig.bind(*args, **kwargs) + + def call(self) -> None: + self.func(*self.bindings.args, *self.bindings.kwargs) + + +class ThreadingManager: + def __init__(self, parent: Gtk.Window) -> None: + self.parent = parent + self.cleanup_func = None + + def call_on_thread(self, dialog_str: str, func: StoredFunc) -> None: + def callback() -> None: + func.call() + GLib.idle_add(self._destroy_on_idle) + + self.wait_dialog = WaitDialog(self.parent, dialog_str) + self.wait_dialog.show_all() + thread = threading.Thread(target=callback) + thread.start() + + def set_cleanup_func(self, func: StoredFunc) -> None: + if type(func) not in (StoredFunc, type(None)): + msg = f"Callback function '{func}' is not of type StoredFunc or None" + logger.critical(msg) + raise TypeError(msg) + self.cleanup_func = func + + def get_cleanup_func(self) -> StoredFunc: + return self.cleanup_func + + def _destroy_on_idle(self) -> None: + self.wait_dialog.destroy() + func = self.get_cleanup_func() + if func is not None: + func.call() + self.set_cleanup_func(None) From bdfebf9a694e4affa6f337abb632a4593e0c533b Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 21 Feb 2026 22:03:46 +0900 Subject: [PATCH 095/309] chore: change modelman to model factory --- dzgui/const/enum.py | 27 +++-------- dzgui/controllers/mc.py | 42 +++-------------- dzgui/model/misc_model.py | 82 ---------------------------------- dzgui/model/model_factory.py | 82 ++++++++++++++++++++++++++++++++++ dzgui/views/trees/tree_log.py | 10 +++-- dzgui/views/trees/tree_menu.py | 14 ++++-- 6 files changed, 110 insertions(+), 147 deletions(-) delete mode 100644 dzgui/model/misc_model.py create mode 100644 dzgui/model/model_factory.py diff --git a/dzgui/const/enum.py b/dzgui/const/enum.py index 1162a95..656782e 100644 --- a/dzgui/const/enum.py +++ b/dzgui/const/enum.py @@ -26,14 +26,6 @@ class Popup(Enum): QUIT = 8 -class Command(Enum): - INTERACTIVE = 1 - ONESHOT = 2 - HELP = 3 - TOGGLE = 4 - THANKS = 5 - - class VAdjustment(Enum): UP = 1 DOWN = 2 @@ -111,14 +103,14 @@ class Preferences(EnumWithAttrs): class NotebookPage(EnumWithAttrs): CHANGELOG = {"crumbs": strings.crumbs.changelog, "statusbar": True} + DEVELOPERS = {"crumbs": strings.crumbs.developers, "statusbar": False} + HELP = {"crumbs": strings.crumbs._help, "statusbar": True} KEYS = {"crumbs": strings.crumbs.keys, "statusbar": True} LOG = {"crumbs": strings.crumbs.log, "statusbar": True} - HELP = {"crumbs": strings.crumbs._help, "statusbar": True} MODS = {"crumbs": strings.crumbs.mods, "statusbar": True} OPTIONS = {"crumbs": strings.crumbs.options, "statusbar": False} SERVERS = {"crumbs": strings.crumbs.servers, "statusbar": True} THANKS = {"crumbs": strings.crumbs.thanks, "statusbar": True} - DEVELOPERS = {"crumbs": strings.crumbs.developers, "statusbar": False} class RowType(EnumWithAttrs): @@ -128,7 +120,7 @@ class RowType(EnumWithAttrs): if string == member.dict["label"]: return member - # TODO: add to strings + # TODO: add to notebook tab tooltips # SERVER_BROWSER = { # "label": "Server browser", # "tooltip": "Used to browse the global server list", @@ -155,6 +147,7 @@ class RowType(EnumWithAttrs): # "quad_label": "Mods", # "type": "mods", # } + # TODO: strings CHANGELOG = { "label": "View changelog", "tooltip": "Opens the DZGUI changelog", @@ -162,7 +155,6 @@ class RowType(EnumWithAttrs): SHOW_LOG = { "label": "Show debug log", "tooltip": "Read the DZGUI log generated since startup", - "quad_label": "Debug log", } SEPARATOR = { "label": "SEPARATOR", @@ -171,43 +163,34 @@ class RowType(EnumWithAttrs): DUMP_LOG = { "label": "Output system info to log file", "tooltip": "Dump diagnostic data for troubleshooting", - "wait_msg": "Generating log", - "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 +# TODO: rename to ContextMenuItem class ContextMenu(EnumWithAttrs): ADD_NOTE = {"label": strings.add_note} ADD_SERVER = {"label": strings.add} diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 710effa..172acce 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -45,14 +45,13 @@ from dzgui.config.userprefs import UserPrefs from dzgui.controllers.emitter import Emitter from dzgui.model.contextmenu import ContextMenuManager from dzgui.model.filtered_model import FilteredModelManager -from dzgui.model.misc_model import ModelManager +from dzgui.model.model_factory import ModelFactory from dzgui.util import strings from dzgui.util._json import read_json, write_json from dzgui.util.diag import write_diagnostic from dzgui.util.format import format_mods, format_player_count from dzgui.util.localize import number from dzgui.util.open_links import open_workshop_page, open_user_workshop -from dzgui.util.redact import redact_log from dzgui.views.dialogs.filepicker import FilePicker from dzgui.views.dialogs.generic import ExceptionDialog, WaitDialog @@ -112,7 +111,7 @@ class Controller(GObject.GObject): self.prefs: UserPrefs self.cleanup_func: StoredFunc = None - self.model_man = ModelManager() + #self.model_man = ModelManager() self.emitter = Emitter() self.emitter.connect("map_selection_changed", self._on_map_selection_changed) self.emitter.connect("check_toggled", self._on_check_toggled) @@ -163,12 +162,6 @@ class Controller(GObject.GObject): def is_auto_install(self) -> bool: return self.query_config(Preferences.INSTALL) - def reinit_map_store(self) -> None: - self.model_man.set_all_maps() - - def append_map(self, map_row: list) -> None: - self.model_man.append_map(map_row) - def unblock_signals(self) -> None: self.block_signals(False) @@ -313,7 +306,7 @@ class Controller(GObject.GObject): @call_on_thread(strings.dialog.modlist) def load_mods(self) -> None: - model = self.model_man.new_mod_store() + model = ModelFactory().make_mod_store() #self.model_man.new_mod_store() path = self.query_config(Preferences.DEFAULT) mods = get_delimited_mods(Path(path)) @@ -543,11 +536,7 @@ class Controller(GObject.GObject): self.push_data_success(parsed, FilterMode.INITIAL) def get_help_row(self) -> str: - tv = self.mediator.menu - model = self.get_help_store() - tree_iter = tv.get_focused_row_iter() - value = model.get_value(tree_iter, 1) - return value.dict["tooltip"] + return self.mediator.menu.get_row_enum() def open_user_workshop(self, uid: str) -> None: # NOTE: uid may contain leading zeroes, not a real integer @@ -634,22 +623,12 @@ class Controller(GObject.GObject): def populate_log(self) -> None: log = self.prefs.paths.debug - store = self.model_man.get_log_store() - store.clear() - # NOTE: this model is reloaded each time as log changes try: - with open(log, "r") as f: - lines = [ - line.split(strings.delimiter) for line in f.read().splitlines() - ] - for record in lines: - clean = redact_log(record) - store.append(clean) + self.mediator.logtreeview.populate_log(log) + self.open_page(NotebookPage.LOG) except Exception as e: dialog = ExceptionDialog(self, str(e)) dialog.run() - return - self.open_page(NotebookPage.LOG) def select_colorized(self) -> None: model = self.get_mod_store() @@ -930,15 +909,6 @@ class Controller(GObject.GObject): def get_dist_cache(self) -> dict[str, "Haversine"]: return self.dist_cache - def get_help_store(self) -> Gtk.ListStore: - return self.model_man.get_help_store() - - def get_modlist_store(self) -> Gtk.ListStore: - return self.model_man.get_modlist_store() - - def get_log_store(self) -> Gtk.ListStore: - return self.model_man.get_log_store() - def get_filters(self) -> list: return self.mediator.filters.get_filters() diff --git a/dzgui/model/misc_model.py b/dzgui/model/misc_model.py deleted file mode 100644 index 6431e1e..0000000 --- a/dzgui/model/misc_model.py +++ /dev/null @@ -1,82 +0,0 @@ -from dataclasses import dataclass -from dzgui.const.enum import HELP_MENU_ROWS - -import gi - -gi.require_version("Gtk", "3.0") -from gi.repository.Gtk import ListStore # noqa E402 -from gi.repository import GObject, Gtk # noqa E402 - - -@dataclass(slots=True, frozen=True) -class ModCols: - name: str - symlink: str - directory: str - size: float - color: str - - -@dataclass(slots=True, frozen=True) -class LogCols: - timestamp: str - flag: str - traceback: str - msg: str - - -@dataclass(slots=True, frozen=True) -class ServerModCols: - name: str - uid: GObject.TYPE_INT64 - installed: str - - -@dataclass(slots=True, frozen=True) -class MenuCols: - name: str - hidden: GObject.TYPE_PYOBJECT - - -class ModelManager: - """ - Manager for miscellaneous ListStores - """ - - def __init__(self) -> None: - - self.map_store = ListStore(str) - self.help_store = self.new_model_from_class(MenuCols) - self.log_store = self.new_model_from_class(LogCols) - # FIXME: needs to be generated on demand for servers - self.modlist_store = self.new_model_from_class(ServerModCols) - - for row in HELP_MENU_ROWS: - label = row.dict["label"] - self.help_store.append([label, row]) - - def new_model_from_class(self, cls: type) -> ListStore: - store = ListStore(*[ftype for field, ftype in cls.__annotations__.items()]) - return store - - def get_map_store(self) -> ListStore: - return self.map_store - - def get_help_store(self) -> ListStore: - return self.help_store - - def new_mod_store(self) -> ListStore: - return self.new_model_from_class(ModCols) - - def get_log_store(self) -> ListStore: - return self.log_store - - def get_modlist_store(self) -> ListStore: - return self.modlist_store - - def set_all_maps(self) -> None: - self.map_store.clear() - self.map_store.append(["All maps"]) - - def append_map(self, row: list) -> None: - self.map_store.append(row) diff --git a/dzgui/model/model_factory.py b/dzgui/model/model_factory.py new file mode 100644 index 0000000..afeb3c1 --- /dev/null +++ b/dzgui/model/model_factory.py @@ -0,0 +1,82 @@ +from dataclasses import dataclass + +from dzgui.const.enum import HELP_MENU_ROWS +from dzgui.util.redact import redact_log +from dzgui.util.strings import delimiter +from dzgui.views.dialogs.generic import ExceptionDialog + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository.Gtk import ListStore # noqa E402 +from gi.repository import GObject # noqa E402 + + +@dataclass(slots=True, frozen=True) +class ModCols: + name: str + symlink: str + directory: str + size: float + color: str + + +@dataclass(slots=True, frozen=True) +class LogCols: + timestamp: str + flag: str + traceback: str + msg: str + + +@dataclass(slots=True, frozen=True) +class ServerModCols: + name: str + uid: GObject.TYPE_INT64 + installed: str + + +@dataclass(slots=True, frozen=True) +class MenuCols: + name: str + hidden: GObject.TYPE_PYOBJECT + + +class ModelFactory: + def __init__(self) -> None: + pass + + def new_model_from_logfile(self, path: str) -> None: + store = self.make_log_store() + with open(path, "r") as f: + lines = [ + line.split(delimiter) for line in f.read().splitlines() + ] + for record in lines: + # NOTE: strip PII and API keys + clean = redact_log(record) + store.append(clean) + return store + + def new_model_from_class(self, cls: type) -> ListStore: + store = ListStore(*[ftype for field, ftype in cls.__annotations__.items()]) + return store + + def make_map_store(self) -> ListStore: + return ListStore(str) + + def make_help_store(self) -> ListStore: + store = self.new_model_from_class(MenuCols) + for row in HELP_MENU_ROWS: + label = row.dict["label"] + store.append([label, row]) + return store + + def make_mod_store(self) -> ListStore: + return self.new_model_from_class(ModCols) + + def make_log_store(self) -> ListStore: + return self.new_model_from_class(LogCols) + + def make_server_mod_store(self) -> ListStore: + return self.new_model_from_class(ServerModCols) diff --git a/dzgui/views/trees/tree_log.py b/dzgui/views/trees/tree_log.py index b5ec90a..1824558 100644 --- a/dzgui/views/trees/tree_log.py +++ b/dzgui/views/trees/tree_log.py @@ -3,6 +3,7 @@ import logging from typing import TYPE_CHECKING from dzgui.const.enum import ContextMenuGroup +from dzgui.model.model_factory import ModelFactory from dzgui.util import strings from dzgui.views.trees.tree_base import TreeView from dzgui.views.mixins.context_mixin import ContextMixin @@ -25,13 +26,11 @@ class LogTreeView(ContextMixin, TreeView): self.controller = controller self.controller.register_widget("logtreeview", self) - # TODO: maybe put this in init self.set_headers_visible(True) self.set_fixed_height_mode(True) self.get_selection().set_mode(Gtk.SelectionMode.MULTIPLE) - model = self.controller.get_log_store() - self.set_model(model) + self.set_model(None) for i, column_title in enumerate(strings.log_cols): renderer = Gtk.CellRendererText() @@ -44,7 +43,10 @@ class LogTreeView(ContextMixin, TreeView): self.connect("key-press-event", self._on_log_keypress) self.connect("button-press-event", self._on_log_keypress) - self.s = self.get_selection().get_selected_rows() + def populate_log(self, filepath: str) -> None: + model = ModelFactory().new_model_from_logfile(filepath) + self.set_model(model) + self.set_cursor(0) def _on_log_keypress(self, widget: Gtk.Widget, event: Gdk.EventKey) -> None: self.present_menu(widget, event) diff --git a/dzgui/views/trees/tree_menu.py b/dzgui/views/trees/tree_menu.py index 2319a24..8292bb1 100644 --- a/dzgui/views/trees/tree_menu.py +++ b/dzgui/views/trees/tree_menu.py @@ -1,7 +1,8 @@ import logging -from typing import TYPE_CHECKING +from typing import Any, TYPE_CHECKING from dzgui.const.enum import RowType, NotebookPage +from dzgui.model.model_factory import ModelFactory from dzgui.util.open_links import open_link_by_rowtype from dzgui.views.trees.tree_base import TreeView @@ -32,7 +33,7 @@ class MenuTreeView(TreeView): column.set_sizing(Gtk.TreeViewColumnSizing.FIXED) self.append_column(column) - help_store = self.controller.get_help_store() + help_store = ModelFactory().make_help_store() self.set_model(help_store) self.selected_row = self.get_selection() @@ -45,6 +46,7 @@ class MenuTreeView(TreeView): def _parent_row_activated( self, tree: TreeView, path: Gtk.TreePath, column: Gtk.TreeViewColumn ) -> None: + # FIXME: may be expected to return str, not enum row_type = self.get_value_at_index(1) match row_type: @@ -66,5 +68,11 @@ class MenuTreeView(TreeView): RowType.SPONSOR, ] if row_type in docs: - # FIXME: prior method is returning a str, not enum open_link_by_rowtype(row_type) + + def get_row_enum(self) -> Any: + # NOTE: col 1 contains a RowType enum + model = self.get_model() + _iter = self.get_focused_row_iter() + rowtype = model.get_value(_iter, 1) + return rowtype.dict["tooltip"] From 473dcafb26f71277248a104767758dadd166d988 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 22 Feb 2026 01:42:36 +0900 Subject: [PATCH 096/309] feat: ServerModelManager WIP --- dzgui/controllers/mc.py | 52 ++-- dzgui/{model => managers}/contextmenu.py | 2 +- dzgui/{model => managers}/thread_man.py | 12 +- dzgui/model/filtered_model.py | 40 +-- dzgui/model/servers.py | 324 +++++++++++++++++++++++ dzgui/views/base.py | 12 +- dzgui/views/pages/servers.py | 5 +- dzgui/views/trees/tree_servers.py | 2 +- 8 files changed, 396 insertions(+), 53 deletions(-) rename dzgui/{model => managers}/contextmenu.py (98%) rename dzgui/{model => managers}/thread_man.py (85%) create mode 100644 dzgui/model/servers.py diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 172acce..101117d 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -43,8 +43,9 @@ from dzgui.config import update from dzgui.config.query import lookup from dzgui.config.userprefs import UserPrefs from dzgui.controllers.emitter import Emitter -from dzgui.model.contextmenu import ContextMenuManager +from dzgui.managers.contextmenu import ContextMenuManager from dzgui.model.filtered_model import FilteredModelManager +from dzgui.model.servers import ServerModelManager from dzgui.model.model_factory import ModelFactory from dzgui.util import strings from dzgui.util._json import read_json, write_json @@ -111,7 +112,6 @@ class Controller(GObject.GObject): self.prefs: UserPrefs self.cleanup_func: StoredFunc = None - #self.model_man = ModelManager() self.emitter = Emitter() self.emitter.connect("map_selection_changed", self._on_map_selection_changed) self.emitter.connect("check_toggled", self._on_check_toggled) @@ -481,7 +481,7 @@ class Controller(GObject.GObject): # FIXME: refiltration should occur in thread model.append(self.insert_record[0]) filtered = filter_man.filter(FilterMode.INITIAL) - treeview.set_model(filter_man.get_model()) + treeview.set_model(filter_man.get_proxy_model()) # TODO: update statusbar context = self.get_active_context() # TODO: adding a row may update available maps @@ -640,6 +640,7 @@ class Controller(GObject.GObject): sel.select_path(path) # TODO: make as method of tree? + # TODO: could have a LocalModManager that accepts button enums def uncolorize_mods(self) -> None: model = self.get_mod_store() for mod in model: @@ -856,7 +857,7 @@ class Controller(GObject.GObject): self, filter_man: "FilteredModelManager", mode: FilterMode, label: str ) -> None: filter_man.filter(mode, label) - self.to_insert = filter_man.get_model() + self.to_insert = filter_man.get_proxy_model() print("filtering threaded") self.cleanup_func = StoredFunc(self.cleanup_on_success) @@ -872,31 +873,33 @@ class Controller(GObject.GObject): return self.filter_man def set_filter_man(self, filter_man: "FilteredModelManager") -> None: + # TODO: used when staging filter man outside of thread self.filter_man = filter_man - def populate_model(self) -> None: + def populate_model(self, tv: Gtk.TreeView) -> None: # NOTE: prepare GTK objects outside of thread - treeview = self.get_active_treeview() - if treeview.is_loaded() is True: - self.emitter.emit("servers_loaded", treeview.get_enum()) - return + #if tv.is_loaded() is True: + # self.emitter.emit("servers_loaded", tv.get_enum()) + # return - func, jobs = treeview.get_query_func() - if func is None: - self.emitter.emit("servers_loaded", treeview.get_enum()) - treeview.set_model(None) - return + #func, jobs = tv.get_query_func() + ServerModelManager(self, tv, first_iteration=True) + + #if func is None: + # self.emitter.emit("servers_loaded", tv.get_enum()) + # tv.set_model(None) + # return # TODO: clear ephemeral model if necessary # manager = treeview.get_filter_man() # manager.clear_model() - self.first_iteration = True - self.pending_jobs = jobs - # TODO: get filter manager a priori and set it so that it can be accessed out of thread - filter_man = treeview.get_filter_man() - self.set_filter_man(filter_man) - self.run_query_func(func) + # self.first_iteration = True + # self.pending_jobs = jobs + # # TODO: get filter manager a priori and set it so that it can be accessed out of thread + # filter_man = treeview.get_filter_man() + # self.set_filter_man(filter_man) + # self.run_query_func(func) def get_favorite(self) -> tuple[str, str] | tuple[None, None]: fav = str(self.query_config(Preferences.FAV_LBL)) @@ -950,11 +953,16 @@ class Controller(GObject.GObject): def _on_servers_loaded(self, emitter: "Emitter", tab: "ServerTab") -> None: # NOTE: workaround for GTK bug where fullscreen causes headers to vanish when model is None + # TODO: this should be internal to servers page state = self.has_server_model() tv = self.get_active_treeview() tv.set_headers_visible(state) tv.set_headers_clickable(state) + def has_server_model(self) -> bool: + treeview = self.get_active_treeview() + return treeview.get_model() is not None + def _on_check_toggled(self, emitter: Emitter, label: str, state: bool) -> None: map_man = self.get_map_man() map_man.set_filter(label, state) @@ -971,10 +979,6 @@ class Controller(GObject.GObject): map_man.set_selected_map(selection) self.refilter_model(FilterMode.MAP) - def has_server_model(self) -> bool: - treeview = self.get_active_treeview() - return treeview.get_model() is not None - def hide_widgets_on_init(self) -> None: self.mediator.grid.conpan.set_lan_visible(False) self.mediator.grid.right_panel.sel_panel.hide() diff --git a/dzgui/model/contextmenu.py b/dzgui/managers/contextmenu.py similarity index 98% rename from dzgui/model/contextmenu.py rename to dzgui/managers/contextmenu.py index 728b7b6..9c84b21 100644 --- a/dzgui/model/contextmenu.py +++ b/dzgui/managers/contextmenu.py @@ -3,7 +3,7 @@ import logging from typing import TYPE_CHECKING from dzgui.const.enum import ContextMenu, Preferences -from dzgui.model.thread_man import ThreadingManager +from dzgui.managers.thread_man import ThreadingManager from dzgui.util import strings from dzgui.util.open_links import open_workshop_page diff --git a/dzgui/model/thread_man.py b/dzgui/managers/thread_man.py similarity index 85% rename from dzgui/model/thread_man.py rename to dzgui/managers/thread_man.py index dc10d28..18e0d3d 100644 --- a/dzgui/model/thread_man.py +++ b/dzgui/managers/thread_man.py @@ -47,6 +47,7 @@ class StoredFunc: class ThreadingManager: def __init__(self, parent: Gtk.Window) -> None: self.parent = parent + self.jobs = 1 self.cleanup_func = None def call_on_thread(self, dialog_str: str, func: StoredFunc) -> None: @@ -54,11 +55,20 @@ class ThreadingManager: func.call() GLib.idle_add(self._destroy_on_idle) - self.wait_dialog = WaitDialog(self.parent, dialog_str) + self.wait_dialog = WaitDialog(self.parent, dialog_str, jobs=self.jobs) self.wait_dialog.show_all() thread = threading.Thread(target=callback) thread.start() + def set_job_count(self, jobs: int) -> None: + self.jobs = jobs + + def increment_dialog(self) -> None: + GLib.idle_add(self.wait_dialog.increment) + + def increment_dialog_with_str(self, text: str) -> None: + GLib.idle_add(lambda: self.wait_dialog.increment(text)) + def set_cleanup_func(self, func: StoredFunc) -> None: if type(func) not in (StoredFunc, type(None)): msg = f"Callback function '{func}' is not of type StoredFunc or None" diff --git a/dzgui/model/filtered_model.py b/dzgui/model/filtered_model.py index a618e24..a2b7d8d 100644 --- a/dzgui/model/filtered_model.py +++ b/dzgui/model/filtered_model.py @@ -53,7 +53,7 @@ class FilteredModelManager: self.filter_cache = {} self.ping_cache: dict[str, int] = {} - self.ephemeral_model: ListStore = None + self.proxy_model: ListStore = None self.control_model: list = None self.filtered: list = None @@ -68,13 +68,13 @@ class FilteredModelManager: self.keyword_filter = keyword def append_row(self, row: list) -> None: - self.ephemeral_model.append(row) + self.proxy_model.append(row) - def clear_model(self) -> None: - self.ephemeral_model.clear() + def clear_proxy_model(self) -> None: + self.proxy_model.clear() - def get_model(self) -> ListStore: - return self.ephemeral_model + def get_proxy_model(self) -> ListStore: + return self.proxy_model def new_model_from_class(self, cls: type) -> ListStore: store = ListStore(*[ftype for field, ftype in cls.__annotations__.items()]) @@ -89,7 +89,7 @@ class FilteredModelManager: if filters in self.filter_cache: cache = self.filter_cache[filters] - self.set_model(cache[0]) + self.set_proxy_model(cache[0]) self.set_filtered(cache[1]) return @@ -128,11 +128,13 @@ class FilteredModelManager: if len(rows) > 0: rows = self.sort_rows(rows) for i, row in enumerate(rows): + # TODO: consider overriding append() method of Gtk.ListStore + # check Gtk source code clone.insert_with_values(i, tuple(range(0, n_cols)), row) #clone.append(row) self.set_cache(filters, clone, rows) - self.set_model(clone) + self.set_proxy_model(clone) return clone def sort_rows(self, rows: list) -> list: @@ -266,11 +268,11 @@ class FilteredModelManager: def get_filtered(self) -> list: return self.filtered - def set_model(self, model: ListStore | None) -> None: + def set_proxy_model(self, model: ListStore | None) -> None: """ - ListStore representation of the model + ListStore representation of the raw model after filtration """ - self.ephemeral_model = model + self.proxy_model = model def set_control(self, rows: list) -> None: """ @@ -281,20 +283,20 @@ class FilteredModelManager: def get_control(self) -> list: return self.control_model - @deprecated("Legacy code") - def set_success(self, result: bool) -> None: - self.success = result + #@deprecated("Legacy code") + #def set_success(self, result: bool) -> None: + # self.success = result - @deprecated("Legacy code") - def get_success(self) -> bool: - return self.success + #@deprecated("Legacy code") + #def get_success(self) -> bool: + # return self.success - # NOTE: used when adding/removing rows in-situ in the ephemeral model + # NOTE: used when adding/removing rows in-situ in the proxy model # and syncing changes to control model, but ignored for player count/ping updates # cf. remove_from_history(), remove_server() # NOTE: this can most likely be simplified for v7 def wipe_cache(self, full=False) -> None: - self.set_success(True) + # self.set_success(True) self.filtered = None self.filter_cache = {} self.ping_cache = {} diff --git a/dzgui/model/servers.py b/dzgui/model/servers.py new file mode 100644 index 0000000..75133df --- /dev/null +++ b/dzgui/model/servers.py @@ -0,0 +1,324 @@ +from datetime import datetime +import logging + +from concurrent.futures import wait, as_completed +from concurrent.futures import ThreadPoolExecutor + +import dzgui.api.servers as Servers +from dzgui.const.constants import ( + APPID_DAYZ, + APPID_DAYZ_EXP, +) +from dzgui.const.enum import FilterMode, Preferences, ServerTab +from dzgui.managers.thread_man import call_on_thread, StoredFunc, ThreadingManager +from dzgui.util.strings import dialog +from dzgui.views.dialogs.generic import ExceptionDialog + +from typing import Optional, TYPE_CHECKING + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk, GLib # noqa E402 + + +if TYPE_CHECKING: + from dzgui.controllers.mc import Controller + from dzgui.controllers.emitter import Emitter + from dzgui.model.filtered_model import FilteredModelManager + +logger = logging.getLogger(__name__) + +class ServerModelManager: + def __init__(self, controller: "Controller", tv: Gtk.TreeView, first_iteration=False) -> None: + + enum = tv.get_enum() + self.controller = controller + self.emitter = controller.get_emitter() + if tv.is_loaded(): + self.emitter.emit("servers_loaded", enum) + + self.tv = tv + self.jobs = 1 + + self.filter_man = tv.get_filter_man() + self.thread_man = ThreadingManager(parent=controller) + + # TODO: if first iteration, clear filter man control model + # literal first load: iteration 1 + # refresh: iteration 1 (wipe model) + # filter: iteration N+1 + self.first_iteration = first_iteration + + match enum: + case ServerTab.BROWSER: + # NOTE: extra DAYZ_EXP param + self.thread_man.set_job_count(len(Servers.params) + 1) + self._dump_api() + case ServerTab.SAVED: + self._dump_favorites() + case ServerTab.RECENT: + # TODO: get row count + self._dump_history() + case ServerTab.LAN: + self._dump_lan() + + @call_on_thread(dialog.fetching) + def _dump_api(self) -> None: + key = self.controller.query_config(Preferences.STEAM) + job = Servers.query_api + params = Servers.params + servers = [] + with ThreadPoolExecutor() as executor: + futures = [executor.submit(job, key, APPID_DAYZ, param) for param in params] + for future in as_completed(futures): + try: + self.thread_man.increment_dialog() + res = future.result(timeout=3) + if res.status != 200 or not res.parsed: + self.thread_man.set_cleanup_func(StoredFunc(self._cleanup_on_failure)) + return + j = res.json + servers += j["response"]["servers"] + except Exception as e: + # TODO: could store exception in cleanup func + logger.critical(e) + self.thread_man.set_cleanup_func(StoredFunc(self._cleanup_on_failure)) + return + + # NOTE: This step is allowed to fail, since this metadata is incidental + res = Servers.query_api(key, APPID_DAYZ_EXP, "") + if res.status == 200 and res.parsed is True: + j = res.json + servers += j["response"]["servers"] + + # TODO: strings + #self.thread_man.increment_dialog_with_str("Unpacking servers") + # TODO: try/except + parsed = Servers.parse_json(servers) + self._push_data_success(parsed, FilterMode.INITIAL) + + # TODO: strings + @call_on_thread("scanning LAN ports") + def _dump_lan(self, port: int, early_abort: bool) -> None: + servers = [] + ports = range(1, 256) + + event = threading.Event() + with ThreadPoolExecutor() as executor: + futures = [ + executor.submit(Servers.test_ip, i, port, event) for i in ports + ] + for future in as_completed(futures): + try: + res = future.result(timeout=0.5) + if res is not None and early_abort is True: + # NOTE: first non-empty hit, flag pending threads to close + event.set() + servers.append(res) + self.cleanup_func = StoredFunc(self._cleanup_on_success) + return + if res is None: + continue + servers.append(res) + except Exception as e: + logger.critical(e) + self.cleanup_func = StoredFunc(self._cleanup_on_failure) + if len(servers) == 0: + self.cleanup_func = StoredFunc(self._cleanup_on_failure) + return + parsed = Servers.parse_json(servers) + self._push_data_success(parsed, FilterMode.INITIAL) + + # TODO: strings + @call_on_thread("dumping ips") + def _dump_ips(self, ips: list[str]) -> None: + # NOTE: block malformed records (TODO: add github issue no.) + # TODO: sanitize ip list at config time, drop this + ips = [ip for ip in ips if len(ip.split(":")) == 3 and ip.split(":")[2] != ""] + with ThreadPoolExecutor() as executor: + futures = [ + executor.submit( + Servers.query_direct, + ip.split(":")[0], + int(ip.split(":")[2]), + ) + for ip in ips + ] + servers = [] + for future in as_completed(futures): + res = future.result() + self.thread_man.increment_dialog() + # NOTE: failing entries are culled + if res is None: + continue + servers.append(res) + if len(servers) == 0: + self.cleanup_func = StoredFunc(self._cleanup_on_failure) + return + + # NOTE: 1 extra progress bar pass for parsing + parsed = Servers.parse_json(servers) + self._push_data_success(parsed, FilterMode.INITIAL) + + def _query_ip_id(self, addr: str) -> None: + # NOTE: Battlemetrics + if addr.isdigit(): + # FIXME: + config = self.controller.get_prefs().paths.config + resolved = map_id_to_record(config, addr) + res = Servers.query_direct(resolved.ip, resolved.qport) + else: + record = addr.split(":") + ip, qport = record[0], record[1] + res = Servers.query_direct(ip, int(qport)) + return res + + # TODO: strings + @call_on_thread("querying address") + def _connect_by_id_or_ip(self, addr: str) -> None: + res = self.query_ip_id(addr) + + @call_on_thread("querying address") + def _add_by_id_or_ip(self, addr: str) -> None: + res = self.query_ip_id(addr) + # TODO: investigate this + if res is None: + self.set_cleanup_func(StoredFunc(self._cleanup_on_failure)) + return + # NOTE: single record insertion + self.insert_record = Servers.parse_json([res]) + # TODO: add into saved servers file + # TODO: update saved servers model + # NOTE: this can be called from other tabs--if current focus is not ServerTab.SAVED, update label only + # TODO: saved servers might not be loaded yet, in which case should just update local file only + # TODO: perform simple equality comparison of self.tv.get_enum() == ServerTab.SAVED + # FIXME: filter man is saved on a per tab basis, so this will mismatch + self.set_cleanup_func(StoredFunc(self._cleanup_on_insert)) + + def _dump_history(self) -> None: + history = self.controller.get_prefs().paths.history + # TODO: customize statusbar to mention how records are added after connecting + try: + with open(history, "r") as f: + rows = [row.rstrip("\n") for row in f] + except OSError: + self.cleanup_func = StoredFunc(self._cleanup_on_failure, False) + return + if len(rows) == 0: + self.cleanup_func = StoredFunc(self._cleanup_on_failure, False) + return + self.thread_man.set_job_count(len(rows)) + self._dump_ips(rows) + + def _dump_favorites(self) -> None: + ips = self.controller.query_config(Preferences.IP_LIST) + self.thread_man.set_job_count(len(ips)) + # TODO: customize statusbar to mention how records can be added via contextmenu + if len(ips) == 0: + # FIXME: this is not a failure, just a quiet exit with custom statusbar + # TODO: add custom statusbar parameters + self.cleanup_func = StoredFunc(self._cleanup_on_failure, False) + return + self._dump_ips(ips) + + def _cleanup_on_insert(self) -> None: + filter_man = self.get_filter_man() + model = filter_man.get_control() + + # FIXME: this is a single row insertion, + # but refiltration should occur in thread for consistency/scalability + model.append(self.insert_record[0]) + proxy = filter_man.filter(FilterMode.INITIAL) + + # TODO: get proxy model out of thread + proxy = self.get_filter_man().get_proxy_model() + self.tv.set_model(proxy) + + # TODO: update statusbar + context = self.tv.get_enum() + # TODO: adding a row may update available maps + # TODO: if all filters are already applied, strange behavior may occur + # -> need to insert and update per current filters + self.emitter.emit("servers_loaded", context) + + def _cleanup_on_success(self) -> None: + self.pending_jobs = 1 + self.tv.set_loaded(True) + self.tv.set_model(self.to_insert) + + # TODO: this will allow history and saved tab to emit signals to statusbar + # CHORE: test if treeview's sort method inserts row at the correct index + # inserting a row serializes file on disk, updates control model for that tab, and + # reapplies filters to ephemeral model; since filters are applied, in-situ insertion might not be necessary + # TODO: will be inserted out of order + #self.to_insert.connect("row-inserted", lambda *args: print("row inserted into model")) + + # TODO: signals or other approach to deferring map + # model insertion after thread closes + # cf. servers_loaded signal + + # TODO: servers_loaded vs servers_reloaded + context = self.tv.get_enum() + self.emitter.emit("servers_loaded", context) + + # CHORE: this is placeholder logic + if self.first_iteration: + map_man = self.tv.get_map_man() + map_man.set_unique_maps(self.new_maps) + self.emitter.emit("servers_loaded_init") + self.first_iteration = False + self.new_maps = None + + self.tv.grab_focus() + + def _cleanup_on_failure(self, show_dialog=True) -> None: + self.treeview.set_loaded(True) + map_man = self.treeview.get_map_man() + + # TODO: disable map, keyword, and filter widgets if model is None + # -> signal driven (servers_empty) + # TODO: what if refresh action occurred and failed, and the old model is still valid? + # skip the step below if refresh action failed + # do not wipe control model in this case + # e.g. if treeview.is_refresh(): + # revert old model + # wipe refresh state to False + + self.treeview.set_model(None) + self.treeview.grab_focus() + + map_man.set_unique_maps(None) + context = self.treeview.get_enum() + + # TODO: distinguish signals, e.g. "servers_failed_to_load", "servers_loaded_empty" + # customize statusbar accordingly + self.emitter.emit("servers_loaded", context) + if show_dialog: + dialog = ExceptionDialog(self, strings.api_warn_msg) + dialog.run() + + # TODO: break into initial dump and refilter modes, can drop filtermode kwarg + # and stop pushing empty data + def _push_data_success(self, data: tuple, mode: Optional[FilterMode]) -> None: + # FIXME: calls treeview read methods in thread + # treeview = self.get_active_treeview() + # manager = treeview.get_filter_man() + manager = self._get_filter_man() + + if data is None: + self.to_insert = None + else: + if mode == FilterMode.INITIAL: + manager.set_control(data) + manager.filter(mode) + self.to_insert = manager.get_proxy_model() + # TODO: pre parse maps + u_maps = set([row[1] for row in data]) + self.new_maps = sorted(u_maps) + + self.thread_man.set_cleanup_func(StoredFunc(self._cleanup_on_success)) + + def _get_filter_man(self) -> "FilteredModelManager": + return self.filter_man + diff --git a/dzgui/views/base.py b/dzgui/views/base.py index 2f0d81b..0abea18 100644 --- a/dzgui/views/base.py +++ b/dzgui/views/base.py @@ -152,20 +152,18 @@ class OuterWindow(Gtk.Window): MainController.set_resolution(self) self.show_all() - css.load_css() - # TODO: first run MainController.open_page(NotebookPage.SERVERS) - MainController.hide_widgets_on_init() + self.grid.hide_widgets_on_init() # TODO: POC, trigger page change here MainController.loaded = True - # TODO: debug flag + # TODO: debug flag, perhaps drop if MainController.get_prefs().is_debug: return - MainController.populate_model() + MainController.populate_model(MainController.get_active_treeview()) def _on_delete_event(self, window: "OuterWindow", event: Gdk.EventKey) -> None: self.halt_proc_and_quit() @@ -352,6 +350,10 @@ class Grid(Gtk.Grid): self.emitter.connect("server_page_toggled", self.toggle_filter_panels) + def hide_widgets_on_init(self) -> None: + self.conpan.set_lan_visible(False) + self.right_panel.sel_panel.hide() + def _shrink(self, button: Gtk.Button) -> None: tv = MainController.get_active_treeview() tv.shrink_to_fit() diff --git a/dzgui/views/pages/servers.py b/dzgui/views/pages/servers.py index 1b0bb14..f56f955 100644 --- a/dzgui/views/pages/servers.py +++ b/dzgui/views/pages/servers.py @@ -100,11 +100,12 @@ class ServerNotebook(Gtk.ScrolledWindow): if label is None: return - # TODO: strings + # TODO: strings, tab activity notifier text = label.strip("*") self.notebook.set_tab_label_text(child, text) + # NOTE: spawns a thread - self.controller.populate_model() + self.controller.populate_model(self.get_active_treeview()) def get_active_treeview(self) -> ServerTreeView: index = self.notebook.get_current_page() diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index 972a94f..dcdf05a 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -48,7 +48,7 @@ class ServerTreeView(ContextMixin, TreeView): self.query_func: Callable = None self.filter_man = FilteredModelManager(controller) - model = self.filter_man.get_model() + model = self.filter_man.get_proxy_model() self.set_model(model) # NOTE: each tab context has its own unique maps From 99ede36a325822cfcf5ed7f10fbc3a91145291b6 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 22 Feb 2026 01:48:23 +0900 Subject: [PATCH 097/309] chore: ServerModelManager WIP --- dzgui/controllers/mc.py | 470 +++++++++++++++++------------------ dzgui/model/servers.py | 17 ++ dzgui/views/pages/servers.py | 6 - 3 files changed, 240 insertions(+), 253 deletions(-) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 101117d..8215c51 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -304,6 +304,7 @@ class Controller(GObject.GObject): self.mediator.modtreeview.set_model(model) self.emitter.emit("mod_page_loaded") + # TODO: delegate to threadmanager @call_on_thread(strings.dialog.modlist) def load_mods(self) -> None: model = ModelFactory().make_mod_store() #self.model_man.new_mod_store() @@ -375,165 +376,165 @@ class Controller(GObject.GObject): self.open_page(button.opens) - def dump_favorites(self) -> None: - # TODO: if list is empty, return failure - ips = self.query_config(Preferences.IP_LIST) - if len(ips) == 0: - # FIXME: this is not a failure, just a quiet exit with custom statusbar - # TODO: add custom statusbar parameter - self.cleanup_func = StoredFunc(self.cleanup_on_failure, False) - return - self.dump_ips(ips) + # def dump_favorites(self) -> None: + # # TODO: if list is empty, return failure + # ips = self.query_config(Preferences.IP_LIST) + # if len(ips) == 0: + # # FIXME: this is not a failure, just a quiet exit with custom statusbar + # # TODO: add custom statusbar parameter + # self.cleanup_func = StoredFunc(self.cleanup_on_failure, False) + # return + # self.dump_ips(ips) - def dump_history(self) -> None: - history = self.prefs.paths.history - # TODO: customize statusbar to mention how records are added - try: - with open(history, "r") as f: - rows = [row.rstrip("\n") for row in f] - except OSError: - self.cleanup_func = StoredFunc(self.cleanup_on_failure, False) - return - if len(rows) == 0: - self.cleanup_func = StoredFunc(self.cleanup_on_failure, False) - return - self.dump_ips(rows) + # def dump_history(self) -> None: + # history = self.prefs.paths.history + # # TODO: customize statusbar to mention how records are added + # try: + # with open(history, "r") as f: + # rows = [row.rstrip("\n") for row in f] + # except OSError: + # self.cleanup_func = StoredFunc(self.cleanup_on_failure, False) + # return + # if len(rows) == 0: + # self.cleanup_func = StoredFunc(self.cleanup_on_failure, False) + # return + # self.dump_ips(rows) - def dump_ips(self, ips: list) -> None: - # NOTE: block malformed records (TODO: add github issue no.) - ips = [ip for ip in ips if len(ip.split(":")) == 3 and ip.split(":")[2] != ""] - with ThreadPoolExecutor() as executor: - futures = [ - executor.submit( - Servers.query_direct, - ip.split(":")[0], - int(ip.split(":")[2]), - ) - for ip in ips - ] - # TODO: update dialog in main loop - # wait(futures) - serv = [] - for future in as_completed(futures): - res = future.result() - # NOTE: discard failing entries - if res is None: - continue - serv.append(res) - if len(serv) == 0: - self.cleanup_func = StoredFunc(self.cleanup_on_failure) - return - parsed = Servers.parse_json(serv) - self.push_data_success(parsed, FilterMode.INITIAL) + # def dump_ips(self, ips: list) -> None: + # # NOTE: block malformed records (TODO: add github issue no.) + # ips = [ip for ip in ips if len(ip.split(":")) == 3 and ip.split(":")[2] != ""] + # with ThreadPoolExecutor() as executor: + # futures = [ + # executor.submit( + # Servers.query_direct, + # ip.split(":")[0], + # int(ip.split(":")[2]), + # ) + # for ip in ips + # ] + # # TODO: update dialog in main loop + # # wait(futures) + # serv = [] + # for future in as_completed(futures): + # res = future.result() + # # NOTE: discard failing entries + # if res is None: + # continue + # serv.append(res) + # if len(serv) == 0: + # self.cleanup_func = StoredFunc(self.cleanup_on_failure) + # return + # parsed = Servers.parse_json(serv) + # self.push_data_success(parsed, FilterMode.INITIAL) # TODO: strings - @call_on_thread("scanning LAN ports") - def dump_lan(self, port: int, early_abort: bool) -> None: - serv = [] - event = threading.Event() - with ThreadPoolExecutor() as executor: - futures = [ - executor.submit(Servers.test_ip, i, port, event) for i in range(1, 256) - ] - for future in as_completed(futures): - try: - res = future.result(timeout=0.5) - if res is not None and early_abort is True: - event.set() - self.cleanup_func = StoredFunc(self.cleanup_on_failure) - return - if res is None: - continue - serv.append(res) - except Exception as e: - logger.critical(e) - self.cleanup_func = StoredFunc(self.cleanup_on_failure) - if len(serv) == 0: - self.cleanup_func = StoredFunc(self.cleanup_on_failure) - return - parsed = Servers.parse_json(serv) - self.push_data_success(parsed, FilterMode.INITIAL) + # @call_on_thread("scanning LAN ports") + # def dump_lan(self, port: int, early_abort: bool) -> None: + # serv = [] + # event = threading.Event() + # with ThreadPoolExecutor() as executor: + # futures = [ + # executor.submit(Servers.test_ip, i, port, event) for i in range(1, 256) + # ] + # for future in as_completed(futures): + # try: + # res = future.result(timeout=0.5) + # if res is not None and early_abort is True: + # event.set() + # self.cleanup_func = StoredFunc(self.cleanup_on_failure) + # return + # if res is None: + # continue + # serv.append(res) + # except Exception as e: + # logger.critical(e) + # self.cleanup_func = StoredFunc(self.cleanup_on_failure) + # if len(serv) == 0: + # self.cleanup_func = StoredFunc(self.cleanup_on_failure) + # return + # parsed = Servers.parse_json(serv) + # self.push_data_success(parsed, FilterMode.INITIAL) - def query_ip_id(self, addr: str) -> None: - # NOTE: Battlemetrics - # TODO: stores full ip, gameport, qport in file as canonical record - if addr.isdigit(): - config = self.prefs.paths.config - resolved = map_id_to_record(config, addr) - res = Servers.query_direct(resolved.ip, resolved.qport) - else: - record = addr.split(":") - ip, qport = record[0], record[1] - res = Servers.query_direct(ip, int(qport)) - return res + # def query_ip_id(self, addr: str) -> None: + # # NOTE: Battlemetrics + # # TODO: stores full ip, gameport, qport in file as canonical record + # if addr.isdigit(): + # config = self.prefs.paths.config + # resolved = map_id_to_record(config, addr) + # res = Servers.query_direct(resolved.ip, resolved.qport) + # else: + # record = addr.split(":") + # ip, qport = record[0], record[1] + # res = Servers.query_direct(ip, int(qport)) + # return res - # TODO: strings - @call_on_thread("querying address") - def connect_by_id_or_ip(self, addr: str) -> None: - res = self.query_ip_id(addr) + # # TODO: strings + # @call_on_thread("querying address") + # def connect_by_id_or_ip(self, addr: str) -> None: + # res = self.query_ip_id(addr) - def cleanup_on_insert(self) -> None: - # FIXME: grab filterman outside of thread - treeview = self.mediator.servers.saved - filter_man = treeview.get_filter_man() - model = filter_man.get_control() - print(len(model)) - # FIXME: refiltration should occur in thread - model.append(self.insert_record[0]) - filtered = filter_man.filter(FilterMode.INITIAL) - treeview.set_model(filter_man.get_proxy_model()) - # TODO: update statusbar - context = self.get_active_context() - # TODO: adding a row may update available maps - # TODO: if all filters are already applied, strange behavior may occur - # need to insert and update all according logic per current filters - self.emitter.emit("servers_loaded", context) - # model = treeview.get_model().append(self.insert_record[0]) + # def cleanup_on_insert(self) -> None: + # # FIXME: grab filterman outside of thread + # treeview = self.mediator.servers.saved + # filter_man = treeview.get_filter_man() + # model = filter_man.get_control() + # print(len(model)) + # # FIXME: refiltration should occur in thread + # model.append(self.insert_record[0]) + # filtered = filter_man.filter(FilterMode.INITIAL) + # treeview.set_model(filter_man.get_proxy_model()) + # # TODO: update statusbar + # context = self.get_active_context() + # # TODO: adding a row may update available maps + # # TODO: if all filters are already applied, strange behavior may occur + # # need to insert and update all according logic per current filters + # self.emitter.emit("servers_loaded", context) + # # model = treeview.get_model().append(self.insert_record[0]) - @call_on_thread("querying address") - def add_by_id_or_ip(self, addr: str) -> None: - res = self.query_ip_id(addr) - self.insert_record = Servers.parse_json([res]) - # if res is None: - # # TODO: set cleanupfunc with dialog - # return - # TODO: add into saved servers file - # TODO: update saved servers model - # if current focus is not save servers, update label - # TODO: saved servers might not be loaded yet, in which case - # should just add to server file - self.set_cleanup_func(StoredFunc(self.cleanup_on_insert)) + # @call_on_thread("querying address") + # def add_by_id_or_ip(self, addr: str) -> None: + # res = self.query_ip_id(addr) + # self.insert_record = Servers.parse_json([res]) + # # if res is None: + # # # TODO: set cleanupfunc with dialog + # # return + # # TODO: add into saved servers file + # # TODO: update saved servers model + # # if current focus is not save servers, update label + # # TODO: saved servers might not be loaded yet, in which case + # # should just add to server file + # self.set_cleanup_func(StoredFunc(self.cleanup_on_insert)) - def dump_api(self) -> None: - key = self.query_config(Preferences.STEAM) - job = Servers.query_api - params = Servers.params - serv = [] - with ThreadPoolExecutor() as executor: - futures = [executor.submit(job, key, APPID_DAYZ, param) for param in params] - for future in as_completed(futures): - try: - GLib.idle_add(lambda: self.wait_dialog.increment()) - res = future.result(timeout=3) - if res.status != 200 or not res.parsed: - self.cleanup_func = StoredFunc(self.cleanup_on_failure) - return - j = res.json - serv += j["response"]["servers"] - except Exception as e: - logger.critical(e) - self.cleanup_func = StoredFunc(self.cleanup_on_failure) - return + # def dump_api(self) -> None: + # key = self.query_config(Preferences.STEAM) + # job = Servers.query_api + # params = Servers.params + # serv = [] + # with ThreadPoolExecutor() as executor: + # futures = [executor.submit(job, key, APPID_DAYZ, param) for param in params] + # for future in as_completed(futures): + # try: + # GLib.idle_add(lambda: self.wait_dialog.increment()) + # res = future.result(timeout=3) + # if res.status != 200 or not res.parsed: + # self.cleanup_func = StoredFunc(self.cleanup_on_failure) + # return + # j = res.json + # serv += j["response"]["servers"] + # except Exception as e: + # logger.critical(e) + # self.cleanup_func = StoredFunc(self.cleanup_on_failure) + # return - # NOTE: This step is allowed to fail, since this metadata is incidental - res = Servers.query_api(key, APPID_DAYZ_EXP, "") - if res.status == 200 and res.parsed is True: - j = res.json - serv += j["response"]["servers"] + # # NOTE: This step is allowed to fail, since this metadata is incidental + # res = Servers.query_api(key, APPID_DAYZ_EXP, "") + # if res.status == 200 and res.parsed is True: + # j = res.json + # serv += j["response"]["servers"] - GLib.idle_add(lambda: self.wait_dialog.increment("Unpacking servers")) - parsed = Servers.parse_json(serv) - self.push_data_success(parsed, FilterMode.INITIAL) + # GLib.idle_add(lambda: self.wait_dialog.increment("Unpacking servers")) + # parsed = Servers.parse_json(serv) + # self.push_data_success(parsed, FilterMode.INITIAL) def get_help_row(self) -> str: return self.mediator.menu.get_row_enum() @@ -548,6 +549,7 @@ class Controller(GObject.GObject): remove_stale_signatures(self.prefs.paths.config, self.prefs.paths.version) # TODO: strings + # TODO: delegate to LocalModManager @call_on_thread("deleting mod") def delete_single_mod(self, tree_path: Gtk.TreePath) -> None: config = self.prefs.paths.config @@ -671,84 +673,84 @@ class Controller(GObject.GObject): def get_map_man(self) -> "MapManager": return self.get_active_treeview().get_map_man() - def cleanup_on_success(self) -> None: - self.pending_jobs = 1 - treeview = self.get_active_treeview() - treeview.set_loaded(True) - treeview.set_model(self.to_insert) + # def cleanup_on_success(self) -> None: + # self.pending_jobs = 1 + # treeview = self.get_active_treeview() + # treeview.set_loaded(True) + # treeview.set_model(self.to_insert) - # TODO: this will allow history and saved tab to emit signals to statusbar - # CHORE: test if treeview's sort method inserts row at the correct index - # inserting a row serializes file on disk, updates control model for that tab, and - # reapplies filters to ephemeral model; since filters are applied, in-situ insertion might not be necessary - # TODO: will be inserted out of order - # self.to_insert.connect("row-inserted", lambda *args: print("row inserted into model")) + # # TODO: this will allow history and saved tab to emit signals to statusbar + # # CHORE: test if treeview's sort method inserts row at the correct index + # # inserting a row serializes file on disk, updates control model for that tab, and + # # reapplies filters to ephemeral model; since filters are applied, in-situ insertion might not be necessary + # # TODO: will be inserted out of order + # # self.to_insert.connect("row-inserted", lambda *args: print("row inserted into model")) - # TODO: signals or other approach to deferring map - # model insertion after thread closes - # cf. servers_loaded signal + # # TODO: signals or other approach to deferring map + # # model insertion after thread closes + # # cf. servers_loaded signal - # TODO: servers_loaded vs servers_reloaded - context = self.get_active_context() - self.emitter.emit("servers_loaded", context) + # # TODO: servers_loaded vs servers_reloaded + # context = self.get_active_context() + # self.emitter.emit("servers_loaded", context) - # CHORE: this is placeholder logic - if self.first_iteration: - map_man = treeview.get_map_man() - map_man.set_unique_maps(self.new_maps) - self.emitter.emit("servers_loaded_init") - self.first_iteration = False - self.new_maps = None + # # CHORE: this is placeholder logic + # if self.first_iteration: + # map_man = treeview.get_map_man() + # map_man.set_unique_maps(self.new_maps) + # self.emitter.emit("servers_loaded_init") + # self.first_iteration = False + # self.new_maps = None - treeview.grab_focus() + # treeview.grab_focus() - def cleanup_on_failure(self, show_dialog=True) -> None: - treeview = self.get_active_treeview() - treeview.set_loaded(True) - map_man = treeview.get_map_man() + # def cleanup_on_failure(self, show_dialog=True) -> None: + # treeview = self.get_active_treeview() + # treeview.set_loaded(True) + # map_man = treeview.get_map_man() - # TODO: disable map, keyword, and filter widgets if model is None - # -> signal driven (servers_empty) - # TODO: what if refresh action occurred and failed, and the old model is still valid? - # skip the step below if refresh action failed - # do not wipe control model in this case - # e.g. if treeview.is_refresh(): - # revert old model - # wipe refresh state to False + # # TODO: disable map, keyword, and filter widgets if model is None + # # -> signal driven (servers_empty) + # # TODO: what if refresh action occurred and failed, and the old model is still valid? + # # skip the step below if refresh action failed + # # do not wipe control model in this case + # # e.g. if treeview.is_refresh(): + # # revert old model + # # wipe refresh state to False - treeview.set_model(None) - treeview.grab_focus() + # treeview.set_model(None) + # treeview.grab_focus() - map_man.set_unique_maps(None) - context = self.get_active_context() + # map_man.set_unique_maps(None) + # context = self.get_active_context() - # TODO: distinguish signals, e.g. "servers_failed_to_load", "servers_loaded_empty" - # customize statusbar accordingly - self.emitter.emit("servers_loaded", context) - if show_dialog: - dialog = ExceptionDialog(self, strings.api_warn_msg) - dialog.run() + # # TODO: distinguish signals, e.g. "servers_failed_to_load", "servers_loaded_empty" + # # customize statusbar accordingly + # self.emitter.emit("servers_loaded", context) + # if show_dialog: + # dialog = ExceptionDialog(self, strings.api_warn_msg) + # dialog.run() - # TODO: break into initial dump and refilter modes, can drop filtermode kwarg - # and stop pushing empty data - def push_data_success(self, data: tuple, mode: Optional[FilterMode]) -> None: - # FIXME: calls treeview read methods in thread - # treeview = self.get_active_treeview() - # manager = treeview.get_filter_man() - manager = self.get_filter_man() + # # TODO: break into initial dump and refilter modes, can drop filtermode kwarg + # # and stop pushing empty data + # def push_data_success(self, data: tuple, mode: Optional[FilterMode]) -> None: + # # FIXME: calls treeview read methods in thread + # # treeview = self.get_active_treeview() + # # manager = treeview.get_filter_man() + # manager = self.get_filter_man() - if data is None: - self.to_insert = None - else: - if mode == FilterMode.INITIAL: - manager.set_control(data) - manager.filter(mode) - self.to_insert = manager.get_model() - # TODO: pre parse maps - u_maps = set([row[1] for row in data]) - self.new_maps = sorted(u_maps) + # if data is None: + # self.to_insert = None + # else: + # if mode == FilterMode.INITIAL: + # manager.set_control(data) + # manager.filter(mode) + # self.to_insert = manager.get_model() + # # TODO: pre parse maps + # u_maps = set([row[1] for row in data]) + # self.new_maps = sorted(u_maps) - self.cleanup_func = StoredFunc(self.cleanup_on_success) + # self.cleanup_func = StoredFunc(self.cleanup_on_success) def highlight_stale_cleanup(self, stale_mods: list) -> None: """Manipulates attached ListStore in the main event loop""" @@ -848,18 +850,14 @@ class Controller(GObject.GObject): def get_statusbar(self) -> None: return self.mediator.statusbar - @call_on_thread(strings.dialog.fetching) - def run_query_func(self, func: StoredFunc) -> None: - func.call() - - @call_on_thread(strings.dialog.filtering) - def filter_threaded( - self, filter_man: "FilteredModelManager", mode: FilterMode, label: str - ) -> None: - filter_man.filter(mode, label) - self.to_insert = filter_man.get_proxy_model() - print("filtering threaded") - self.cleanup_func = StoredFunc(self.cleanup_on_success) + # @call_on_thread(strings.dialog.filtering) + # def filter_threaded( + # self, filter_man: "FilteredModelManager", mode: FilterMode, label: str + # ) -> None: + # filter_man.filter(mode, label) + # self.to_insert = filter_man.get_proxy_model() + # print("filtering threaded") + # self.cleanup_func = StoredFunc(self.cleanup_on_success) # TODO: call filter_man methods directly def refilter_model(self, mode: FilterMode, label: Optional[str] = None) -> None: @@ -877,30 +875,8 @@ class Controller(GObject.GObject): self.filter_man = filter_man def populate_model(self, tv: Gtk.TreeView) -> None: - # NOTE: prepare GTK objects outside of thread - #if tv.is_loaded() is True: - # self.emitter.emit("servers_loaded", tv.get_enum()) - # return - - #func, jobs = tv.get_query_func() ServerModelManager(self, tv, first_iteration=True) - #if func is None: - # self.emitter.emit("servers_loaded", tv.get_enum()) - # tv.set_model(None) - # return - - # TODO: clear ephemeral model if necessary - # manager = treeview.get_filter_man() - # manager.clear_model() - - # self.first_iteration = True - # self.pending_jobs = jobs - # # TODO: get filter manager a priori and set it so that it can be accessed out of thread - # filter_man = treeview.get_filter_man() - # self.set_filter_man(filter_man) - # self.run_query_func(func) - def get_favorite(self) -> tuple[str, str] | tuple[None, None]: fav = str(self.query_config(Preferences.FAV_LBL)) if len(fav) < 1: diff --git a/dzgui/model/servers.py b/dzgui/model/servers.py index 75133df..d0abc37 100644 --- a/dzgui/model/servers.py +++ b/dzgui/model/servers.py @@ -322,3 +322,20 @@ class ServerModelManager: def _get_filter_man(self) -> "FilteredModelManager": return self.filter_man + # TODO: unimplemented + # @call_on_thread(strings.dialog.filtering) + # def filter_threaded( + # self, filter_man: "FilteredModelManager", mode: FilterMode, label: str + # ) -> None: + # filter_man.filter(mode, label) + # self.to_insert = filter_man.get_proxy_model() + # print("filtering threaded") + # self.cleanup_func = StoredFunc(self.cleanup_on_success) + + # TODO: call filter_man methods directly + #def refilter_model(self, mode: FilterMode, label: Optional[str] = None) -> None: + # tv = self.get_active_treeview() + # filter_man = tv.get_filter_man() + # if filter_man.get_control() is None: + # return + # self.filter_threaded(filter_man, mode, label) diff --git a/dzgui/views/pages/servers.py b/dzgui/views/pages/servers.py index f56f955..36d99ca 100644 --- a/dzgui/views/pages/servers.py +++ b/dzgui/views/pages/servers.py @@ -37,12 +37,6 @@ class ServerNotebook(Gtk.ScrolledWindow): ) self.lan = ServerTreeView(controller, ServerTab.LAN, ContextMenuGroup.SCAN_LAN) - # TODO: number of jobs will not be known a priori unless files are read - self.recent.set_query_func(StoredFunc(self.controller.dump_history)) - self.saved.set_query_func(StoredFunc(self.controller.dump_favorites)) - self.lan.set_query_func(StoredFunc(self.controller.dump_test_2)) - self.browser.set_query_func(StoredFunc(self.controller.dump_api), jobs=11) - tabs = [ (self.browser, server_labels.browser), (self.saved, server_labels.saved), From 68cd5672498e2b31df05c12ae4d0d95199de9c08 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 22 Feb 2026 04:04:37 +0900 Subject: [PATCH 098/309] chore: move signal emission into ServerTreeView --- dzgui/controllers/mc.py | 29 ++++----- dzgui/managers/thread_man.py | 10 +++- dzgui/model/servers.py | 79 ++++++++++++------------- dzgui/util/strings.py | 2 + dzgui/views/components/connect_panel.py | 5 +- dzgui/views/pages/servers.py | 28 ++++++++- dzgui/views/trees/tree_servers.py | 15 +---- 7 files changed, 90 insertions(+), 78 deletions(-) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 8215c51..ebdcdc3 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -112,6 +112,8 @@ class Controller(GObject.GObject): self.prefs: UserPrefs self.cleanup_func: StoredFunc = None + #self.config_man = ConfigManager() + self.emitter = Emitter() self.emitter.connect("map_selection_changed", self._on_map_selection_changed) self.emitter.connect("check_toggled", self._on_check_toggled) @@ -150,12 +152,15 @@ class Controller(GObject.GObject): logger.critical(f"{attr} is not a valid AppNavigation attribute.") def get_prefs(self) -> UserPrefs: + # return self.config_man.get_prefs() return self.prefs def set_prefs(self, prefs: UserPrefs) -> None: + # self.config_man.set_prefs() self.prefs = prefs def query_config(self, key: Preferences) -> str | bool | list: + # return self.config_man.lookup(key) config = self.prefs.paths.config return lookup(config, key) @@ -201,6 +206,7 @@ class Controller(GObject.GObject): widget.handler_unblock_by_func(func) def toggle_debug_mode(self) -> None: + # self.config_man().toggle_config(Preferences.DEBUG) self.toggle_config(Preferences.DEBUG) def get_active_context(self) -> NotebookPage: @@ -217,6 +223,7 @@ class Controller(GObject.GObject): treeview = self.get_active_treeview() columns = treeview.get_columns() + #columns_file = self.config_man.get_columns() columns_file = self.prefs.paths.columns try: data = JSON.read_json(columns_file) @@ -242,6 +249,7 @@ class Controller(GObject.GObject): w, h = self.mediator.window.get_size() data = {"res": {"width": w, "height": h}} + #res_path = self.config_man.get_resolution() res_path = self.prefs.paths.resolution try: write_json(data, res_path) @@ -279,6 +287,7 @@ class Controller(GObject.GObject): if haversine is None: dist = "Unknown" else: + # self.config_man.use_miles() if self.prefs.use_miles: raw = round(haversine.as_miles()) separated = number(raw) @@ -651,25 +660,6 @@ class Controller(GObject.GObject): model[path][4] = None self.mediator.modtreeview.set_cursor(0) - def dump_test_2(self) -> None: - data = ( - [ - "BAR", - "chernarusplus", - "a", - "a", - 1, - 1, - 1, - "185.207.214.16:2302", - 1, - 1, - "a", - False, - ], - ) - self.push_data_success(data, FilterMode.INITIAL) - def get_map_man(self) -> "MapManager": return self.get_active_treeview().get_map_man() @@ -928,6 +918,7 @@ class Controller(GObject.GObject): self.emitter.emit("load_maps", store) def _on_servers_loaded(self, emitter: "Emitter", tab: "ServerTab") -> None: + return # NOTE: workaround for GTK bug where fullscreen causes headers to vanish when model is None # TODO: this should be internal to servers page state = self.has_server_model() diff --git a/dzgui/managers/thread_man.py b/dzgui/managers/thread_man.py index 18e0d3d..7eafcf6 100644 --- a/dzgui/managers/thread_man.py +++ b/dzgui/managers/thread_man.py @@ -3,7 +3,7 @@ import logging import threading from functools import wraps -from typing import Callable +from typing import Callable, Optional from dzgui.views.dialogs.generic import WaitDialog @@ -49,6 +49,7 @@ class ThreadingManager: self.parent = parent self.jobs = 1 self.cleanup_func = None + self.alternate_statusbar = None def call_on_thread(self, dialog_str: str, func: StoredFunc) -> None: def callback() -> None: @@ -69,6 +70,13 @@ class ThreadingManager: def increment_dialog_with_str(self, text: str) -> None: GLib.idle_add(lambda: self.wait_dialog.increment(text)) + # TODO: this should not be delegated here + def set_alternate_statusbar(self, msg: str) -> None: + self.alternate_statusbar = msg + + def get_alternate_statusbar(self) -> Optional[str]: + return self.alternate_statusbar + def set_cleanup_func(self, func: StoredFunc) -> None: if type(func) not in (StoredFunc, type(None)): msg = f"Callback function '{func}' is not of type StoredFunc or None" diff --git a/dzgui/model/servers.py b/dzgui/model/servers.py index d0abc37..bfceaca 100644 --- a/dzgui/model/servers.py +++ b/dzgui/model/servers.py @@ -1,5 +1,6 @@ from datetime import datetime import logging +import threading from concurrent.futures import wait, as_completed from concurrent.futures import ThreadPoolExecutor @@ -21,6 +22,8 @@ import gi gi.require_version("Gtk", "3.0") from gi.repository import Gtk, GLib # noqa E402 +LAN_TIMEOUT = 0.5 +API_TIMEOUT = 3 if TYPE_CHECKING: from dzgui.controllers.mc import Controller @@ -29,6 +32,10 @@ if TYPE_CHECKING: logger = logging.getLogger(__name__) + +# TODO: failure: spawns error dialog +# TODO: non failure with empty model: updates statusbar + class ServerModelManager: def __init__(self, controller: "Controller", tv: Gtk.TreeView, first_iteration=False) -> None: @@ -37,6 +44,7 @@ class ServerModelManager: self.emitter = controller.get_emitter() if tv.is_loaded(): self.emitter.emit("servers_loaded", enum) + return self.tv = tv self.jobs = 1 @@ -61,7 +69,8 @@ class ServerModelManager: # TODO: get row count self._dump_history() case ServerTab.LAN: - self._dump_lan() + return + #self._dump_lan() @call_on_thread(dialog.fetching) def _dump_api(self) -> None: @@ -74,7 +83,7 @@ class ServerModelManager: for future in as_completed(futures): try: self.thread_man.increment_dialog() - res = future.result(timeout=3) + res = future.result(timeout=API_TIMEOUT) if res.status != 200 or not res.parsed: self.thread_man.set_cleanup_func(StoredFunc(self._cleanup_on_failure)) return @@ -92,15 +101,12 @@ class ServerModelManager: j = res.json servers += j["response"]["servers"] - # TODO: strings - #self.thread_man.increment_dialog_with_str("Unpacking servers") - # TODO: try/except + # TODO: try/except when parsing parsed = Servers.parse_json(servers) - self._push_data_success(parsed, FilterMode.INITIAL) + self._push_data(parsed, FilterMode.INITIAL) - # TODO: strings - @call_on_thread("scanning LAN ports") - def _dump_lan(self, port: int, early_abort: bool) -> None: + @call_on_thread(dialog.scanning) + def dump_lan(self, port: int, early_abort: bool) -> None: servers = [] ports = range(1, 256) @@ -111,24 +117,24 @@ class ServerModelManager: ] for future in as_completed(futures): try: - res = future.result(timeout=0.5) + res = future.result(timeout=LAN_TIMEOUT) if res is not None and early_abort is True: # NOTE: first non-empty hit, flag pending threads to close event.set() servers.append(res) - self.cleanup_func = StoredFunc(self._cleanup_on_success) + self.thread_man.set_cleanup_func(StoredFunc(self._cleanup_on_success)) return if res is None: continue servers.append(res) except Exception as e: logger.critical(e) - self.cleanup_func = StoredFunc(self._cleanup_on_failure) + self.thread_man.set_cleanup_func(StoredFunc(self._cleanup_on_failure)) if len(servers) == 0: - self.cleanup_func = StoredFunc(self._cleanup_on_failure) + self.thread_man.set_cleanup_func(StoredFunc(self._cleanup_on_failure)) return parsed = Servers.parse_json(servers) - self._push_data_success(parsed, FilterMode.INITIAL) + self._push_data(parsed, FilterMode.INITIAL) # TODO: strings @call_on_thread("dumping ips") @@ -136,10 +142,11 @@ class ServerModelManager: # NOTE: block malformed records (TODO: add github issue no.) # TODO: sanitize ip list at config time, drop this ips = [ip for ip in ips if len(ip.split(":")) == 3 and ip.split(":")[2] != ""] + job = Servers.query_direct with ThreadPoolExecutor() as executor: futures = [ executor.submit( - Servers.query_direct, + job, ip.split(":")[0], int(ip.split(":")[2]), ) @@ -154,12 +161,11 @@ class ServerModelManager: continue servers.append(res) if len(servers) == 0: - self.cleanup_func = StoredFunc(self._cleanup_on_failure) + self.thread_man.set_cleanup_func(StoredFunc(self._cleanup_on_failure)) return - # NOTE: 1 extra progress bar pass for parsing parsed = Servers.parse_json(servers) - self._push_data_success(parsed, FilterMode.INITIAL) + self._push_data(parsed, FilterMode.INITIAL) def _query_ip_id(self, addr: str) -> None: # NOTE: Battlemetrics @@ -177,7 +183,8 @@ class ServerModelManager: # TODO: strings @call_on_thread("querying address") def _connect_by_id_or_ip(self, addr: str) -> None: - res = self.query_ip_id(addr) + res = self._query_ip_id(addr) + # TODO: unimplemented @call_on_thread("querying address") def _add_by_id_or_ip(self, addr: str) -> None: @@ -193,7 +200,8 @@ class ServerModelManager: # NOTE: this can be called from other tabs--if current focus is not ServerTab.SAVED, update label only # TODO: saved servers might not be loaded yet, in which case should just update local file only # TODO: perform simple equality comparison of self.tv.get_enum() == ServerTab.SAVED - # FIXME: filter man is saved on a per tab basis, so this will mismatch + # FIXME: filter man is saved on a per tab basis, so this will mismatch + # check if servers.get_active_treeview() is same as self.tv self.set_cleanup_func(StoredFunc(self._cleanup_on_insert)) def _dump_history(self) -> None: @@ -203,10 +211,10 @@ class ServerModelManager: with open(history, "r") as f: rows = [row.rstrip("\n") for row in f] except OSError: - self.cleanup_func = StoredFunc(self._cleanup_on_failure, False) + self.thread_man.set_cleanup_func(StoredFunc(self._cleanup_on_failure, False)) return if len(rows) == 0: - self.cleanup_func = StoredFunc(self._cleanup_on_failure, False) + self.thread_man.set_cleanup_func(StoredFunc(self._cleanup_on_failure, False)) return self.thread_man.set_job_count(len(rows)) self._dump_ips(rows) @@ -218,7 +226,7 @@ class ServerModelManager: if len(ips) == 0: # FIXME: this is not a failure, just a quiet exit with custom statusbar # TODO: add custom statusbar parameters - self.cleanup_func = StoredFunc(self._cleanup_on_failure, False) + self.thread_man.set_cleanup_func(StoredFunc(self._cleanup_on_failure, False)) return self._dump_ips(ips) @@ -243,17 +251,10 @@ class ServerModelManager: self.emitter.emit("servers_loaded", context) def _cleanup_on_success(self) -> None: - self.pending_jobs = 1 - self.tv.set_loaded(True) self.tv.set_model(self.to_insert) - # TODO: this will allow history and saved tab to emit signals to statusbar - # CHORE: test if treeview's sort method inserts row at the correct index - # inserting a row serializes file on disk, updates control model for that tab, and - # reapplies filters to ephemeral model; since filters are applied, in-situ insertion might not be necessary - # TODO: will be inserted out of order - #self.to_insert.connect("row-inserted", lambda *args: print("row inserted into model")) - + # inserting a row serializes file on disk, updates control model for that tab, and updates model + # NOTE: when inserting new rows, the entire control model is wiped and rebuilt, then proxy model is swapped in # TODO: signals or other approach to deferring map # model insertion after thread closes # cf. servers_loaded signal @@ -262,7 +263,8 @@ class ServerModelManager: context = self.tv.get_enum() self.emitter.emit("servers_loaded", context) - # CHORE: this is placeholder logic + # CHORE: this is placeholder logic, + # refills map combo with new maps if self.first_iteration: map_man = self.tv.get_map_man() map_man.set_unique_maps(self.new_maps) @@ -270,14 +272,11 @@ class ServerModelManager: self.first_iteration = False self.new_maps = None - self.tv.grab_focus() - def _cleanup_on_failure(self, show_dialog=True) -> None: - self.treeview.set_loaded(True) map_man = self.treeview.get_map_man() # TODO: disable map, keyword, and filter widgets if model is None - # -> signal driven (servers_empty) + # -> signal driven (servers_empty, servers_failed_to_load) # TODO: what if refresh action occurred and failed, and the old model is still valid? # skip the step below if refresh action failed # do not wipe control model in this case @@ -286,8 +285,6 @@ class ServerModelManager: # wipe refresh state to False self.treeview.set_model(None) - self.treeview.grab_focus() - map_man.set_unique_maps(None) context = self.treeview.get_enum() @@ -300,7 +297,7 @@ class ServerModelManager: # TODO: break into initial dump and refilter modes, can drop filtermode kwarg # and stop pushing empty data - def _push_data_success(self, data: tuple, mode: Optional[FilterMode]) -> None: + def _push_data(self, data: tuple, mode: Optional[FilterMode]) -> None: # FIXME: calls treeview read methods in thread # treeview = self.get_active_treeview() # manager = treeview.get_filter_man() @@ -313,7 +310,7 @@ class ServerModelManager: manager.set_control(data) manager.filter(mode) self.to_insert = manager.get_proxy_model() - # TODO: pre parse maps + u_maps = set([row[1] for row in data]) self.new_maps = sorted(u_maps) diff --git a/dzgui/util/strings.py b/dzgui/util/strings.py index febd83d..cba5d78 100644 --- a/dzgui/util/strings.py +++ b/dzgui/util/strings.py @@ -234,6 +234,7 @@ class Dialog: modlist: str working: str updating_mods: str + scanning: str @dataclass(slots=True, frozen=True) @@ -429,6 +430,7 @@ dialog = Dialog( modlist="Fetching modlist", working="Working", updating_mods="Updating mods", + scanning="Scanning LAN ports", ) buttons = Button( diff --git a/dzgui/views/components/connect_panel.py b/dzgui/views/components/connect_panel.py index 93d3b59..2e38462 100644 --- a/dzgui/views/components/connect_panel.py +++ b/dzgui/views/components/connect_panel.py @@ -1,5 +1,6 @@ from typing import TYPE_CHECKING +from dzgui.model.servers import ServerModelManager from dzgui.util.strings import connect_panel, fav_panel, lan_panel from dzgui.views.components.buttons import ( AddButton, @@ -75,7 +76,9 @@ class LanPanel(Gtk.Frame): else: port = self.entry.get_text() abort = self.early_abort.get_active() - self.controller.dump_lan(int(port), abort) + smm = ServerModelManager(self.controller, self.controller.get_active_treeview()) + smm.dump_lan(int(port), abort) + #self.controller.dump_lan(int(port), abort) def _on_radio_toggled(self, button: Gtk.RadioButton) -> None: state = button.get_active() diff --git a/dzgui/views/pages/servers.py b/dzgui/views/pages/servers.py index 36d99ca..907473f 100644 --- a/dzgui/views/pages/servers.py +++ b/dzgui/views/pages/servers.py @@ -55,6 +55,18 @@ class ServerNotebook(Gtk.ScrolledWindow): self.connect("map", self._on_map) self.connect("unmap", self._on_unmap) + self.emitter.connect("servers_loaded", self._on_servers_loaded) + + def _on_servers_loaded(self, emitter: "Emitter", tab: "ServerTab") -> None: + # NOTE: workaround for GTK bug where fullscreen causes headers to vanish when model is None + # TODO: this should be internal to servers page + tv = self.get_active_treeview() + state = False if tv.get_model() is None else True + tv.set_headers_visible(state) + tv.set_headers_clickable(state) + tv.set_loaded(True) + tv.grab_focus() + def _on_map(self, widget: Self) -> None: self.emitter.emit("server_page_toggled", True) # FIXME: only applies to server notebook, not atomic page @@ -137,5 +149,17 @@ class ServerNotebook(Gtk.ScrolledWindow): tab, col, "_on_col_width_changed", False ) - def get_tabs(self) -> tuple: - return (self.browser, self.saved, self.recent, self.lan) + # def get_tabs(self) -> tuple: + # return (self.browser, self.saved, self.recent, self.lan) + + def get_browser(self) -> ServerTreeView: + return self.browser + + def get_saved(self) -> ServerTreeView: + return self.saved + + def get_recent(self) -> ServerTreeView: + return self.recent + + def get_lan(self) -> ServerTreeView: + return self.lan diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index dcdf05a..f05d542 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -45,7 +45,6 @@ class ServerTreeView(ContextMixin, TreeView): self.enum = enum self.loaded = False - self.query_func: Callable = None self.filter_man = FilteredModelManager(controller) model = self.filter_man.get_proxy_model() @@ -59,12 +58,7 @@ class ServerTreeView(ContextMixin, TreeView): self.queue_id: int self.handler_id: int - self.query_func_jobs = 1 - - self.current_proc = None - self.queue = Queue() - # self.queue = multiprocessing.Queue() prefs = self.controller.get_prefs() columns = prefs.paths.columns @@ -129,6 +123,7 @@ class ServerTreeView(ContextMixin, TreeView): self.connect("map", self._on_map) self.connect("unmap", self._on_unmap) + # TODO: why is this being saved? self.thread = None def start_timeout(self) -> None: @@ -184,14 +179,6 @@ class ServerTreeView(ContextMixin, TreeView): if self.get_enum() is ServerTab.LAN: self.emitter.emit("lan_tab_toggled", False) - def set_query_func(self, func: "StoredFunc", jobs: int = 1) -> None: - self.query_func = func - self.query_func_jobs = jobs - - # TODO: possibly split this up - def get_query_func(self) -> tuple["StoredFunc", int]: - return self.query_func, self.query_func_jobs - def _on_col_width_changed( self, col: Gtk.TreeViewColumn, width: GObject.ParamSpecInt ) -> None: From a22d1a6324d081dc04ef62e6dd71de55031f9e19 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 22 Feb 2026 04:11:23 +0900 Subject: [PATCH 099/309] feat: standalone clipboard utility --- dzgui/managers/contextmenu.py | 11 ++++------- dzgui/util/clip.py | 10 ++++++++++ dzgui/views/components/buttons.py | 3 ++- 3 files changed, 16 insertions(+), 8 deletions(-) create mode 100644 dzgui/util/clip.py diff --git a/dzgui/managers/contextmenu.py b/dzgui/managers/contextmenu.py index 9c84b21..be279c1 100644 --- a/dzgui/managers/contextmenu.py +++ b/dzgui/managers/contextmenu.py @@ -5,6 +5,7 @@ from typing import TYPE_CHECKING from dzgui.const.enum import ContextMenu, Preferences from dzgui.managers.thread_man import ThreadingManager from dzgui.util import strings +from dzgui.util.clip import copy_clipboard from dzgui.util.open_links import open_workshop_page gi.require_version("Gtk", "3.0") @@ -72,17 +73,13 @@ class ContextMenuManager: def copy_server_ip(self) -> None: record = self.treeview.get_simplified_ip() - self.copy_clipboard(record) - - def copy_clipboard(self, text: str) -> None: - self.clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) - self.clipboard.set_text(text, -1) + copy_clipboard(record) def copy_server_name(self) -> None: name = self.treeview.get_value_at_index(0) if name is None: return - self.copy_clipboard(name) + copy_clipboard(name) def open_mod_page(self) -> None: mod = self.treeview.get_selected_mod() @@ -101,4 +98,4 @@ class ContextMenuManager: concat = strings.delimiter.join(r) final.append(concat) text = "\n".join(final) - self.copy_clipboard(text) + copy_clipboard(text) diff --git a/dzgui/util/clip.py b/dzgui/util/clip.py new file mode 100644 index 0000000..58bf68e --- /dev/null +++ b/dzgui/util/clip.py @@ -0,0 +1,10 @@ +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository.Gdk import SELECTION_CLIPBOARD # noqa E402 +from gi.repository.Gtk import Clipboard # noqa E402 + + +def copy_clipboard(text: str) -> None: + clipboard = Clipboard.get(SELECTION_CLIPBOARD) + clipboard.set_text(text, -1) diff --git a/dzgui/views/components/buttons.py b/dzgui/views/components/buttons.py index de6141a..04c88c8 100644 --- a/dzgui/views/components/buttons.py +++ b/dzgui/views/components/buttons.py @@ -1,5 +1,6 @@ from typing import Callable, Literal, Self, TYPE_CHECKING +from dzgui.util.clip import copy_clipboard from dzgui.util.strings import atomic_buttons, connect_panel from dzgui.const.constants import ( CLIPBOARD, @@ -69,7 +70,7 @@ class ClipboardButton(IconTextButton): def _on_button_clicked(self, button: Self, func: Callable) -> None: data = func() - self.controller.copy_clipboard(data) + copy_clipboard(data) class WebButton(IconTextButton): From e56b89fd64ee8cf77e10f0e64795fa05b56f3fea Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 22 Feb 2026 22:57:44 +0900 Subject: [PATCH 100/309] fix: set cell renderer properties --- dzgui/controllers/emitter.py | 9 ++- dzgui/controllers/mc.py | 22 +++---- dzgui/managers/thread_man.py | 1 + dzgui/model/model_factory.py | 23 +++++-- dzgui/model/servers.py | 80 ++++++++++++++++--------- dzgui/util/strings.py | 3 +- dzgui/views/components/connect_panel.py | 49 +++++++++++---- dzgui/views/pages/servers.py | 8 +-- dzgui/views/trees/tree_mods.py | 27 +++++++-- dzgui/views/trees/tree_servers.py | 36 ++++------- 10 files changed, 160 insertions(+), 98 deletions(-) diff --git a/dzgui/controllers/emitter.py b/dzgui/controllers/emitter.py index c055e6c..b0c2284 100644 --- a/dzgui/controllers/emitter.py +++ b/dzgui/controllers/emitter.py @@ -33,8 +33,13 @@ class Emitter(GObject.GObject): pass @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=()) - def request_lan_entry_focus(self) -> None: - """User invoked Ctrl-p keybinding from ServerTreeView""" + def request_custom_port_focus(self) -> None: + """User invoked Ctrl-n keybinding from ServerTreeView""" + pass + + @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=()) + def request_default_port_focus(self) -> None: + """user invoked ctrl-d keybinding from servertreeview""" pass @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=()) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index ebdcdc3..379dae5 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -4,18 +4,14 @@ import shutil import threading import traceback -from concurrent.futures import wait, as_completed -from concurrent.futures import ThreadPoolExecutor from functools import wraps from pathlib import Path from typing import Any, Callable, Optional, TYPE_CHECKING from warnings import deprecated import dzgui.api.pefile as PeFile -import dzgui.api.servers as Servers import dzgui.util._json as JSON # noqa -from dzgui.api.bm import map_id_to_record from dzgui.api.mods import ( get_delimited_mods, get_local_mod_path, @@ -112,7 +108,7 @@ class Controller(GObject.GObject): self.prefs: UserPrefs self.cleanup_func: StoredFunc = None - #self.config_man = ConfigManager() + # self.config_man = ConfigManager() self.emitter = Emitter() self.emitter.connect("map_selection_changed", self._on_map_selection_changed) @@ -223,7 +219,7 @@ class Controller(GObject.GObject): treeview = self.get_active_treeview() columns = treeview.get_columns() - #columns_file = self.config_man.get_columns() + # columns_file = self.config_man.get_columns() columns_file = self.prefs.paths.columns try: data = JSON.read_json(columns_file) @@ -249,7 +245,7 @@ class Controller(GObject.GObject): w, h = self.mediator.window.get_size() data = {"res": {"width": w, "height": h}} - #res_path = self.config_man.get_resolution() + # res_path = self.config_man.get_resolution() res_path = self.prefs.paths.resolution try: write_json(data, res_path) @@ -316,13 +312,13 @@ class Controller(GObject.GObject): # TODO: delegate to threadmanager @call_on_thread(strings.dialog.modlist) def load_mods(self) -> None: - model = ModelFactory().make_mod_store() #self.model_man.new_mod_store() + model = ModelFactory().make_mod_store() # self.model_man.new_mod_store() path = self.query_config(Preferences.DEFAULT) mods = get_delimited_mods(Path(path)) for mod in mods: - # NOTE: holds color column - mod.append(None) + # NOTE: show highlight color bool + mod.append(False) model.append(mod) self.cleanup_func = StoredFunc(self.load_mods_cleanup, model) @@ -749,7 +745,7 @@ class Controller(GObject.GObject): it = mod.iter path = model.get_path(it) if int(mod[2]) in stale_mods: - model[path][4] = HEX_RED + model[path][4] = True self.emitter.emit("mods_highlighted") @call_on_thread(strings.dialog.working) @@ -946,10 +942,6 @@ class Controller(GObject.GObject): map_man.set_selected_map(selection) self.refilter_model(FilterMode.MAP) - def hide_widgets_on_init(self) -> None: - self.mediator.grid.conpan.set_lan_visible(False) - self.mediator.grid.right_panel.sel_panel.hide() - def get_notebook(self) -> "Notebook": return self.mediator.notebook diff --git a/dzgui/managers/thread_man.py b/dzgui/managers/thread_man.py index 7eafcf6..036416d 100644 --- a/dzgui/managers/thread_man.py +++ b/dzgui/managers/thread_man.py @@ -21,6 +21,7 @@ def call_on_thread(dialog_str: str) -> Callable: def wrapper(*args, **kwargs): self = args[0] stored = StoredFunc(func, *args, **kwargs) + print("SELF", self) if not hasattr(self, "thread_man"): raise AttributeError if type(self.thread_man) is not ThreadingManager: diff --git a/dzgui/model/model_factory.py b/dzgui/model/model_factory.py index afeb3c1..5e9d687 100644 --- a/dzgui/model/model_factory.py +++ b/dzgui/model/model_factory.py @@ -18,7 +18,7 @@ class ModCols: symlink: str directory: str size: float - color: str + color: bool @dataclass(slots=True, frozen=True) @@ -42,6 +42,17 @@ class MenuCols: hidden: GObject.TYPE_PYOBJECT +class FastInsertListStore(ListStore): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + + def append(self, row) -> None: + total = len(row) + i = len(self) + tree_iter = self.insert_with_values(i, tuple(range(0, total)), row) + return tree_iter + + class ModelFactory: def __init__(self) -> None: pass @@ -49,17 +60,17 @@ class ModelFactory: def new_model_from_logfile(self, path: str) -> None: store = self.make_log_store() with open(path, "r") as f: - lines = [ - line.split(delimiter) for line in f.read().splitlines() - ] + lines = [line.split(delimiter) for line in f.read().splitlines()] for record in lines: # NOTE: strip PII and API keys clean = redact_log(record) store.append(clean) return store - def new_model_from_class(self, cls: type) -> ListStore: - store = ListStore(*[ftype for field, ftype in cls.__annotations__.items()]) + def new_model_from_class(self, cls: type) -> FastInsertListStore: + store = FastInsertListStore( + *[ftype for field, ftype in cls.__annotations__.items()] + ) return store def make_map_store(self) -> ListStore: diff --git a/dzgui/model/servers.py b/dzgui/model/servers.py index bfceaca..8fe14f7 100644 --- a/dzgui/model/servers.py +++ b/dzgui/model/servers.py @@ -12,7 +12,7 @@ from dzgui.const.constants import ( ) from dzgui.const.enum import FilterMode, Preferences, ServerTab from dzgui.managers.thread_man import call_on_thread, StoredFunc, ThreadingManager -from dzgui.util.strings import dialog +from dzgui.util.strings import api_warn_msg, dialog from dzgui.views.dialogs.generic import ExceptionDialog from typing import Optional, TYPE_CHECKING @@ -36,8 +36,11 @@ logger = logging.getLogger(__name__) # TODO: failure: spawns error dialog # TODO: non failure with empty model: updates statusbar + class ServerModelManager: - def __init__(self, controller: "Controller", tv: Gtk.TreeView, first_iteration=False) -> None: + def __init__( + self, controller: "Controller", tv: Gtk.TreeView, first_iteration=False + ) -> None: enum = tv.get_enum() self.controller = controller @@ -58,6 +61,8 @@ class ServerModelManager: # filter: iteration N+1 self.first_iteration = first_iteration + # TODO: pass servers.saved treeview when using conpan regardless of current context + match enum: case ServerTab.BROWSER: # NOTE: extra DAYZ_EXP param @@ -69,8 +74,11 @@ class ServerModelManager: # TODO: get row count self._dump_history() case ServerTab.LAN: - return - #self._dump_lan() + # NOTE: LAN tab is only loaded on demand + pass + case _: + pass + # self._dump_lan() @call_on_thread(dialog.fetching) def _dump_api(self) -> None: @@ -85,14 +93,18 @@ class ServerModelManager: self.thread_man.increment_dialog() res = future.result(timeout=API_TIMEOUT) if res.status != 200 or not res.parsed: - self.thread_man.set_cleanup_func(StoredFunc(self._cleanup_on_failure)) + self.thread_man.set_cleanup_func( + StoredFunc(self._cleanup_on_failure) + ) return j = res.json servers += j["response"]["servers"] except Exception as e: # TODO: could store exception in cleanup func logger.critical(e) - self.thread_man.set_cleanup_func(StoredFunc(self._cleanup_on_failure)) + self.thread_man.set_cleanup_func( + StoredFunc(self._cleanup_on_failure) + ) return # NOTE: This step is allowed to fail, since this metadata is incidental @@ -112,9 +124,7 @@ class ServerModelManager: event = threading.Event() with ThreadPoolExecutor() as executor: - futures = [ - executor.submit(Servers.test_ip, i, port, event) for i in ports - ] + futures = [executor.submit(Servers.test_ip, i, port, event) for i in ports] for future in as_completed(futures): try: res = future.result(timeout=LAN_TIMEOUT) @@ -122,14 +132,18 @@ class ServerModelManager: # NOTE: first non-empty hit, flag pending threads to close event.set() servers.append(res) - self.thread_man.set_cleanup_func(StoredFunc(self._cleanup_on_success)) + self.thread_man.set_cleanup_func( + StoredFunc(self._cleanup_on_success) + ) return if res is None: continue servers.append(res) except Exception as e: logger.critical(e) - self.thread_man.set_cleanup_func(StoredFunc(self._cleanup_on_failure)) + self.thread_man.set_cleanup_func( + StoredFunc(self._cleanup_on_failure) + ) if len(servers) == 0: self.thread_man.set_cleanup_func(StoredFunc(self._cleanup_on_failure)) return @@ -161,7 +175,9 @@ class ServerModelManager: continue servers.append(res) if len(servers) == 0: - self.thread_man.set_cleanup_func(StoredFunc(self._cleanup_on_failure)) + self.thread_man.set_cleanup_func( + StoredFunc(self._cleanup_on_failure) + ) return parsed = Servers.parse_json(servers) @@ -200,8 +216,8 @@ class ServerModelManager: # NOTE: this can be called from other tabs--if current focus is not ServerTab.SAVED, update label only # TODO: saved servers might not be loaded yet, in which case should just update local file only # TODO: perform simple equality comparison of self.tv.get_enum() == ServerTab.SAVED - # FIXME: filter man is saved on a per tab basis, so this will mismatch - # check if servers.get_active_treeview() is same as self.tv + # FIXME: filter man is saved on a per tab basis, so this will mismatch + # check if servers.get_active_treeview() is same as self.tv self.set_cleanup_func(StoredFunc(self._cleanup_on_insert)) def _dump_history(self) -> None: @@ -211,10 +227,14 @@ class ServerModelManager: with open(history, "r") as f: rows = [row.rstrip("\n") for row in f] except OSError: - self.thread_man.set_cleanup_func(StoredFunc(self._cleanup_on_failure, False)) + self.thread_man.set_cleanup_func( + StoredFunc(self._cleanup_on_failure, False) + ) return if len(rows) == 0: - self.thread_man.set_cleanup_func(StoredFunc(self._cleanup_on_failure, False)) + self.thread_man.set_cleanup_func( + StoredFunc(self._cleanup_on_failure, False) + ) return self.thread_man.set_job_count(len(rows)) self._dump_ips(rows) @@ -226,7 +246,9 @@ class ServerModelManager: if len(ips) == 0: # FIXME: this is not a failure, just a quiet exit with custom statusbar # TODO: add custom statusbar parameters - self.thread_man.set_cleanup_func(StoredFunc(self._cleanup_on_failure, False)) + self.thread_man.set_cleanup_func( + StoredFunc(self._cleanup_on_failure, False) + ) return self._dump_ips(ips) @@ -273,10 +295,10 @@ class ServerModelManager: self.new_maps = None def _cleanup_on_failure(self, show_dialog=True) -> None: - map_man = self.treeview.get_map_man() + map_man = self.tv.get_map_man() # TODO: disable map, keyword, and filter widgets if model is None - # -> signal driven (servers_empty, servers_failed_to_load) + # -> signal driven (servers_empty, servers_failed_to_load) # TODO: what if refresh action occurred and failed, and the old model is still valid? # skip the step below if refresh action failed # do not wipe control model in this case @@ -284,15 +306,15 @@ class ServerModelManager: # revert old model # wipe refresh state to False - self.treeview.set_model(None) + self.tv.set_model(None) map_man.set_unique_maps(None) - context = self.treeview.get_enum() + context = self.tv.get_enum() # TODO: distinguish signals, e.g. "servers_failed_to_load", "servers_loaded_empty" - # customize statusbar accordingly + # customize statusbar and dialog accordingly self.emitter.emit("servers_loaded", context) if show_dialog: - dialog = ExceptionDialog(self, strings.api_warn_msg) + dialog = ExceptionDialog(self.controller, api_warn_msg) dialog.run() # TODO: break into initial dump and refilter modes, can drop filtermode kwarg @@ -330,9 +352,9 @@ class ServerModelManager: # self.cleanup_func = StoredFunc(self.cleanup_on_success) # TODO: call filter_man methods directly - #def refilter_model(self, mode: FilterMode, label: Optional[str] = None) -> None: - # tv = self.get_active_treeview() - # filter_man = tv.get_filter_man() - # if filter_man.get_control() is None: - # return - # self.filter_threaded(filter_man, mode, label) + def refilter_model(self, mode: FilterMode, label: Optional[str] = None) -> None: + tv = self.get_active_treeview() + self.filter_man = tv.get_filter_man() + if self.filter_man.get_control() is None: + return + self.filter_threaded(self.filter_man, mode, label) diff --git a/dzgui/util/strings.py b/dzgui/util/strings.py index cba5d78..77959c2 100644 --- a/dzgui/util/strings.py +++ b/dzgui/util/strings.py @@ -132,7 +132,8 @@ servers = { "Ctrl-p": "refresh ping", "Ctrl-f": "jump to keyword search field", "Ctrl-m": "jump to maps field", - "Ctrl-d": "toggle dry run (debug) mode", + "Ctrl-d": "select default LAN port", + "Ctrl-n": "select custom LAN port", "Ctrl-i": "jump to IP insert field", "ESC": "return to table", "1-9": "toggle filter 1-9 on/off", diff --git a/dzgui/views/components/connect_panel.py b/dzgui/views/components/connect_panel.py index 2e38462..7dbde37 100644 --- a/dzgui/views/components/connect_panel.py +++ b/dzgui/views/components/connect_panel.py @@ -32,9 +32,11 @@ class LanPanel(Gtk.Frame): label = BoldLabel(lan_panel.heading) self.set_label_widget(label) - self.radio1 = Gtk.RadioButton.new_with_label(None, lan_panel.default_button) - radio2 = Gtk.RadioButton.new_with_label_from_widget( - self.radio1, lan_panel.custom_button + self.default_radio = Gtk.RadioButton.new_with_label( + None, lan_panel.default_button + ) + self.custom_radio = Gtk.RadioButton.new_with_label_from_widget( + self.default_radio, lan_panel.custom_button ) self.entry = PortEntry(controller) @@ -47,18 +49,22 @@ class LanPanel(Gtk.Frame): "Unless you have multiple DayZ servers on your LAN,\nleave this checked to get results faster" ) + self.entry.connect("activate", self._on_entry_activated) self.entry.connect("string_validated", self._on_port_validated) + self.emitter.connect("request_custom_port_focus", self._on_custom_port_binding) self.emitter.connect( - "request_lan_entry_focus", lambda _: self.entry.grab_focus() + "request_default_port_focus", lambda _: self.default_radio.set_active(True) ) - self.radio1.connect("toggled", self._on_radio_toggled) + self.default_radio.connect("toggled", self._on_radio_toggled) self.grid = Gtk.Grid(margin=10, vexpand=False, column_spacing=15, row_spacing=5) - self.grid.attach(self.radio1, 0, 0, COLS, ROWS) + self.grid.attach(self.default_radio, 0, 0, COLS, ROWS) self.grid.attach_next_to( - radio2, self.radio1, Gtk.PositionType.RIGHT, COLS, ROWS + self.custom_radio, self.default_radio, Gtk.PositionType.RIGHT, COLS, ROWS + ) + self.grid.attach_next_to( + self.entry, self.custom_radio, Gtk.PositionType.RIGHT, COLS, ROWS ) - self.grid.attach_next_to(self.entry, radio2, Gtk.PositionType.RIGHT, COLS, ROWS) self.grid.attach_next_to( self.scan, self.entry, Gtk.PositionType.RIGHT, COLS, ROWS ) @@ -70,23 +76,36 @@ class LanPanel(Gtk.Frame): self.entry.set_sensitive(False) + def _on_custom_port_binding(self, emitter: "Emitter") -> None: + self.custom_radio.set_active(True) + self.entry.grab_focus() + + def _on_entry_activated(self, entry: Gtk.Entry) -> None: + if self.entry.get_text() == "": + return + self.scan_ports() + def _on_scan_clicked(self, button: Gtk.Button) -> None: - if self.radio1.get_active(): + self.scan_ports() + + def scan_ports(self) -> None: + if self.default_radio.get_active(): port = 27016 else: port = self.entry.get_text() abort = self.early_abort.get_active() smm = ServerModelManager(self.controller, self.controller.get_active_treeview()) smm.dump_lan(int(port), abort) - #self.controller.dump_lan(int(port), abort) def _on_radio_toggled(self, button: Gtk.RadioButton) -> None: state = button.get_active() self.entry.set_sensitive(not state) if state: self.scan.set_sensitive(True) - elif len(self.entry.get_text()) < 1: - self.scan.set_sensitive(False) + else: + self.entry.grab_focus() + if len(self.entry.get_text()) < 1: + self.scan.set_sensitive(False) def _on_port_validated(self, entry: Gtk.Entry, state: bool) -> None: self.scan.set_sensitive(state) @@ -182,9 +201,13 @@ class AddPanel(Gtk.Frame): def _submit_query(self) -> None: text = self.entry.get_text() - self.controller.add_by_id_or_ip(text) + # TODO: call servermodelmanager + ServerModelManager().add_by_id_or_ip(text) + # self.controller.add_by_id_or_ip(text) def _on_activate(self, entry: Gtk.Entry) -> None: + if not self.add_server.is_sensitive(): + return self._submit_query() def _on_add_clicked(self, button: Gtk.Button) -> None: diff --git a/dzgui/views/pages/servers.py b/dzgui/views/pages/servers.py index 907473f..4fce415 100644 --- a/dzgui/views/pages/servers.py +++ b/dzgui/views/pages/servers.py @@ -1,6 +1,6 @@ import logging -from typing import Self, TYPE_CHECKING +from typing import Optional, Self, TYPE_CHECKING from dzgui.controllers.mc import StoredFunc from dzgui.const.enum import ContextMenuGroup, ServerTab @@ -76,13 +76,13 @@ class ServerNotebook(Gtk.ScrolledWindow): def _on_unmap(self, widget: Self) -> None: self.emitter.emit("server_page_toggled", False) - def _on_keypress(self, widget: Self, event: Gdk.EventKey) -> None: + def _on_keypress(self, widget: Self, event: Gdk.EventKey) -> Optional[False]: + if event.state is Gdk.ModifierType.CONTROL_MASK: + return False match event.keyval: case Gdk.KEY_n: self.notebook.next_page() case Gdk.KEY_p: - if event.state is Gdk.ModifierType.CONTROL_MASK: - return self.notebook.prev_page() case _: return diff --git a/dzgui/views/trees/tree_mods.py b/dzgui/views/trees/tree_mods.py index f86a8ee..766fae5 100644 --- a/dzgui/views/trees/tree_mods.py +++ b/dzgui/views/trees/tree_mods.py @@ -1,11 +1,12 @@ import logging from typing import Any, TYPE_CHECKING +from dzgui.const.constants import HEX_RED +from dzgui.const.enum import ContextMenuGroup 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 -from dzgui.const.enum import ContextMenuGroup +from dzgui.views.trees.tree_base import TreeView import gi @@ -35,14 +36,15 @@ class ModTreeView(ModsMixin, ContextMixin, TreeView): 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 = Gtk.TreeViewColumn(column_title, renderer, text=i) + column.set_cell_data_func(renderer, self._format_color, func_data=None) + if i == 4: 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) - # NOTE: hidden color property column column.set_sizing(Gtk.TreeViewColumnSizing.FIXED) column.set_sort_column_id(i) if i != 4: @@ -88,6 +90,21 @@ class ModTreeView(ModsMixin, ContextMixin, TreeView): def _parent_selection_changed(self, base_class: TreeView, sel: Gtk.TreeSelection): pass + def _format_color( + self, + column: Gtk.TreeViewColumn, + cell: Gtk.CellRendererText, + model: Gtk.TreeModel, + it: Gtk.TreeIter, + data: Any, + ) -> Any: + state = model[it][4] + if state is True: + cell.set_property("foreground", HEX_RED) + else: + cell.set_property("foreground", None) + return + def _format_float( self, column: Gtk.TreeViewColumn, diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index f05d542..228acff 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -8,12 +8,11 @@ from warnings import deprecated from dzgui.views.mixins.context_mixin import ContextMixin from dzgui.const.enum import ContextMenu, ContextMenuGroup, ServerTab from dzgui.api.servers import Record -from dzgui.api.servers import ping as Ping from dzgui.model.map_model import MapManager from dzgui.model.filtered_model import FilteredModelManager from dzgui.util.dist import CalcDist from dzgui.util import strings -from typing import Callable, Literal, TYPE_CHECKING +from typing import Literal, TYPE_CHECKING from dzgui.views.trees.tree_base import TreeView @@ -28,7 +27,7 @@ from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa logger = logging.getLogger(__name__) if TYPE_CHECKING: - from dzgui.controllers.mc import Controller, StoredFunc + from dzgui.controllers.mc import Controller from dzgui.controllers.emitter import Emitter QUEUE_CHECK_DELAY = 200 @@ -216,7 +215,7 @@ class ServerTreeView(ContextMixin, TreeView): while not self.queue.empty(): latest_result = self.queue.get() - # FIXME: cache is being checked at two intervals + # FIXME: cache is being checked in two passes cache = self.controller.get_dist_cache() if latest_result: @@ -239,9 +238,11 @@ class ServerTreeView(ContextMixin, TreeView): self.emitter.emit("request_maps_focus") case Gdk.KEY_i: self.emitter.emit("request_ip_entry_focus") - case Gdk.KEY_p: + case Gdk.KEY_d: + self.emitter.emit("request_default_port_focus") + case Gdk.KEY_n: if self.enum is ServerTab.LAN: - self.emitter.emit("request_lan_entry_focus") + self.emitter.emit("request_custom_port_focus") case Gdk.KEY_c: self.controller.menu_action(ContextMenu.COPY_SERVER_IP, self) case Gdk.KEY_r: @@ -294,23 +295,12 @@ class ServerTreeView(ContextMixin, TreeView): return f"{addr}:{qport}" def get_record(self) -> Record | None: - # TODO: clean up, use tree_base.get_selected_row() - select = self.get_selection() - sels = select.get_selected_rows() - (model, pathlist) = sels - if len(pathlist) < 1: + r = self.get_record_string() + try: + ip, gameport, qport = r.split(":") + return Record(ip, int(gameport), int(qport)) + except ValueError: return None - path = pathlist[0] - model = self.get_model() - if not model: - return None - addr = model[path][7] - if addr is None: - return - qport = model[path][8] - ip = addr.split(":")[0] - gameport = int(addr.split(":")[1]) - return Record(ip, gameport, qport) def is_loaded(self) -> bool: return self.loaded @@ -348,7 +338,7 @@ class ServerTreeView(ContextMixin, TreeView): # thread = threading.Thread( # daemon=True, # target=ping_server, - # args=(model, it, ip, qport, ping), + # args=(model, it, addr, qport, ping), # ) # thread.start() From 6bbe0d6882ef72c3f3dbf35225e0a9888fd39044 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Mon, 23 Feb 2026 00:29:52 +0900 Subject: [PATCH 101/309] feat: add ConnectionManager --- dzgui/api/bm.py | 15 ++- dzgui/api/servers.py | 25 +++++ dzgui/controllers/mc.py | 27 +++-- dzgui/managers/connection.py | 35 ++++++ dzgui/managers/thread_man.py | 1 - dzgui/model/servers.py | 142 +++++++++++------------- dzgui/views/components/connect_panel.py | 11 +- dzgui/views/components/crumbs.py | 2 +- dzgui/views/pages/servers.py | 2 +- 9 files changed, 159 insertions(+), 101 deletions(-) create mode 100644 dzgui/managers/connection.py diff --git a/dzgui/api/bm.py b/dzgui/api/bm.py index c2597f0..c8c85db 100644 --- a/dzgui/api/bm.py +++ b/dzgui/api/bm.py @@ -1,14 +1,18 @@ import logging import requests from pathlib import Path +from typing import Optional, TYPE_CHECKING -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 logger = logging.getLogger(__name__) +if TYPE_CHECKING: + from dzgui.api.servers import Record + + def get_attributes(config: Path, uid: int) -> str: # TODO: handle if key is not set # TODO: tests for malformed IDs/values @@ -18,7 +22,7 @@ def get_attributes(config: Path, uid: int) -> str: payload: dict[str, str] = { "filter[game]": "dayz", "sort": "-players", - "filter[ids][whitelist]": str(uid) + "filter[ids][whitelist]": str(uid), } res = requests.get(BM_SERVERS, params=payload, headers=hdr) res.raise_for_status() @@ -26,13 +30,14 @@ def get_attributes(config: Path, uid: int) -> str: return j -def map_id_to_record(config: Path, uid: int) -> Record | None: +def map_id_to_record(config: Path, uid: int) -> Optional["Record"]: + from dzgui.api.servers import Record + 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) + except Exception: return None diff --git a/dzgui/api/servers.py b/dzgui/api/servers.py index 06e0b05..c77c5f0 100644 --- a/dzgui/api/servers.py +++ b/dzgui/api/servers.py @@ -12,6 +12,7 @@ import typing # noqa from dataclasses import dataclass from typing import Union +from dzgui.api.bm import map_id_to_record from dzgui.const.constants import REQUEST_TIMEOUT from dzgui.const.endpoints import STEAM_SERVERS from dzgui.util import strings @@ -451,3 +452,27 @@ def get_rules(ip: str, qport: int) -> list[int]: except Exception as e: logger.critical(e) return [] + + +def response_to_fq_ip(res: dict) -> str: + ip = res["addr"].split(":")[0] + gameport = res["gameport"] + qport = res["addr"].split(":")[1] + return f"{ip}:{gameport}:{qport}" + + +def query_id_or_ip(addr: str) -> None: + # NOTE: Battlemetrics + if addr.isdigit(): + try: + config = self.controller.get_prefs().paths.config + resolved = map_id_to_record(config, addr) + res = query_direct(resolved.ip, resolved.qport) + except Exception as e: + logger.critical(e) + return None + else: + record = addr.split(":") + ip, qport = record[0], record[1] + res = query_direct(ip, int(qport)) + return res diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 379dae5..020ec39 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -113,8 +113,8 @@ class Controller(GObject.GObject): self.emitter = Emitter() self.emitter.connect("map_selection_changed", self._on_map_selection_changed) self.emitter.connect("check_toggled", self._on_check_toggled) - self.emitter.connect("servers_loaded", self._on_servers_loaded) self.emitter.connect("servers_loaded_init", self._on_servers_loaded_init) + #self.emitter.connect("servers_loaded", self._on_servers_loaded) # NOTE: suppress requests until entire UI is loaded self.loaded = False @@ -861,7 +861,11 @@ class Controller(GObject.GObject): self.filter_man = filter_man def populate_model(self, tv: Gtk.TreeView) -> None: - ServerModelManager(self, tv, first_iteration=True) + # NOTE: skip on previously loaded tabs + if tv.is_loaded(): + self.emitter.emit("servers_loaded", tv.get_enum()) + return + ServerModelManager(self, tv, first_iteration=True).load() def get_favorite(self) -> tuple[str, str] | tuple[None, None]: fav = str(self.query_config(Preferences.FAV_LBL)) @@ -913,14 +917,14 @@ class Controller(GObject.GObject): store = self.get_map_store() self.emitter.emit("load_maps", store) - def _on_servers_loaded(self, emitter: "Emitter", tab: "ServerTab") -> None: - return - # NOTE: workaround for GTK bug where fullscreen causes headers to vanish when model is None - # TODO: this should be internal to servers page - state = self.has_server_model() - tv = self.get_active_treeview() - tv.set_headers_visible(state) - tv.set_headers_clickable(state) + #def _on_servers_loaded(self, emitter: "Emitter", tab: "ServerTab") -> None: + # return + # # NOTE: workaround for GTK bug where fullscreen causes headers to vanish when model is None + # # TODO: this should be internal to servers page + # state = self.has_server_model() + # tv = self.get_active_treeview() + # tv.set_headers_visible(state) + # tv.set_headers_clickable(state) def has_server_model(self) -> bool: treeview = self.get_active_treeview() @@ -946,6 +950,9 @@ class Controller(GObject.GObject): return self.mediator.notebook def get_servers(self) -> "Notebook": + return self.mediator.servers + + def get_server_notebook(self) -> "Notebook": return self.mediator.servers.notebook def get_window(self) -> "OuterWindow": diff --git a/dzgui/managers/connection.py b/dzgui/managers/connection.py new file mode 100644 index 0000000..f4d9772 --- /dev/null +++ b/dzgui/managers/connection.py @@ -0,0 +1,35 @@ +from typing import TYPE_CHECKING + +import dzgui.api.servers as Servers +from dzgui.managers.thread_man import call_on_thread, StoredFunc, ThreadingManager +from dzgui.util.strings import api_warn_msg, dialog +from dzgui.views.dialogs.generic import ExceptionDialog + + +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 ConnectionManager: + def __init__(self, controller: "Controller") -> None: + + self.controller = controller + self.thread_man = ThreadingManager(parent=controller) + + @call_on_thread(dialog.querying) + def _connect_by_id_or_ip(self, addr: str) -> None: + res = Servers.query_id_or_ip(addr) + if res is None: + self.thread_man.set_cleanup_func(StoredFunc(self._connection_failure)) + # TODO: add to history if successful + print(res) + + def _connection_failure(self) -> None: + # TODO: more explicit warning message, not necessarily API failure? + dialog = ExceptionDialog(self.controller, api_warn_msg) + dialog.run() diff --git a/dzgui/managers/thread_man.py b/dzgui/managers/thread_man.py index 036416d..7eafcf6 100644 --- a/dzgui/managers/thread_man.py +++ b/dzgui/managers/thread_man.py @@ -21,7 +21,6 @@ def call_on_thread(dialog_str: str) -> Callable: def wrapper(*args, **kwargs): self = args[0] stored = StoredFunc(func, *args, **kwargs) - print("SELF", self) if not hasattr(self, "thread_man"): raise AttributeError if type(self.thread_man) is not ThreadingManager: diff --git a/dzgui/model/servers.py b/dzgui/model/servers.py index 8fe14f7..8647944 100644 --- a/dzgui/model/servers.py +++ b/dzgui/model/servers.py @@ -1,40 +1,36 @@ -from datetime import datetime import logging import threading - -from concurrent.futures import wait, as_completed +from concurrent.futures import as_completed from concurrent.futures import ThreadPoolExecutor +from typing import Optional, TYPE_CHECKING import dzgui.api.servers as Servers +from dzgui.const.enum import FilterMode, Preferences, ServerTab from dzgui.const.constants import ( APPID_DAYZ, APPID_DAYZ_EXP, ) -from dzgui.const.enum import FilterMode, Preferences, ServerTab from dzgui.managers.thread_man import call_on_thread, StoredFunc, ThreadingManager from dzgui.util.strings import api_warn_msg, dialog from dzgui.views.dialogs.generic import ExceptionDialog -from typing import Optional, TYPE_CHECKING - import gi gi.require_version("Gtk", "3.0") -from gi.repository import Gtk, GLib # noqa E402 +from gi.repository import Gtk # noqa E402 LAN_TIMEOUT = 0.5 API_TIMEOUT = 3 if TYPE_CHECKING: from dzgui.controllers.mc import Controller - from dzgui.controllers.emitter import Emitter from dzgui.model.filtered_model import FilteredModelManager logger = logging.getLogger(__name__) # TODO: failure: spawns error dialog -# TODO: non failure with empty model: updates statusbar +# TODO: non failure with empty model: updates statusbar with help text class ServerModelManager: @@ -42,28 +38,32 @@ class ServerModelManager: self, controller: "Controller", tv: Gtk.TreeView, first_iteration=False ) -> None: - enum = tv.get_enum() + self.tv = tv + self.enum = tv.get_enum() self.controller = controller self.emitter = controller.get_emitter() - if tv.is_loaded(): - self.emitter.emit("servers_loaded", enum) - return - self.tv = tv self.jobs = 1 + # NOTE: store filter man for access inside thread self.filter_man = tv.get_filter_man() + + # FIXME: change WaitDialog to use parent window only self.thread_man = ThreadingManager(parent=controller) # TODO: if first iteration, clear filter man control model # literal first load: iteration 1 # refresh: iteration 1 (wipe model) # filter: iteration N+1 + # TODO: can drop first iteration arg and process in methods self.first_iteration = first_iteration - # TODO: pass servers.saved treeview when using conpan regardless of current context - - match enum: + def load(self) -> None: + """ + There may be cases where you want to instantiate this class without dumping servers, + e.g., adding saved servers from another tab + """ + match self.enum: case ServerTab.BROWSER: # NOTE: extra DAYZ_EXP param self.thread_man.set_job_count(len(Servers.params) + 1) @@ -71,14 +71,12 @@ class ServerModelManager: case ServerTab.SAVED: self._dump_favorites() case ServerTab.RECENT: - # TODO: get row count self._dump_history() case ServerTab.LAN: # NOTE: LAN tab is only loaded on demand pass case _: pass - # self._dump_lan() @call_on_thread(dialog.fetching) def _dump_api(self) -> None: @@ -113,7 +111,6 @@ class ServerModelManager: j = res.json servers += j["response"]["servers"] - # TODO: try/except when parsing parsed = Servers.parse_json(servers) self._push_data(parsed, FilterMode.INITIAL) @@ -150,11 +147,10 @@ class ServerModelManager: parsed = Servers.parse_json(servers) self._push_data(parsed, FilterMode.INITIAL) - # TODO: strings - @call_on_thread("dumping ips") + @call_on_thread(dialog.fetching) def _dump_ips(self, ips: list[str]) -> None: # NOTE: block malformed records (TODO: add github issue no.) - # TODO: sanitize ip list at config time, drop this + # TODO: sanitize ip list at config time and drop this ips = [ip for ip in ips if len(ip.split(":")) == 3 and ip.split(":")[2] != ""] job = Servers.query_direct with ThreadPoolExecutor() as executor: @@ -183,42 +179,32 @@ class ServerModelManager: parsed = Servers.parse_json(servers) self._push_data(parsed, FilterMode.INITIAL) - def _query_ip_id(self, addr: str) -> None: - # NOTE: Battlemetrics - if addr.isdigit(): - # FIXME: - config = self.controller.get_prefs().paths.config - resolved = map_id_to_record(config, addr) - res = Servers.query_direct(resolved.ip, resolved.qport) - else: - record = addr.split(":") - ip, qport = record[0], record[1] - res = Servers.query_direct(ip, int(qport)) - return res - - # TODO: strings - @call_on_thread("querying address") - def _connect_by_id_or_ip(self, addr: str) -> None: - res = self._query_ip_id(addr) - # TODO: unimplemented - - @call_on_thread("querying address") + @call_on_thread(dialog.querying) def _add_by_id_or_ip(self, addr: str) -> None: - res = self.query_ip_id(addr) - # TODO: investigate this + res = Servers.query_id_or_ip(addr) if res is None: - self.set_cleanup_func(StoredFunc(self._cleanup_on_failure)) + self.thread_man.set_cleanup_func(StoredFunc(self._cleanup_on_failure)) return - # NOTE: single record insertion - self.insert_record = Servers.parse_json([res]) - # TODO: add into saved servers file - # TODO: update saved servers model - # NOTE: this can be called from other tabs--if current focus is not ServerTab.SAVED, update label only - # TODO: saved servers might not be loaded yet, in which case should just update local file only - # TODO: perform simple equality comparison of self.tv.get_enum() == ServerTab.SAVED - # FIXME: filter man is saved on a per tab basis, so this will mismatch - # check if servers.get_active_treeview() is same as self.tv - self.set_cleanup_func(StoredFunc(self._cleanup_on_insert)) + + record = Servers.parse_json([res]) + + filter_man = self._get_filter_man() + model = filter_man.get_control() + + # TODO: make this a method of ConfigManager + fqip = Servers.response_to_fq_ip(res) + ips = self.controller.query_config(Preferences.IP_LIST) + ips.append(fqip) + self.controller.update_config(Preferences.IP_LIST, ips) + + if model is not None: + # NOTE: single record insertion + model.append(record[0]) + # TODO: if all filters are already applied, strange behavior may occur + # -> need to insert and update per current filters + filter_man.filter(FilterMode.INITIAL) + + self.thread_man.set_cleanup_func(StoredFunc(self._cleanup_single_ip)) def _dump_history(self) -> None: history = self.controller.get_prefs().paths.history @@ -242,6 +228,7 @@ class ServerModelManager: def _dump_favorites(self) -> None: ips = self.controller.query_config(Preferences.IP_LIST) self.thread_man.set_job_count(len(ips)) + # TODO: customize statusbar to mention how records can be added via contextmenu if len(ips) == 0: # FIXME: this is not a failure, just a quiet exit with custom statusbar @@ -252,25 +239,20 @@ class ServerModelManager: return self._dump_ips(ips) - def _cleanup_on_insert(self) -> None: - filter_man = self.get_filter_man() - model = filter_man.get_control() - - # FIXME: this is a single row insertion, - # but refiltration should occur in thread for consistency/scalability - model.append(self.insert_record[0]) - proxy = filter_man.filter(FilterMode.INITIAL) - - # TODO: get proxy model out of thread - proxy = self.get_filter_man().get_proxy_model() + def _cleanup_single_ip(self) -> None: + proxy = self._get_filter_man().get_proxy_model() self.tv.set_model(proxy) - # TODO: update statusbar - context = self.tv.get_enum() + # TODO: if current tab != self.saved, add label # TODO: adding a row may update available maps - # TODO: if all filters are already applied, strange behavior may occur - # -> need to insert and update per current filters - self.emitter.emit("servers_loaded", context) + self.emitter.emit("servers_loaded", self.enum) + self._update_maps() + + def _update_maps(self) -> None: + map_man = self.tv.get_map_man() + map_man.set_unique_maps(self._get_new_maps()) + self.emitter.emit("servers_loaded_init") + self.first_iteration = False def _cleanup_on_success(self) -> None: self.tv.set_model(self.to_insert) @@ -285,14 +267,8 @@ class ServerModelManager: context = self.tv.get_enum() self.emitter.emit("servers_loaded", context) - # CHORE: this is placeholder logic, - # refills map combo with new maps if self.first_iteration: - map_man = self.tv.get_map_man() - map_man.set_unique_maps(self.new_maps) - self.emitter.emit("servers_loaded_init") - self.first_iteration = False - self.new_maps = None + self._update_maps() def _cleanup_on_failure(self, show_dialog=True) -> None: map_man = self.tv.get_map_man() @@ -334,10 +310,16 @@ class ServerModelManager: self.to_insert = manager.get_proxy_model() u_maps = set([row[1] for row in data]) - self.new_maps = sorted(u_maps) + self._set_new_maps(sorted(u_maps)) self.thread_man.set_cleanup_func(StoredFunc(self._cleanup_on_success)) + def _set_new_maps(self, maps: list[str]) -> None: + self.new_maps = maps + + def _get_new_maps(self) -> list[str]: + return self.new_maps + def _get_filter_man(self) -> "FilteredModelManager": return self.filter_man diff --git a/dzgui/views/components/connect_panel.py b/dzgui/views/components/connect_panel.py index 7dbde37..117c442 100644 --- a/dzgui/views/components/connect_panel.py +++ b/dzgui/views/components/connect_panel.py @@ -1,5 +1,6 @@ from typing import TYPE_CHECKING +from dzgui.managers.connection import ConnectionManager from dzgui.model.servers import ServerModelManager from dzgui.util.strings import connect_panel, fav_panel, lan_panel from dzgui.views.components.buttons import ( @@ -178,6 +179,7 @@ class AddPanel(Gtk.Frame): self.add_server.connect("clicked", self._on_add_clicked) self.conn_server = SteamConnectButton() + self.conn_server.connect("clicked", self._on_connect_clicked) self.conn_server.set_sensitive(False) self.add_server.set_sensitive(False) @@ -199,11 +201,14 @@ class AddPanel(Gtk.Frame): self.add(self.grid) + def _on_connect_clicked(self, button: Gtk.Button) -> None: + text = self.entry.get_text() + ConnectionManager(self.controller)._connect_by_id_or_ip(text) + def _submit_query(self) -> None: text = self.entry.get_text() - # TODO: call servermodelmanager - ServerModelManager().add_by_id_or_ip(text) - # self.controller.add_by_id_or_ip(text) + saved = self.controller.get_servers().get_saved() + ServerModelManager(self.controller, saved)._add_by_id_or_ip(text) def _on_activate(self, entry: Gtk.Entry) -> None: if not self.add_server.is_sensitive(): diff --git a/dzgui/views/components/crumbs.py b/dzgui/views/components/crumbs.py index 4405b28..6588b93 100644 --- a/dzgui/views/components/crumbs.py +++ b/dzgui/views/components/crumbs.py @@ -22,7 +22,7 @@ class Breadcrumbs(Gtk.Label): self.set_markup(crumbs) notebook = controller.get_notebook() - servers = controller.get_servers() + servers = controller.get_server_notebook() notebook.connect_after("switch-page", self._on_notebook_page_changed) servers.connect_after("switch-page", self._on_server_tab_changed) diff --git a/dzgui/views/pages/servers.py b/dzgui/views/pages/servers.py index 4fce415..e03b25d 100644 --- a/dzgui/views/pages/servers.py +++ b/dzgui/views/pages/servers.py @@ -2,7 +2,6 @@ import logging from typing import Optional, Self, TYPE_CHECKING -from dzgui.controllers.mc import StoredFunc from dzgui.const.enum import ContextMenuGroup, ServerTab from dzgui.views.trees.tree_servers import ServerTreeView from dzgui.util.strings import server_labels @@ -16,6 +15,7 @@ logger = logging.getLogger(__name__) if TYPE_CHECKING: from dzgui.controllers.mc import Controller + from dzgui.controllers.mc import Emitter class ServerNotebook(Gtk.ScrolledWindow): From 748438414c951b29cc6075d7e43fbf7113f9aa43 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Mon, 23 Feb 2026 01:05:30 +0900 Subject: [PATCH 102/309] chore: update controller/config calls --- dzgui/controllers/mc.py | 13 ++++++++----- dzgui/model/servers.py | 13 +++++++------ dzgui/views/pages/servers.py | 2 ++ 3 files changed, 17 insertions(+), 11 deletions(-) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 020ec39..a13cc4d 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -39,6 +39,7 @@ from dzgui.config import update from dzgui.config.query import lookup from dzgui.config.userprefs import UserPrefs from dzgui.controllers.emitter import Emitter +from dzgui.managers.config import ConfigManager from dzgui.managers.contextmenu import ContextMenuManager from dzgui.model.filtered_model import FilteredModelManager from dzgui.model.servers import ServerModelManager @@ -108,13 +109,11 @@ class Controller(GObject.GObject): self.prefs: UserPrefs self.cleanup_func: StoredFunc = None - # self.config_man = ConfigManager() - self.emitter = Emitter() self.emitter.connect("map_selection_changed", self._on_map_selection_changed) self.emitter.connect("check_toggled", self._on_check_toggled) self.emitter.connect("servers_loaded_init", self._on_servers_loaded_init) - #self.emitter.connect("servers_loaded", self._on_servers_loaded) + # self.emitter.connect("servers_loaded", self._on_servers_loaded) # NOTE: suppress requests until entire UI is loaded self.loaded = False @@ -152,7 +151,7 @@ class Controller(GObject.GObject): return self.prefs def set_prefs(self, prefs: UserPrefs) -> None: - # self.config_man.set_prefs() + self.config_man = ConfigManager(prefs) self.prefs = prefs def query_config(self, key: Preferences) -> str | bool | list: @@ -346,6 +345,7 @@ class Controller(GObject.GObject): dialog.run() # TODO: suppress signals # then reenable (or it spawns dialog twice) + # TODO: do this on demand for certain changes self.mediator.grid.notebook.settings.populate_settings() return @@ -917,7 +917,7 @@ class Controller(GObject.GObject): store = self.get_map_store() self.emitter.emit("load_maps", store) - #def _on_servers_loaded(self, emitter: "Emitter", tab: "ServerTab") -> None: + # def _on_servers_loaded(self, emitter: "Emitter", tab: "ServerTab") -> None: # return # # NOTE: workaround for GTK bug where fullscreen causes headers to vanish when model is None # # TODO: this should be internal to servers page @@ -960,3 +960,6 @@ class Controller(GObject.GObject): def get_menu(self) -> "MenuTreeView": return self.mediator.menu + + def get_config_man(self) -> ConfigManager: + return self.config_man diff --git a/dzgui/model/servers.py b/dzgui/model/servers.py index 8647944..87a8bdf 100644 --- a/dzgui/model/servers.py +++ b/dzgui/model/servers.py @@ -10,6 +10,7 @@ from dzgui.const.constants import ( APPID_DAYZ, APPID_DAYZ_EXP, ) +from dzgui.managers.config import ConfigManager from dzgui.managers.thread_man import call_on_thread, StoredFunc, ThreadingManager from dzgui.util.strings import api_warn_msg, dialog from dzgui.views.dialogs.generic import ExceptionDialog @@ -80,7 +81,8 @@ class ServerModelManager: @call_on_thread(dialog.fetching) def _dump_api(self) -> None: - key = self.controller.query_config(Preferences.STEAM) + config_man = self.controller.get_config_man() + key = config_man.lookup(Preferences.STEAM) job = Servers.query_api params = Servers.params servers = [] @@ -191,11 +193,9 @@ class ServerModelManager: filter_man = self._get_filter_man() model = filter_man.get_control() - # TODO: make this a method of ConfigManager fqip = Servers.response_to_fq_ip(res) - ips = self.controller.query_config(Preferences.IP_LIST) - ips.append(fqip) - self.controller.update_config(Preferences.IP_LIST, ips) + config_man = self.controller.get_config_man() + config_man.add_saved_server(fqip) if model is not None: # NOTE: single record insertion @@ -226,7 +226,8 @@ class ServerModelManager: self._dump_ips(rows) def _dump_favorites(self) -> None: - ips = self.controller.query_config(Preferences.IP_LIST) + config_man = self.controller.get_config_man() + ips = config_man.lookup(Preferences.IP_LIST) self.thread_man.set_job_count(len(ips)) # TODO: customize statusbar to mention how records can be added via contextmenu diff --git a/dzgui/views/pages/servers.py b/dzgui/views/pages/servers.py index e03b25d..509bfd6 100644 --- a/dzgui/views/pages/servers.py +++ b/dzgui/views/pages/servers.py @@ -102,6 +102,8 @@ class ServerNotebook(Gtk.ScrolledWindow): if self.controller.loaded is False: return + # TODO: wipe statusbar when changing pages + label = self.notebook.get_tab_label_text(child) if label is None: return From 29c4fbcfbcc73eaf4abeb017dab69839eea65fff Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Mon, 23 Feb 2026 01:06:07 +0900 Subject: [PATCH 103/309] feat: add ConfigManager --- dzgui/managers/config.py | 96 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 dzgui/managers/config.py diff --git a/dzgui/managers/config.py b/dzgui/managers/config.py new file mode 100644 index 0000000..470af34 --- /dev/null +++ b/dzgui/managers/config.py @@ -0,0 +1,96 @@ +import logging +import traceback + +from typing import Any, TYPE_CHECKING + +from dzgui.const.constants import STEAM_CMD, FLATPAK_RUN_CMD, FLATPAK_SANDBOX +from dzgui.const.enum import Preferences +from dzgui.views.dialogs.generic import ExceptionDialog +from dzgui.util._json import read_json, write_json + +# import dzgui.api.servers as Servers +if TYPE_CHECKING: + from dzgui.config.userprefs import UserPrefs + +logger = logging.getLogger(__name__) + + +class ConfigManager: + def __init__(self, prefs: "UserPrefs") -> None: + self.config = prefs.paths.config + + def lookup(self, enum: Preferences) -> Any: + # if path.is_file() is False: + # raise ConfigFileError("Not a valid file") + key = self.enum_to_key(enum) + config = self.get_config() + try: + return config[key] + except KeyError: + return None + + def add_saved_server(self, record: str) -> None: + ips = self.lookup(Preferences.IP_LIST) + ips.append(record) + self.update_config(Preferences.IP_LIST, ips) + + # TODO: drop/reroute + def update_config(self, key: Preferences, value: str) -> None: + self.write_config(key, value) + # TODO: suppress signals + # then reenable (or it spawns dialog twice) + # TODO: do this on demand if/when in options page + # self.mediator.grid.notebook.settings.populate_settings() + return + + def get_config(self) -> dict: + # TODO: is this being called twice? + try: + return read_json(self.config) + except Exception as e: + logger.critical(e) + raise e + + def get_favorites(self) -> list[str]: + return self.lookup(Preferences.IP_LIST) + + def is_in_favs(self, record: str) -> bool: + favs = self.get_favorites() + if record in favs: + return True + return False + + def enum_to_key(self, enum: Preferences) -> str: + return enum.dict["key"] + + def get_client_index(client: str) -> int: + if client == STEAM_CMD: + return 0 + if client == FLATPAK_RUN_CMD: + return 1 + if client == FLATPAK_SANDBOX: + return 2 + + def toggle_config(self, key: Preferences) -> None: + try: + real_key = self.enum_to_key(key) + _json = self.get_config() + cur_val = not _json[real_key] + _json[real_key] = cur_val + write_json(_json, self.config) + except Exception as e: + logger.critical(e) + raise e + + def write_config(self, key: Preferences, value: str) -> None: + try: + real_key = self.enum_to_key(key) + conf = self.get_config() + conf[real_key] = value + write_json(conf, self.config) + except Exception as e: + logger.critical(e) + trace = traceback.format_exc() + dialog = ExceptionDialog(self, trace) + dialog.run() + raise e From 204cca2f090c88498eb63325136da6cfadd30189 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Mon, 23 Feb 2026 01:54:40 +0900 Subject: [PATCH 104/309] fix: prevent dialog destruction --- dzgui/views/dialogs/generic.py | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/dzgui/views/dialogs/generic.py b/dzgui/views/dialogs/generic.py index 708b124..c1897a5 100644 --- a/dzgui/views/dialogs/generic.py +++ b/dzgui/views/dialogs/generic.py @@ -1,4 +1,3 @@ -import textwrap from typing import Literal, Self, TYPE_CHECKING from dzgui.const.constants import NO_EXPAND, NO_FILL, EXPAND, FILL @@ -52,7 +51,6 @@ class GenericDialog(Gtk.MessageDialog): self.set_position(Gtk.WindowPosition.CENTER_ON_PARENT) self.set_default_response(Gtk.ResponseType.OK) - self.connect("response", self._on_response) self.get_action_area().set_layout(Gtk.ButtonBoxStyle.CENTER) self.get_action_area().set_margin_bottom(20) @@ -60,10 +58,6 @@ class GenericDialog(Gtk.MessageDialog): self.outer.set_margin_start(30) self.outer.set_margin_end(30) - def _on_response(self, dialog: Self, response: Gtk.ResponseType) -> None: - self.destroy() - return response - class ConfirmationDialog(GenericDialog): def __init__(self, controller: "Controller", secondary: str): @@ -113,7 +107,7 @@ class WaitDialog(GenericDialog): self.jobs = jobs self.cur_job = 1 - self.connect("delete-event", self._on_dialog_delete) + self.connect("delete-event", lambda widget, event: True) content = self.get_content_area() spinner = Gtk.Spinner() self.prog = Gtk.ProgressBar() @@ -130,14 +124,6 @@ class WaitDialog(GenericDialog): self.prog.set_fraction(fraction) self.cur_job += 1 - def _on_dialog_delete( - self, response_id: Gtk.ResponseType, event: Gdk.Event - ) -> Literal[True]: - """ - Prevent manual dialog destruction - """ - return True - class QuitDialog(GenericDialog): def __init__(self, controller: "Controller", secondary: str): From 5ae18485e8fdb81ba08319093fff43e4ac73c499 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Mon, 23 Feb 2026 01:55:11 +0900 Subject: [PATCH 105/309] fix: start LAN columns hidden --- dzgui/views/pages/servers.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dzgui/views/pages/servers.py b/dzgui/views/pages/servers.py index 509bfd6..52940d7 100644 --- a/dzgui/views/pages/servers.py +++ b/dzgui/views/pages/servers.py @@ -36,6 +36,7 @@ class ServerNotebook(Gtk.ScrolledWindow): controller, ServerTab.RECENT, ContextMenuGroup.RECENT ) self.lan = ServerTreeView(controller, ServerTab.LAN, ContextMenuGroup.SCAN_LAN) + self.lan.set_headers_visible(False) tabs = [ (self.browser, server_labels.browser), @@ -63,7 +64,7 @@ class ServerNotebook(Gtk.ScrolledWindow): tv = self.get_active_treeview() state = False if tv.get_model() is None else True tv.set_headers_visible(state) - tv.set_headers_clickable(state) + # tv.set_headers_clickable(state) tv.set_loaded(True) tv.grab_focus() From 575e591c1020f54809bf963f32e34fa5640f4d7a Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Mon, 23 Feb 2026 02:24:14 +0900 Subject: [PATCH 106/309] feat: restore partial context menu functionality --- dzgui/api/bm.py | 7 +++-- dzgui/api/servers.py | 8 +++--- dzgui/controllers/mc.py | 4 +-- dzgui/managers/connection.py | 6 ++--- dzgui/managers/contextmenu.py | 11 ++++++++ dzgui/model/servers.py | 2 +- dzgui/views/components/connect_panel.py | 2 +- dzgui/views/mixins/context_mixin.py | 11 ++++++-- dzgui/views/trees/tree_log.py | 14 +++++++++- dzgui/views/trees/tree_servers.py | 36 ++++++++++++++----------- 10 files changed, 68 insertions(+), 33 deletions(-) diff --git a/dzgui/api/bm.py b/dzgui/api/bm.py index c8c85db..196583b 100644 --- a/dzgui/api/bm.py +++ b/dzgui/api/bm.py @@ -13,10 +13,9 @@ if TYPE_CHECKING: from dzgui.api.servers import Record -def get_attributes(config: Path, uid: int) -> str: +def get_attributes(key: str, 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] = { @@ -30,11 +29,11 @@ def get_attributes(config: Path, uid: int) -> str: return j -def map_id_to_record(config: Path, uid: int) -> Optional["Record"]: +def map_id_to_record(key: str, uid: int) -> Optional["Record"]: from dzgui.api.servers import Record try: - record = get_attributes(config, uid) + record = get_attributes(key, uid) ip = record["ip"] port = record["port"] qport = record["portQuery"] diff --git a/dzgui/api/servers.py b/dzgui/api/servers.py index c77c5f0..50b2dd6 100644 --- a/dzgui/api/servers.py +++ b/dzgui/api/servers.py @@ -461,18 +461,18 @@ def response_to_fq_ip(res: dict) -> str: return f"{ip}:{gameport}:{qport}" -def query_id_or_ip(addr: str) -> None: +def query_id_or_ip(addr: str, key: str) -> None: # NOTE: Battlemetrics if addr.isdigit(): try: - config = self.controller.get_prefs().paths.config - resolved = map_id_to_record(config, addr) + resolved = map_id_to_record(key, addr) res = query_direct(resolved.ip, resolved.qport) except Exception as e: logger.critical(e) return None else: record = addr.split(":") - ip, qport = record[0], record[1] + # TODO: create a Record object + ip, qport = record[0], record[2] res = query_direct(ip, int(qport)) return res diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index a13cc4d..9eb54c8 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -605,8 +605,8 @@ class Controller(GObject.GObject): record = treeview.get_record_string() try: - self.update_config(Preferences.FAV_LBL, name) - self.update_config(Preferences.FAV_SRV, record) + self.config_man.write_config(Preferences.FAV_LBL, name) + self.config_man.write_config(Preferences.FAV_SRV, record) except Exception as e: logger.critical(e) # TODO: add a failure dialog here diff --git a/dzgui/managers/connection.py b/dzgui/managers/connection.py index f4d9772..5787375 100644 --- a/dzgui/managers/connection.py +++ b/dzgui/managers/connection.py @@ -22,12 +22,12 @@ class ConnectionManager: self.thread_man = ThreadingManager(parent=controller) @call_on_thread(dialog.querying) - def _connect_by_id_or_ip(self, addr: str) -> None: - res = Servers.query_id_or_ip(addr) + def connect_by_id_or_ip(self, addr: str, key: str) -> None: + res = Servers.query_id_or_ip(addr, key) + print(res) if res is None: self.thread_man.set_cleanup_func(StoredFunc(self._connection_failure)) # TODO: add to history if successful - print(res) def _connection_failure(self) -> None: # TODO: more explicit warning message, not necessarily API failure? diff --git a/dzgui/managers/contextmenu.py b/dzgui/managers/contextmenu.py index be279c1..7b074fe 100644 --- a/dzgui/managers/contextmenu.py +++ b/dzgui/managers/contextmenu.py @@ -3,7 +3,9 @@ import logging from typing import TYPE_CHECKING from dzgui.const.enum import ContextMenu, Preferences +from dzgui.managers.connection import ConnectionManager from dzgui.managers.thread_man import ThreadingManager +from dzgui.model.servers import ServerModelManager from dzgui.util import strings from dzgui.util.clip import copy_clipboard from dzgui.util.open_links import open_workshop_page @@ -50,7 +52,16 @@ class ContextMenuManager: self.controller.set_fav() # THREADED + case ContextMenu.CONNECT: + record = self.treeview.get_record_string() + # FIXME: context menu connections do not require BM key + key = self.controller.get_config_man().lookup(Preferences.BM) + ConnectionManager(self.controller).connect_by_id_or_ip(record, key) case ContextMenu.ADD_SERVER: + record = self.treeview.get_record_string() + ServerModelManager(self.controller, self.treeview).add_by_id_or_ip( + record + ) # add to saved servers model verbatim and sort in place # update tab with ! # update config file with IP diff --git a/dzgui/model/servers.py b/dzgui/model/servers.py index 87a8bdf..8640dc6 100644 --- a/dzgui/model/servers.py +++ b/dzgui/model/servers.py @@ -182,7 +182,7 @@ class ServerModelManager: self._push_data(parsed, FilterMode.INITIAL) @call_on_thread(dialog.querying) - def _add_by_id_or_ip(self, addr: str) -> None: + def add_by_id_or_ip(self, addr: str) -> None: res = Servers.query_id_or_ip(addr) if res is None: self.thread_man.set_cleanup_func(StoredFunc(self._cleanup_on_failure)) diff --git a/dzgui/views/components/connect_panel.py b/dzgui/views/components/connect_panel.py index 117c442..18f31c7 100644 --- a/dzgui/views/components/connect_panel.py +++ b/dzgui/views/components/connect_panel.py @@ -203,7 +203,7 @@ class AddPanel(Gtk.Frame): def _on_connect_clicked(self, button: Gtk.Button) -> None: text = self.entry.get_text() - ConnectionManager(self.controller)._connect_by_id_or_ip(text) + ConnectionManager(self.controller).connect_by_id_or_ip(text) def _submit_query(self) -> None: text = self.entry.get_text() diff --git a/dzgui/views/mixins/context_mixin.py b/dzgui/views/mixins/context_mixin.py index 85fae0a..e673049 100644 --- a/dzgui/views/mixins/context_mixin.py +++ b/dzgui/views/mixins/context_mixin.py @@ -19,7 +19,6 @@ class ContextMixin(TreeView): event: Gdk.EventButton | Gdk.EventKey, ) -> None: - # FIXME: start debug log focused if self.is_selection_empty(): return False @@ -80,7 +79,15 @@ class ContextMixin(TreeView): (path, col, cellx, celly) = pathinfo if path is None: return True - self.set_cursor(path, col, False) + # return True + selection = self.get_selection() + model, selected_paths = selection.get_selected_rows() + if path not in selected_paths: + for p in selected_paths: + selection.unselect_path(p) + self.set_cursor(path, col, False) + return True + # FIXME: if selection is not multiple, change cursor except AttributeError: pass diff --git a/dzgui/views/trees/tree_log.py b/dzgui/views/trees/tree_log.py index 1824558..026db11 100644 --- a/dzgui/views/trees/tree_log.py +++ b/dzgui/views/trees/tree_log.py @@ -40,13 +40,25 @@ class LogTreeView(ContextMixin, TreeView): column.set_sort_column_id(i) self.append_column(column) + self.connect("button-press-event", self._on_log_buttonpress) self.connect("key-press-event", self._on_log_keypress) - self.connect("button-press-event", self._on_log_keypress) def populate_log(self, filepath: str) -> None: model = ModelFactory().new_model_from_logfile(filepath) self.set_model(model) self.set_cursor(0) + def _on_log_buttonpress(self, widget: Gtk.Widget, event: Gdk.EventButton) -> None: + if event.button == Gdk.BUTTON_SECONDARY: + path, col, x, y = self.get_path_at_pos(int(event.x), int(event.y)) + if path: + selection = self.get_selection() + model, selected_paths = selection.get_selected_rows() + if path not in selected_paths: + selection.select_path(path) + self.present_menu(widget, event) + return True + return False + def _on_log_keypress(self, widget: Gtk.Widget, event: Gdk.EventKey) -> None: self.present_menu(widget, event) diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index 228acff..cf363c3 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -256,22 +256,28 @@ class ServerTreeView(ContextMixin, TreeView): self.emitter.emit("check_button_pressed", event.keyval) # TODO: unimplemented - # def is_modded(self) -> bool: - # select = self.get_selection() - # sels = select.get_selected_rows() - # (model, pathlist) = sels - # path = pathlist[0] - # tree_iter = model.get_iter(path) - # mods = model.get_value(tree_iter, 11) - # return mods + def is_modded(self) -> bool: + select = self.get_selection() + sels = select.get_selected_rows() + (model, pathlist) = sels + path = pathlist[0] + tree_iter = model.get_iter(path) + has_mods = model.get_value(tree_iter, 11) + return has_mods - # TODO: unimplemented - # def is_in_favs(self) -> bool: - # record = self.get_record_string() - # proc = call_out("is_in_favs", record) - # if proc.returncode == 0: - # return True - # return False + def get_selected_row(self) -> Gtk.TreeModelRow: + sel = self.get_selection() + sels = sel.get_selected_rows() + return sels[0] + + def is_in_favs(self) -> bool: + from dzgui.const.enum import Preferences + + record = self.get_record_string() + ips = self.controller.get_config_man().lookup(Preferences.IP_LIST) + if record in ips: + return True + return False def _parent_row_activated( self, tree: TreeView, path: Gtk.TreePath, column: Gtk.TreeViewColumn From 8d1cc4bb683406a37f77b6e5c092ca0aeaf2a89b Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Mon, 23 Feb 2026 02:53:26 +0900 Subject: [PATCH 107/309] chore: drop deprecated controller logic --- dzgui/controllers/mc.py | 275 +------------------------ dzgui/model/filtered_model.py | 10 +- dzgui/model/servers.py | 32 +-- dzgui/views/components/filter_panel.py | 6 +- 4 files changed, 27 insertions(+), 296 deletions(-) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 9eb54c8..4c5d663 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -113,7 +113,6 @@ class Controller(GObject.GObject): self.emitter.connect("map_selection_changed", self._on_map_selection_changed) self.emitter.connect("check_toggled", self._on_check_toggled) self.emitter.connect("servers_loaded_init", self._on_servers_loaded_init) - # self.emitter.connect("servers_loaded", self._on_servers_loaded) # NOTE: suppress requests until entire UI is loaded self.loaded = False @@ -214,6 +213,7 @@ class Controller(GObject.GObject): def grab_active_treeview(self) -> None: self.get_active_treeview().grab_focus() + # TODO: resolution manager or config manager def save_res_and_quit(self, *args: Any) -> None: treeview = self.get_active_treeview() columns = treeview.get_columns() @@ -381,166 +381,6 @@ class Controller(GObject.GObject): self.open_page(button.opens) - # def dump_favorites(self) -> None: - # # TODO: if list is empty, return failure - # ips = self.query_config(Preferences.IP_LIST) - # if len(ips) == 0: - # # FIXME: this is not a failure, just a quiet exit with custom statusbar - # # TODO: add custom statusbar parameter - # self.cleanup_func = StoredFunc(self.cleanup_on_failure, False) - # return - # self.dump_ips(ips) - - # def dump_history(self) -> None: - # history = self.prefs.paths.history - # # TODO: customize statusbar to mention how records are added - # try: - # with open(history, "r") as f: - # rows = [row.rstrip("\n") for row in f] - # except OSError: - # self.cleanup_func = StoredFunc(self.cleanup_on_failure, False) - # return - # if len(rows) == 0: - # self.cleanup_func = StoredFunc(self.cleanup_on_failure, False) - # return - # self.dump_ips(rows) - - # def dump_ips(self, ips: list) -> None: - # # NOTE: block malformed records (TODO: add github issue no.) - # ips = [ip for ip in ips if len(ip.split(":")) == 3 and ip.split(":")[2] != ""] - # with ThreadPoolExecutor() as executor: - # futures = [ - # executor.submit( - # Servers.query_direct, - # ip.split(":")[0], - # int(ip.split(":")[2]), - # ) - # for ip in ips - # ] - # # TODO: update dialog in main loop - # # wait(futures) - # serv = [] - # for future in as_completed(futures): - # res = future.result() - # # NOTE: discard failing entries - # if res is None: - # continue - # serv.append(res) - # if len(serv) == 0: - # self.cleanup_func = StoredFunc(self.cleanup_on_failure) - # return - # parsed = Servers.parse_json(serv) - # self.push_data_success(parsed, FilterMode.INITIAL) - - # TODO: strings - # @call_on_thread("scanning LAN ports") - # def dump_lan(self, port: int, early_abort: bool) -> None: - # serv = [] - # event = threading.Event() - # with ThreadPoolExecutor() as executor: - # futures = [ - # executor.submit(Servers.test_ip, i, port, event) for i in range(1, 256) - # ] - # for future in as_completed(futures): - # try: - # res = future.result(timeout=0.5) - # if res is not None and early_abort is True: - # event.set() - # self.cleanup_func = StoredFunc(self.cleanup_on_failure) - # return - # if res is None: - # continue - # serv.append(res) - # except Exception as e: - # logger.critical(e) - # self.cleanup_func = StoredFunc(self.cleanup_on_failure) - # if len(serv) == 0: - # self.cleanup_func = StoredFunc(self.cleanup_on_failure) - # return - # parsed = Servers.parse_json(serv) - # self.push_data_success(parsed, FilterMode.INITIAL) - - # def query_ip_id(self, addr: str) -> None: - # # NOTE: Battlemetrics - # # TODO: stores full ip, gameport, qport in file as canonical record - # if addr.isdigit(): - # config = self.prefs.paths.config - # resolved = map_id_to_record(config, addr) - # res = Servers.query_direct(resolved.ip, resolved.qport) - # else: - # record = addr.split(":") - # ip, qport = record[0], record[1] - # res = Servers.query_direct(ip, int(qport)) - # return res - - # # TODO: strings - # @call_on_thread("querying address") - # def connect_by_id_or_ip(self, addr: str) -> None: - # res = self.query_ip_id(addr) - - # def cleanup_on_insert(self) -> None: - # # FIXME: grab filterman outside of thread - # treeview = self.mediator.servers.saved - # filter_man = treeview.get_filter_man() - # model = filter_man.get_control() - # print(len(model)) - # # FIXME: refiltration should occur in thread - # model.append(self.insert_record[0]) - # filtered = filter_man.filter(FilterMode.INITIAL) - # treeview.set_model(filter_man.get_proxy_model()) - # # TODO: update statusbar - # context = self.get_active_context() - # # TODO: adding a row may update available maps - # # TODO: if all filters are already applied, strange behavior may occur - # # need to insert and update all according logic per current filters - # self.emitter.emit("servers_loaded", context) - # # model = treeview.get_model().append(self.insert_record[0]) - - # @call_on_thread("querying address") - # def add_by_id_or_ip(self, addr: str) -> None: - # res = self.query_ip_id(addr) - # self.insert_record = Servers.parse_json([res]) - # # if res is None: - # # # TODO: set cleanupfunc with dialog - # # return - # # TODO: add into saved servers file - # # TODO: update saved servers model - # # if current focus is not save servers, update label - # # TODO: saved servers might not be loaded yet, in which case - # # should just add to server file - # self.set_cleanup_func(StoredFunc(self.cleanup_on_insert)) - - # def dump_api(self) -> None: - # key = self.query_config(Preferences.STEAM) - # job = Servers.query_api - # params = Servers.params - # serv = [] - # with ThreadPoolExecutor() as executor: - # futures = [executor.submit(job, key, APPID_DAYZ, param) for param in params] - # for future in as_completed(futures): - # try: - # GLib.idle_add(lambda: self.wait_dialog.increment()) - # res = future.result(timeout=3) - # if res.status != 200 or not res.parsed: - # self.cleanup_func = StoredFunc(self.cleanup_on_failure) - # return - # j = res.json - # serv += j["response"]["servers"] - # except Exception as e: - # logger.critical(e) - # self.cleanup_func = StoredFunc(self.cleanup_on_failure) - # return - - # # NOTE: This step is allowed to fail, since this metadata is incidental - # res = Servers.query_api(key, APPID_DAYZ_EXP, "") - # if res.status == 200 and res.parsed is True: - # j = res.json - # serv += j["response"]["servers"] - - # GLib.idle_add(lambda: self.wait_dialog.increment("Unpacking servers")) - # parsed = Servers.parse_json(serv) - # self.push_data_success(parsed, FilterMode.INITIAL) - def get_help_row(self) -> str: return self.mediator.menu.get_row_enum() @@ -659,85 +499,6 @@ class Controller(GObject.GObject): def get_map_man(self) -> "MapManager": return self.get_active_treeview().get_map_man() - # def cleanup_on_success(self) -> None: - # self.pending_jobs = 1 - # treeview = self.get_active_treeview() - # treeview.set_loaded(True) - # treeview.set_model(self.to_insert) - - # # TODO: this will allow history and saved tab to emit signals to statusbar - # # CHORE: test if treeview's sort method inserts row at the correct index - # # inserting a row serializes file on disk, updates control model for that tab, and - # # reapplies filters to ephemeral model; since filters are applied, in-situ insertion might not be necessary - # # TODO: will be inserted out of order - # # self.to_insert.connect("row-inserted", lambda *args: print("row inserted into model")) - - # # TODO: signals or other approach to deferring map - # # model insertion after thread closes - # # cf. servers_loaded signal - - # # TODO: servers_loaded vs servers_reloaded - # context = self.get_active_context() - # self.emitter.emit("servers_loaded", context) - - # # CHORE: this is placeholder logic - # if self.first_iteration: - # map_man = treeview.get_map_man() - # map_man.set_unique_maps(self.new_maps) - # self.emitter.emit("servers_loaded_init") - # self.first_iteration = False - # self.new_maps = None - - # treeview.grab_focus() - - # def cleanup_on_failure(self, show_dialog=True) -> None: - # treeview = self.get_active_treeview() - # treeview.set_loaded(True) - # map_man = treeview.get_map_man() - - # # TODO: disable map, keyword, and filter widgets if model is None - # # -> signal driven (servers_empty) - # # TODO: what if refresh action occurred and failed, and the old model is still valid? - # # skip the step below if refresh action failed - # # do not wipe control model in this case - # # e.g. if treeview.is_refresh(): - # # revert old model - # # wipe refresh state to False - - # treeview.set_model(None) - # treeview.grab_focus() - - # map_man.set_unique_maps(None) - # context = self.get_active_context() - - # # TODO: distinguish signals, e.g. "servers_failed_to_load", "servers_loaded_empty" - # # customize statusbar accordingly - # self.emitter.emit("servers_loaded", context) - # if show_dialog: - # dialog = ExceptionDialog(self, strings.api_warn_msg) - # dialog.run() - - # # TODO: break into initial dump and refilter modes, can drop filtermode kwarg - # # and stop pushing empty data - # def push_data_success(self, data: tuple, mode: Optional[FilterMode]) -> None: - # # FIXME: calls treeview read methods in thread - # # treeview = self.get_active_treeview() - # # manager = treeview.get_filter_man() - # manager = self.get_filter_man() - - # if data is None: - # self.to_insert = None - # else: - # if mode == FilterMode.INITIAL: - # manager.set_control(data) - # manager.filter(mode) - # self.to_insert = manager.get_model() - # # TODO: pre parse maps - # u_maps = set([row[1] for row in data]) - # self.new_maps = sorted(u_maps) - - # self.cleanup_func = StoredFunc(self.cleanup_on_success) - def highlight_stale_cleanup(self, stale_mods: list) -> None: """Manipulates attached ListStore in the main event loop""" model = self.get_mod_store() @@ -836,23 +597,6 @@ class Controller(GObject.GObject): def get_statusbar(self) -> None: return self.mediator.statusbar - # @call_on_thread(strings.dialog.filtering) - # def filter_threaded( - # self, filter_man: "FilteredModelManager", mode: FilterMode, label: str - # ) -> None: - # filter_man.filter(mode, label) - # self.to_insert = filter_man.get_proxy_model() - # print("filtering threaded") - # self.cleanup_func = StoredFunc(self.cleanup_on_success) - - # TODO: call filter_man methods directly - def refilter_model(self, mode: FilterMode, label: Optional[str] = None) -> None: - tv = self.get_active_treeview() - filter_man = tv.get_filter_man() - if filter_man.get_control() is None: - return - self.filter_threaded(filter_man, mode, label) - def get_filter_man(self) -> "FilteredModelManager": return self.filter_man @@ -865,7 +609,7 @@ class Controller(GObject.GObject): if tv.is_loaded(): self.emitter.emit("servers_loaded", tv.get_enum()) return - ServerModelManager(self, tv, first_iteration=True).load() + ServerModelManager(self, tv).load() def get_favorite(self) -> tuple[str, str] | tuple[None, None]: fav = str(self.query_config(Preferences.FAV_LBL)) @@ -917,19 +661,11 @@ class Controller(GObject.GObject): store = self.get_map_store() self.emitter.emit("load_maps", store) - # def _on_servers_loaded(self, emitter: "Emitter", tab: "ServerTab") -> None: - # return - # # NOTE: workaround for GTK bug where fullscreen causes headers to vanish when model is None - # # TODO: this should be internal to servers page - # state = self.has_server_model() - # tv = self.get_active_treeview() - # tv.set_headers_visible(state) - # tv.set_headers_clickable(state) - def has_server_model(self) -> bool: treeview = self.get_active_treeview() return treeview.get_model() is not None + # TODO: this could be internal to filter panel now def _on_check_toggled(self, emitter: Emitter, label: str, state: bool) -> None: map_man = self.get_map_man() map_man.set_filter(label, state) @@ -938,13 +674,14 @@ class Controller(GObject.GObject): mode = FilterMode.TOGGLE_ON else: mode = FilterMode.TOGGLE_OFF - self.refilter_model(mode, label) + ServerModelManager(self, self.get_active_treeview()).refilter(mode, label) # TODO: map man should be consolidated into filter man def _on_map_selection_changed(self, emitter: Emitter, selection: str) -> None: map_man = self.get_map_man() map_man.set_selected_map(selection) - self.refilter_model(FilterMode.MAP) + # FIXME: label should not be mandatory + ServerModelManager(self, self.get_active_treeview()).refilter(FilterMode.MAP) def get_notebook(self) -> "Notebook": return self.mediator.notebook diff --git a/dzgui/model/filtered_model.py b/dzgui/model/filtered_model.py index a2b7d8d..e628594 100644 --- a/dzgui/model/filtered_model.py +++ b/dzgui/model/filtered_model.py @@ -131,7 +131,7 @@ class FilteredModelManager: # TODO: consider overriding append() method of Gtk.ListStore # check Gtk source code clone.insert_with_values(i, tuple(range(0, n_cols)), row) - #clone.append(row) + # clone.append(row) self.set_cache(filters, clone, rows) self.set_proxy_model(clone) @@ -283,12 +283,12 @@ class FilteredModelManager: def get_control(self) -> list: return self.control_model - #@deprecated("Legacy code") - #def set_success(self, result: bool) -> None: + # @deprecated("Legacy code") + # def set_success(self, result: bool) -> None: # self.success = result - #@deprecated("Legacy code") - #def get_success(self) -> bool: + # @deprecated("Legacy code") + # def get_success(self) -> bool: # return self.success # NOTE: used when adding/removing rows in-situ in the proxy model diff --git a/dzgui/model/servers.py b/dzgui/model/servers.py index 8640dc6..8c40e06 100644 --- a/dzgui/model/servers.py +++ b/dzgui/model/servers.py @@ -35,15 +35,14 @@ logger = logging.getLogger(__name__) class ServerModelManager: - def __init__( - self, controller: "Controller", tv: Gtk.TreeView, first_iteration=False - ) -> None: + def __init__(self, controller: "Controller", tv: Gtk.TreeView) -> None: self.tv = tv self.enum = tv.get_enum() self.controller = controller self.emitter = controller.get_emitter() + self.first_iteration: bool self.jobs = 1 # NOTE: store filter man for access inside thread @@ -52,18 +51,18 @@ class ServerModelManager: # FIXME: change WaitDialog to use parent window only self.thread_man = ThreadingManager(parent=controller) + # TODO: can drop first iteration arg and process in methods # TODO: if first iteration, clear filter man control model # literal first load: iteration 1 # refresh: iteration 1 (wipe model) # filter: iteration N+1 - # TODO: can drop first iteration arg and process in methods - self.first_iteration = first_iteration def load(self) -> None: """ There may be cases where you want to instantiate this class without dumping servers, e.g., adding saved servers from another tab """ + self.first_iteration = True match self.enum: case ServerTab.BROWSER: # NOTE: extra DAYZ_EXP param @@ -325,19 +324,10 @@ class ServerModelManager: return self.filter_man # TODO: unimplemented - # @call_on_thread(strings.dialog.filtering) - # def filter_threaded( - # self, filter_man: "FilteredModelManager", mode: FilterMode, label: str - # ) -> None: - # filter_man.filter(mode, label) - # self.to_insert = filter_man.get_proxy_model() - # print("filtering threaded") - # self.cleanup_func = StoredFunc(self.cleanup_on_success) - - # TODO: call filter_man methods directly - def refilter_model(self, mode: FilterMode, label: Optional[str] = None) -> None: - tv = self.get_active_treeview() - self.filter_man = tv.get_filter_man() - if self.filter_man.get_control() is None: - return - self.filter_threaded(self.filter_man, mode, label) + @call_on_thread(dialog.filtering) + def refilter(self, mode: FilterMode, label: str) -> None: + self.first_iteration = False + filter_man = self._get_filter_man() + filter_man.filter(mode, label) + self.to_insert = filter_man.get_proxy_model() + self.thread_man.set_cleanup_func(StoredFunc(self._cleanup_on_success)) diff --git a/dzgui/views/components/filter_panel.py b/dzgui/views/components/filter_panel.py index e3c9198..38e9359 100644 --- a/dzgui/views/components/filter_panel.py +++ b/dzgui/views/components/filter_panel.py @@ -3,6 +3,7 @@ from typing import Literal, TYPE_CHECKING from dzgui.const.enum import FilterMode from dzgui.const.constants import EXPAND, NO_EXPAND, NO_FILL, NO_PADDING, SEARCH_ICON +from dzgui.model.servers import ServerModelManager from dzgui.util.margins import set_surrounding_margins from dzgui.views.components.labels import BoldLabel @@ -119,7 +120,10 @@ class KeywordEntry(Gtk.Entry): logger.info(f"User filtered by keyword '{keyword}'") self.emitter.emit("keyword_set", keyword) - self.controller.refilter_model(FilterMode.KEYWORD, keyword) + + ServerModelManager( + self.controller, self.controller.get_active_treeview() + ).refilter(FilterMode.KEYWORD, keyword) class FilterPanel(Gtk.Box): From 3859188adea6ac6cc3db77325ed0bfb9dada0abc Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Mon, 23 Feb 2026 03:14:29 +0900 Subject: [PATCH 108/309] feat: preserve records on failure --- dzgui/managers/thread_man.py | 2 +- dzgui/model/servers.py | 59 ++++++++++++++++++------------------ 2 files changed, 30 insertions(+), 31 deletions(-) diff --git a/dzgui/managers/thread_man.py b/dzgui/managers/thread_man.py index 7eafcf6..117f913 100644 --- a/dzgui/managers/thread_man.py +++ b/dzgui/managers/thread_man.py @@ -70,7 +70,7 @@ class ThreadingManager: def increment_dialog_with_str(self, text: str) -> None: GLib.idle_add(lambda: self.wait_dialog.increment(text)) - # TODO: this should not be delegated here + # TODO: this should not be delegated here, set in cleanup func def set_alternate_statusbar(self, msg: str) -> None: self.alternate_statusbar = msg diff --git a/dzgui/model/servers.py b/dzgui/model/servers.py index 8c40e06..71a4f73 100644 --- a/dzgui/model/servers.py +++ b/dzgui/model/servers.py @@ -10,7 +10,6 @@ from dzgui.const.constants import ( APPID_DAYZ, APPID_DAYZ_EXP, ) -from dzgui.managers.config import ConfigManager from dzgui.managers.thread_man import call_on_thread, StoredFunc, ThreadingManager from dzgui.util.strings import api_warn_msg, dialog from dzgui.views.dialogs.generic import ExceptionDialog @@ -43,6 +42,7 @@ class ServerModelManager: self.emitter = controller.get_emitter() self.first_iteration: bool + self.preserve_on_fail: False self.jobs = 1 # NOTE: store filter man for access inside thread @@ -51,10 +51,9 @@ class ServerModelManager: # FIXME: change WaitDialog to use parent window only self.thread_man = ThreadingManager(parent=controller) - # TODO: can drop first iteration arg and process in methods # TODO: if first iteration, clear filter man control model # literal first load: iteration 1 - # refresh: iteration 1 (wipe model) + # refresh: should be functionally identical to iteration 1 # filter: iteration N+1 def load(self) -> None: @@ -78,6 +77,10 @@ class ServerModelManager: case _: pass + def refresh(self) -> None: + self.preserve_on_fail = True + self.load() + @call_on_thread(dialog.fetching) def _dump_api(self) -> None: config_man = self.controller.get_config_man() @@ -190,24 +193,25 @@ class ServerModelManager: record = Servers.parse_json([res]) filter_man = self._get_filter_man() - model = filter_man.get_control() + raw_model = filter_man.get_control() fqip = Servers.response_to_fq_ip(res) config_man = self.controller.get_config_man() config_man.add_saved_server(fqip) - if model is not None: - # NOTE: single record insertion - model.append(record[0]) - # TODO: if all filters are already applied, strange behavior may occur - # -> need to insert and update per current filters - filter_man.filter(FilterMode.INITIAL) + # NOTE: tab was not instantiated yet + if model is None: + return + + raw_model.append(record[0]) + # TODO: if all filters are already applied, strange behavior may occur + # -> need to insert and update per current filters + filter_man.filter(FilterMode.INITIAL) self.thread_man.set_cleanup_func(StoredFunc(self._cleanup_single_ip)) def _dump_history(self) -> None: history = self.controller.get_prefs().paths.history - # TODO: customize statusbar to mention how records are added after connecting try: with open(history, "r") as f: rows = [row.rstrip("\n") for row in f] @@ -217,6 +221,8 @@ class ServerModelManager: ) return if len(rows) == 0: + # TODO: customize statusbar to mention how records are added after connecting + # cf. cleanup on empty self.thread_man.set_cleanup_func( StoredFunc(self._cleanup_on_failure, False) ) @@ -258,52 +264,46 @@ class ServerModelManager: self.tv.set_model(self.to_insert) # inserting a row serializes file on disk, updates control model for that tab, and updates model + # TODO: make sure control model len is N + 1 # NOTE: when inserting new rows, the entire control model is wiped and rebuilt, then proxy model is swapped in # TODO: signals or other approach to deferring map # model insertion after thread closes # cf. servers_loaded signal # TODO: servers_loaded vs servers_reloaded - context = self.tv.get_enum() - self.emitter.emit("servers_loaded", context) + self.emitter.emit("servers_loaded", self.enum) if self.first_iteration: self._update_maps() - def _cleanup_on_failure(self, show_dialog=True) -> None: - map_man = self.tv.get_map_man() + # def _cleanup_on_empty(): + def _cleanup_on_failure(self, show_dialog=True) -> None: # TODO: disable map, keyword, and filter widgets if model is None # -> signal driven (servers_empty, servers_failed_to_load) - # TODO: what if refresh action occurred and failed, and the old model is still valid? - # skip the step below if refresh action failed - # do not wipe control model in this case - # e.g. if treeview.is_refresh(): - # revert old model - # wipe refresh state to False - self.tv.set_model(None) - map_man.set_unique_maps(None) - context = self.tv.get_enum() + # NOTE: used by refresh action + if not self.preserve_on_fail: + self.tv.set_model(None) + map_man = self.tv.get_map_man() + map_man.set_unique_maps(None) + # TODO: emit signal to not disable widget sensitivity # TODO: distinguish signals, e.g. "servers_failed_to_load", "servers_loaded_empty" # customize statusbar and dialog accordingly - self.emitter.emit("servers_loaded", context) + self.emitter.emit("servers_loaded", self.enum) if show_dialog: dialog = ExceptionDialog(self.controller, api_warn_msg) dialog.run() # TODO: break into initial dump and refilter modes, can drop filtermode kwarg - # and stop pushing empty data def _push_data(self, data: tuple, mode: Optional[FilterMode]) -> None: - # FIXME: calls treeview read methods in thread - # treeview = self.get_active_treeview() - # manager = treeview.get_filter_man() manager = self._get_filter_man() if data is None: self.to_insert = None else: + # FIXME: filterman calls GTK methods in thread if mode == FilterMode.INITIAL: manager.set_control(data) manager.filter(mode) @@ -323,7 +323,6 @@ class ServerModelManager: def _get_filter_man(self) -> "FilteredModelManager": return self.filter_man - # TODO: unimplemented @call_on_thread(dialog.filtering) def refilter(self, mode: FilterMode, label: str) -> None: self.first_iteration = False From d6fd7b24944dbbe9f4dd9ab3563cd1ab384c472a Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Mon, 23 Feb 2026 06:22:32 +0900 Subject: [PATCH 109/309] feat: add extend() method to ListStore --- dzgui/controllers/mc.py | 4 +- dzgui/model/filtered_model.py | 33 +++-------- dzgui/model/model_factory.py | 94 +++++++++++++++++++++++++++---- dzgui/util/localize.py | 1 + dzgui/views/pages/servers.py | 1 - dzgui/views/trees/tree_servers.py | 3 +- 6 files changed, 94 insertions(+), 42 deletions(-) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 4c5d663..67830e9 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -315,10 +315,10 @@ class Controller(GObject.GObject): path = self.query_config(Preferences.DEFAULT) mods = get_delimited_mods(Path(path)) + # NOTE: cell renderer highlight toggle for mod in mods: - # NOTE: show highlight color bool mod.append(False) - model.append(mod) + model.extend(mods) self.cleanup_func = StoredFunc(self.load_mods_cleanup, model) diff --git a/dzgui/model/filtered_model.py b/dzgui/model/filtered_model.py index e628594..6cefc27 100644 --- a/dzgui/model/filtered_model.py +++ b/dzgui/model/filtered_model.py @@ -1,11 +1,10 @@ import datetime import re -from dataclasses import dataclass from typing import Optional, TYPE_CHECKING -from warnings import deprecated from dzgui.const.enum import FilterMode +from dzgui.model.model_factory import ModelFactory from dzgui.util import strings import gi @@ -19,22 +18,6 @@ if TYPE_CHECKING: from dzgui.controllers.emitter import Emitter -@dataclass(slots=True, frozen=True) -class ServerColumns: - name: str - _map: str - perspective: str - gametime: str - players: int - _max: int - queue: int - ip: str - qport: int - ping: int - provider: str - modded: bool - - class FilteredModelManager: """ Manages access to cached ListStore resources and @@ -42,7 +25,9 @@ class FilteredModelManager: which share the same column structure. A FilteredModelManager is attached to each ServerTreeView. - Filter methods are not thread-safe in themselves. + + Raw data is cached before being packed into a ListStore, see get_control() + Filtration creates a proxy of the TreeView's model, see get_proxy_model() """ def __init__(self, controller: "Controller") -> None: @@ -117,21 +102,17 @@ class FilteredModelManager: rows = self.filter_toggle_on(filters, *args) # TODO: unimplemented + # just write pings into control model instead # if mode is not FilterMode.INITIAL: # for row in rows: # if row[7] in self.ping_cache: # row[9] = self.ping_cache[row[7]] # NOTE: this ListStore manipulation must remain local to the thread - clone = self.new_model_from_class(ServerColumns) - n_cols = clone.get_n_columns() + clone = ModelFactory().make_server_store() if len(rows) > 0: rows = self.sort_rows(rows) - for i, row in enumerate(rows): - # TODO: consider overriding append() method of Gtk.ListStore - # check Gtk source code - clone.insert_with_values(i, tuple(range(0, n_cols)), row) - # clone.append(row) + clone.extend(rows) self.set_cache(filters, clone, rows) self.set_proxy_model(clone) diff --git a/dzgui/model/model_factory.py b/dzgui/model/model_factory.py index 5e9d687..ed0f250 100644 --- a/dzgui/model/model_factory.py +++ b/dzgui/model/model_factory.py @@ -1,9 +1,9 @@ from dataclasses import dataclass +from typing import Any from dzgui.const.enum import HELP_MENU_ROWS from dzgui.util.redact import redact_log from dzgui.util.strings import delimiter -from dzgui.views.dialogs.generic import ExceptionDialog import gi @@ -11,6 +11,27 @@ gi.require_version("Gtk", "3.0") from gi.repository.Gtk import ListStore # noqa E402 from gi.repository import GObject # noqa E402 +GTYPE_TO_PYTHON = { + GObject.type_from_name(GObject.type_name(ptype)): ptype + for ptype in (int, float, str, bool, object) +} + + +@dataclass(slots=True, frozen=True) +class ServerCols: + name: str + _map: str + perspective: str + gametime: str + players: int + _max: int + queue: int + ip: str + qport: int + ping: int + provider: str + modded: bool + @dataclass(slots=True, frozen=True) class ModCols: @@ -38,7 +59,7 @@ class ServerModCols: @dataclass(slots=True, frozen=True) class MenuCols: - name: str + name: GObject.TYPE_STRING # str hidden: GObject.TYPE_PYOBJECT @@ -46,10 +67,52 @@ class FastInsertListStore(ListStore): def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) + def _is_same_length(self, lists: list[list[Any]]) -> bool: + first_len = len(lists[0]) + if not all(len(sublist) == first_len for sublist in lists): + return False + return True + + def _is_type_homogeneous(self, lists: list[list[Any]]) -> bool: + transposed = zip(*lists) + + for column in transposed: + first_type = type(column[0]) + if not all(type(item) is first_type for item in column): + return False + return True + + def extend(self, rows: list[list[Any]]) -> None: + """ + Compared to calling append() directly, introduces negligible overhead, + but guarantees type and length equivalence prior to insertion + """ + n_cols = self.get_n_columns() + expected_types = [ + GTYPE_TO_PYTHON[self.get_column_type(i)] for i in range(n_cols) + ] + if not self._is_same_length(rows): + raise ValueError("Sublists are not of uniform length") + if not self._is_type_homogeneous(rows): + raise TypeError("Sublists are not type homogeneous") + if not all(isinstance(a, b) for a, b in zip(rows[0], expected_types)): + raise TypeError("Sublist types are not same as ListStore") + if len(rows[0]) != n_cols: + raise ValueError("Sublist column length is not same as ListStore") + + for row in rows: + self.append(row) + def append(self, row) -> None: + """ + Optimized for speed, but makes no assurances about row homogeneity + and may segfault if types and length are not identical to ListStore. + For this reason, it is recommended to use the extend() method to insert + an entire list of lists + """ total = len(row) i = len(self) - tree_iter = self.insert_with_values(i, tuple(range(0, total)), row) + tree_iter = self.insert_with_values(i, tuple(range(total)), row) return tree_iter @@ -62,7 +125,7 @@ class ModelFactory: with open(path, "r") as f: lines = [line.split(delimiter) for line in f.read().splitlines()] for record in lines: - # NOTE: strip PII and API keys + # NOTE: strips PII and API keys clean = redact_log(record) store.append(clean) return store @@ -73,21 +136,28 @@ class ModelFactory: ) return store - def make_map_store(self) -> ListStore: + def make_map_store(self) -> FastInsertListStore: return ListStore(str) - def make_help_store(self) -> ListStore: + def make_help_store(self) -> FastInsertListStore: store = self.new_model_from_class(MenuCols) - for row in HELP_MENU_ROWS: - label = row.dict["label"] - store.append([label, row]) + rows = [[row.dict["label"], row] for row in HELP_MENU_ROWS] + # for row in rows:HELP_MENU_ROWS: + # print(type(row) is object) + store.extend(rows) + # for row in HELP_MENU_ROWS: + # label = row.dict["label"] + # store.append([label, row]) return store - def make_mod_store(self) -> ListStore: + def make_mod_store(self) -> FastInsertListStore: return self.new_model_from_class(ModCols) - def make_log_store(self) -> ListStore: + def make_log_store(self) -> FastInsertListStore: return self.new_model_from_class(LogCols) - def make_server_mod_store(self) -> ListStore: + def make_server_mod_store(self) -> FastInsertListStore: return self.new_model_from_class(ServerModCols) + + def make_server_store(self) -> FastInsertListStore: + return self.new_model_from_class(ServerCols) diff --git a/dzgui/util/localize.py b/dzgui/util/localize.py index 5fe9fc6..fd4b5c6 100644 --- a/dzgui/util/localize.py +++ b/dzgui/util/localize.py @@ -1,6 +1,7 @@ import os import locale + def number(num: int | float) -> str: spec = "" if type(num) is int: diff --git a/dzgui/views/pages/servers.py b/dzgui/views/pages/servers.py index 52940d7..cd85620 100644 --- a/dzgui/views/pages/servers.py +++ b/dzgui/views/pages/servers.py @@ -36,7 +36,6 @@ class ServerNotebook(Gtk.ScrolledWindow): controller, ServerTab.RECENT, ContextMenuGroup.RECENT ) self.lan = ServerTreeView(controller, ServerTab.LAN, ContextMenuGroup.SCAN_LAN) - self.lan.set_headers_visible(False) tabs = [ (self.browser, server_labels.browser), diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index cf363c3..20341b4 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -53,7 +53,8 @@ class ServerTreeView(ContextMixin, TreeView): self.map_man = MapManager() self.set_fixed_height_mode(True) - self.set_headers_visible(True) + # NOTE: headers become visible on model load + self.set_headers_visible(False) self.queue_id: int self.handler_id: int From fd9a23c6d66cc65925821cab1766acb156ae7e9e Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Mon, 23 Feb 2026 19:39:35 +0900 Subject: [PATCH 110/309] chore: clear linting errors --- dzgui/api/bm.py | 3 -- dzgui/api/mods.py | 13 ++++--- dzgui/api/servers.py | 5 ++- dzgui/controllers/emitter.py | 12 +------ dzgui/controllers/mc.py | 22 +++++------- dzgui/managers/contextmenu.py | 13 +++++-- dzgui/managers/thread_man.py | 2 +- dzgui/model/filtered_model.py | 5 ++- dzgui/model/model_factory.py | 29 +++++++-------- dzgui/model/servers.py | 52 ++++++++++++++------------- dzgui/util/dist.py | 5 +-- dzgui/views/base.py | 1 - dzgui/views/components/buttons.py | 1 + dzgui/views/components/right_panel.py | 4 --- dzgui/views/components/statusbar.py | 3 +- dzgui/views/mixins/context_mixin.py | 8 ++--- 16 files changed, 82 insertions(+), 96 deletions(-) diff --git a/dzgui/api/bm.py b/dzgui/api/bm.py index 196583b..1f25838 100644 --- a/dzgui/api/bm.py +++ b/dzgui/api/bm.py @@ -1,11 +1,8 @@ import logging import requests -from pathlib import Path from typing import Optional, TYPE_CHECKING -from dzgui.config.query import lookup from dzgui.const.endpoints import BM_SERVERS -from dzgui.const.enum import Preferences logger = logging.getLogger(__name__) diff --git a/dzgui/api/mods.py b/dzgui/api/mods.py index 9010079..5e833ad 100644 --- a/dzgui/api/mods.py +++ b/dzgui/api/mods.py @@ -50,7 +50,7 @@ def get_local_mods(workshop_path: Path) -> list[Path]: return mods -# TODO: TEST: mock bad meta files with fixtures and remove them +# TODO: TEST: mock bad meta files with fixtures and skip them def parse_meta(file: Path) -> ModMeta: mod = file / "meta.cpp" if mod.exists() is False: @@ -67,7 +67,7 @@ def parse_meta(file: Path) -> ModMeta: if tok == "protocol" or tok == "publishedid": ntok = lex.get_token() elif tok == "timestamp": - # some malformed .NET tick conversions result in numbers < 0 + # NOTE: some malformed .NET tick conversions result in numbers < 0 ntok = lex.get_token() if ntok == "-": ntok += str(lex.get_token()) @@ -99,7 +99,8 @@ def get_delimited_mods(steam_path: Path) -> list[Any]: if meta is None: continue size = get_mod_size(mod) - clean.append([meta.name, symlink, mod_dir, size]) + # NOTE: final col is cell renderer highlight toggle + clean.append([meta.name, symlink, mod_dir, size, False]) clean.sort(key=lambda row: row[0]) return clean @@ -147,6 +148,7 @@ def remove_stale_signatures(config: Path, versions: Path) -> None: for line in lines: f.write(line) + def find_stale_mods(config: Path) -> list[int]: def push_record(rec: str) -> list: add = rec.split(":") @@ -163,10 +165,7 @@ def find_stale_mods(config: Path) -> list[int]: remote_mods = [] with ThreadPoolExecutor() as executor: - futures = [ - executor.submit(push_record, record) - for record in records - ] + futures = [executor.submit(push_record, record) for record in records] wait(futures) for future in futures: res = future.result() diff --git a/dzgui/api/servers.py b/dzgui/api/servers.py index 50b2dd6..82d5fd2 100644 --- a/dzgui/api/servers.py +++ b/dzgui/api/servers.py @@ -7,10 +7,9 @@ import requests import socket import subprocess import threading -import typing # noqa from dataclasses import dataclass -from typing import Union +from typing import Optional, Union from dzgui.api.bm import map_id_to_record from dzgui.const.constants import REQUEST_TIMEOUT @@ -461,7 +460,7 @@ def response_to_fq_ip(res: dict) -> str: return f"{ip}:{gameport}:{qport}" -def query_id_or_ip(addr: str, key: str) -> None: +def query_id_or_ip(addr: str, key: str) -> Optional[dict]: # NOTE: Battlemetrics if addr.isdigit(): try: diff --git a/dzgui/controllers/emitter.py b/dzgui/controllers/emitter.py index b0c2284..c84fd70 100644 --- a/dzgui/controllers/emitter.py +++ b/dzgui/controllers/emitter.py @@ -3,7 +3,7 @@ from typing import Union, TYPE_CHECKING import gi gi.require_version("Gtk", "3.0") -from gi.repository.Gtk import ListStore +from gi.repository.Gtk import ListStore # noqa E402 from gi.repository import Gdk, GObject # noqa E402 if TYPE_CHECKING: @@ -98,16 +98,6 @@ class Emitter(GObject.GObject): def check_toggled(self, label: str, state: bool) -> None: pass - @GObject.Signal( - flags=GObject.SignalFlags.RUN_LAST, - arg_types=( - str, - bool, - ), - ) - def check_toggled(self, label: str, state: bool) -> None: - pass - @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object,)) def servers_loaded(self, tag: "ServerTab") -> None: pass diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 67830e9..9f7b648 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -6,7 +6,7 @@ import traceback from functools import wraps from pathlib import Path -from typing import Any, Callable, Optional, TYPE_CHECKING +from typing import Any, Callable, TYPE_CHECKING from warnings import deprecated import dzgui.api.pefile as PeFile @@ -49,7 +49,7 @@ from dzgui.util._json import read_json, write_json from dzgui.util.diag import write_diagnostic from dzgui.util.format import format_mods, format_player_count from dzgui.util.localize import number -from dzgui.util.open_links import open_workshop_page, open_user_workshop +from dzgui.util.open_links import open_user_workshop from dzgui.views.dialogs.filepicker import FilePicker from dzgui.views.dialogs.generic import ExceptionDialog, WaitDialog @@ -161,8 +161,8 @@ class Controller(GObject.GObject): def is_auto_install(self) -> bool: return self.query_config(Preferences.INSTALL) - def unblock_signals(self) -> None: - self.block_signals(False) + # def unblock_signals(self) -> None: + # self.block_signals(False) # @deprecated("Currently unused") # def block_signals(self, state: bool = True) -> None: @@ -203,7 +203,7 @@ class Controller(GObject.GObject): # self.config_man().toggle_config(Preferences.DEBUG) self.toggle_config(Preferences.DEBUG) - def get_active_context(self) -> NotebookPage: + def get_active_context(self) -> Gtk.TreeView: tv = self.get_active_treeview() return tv.get_enum() @@ -311,13 +311,9 @@ class Controller(GObject.GObject): # TODO: delegate to threadmanager @call_on_thread(strings.dialog.modlist) def load_mods(self) -> None: - model = ModelFactory().make_mod_store() # self.model_man.new_mod_store() + model = ModelFactory().make_mod_store() path = self.query_config(Preferences.DEFAULT) mods = get_delimited_mods(Path(path)) - - # NOTE: cell renderer highlight toggle - for mod in mods: - mod.append(False) model.extend(mods) self.cleanup_func = StoredFunc(self.load_mods_cleanup, model) @@ -394,7 +390,7 @@ class Controller(GObject.GObject): remove_stale_signatures(self.prefs.paths.config, self.prefs.paths.version) # TODO: strings - # TODO: delegate to LocalModManager + # TODO: delegate to LocalModManager or api/mods.py @call_on_thread("deleting mod") def delete_single_mod(self, tree_path: Gtk.TreePath) -> None: config = self.prefs.paths.config @@ -584,7 +580,7 @@ class Controller(GObject.GObject): def refresh_tree(self) -> None: treeview = self.get_active_treeview() treeview.set_loaded(False) - self.populate_model() + ServerModelManager(self, treeview).refresh() def get_player_count(self) -> str: treeview = self.get_active_treeview() @@ -619,7 +615,7 @@ class Controller(GObject.GObject): addr = ip.split(":") return fav, f"{addr[0]}:{addr[2]}" - def get_dist_cache(self) -> dict[str, "Haversine"]: + def get_dist_cache(self) -> dict[str, "Haversine", "ServerTab"]: return self.dist_cache def get_filters(self) -> list: diff --git a/dzgui/managers/contextmenu.py b/dzgui/managers/contextmenu.py index 7b074fe..33c9198 100644 --- a/dzgui/managers/contextmenu.py +++ b/dzgui/managers/contextmenu.py @@ -1,6 +1,6 @@ import gi import logging -from typing import TYPE_CHECKING +from typing import Union, TYPE_CHECKING from dzgui.const.enum import ContextMenu, Preferences from dzgui.managers.connection import ConnectionManager @@ -10,6 +10,9 @@ from dzgui.util import strings from dzgui.util.clip import copy_clipboard from dzgui.util.open_links import open_workshop_page +from dzgui.views.trees.tree_servers import ServerTreeView + + gi.require_version("Gtk", "3.0") from gi.repository import Gtk, Gdk, GLib, GObject # noqa E402 @@ -21,7 +24,11 @@ if TYPE_CHECKING: class ContextMenuManager: - def __init__(self, treeview: Gtk.TreeView, controller: "Controller") -> None: + def __init__( + self, + treeview: Gtk.TreeView, + controller: "Controller", + ) -> None: self.controller = controller self.treeview = treeview self.thread_man = ThreadingManager(parent=controller) @@ -83,6 +90,8 @@ class ContextMenuManager: pass def copy_server_ip(self) -> None: + if not isinstance(self.treeview, ServerTreeView): + return record = self.treeview.get_simplified_ip() copy_clipboard(record) diff --git a/dzgui/managers/thread_man.py b/dzgui/managers/thread_man.py index 117f913..e51e3c9 100644 --- a/dzgui/managers/thread_man.py +++ b/dzgui/managers/thread_man.py @@ -18,7 +18,7 @@ logger = logging.getLogger(__name__) def call_on_thread(dialog_str: str) -> Callable: def decorator(func: Callable) -> Callable: @wraps(func) - def wrapper(*args, **kwargs): + def wrapper(*args: Any, **kwargs: Any) -> Callable: self = args[0] stored = StoredFunc(func, *args, **kwargs) if not hasattr(self, "thread_man"): diff --git a/dzgui/model/filtered_model.py b/dzgui/model/filtered_model.py index 6cefc27..ba3ccd2 100644 --- a/dzgui/model/filtered_model.py +++ b/dzgui/model/filtered_model.py @@ -1,4 +1,3 @@ -import datetime import re from typing import Optional, TYPE_CHECKING @@ -281,5 +280,5 @@ class FilteredModelManager: self.filtered = None self.filter_cache = {} self.ping_cache = {} - if full: - self.control_model = None + # if full: + # self.control_model = None diff --git a/dzgui/model/model_factory.py b/dzgui/model/model_factory.py index ed0f250..0c62ba8 100644 --- a/dzgui/model/model_factory.py +++ b/dzgui/model/model_factory.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Any +from typing import Any, Sequence, Union from dzgui.const.enum import HELP_MENU_ROWS from dzgui.util.redact import redact_log @@ -8,7 +8,7 @@ from dzgui.util.strings import delimiter import gi gi.require_version("Gtk", "3.0") -from gi.repository.Gtk import ListStore # noqa E402 +from gi.repository.Gtk import ListStore, TreeIter # noqa E402 from gi.repository import GObject # noqa E402 GTYPE_TO_PYTHON = { @@ -53,27 +53,27 @@ class LogCols: @dataclass(slots=True, frozen=True) class ServerModCols: name: str - uid: GObject.TYPE_INT64 + uid: int # GObject.TYPE_INT64 installed: str @dataclass(slots=True, frozen=True) class MenuCols: - name: GObject.TYPE_STRING # str - hidden: GObject.TYPE_PYOBJECT + name: str + hidden: object # GObject.TYPE_PYOBJECT class FastInsertListStore(ListStore): - def __init__(self, *args, **kwargs) -> None: + def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) - def _is_same_length(self, lists: list[list[Any]]) -> bool: + def _is_same_length(self, lists: Sequence[Any]) -> bool: first_len = len(lists[0]) if not all(len(sublist) == first_len for sublist in lists): return False return True - def _is_type_homogeneous(self, lists: list[list[Any]]) -> bool: + def _is_type_homogeneous(self, lists: Sequence[Any]) -> bool: transposed = zip(*lists) for column in transposed: @@ -82,7 +82,7 @@ class FastInsertListStore(ListStore): return False return True - def extend(self, rows: list[list[Any]]) -> None: + def extend(self, rows: Sequence[Any]) -> None: """ Compared to calling append() directly, introduces negligible overhead, but guarantees type and length equivalence prior to insertion @@ -103,7 +103,7 @@ class FastInsertListStore(ListStore): for row in rows: self.append(row) - def append(self, row) -> None: + def append(self, row: list[Any] | tuple[Any, ...] | None = ...) -> TreeIter: """ Optimized for speed, but makes no assurances about row homogeneity and may segfault if types and length are not identical to ListStore. @@ -120,7 +120,7 @@ class ModelFactory: def __init__(self) -> None: pass - def new_model_from_logfile(self, path: str) -> None: + def new_model_from_logfile(self, path: str) -> FastInsertListStore: store = self.make_log_store() with open(path, "r") as f: lines = [line.split(delimiter) for line in f.read().splitlines()] @@ -137,17 +137,12 @@ class ModelFactory: return store def make_map_store(self) -> FastInsertListStore: - return ListStore(str) + return FastInsertListStore(str) def make_help_store(self) -> FastInsertListStore: store = self.new_model_from_class(MenuCols) rows = [[row.dict["label"], row] for row in HELP_MENU_ROWS] - # for row in rows:HELP_MENU_ROWS: - # print(type(row) is object) store.extend(rows) - # for row in HELP_MENU_ROWS: - # label = row.dict["label"] - # store.append([label, row]) return store def make_mod_store(self) -> FastInsertListStore: diff --git a/dzgui/model/servers.py b/dzgui/model/servers.py index 71a4f73..c8ac7a2 100644 --- a/dzgui/model/servers.py +++ b/dzgui/model/servers.py @@ -42,7 +42,7 @@ class ServerModelManager: self.emitter = controller.get_emitter() self.first_iteration: bool - self.preserve_on_fail: False + self.preserve_on_fail = False self.jobs = 1 # NOTE: store filter man for access inside thread @@ -80,6 +80,8 @@ class ServerModelManager: def refresh(self) -> None: self.preserve_on_fail = True self.load() + # TODO: if refresh is active, callback to button decrement signal + # and do not mark refresh as sensitive @call_on_thread(dialog.fetching) def _dump_api(self) -> None: @@ -109,14 +111,14 @@ class ServerModelManager: ) return - # NOTE: This step is allowed to fail, since this metadata is incidental + # NOTE: this step is allowed to fail, since this metadata is incidental res = Servers.query_api(key, APPID_DAYZ_EXP, "") if res.status == 200 and res.parsed is True: j = res.json servers += j["response"]["servers"] parsed = Servers.parse_json(servers) - self._push_data(parsed, FilterMode.INITIAL) + self._push_data(parsed) @call_on_thread(dialog.scanning) def dump_lan(self, port: int, early_abort: bool) -> None: @@ -149,14 +151,16 @@ class ServerModelManager: self.thread_man.set_cleanup_func(StoredFunc(self._cleanup_on_failure)) return parsed = Servers.parse_json(servers) - self._push_data(parsed, FilterMode.INITIAL) + self._push_data(parsed) @call_on_thread(dialog.fetching) def _dump_ips(self, ips: list[str]) -> None: # NOTE: block malformed records (TODO: add github issue no.) # TODO: sanitize ip list at config time and drop this + # TODO: make test for this ips = [ip for ip in ips if len(ip.split(":")) == 3 and ip.split(":")[2] != ""] job = Servers.query_direct + servers = [] with ThreadPoolExecutor() as executor: futures = [ executor.submit( @@ -166,9 +170,8 @@ class ServerModelManager: ) for ip in ips ] - servers = [] for future in as_completed(futures): - res = future.result() + res = future.result(timeout=API_TIMEOUT) self.thread_man.increment_dialog() # NOTE: failing entries are culled if res is None: @@ -181,7 +184,7 @@ class ServerModelManager: return parsed = Servers.parse_json(servers) - self._push_data(parsed, FilterMode.INITIAL) + self._push_data(parsed) @call_on_thread(dialog.querying) def add_by_id_or_ip(self, addr: str) -> None: @@ -199,8 +202,8 @@ class ServerModelManager: config_man = self.controller.get_config_man() config_man.add_saved_server(fqip) - # NOTE: tab was not instantiated yet - if model is None: + # NOTE: if tab was not instantiated yet + if raw_model is None: return raw_model.append(record[0]) @@ -250,8 +253,9 @@ class ServerModelManager: self.tv.set_model(proxy) # TODO: if current tab != self.saved, add label - # TODO: adding a row may update available maps self.emitter.emit("servers_loaded", self.enum) + # NOTE: adding a row may update available maps + # TODO: test this self._update_maps() def _update_maps(self) -> None: @@ -263,8 +267,8 @@ class ServerModelManager: def _cleanup_on_success(self) -> None: self.tv.set_model(self.to_insert) - # inserting a row serializes file on disk, updates control model for that tab, and updates model # TODO: make sure control model len is N + 1 + # inserting a row serializes file on disk, updates control model for that tab, and updates model # NOTE: when inserting new rows, the entire control model is wiped and rebuilt, then proxy model is swapped in # TODO: signals or other approach to deferring map # model insertion after thread closes @@ -296,21 +300,21 @@ class ServerModelManager: dialog = ExceptionDialog(self.controller, api_warn_msg) dialog.run() - # TODO: break into initial dump and refilter modes, can drop filtermode kwarg - def _push_data(self, data: tuple, mode: Optional[FilterMode]) -> None: + def _push_data(self, data: list) -> None: + # if data is None: + # self.to_insert = None + # else: + # FIXME: filterman calls GTK methods in thread + # if mode == FilterMode.INITIAL: + manager = self._get_filter_man() + manager.wipe_cache() + manager.set_control(data) + manager.filter(FilterMode.INITIAL) + self.to_insert = manager.get_proxy_model() - if data is None: - self.to_insert = None - else: - # FIXME: filterman calls GTK methods in thread - if mode == FilterMode.INITIAL: - manager.set_control(data) - manager.filter(mode) - self.to_insert = manager.get_proxy_model() - - u_maps = set([row[1] for row in data]) - self._set_new_maps(sorted(u_maps)) + u_maps = set([row[1] for row in data]) + self._set_new_maps(sorted(u_maps)) self.thread_man.set_cleanup_func(StoredFunc(self._cleanup_on_success)) diff --git a/dzgui/util/dist.py b/dzgui/util/dist.py index d886e54..8a60cc9 100644 --- a/dzgui/util/dist.py +++ b/dzgui/util/dist.py @@ -13,6 +13,7 @@ from gi.repository import Gtk # noqa E402 if TYPE_CHECKING: from dzgui.const.enum import ServerTab from dzgui.controllers.mc import Controller + from queue import Queue logger = logging.getLogger(__name__) @@ -53,9 +54,9 @@ class CalcDist: self.controller = controller self.result_queue = result_queue self.addr = addr - self.ip = self.addr #.split(":")[0] + self.ip = self.addr # .split(":")[0] - #def run(self) -> None: + # def run(self) -> None: cache = self.controller.get_dist_cache() if self.addr in cache: logger.info(f"Address '{self.addr}' already in cache") diff --git a/dzgui/views/base.py b/dzgui/views/base.py index 0abea18..c5194f7 100644 --- a/dzgui/views/base.py +++ b/dzgui/views/base.py @@ -273,7 +273,6 @@ class Notebook(ScrollableMixin, Gtk.Notebook): # type: ignore if widget is None: return - # FIXME: deprecated widget.grab_content_area() def get_page(self) -> Gtk.Widget | None: diff --git a/dzgui/views/components/buttons.py b/dzgui/views/components/buttons.py index 04c88c8..32ee330 100644 --- a/dzgui/views/components/buttons.py +++ b/dzgui/views/components/buttons.py @@ -103,6 +103,7 @@ class RefreshButton(IconTextButton): # FIXME: do this after threaded call fully finishes # FIXME: other signals cause refresh button to become sensitive # after model is loaded + # TODO: put in smm cleanup func, use signal GLib.timeout_add_seconds(1, self.decrement) self.controller.refresh_tree() diff --git a/dzgui/views/components/right_panel.py b/dzgui/views/components/right_panel.py index 3a1cdda..386de3c 100644 --- a/dzgui/views/components/right_panel.py +++ b/dzgui/views/components/right_panel.py @@ -45,10 +45,6 @@ class RightPanel(Gtk.Box): for widget in (self.refresh_button, self.filters_vbox): widget.set_sensitive(state) - # FIXME: may wish to reload these in place to show new data - # if context in (ServerTab.RECENT, ServerTab.SAVED): - # self.refresh_button.set_sensitive(False) - # TODO: reference for ping pass # def _on_ping_clicked(self, button: Gtk.Button) -> None: # block_signals() diff --git a/dzgui/views/components/statusbar.py b/dzgui/views/components/statusbar.py index 18716c1..fba3bcc 100644 --- a/dzgui/views/components/statusbar.py +++ b/dzgui/views/components/statusbar.py @@ -150,10 +150,11 @@ class Statusbar(Gtk.Grid): def set_cache(self, string: str) -> None: self.cache = string - def set_text(self, string: str, context: str) -> None: + def set_text(self, string: str, context: str) -> int: meta = self.statusbar.get_context_id(context) self.statusbar.push(meta, string) self.set_cache(string) + return meta def _help_row_changed(self, tree: "TreeView", sel: Gtk.TreeSelection) -> None: row = tree.get_value_at_index(1) diff --git a/dzgui/views/mixins/context_mixin.py b/dzgui/views/mixins/context_mixin.py index e673049..bcb9c72 100644 --- a/dzgui/views/mixins/context_mixin.py +++ b/dzgui/views/mixins/context_mixin.py @@ -55,8 +55,8 @@ class ContextMixin(TreeView): self.context_menu.select_first(False) - def _process_dynamic_row(self, row: ContextMenu) -> None: - if row == ContextMenu.ADD_SERVER and self.is_in_favs(): + def _process_dynamic_row(self, row: ContextMenu) -> Gtk.MenuItem: + if row == ContextMenu.ADD_SERVER and self.is_in_favs(): # type: ignore row = ContextMenu.REMOVE_SERVER item = Gtk.MenuItem(label=row.dict["label"]) @@ -71,7 +71,7 @@ class ContextMixin(TreeView): # if self.get_record_string() in notes_cache: # item.set_label(strings.edit_note) - def _process_button_event(self, event: Gdk.EventButton) -> None: + def _process_button_event(self, event: Gdk.EventButton) -> bool: try: pathinfo = self.get_path_at_pos(int(event.x), int(event.y)) if pathinfo is None: @@ -89,7 +89,7 @@ class ContextMixin(TreeView): return True # FIXME: if selection is not multiple, change cursor except AttributeError: - pass + return False def _on_menu_click(self, widget: Gtk.MenuItem, enum: ContextMenu) -> None: self.controller.menu_action(enum, self) From 0283baf3a97c1c9957c97274327cbbe18a01fb58 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Mon, 23 Feb 2026 21:53:20 +0900 Subject: [PATCH 111/309] chore: rename to ProxyModelManager --- dzgui/controllers/mc.py | 22 +++---- dzgui/managers/thread_man.py | 1 + dzgui/model/map_model.py | 18 +++--- .../{filtered_model.py => proxy_model.py} | 10 ++- dzgui/model/servers.py | 24 ++++---- dzgui/views/components/filter_panel.py | 61 ++++++++++--------- dzgui/views/trees/tree_servers.py | 12 ++-- 7 files changed, 82 insertions(+), 66 deletions(-) rename dzgui/model/{filtered_model.py => proxy_model.py} (97%) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 9f7b648..b555fb3 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -41,7 +41,7 @@ from dzgui.config.userprefs import UserPrefs from dzgui.controllers.emitter import Emitter from dzgui.managers.config import ConfigManager from dzgui.managers.contextmenu import ContextMenuManager -from dzgui.model.filtered_model import FilteredModelManager +from dzgui.model.proxy_model import ProxyModelManager from dzgui.model.servers import ServerModelManager from dzgui.model.model_factory import ModelFactory from dzgui.util import strings @@ -585,20 +585,20 @@ class Controller(GObject.GObject): def get_player_count(self) -> str: treeview = self.get_active_treeview() model = treeview.get_model() - filter_man = treeview.get_filter_man() - control_model = filter_man.get_control() + proxy_man = treeview.get_proxy_man() + control_model = proxy_man.get_control() count = format_player_count(model, control_model) return count def get_statusbar(self) -> None: return self.mediator.statusbar - def get_filter_man(self) -> "FilteredModelManager": - return self.filter_man + def get_proxy_man(self) -> "ProxyModelManager": + return self.proxy_man - def set_filter_man(self, filter_man: "FilteredModelManager") -> None: - # TODO: used when staging filter man outside of thread - self.filter_man = filter_man + # def set_proxy_man(self, proxy_man: "ProxyModelManager") -> None: + # # TODO: used when staging filter man outside of thread + # self.proxy_man = proxy_man def populate_model(self, tv: Gtk.TreeView) -> None: # NOTE: skip on previously loaded tabs @@ -624,8 +624,8 @@ class Controller(GObject.GObject): # TODO: filterman calls back to here, gets convoluted def get_keyword(self) -> str: tv = self.get_active_treeview() - filter_man = tv.get_filter_man() - return filter_man.get_keyword_filter() + proxy_man = tv.get_proxy_man() + return proxy_man.get_keyword_filter() def get_map_store(self) -> Gtk.ListStore: map_man = self.get_map_man() @@ -677,7 +677,7 @@ class Controller(GObject.GObject): map_man = self.get_map_man() map_man.set_selected_map(selection) # FIXME: label should not be mandatory - ServerModelManager(self, self.get_active_treeview()).refilter(FilterMode.MAP) + ServerModelManager(self, self.get_active_treeview()).refilter(FilterMode.MAP, selection) def get_notebook(self) -> "Notebook": return self.mediator.notebook diff --git a/dzgui/managers/thread_man.py b/dzgui/managers/thread_man.py index e51e3c9..18a520c 100644 --- a/dzgui/managers/thread_man.py +++ b/dzgui/managers/thread_man.py @@ -1,6 +1,7 @@ import inspect import logging import threading +from typing import Any from functools import wraps from typing import Callable, Optional diff --git a/dzgui/model/map_model.py b/dzgui/model/map_model.py index 2083f76..42739cb 100644 --- a/dzgui/model/map_model.py +++ b/dzgui/model/map_model.py @@ -1,18 +1,18 @@ -import gi +from typing import TYPE_CHECKING +from dzgui.model.model_factory import ModelFactory from dzgui.util import strings from dzgui.util.strings import all_maps -gi.require_version("Gtk", "3.0") -from gi.repository.Gtk import ListStore # noqa E402 -from gi.repository import Gtk # noqa E402 +if TYPE_CHECKING: + from dzgui.model.model_factory import FastInsertListStore # TODO: rename class to MetaManager class MapManager: def __init__(self) -> None: - self.map_store = ListStore(str) + self.map_store = ModelFactory().make_map_store() self.prior_map: str self.selected_map = all_maps @@ -54,11 +54,11 @@ class MapManager: def set_filter(self, label: str, state: bool) -> None: self.enabled_filters[label] = state - def get_map_store(self) -> ListStore: + def get_map_store(self) -> "FastInsertListStore": return self.map_store def reinit_map_store(self) -> None: - model = ListStore(str) + model = ModelFactory().make_map_store() model.append([all_maps]) self.map_store = model self.selected_map = all_maps @@ -88,3 +88,7 @@ class MapManager: self.reinit_map_store() for m in maps: self.append_map([m]) + + # when switching views, just grab the map store, active keyword, active map, and selected checks + # for that view and apply them to filter panel outside of thread + # initialize proxyman with access to filterman diff --git a/dzgui/model/filtered_model.py b/dzgui/model/proxy_model.py similarity index 97% rename from dzgui/model/filtered_model.py rename to dzgui/model/proxy_model.py index ba3ccd2..33df8e6 100644 --- a/dzgui/model/filtered_model.py +++ b/dzgui/model/proxy_model.py @@ -17,13 +17,13 @@ if TYPE_CHECKING: from dzgui.controllers.emitter import Emitter -class FilteredModelManager: +class ProxyModelManager: """ Manages access to cached ListStore resources and performs filtering on behalf of atomic TreeViews, which share the same column structure. - A FilteredModelManager is attached to each ServerTreeView. + A ProxyModelManager is attached to each ServerTreeView. Raw data is cached before being packed into a ListStore, see get_control() Filtration creates a proxy of the TreeView's model, see get_proxy_model() @@ -282,3 +282,9 @@ class FilteredModelManager: self.ping_cache = {} # if full: # self.control_model = None + + def set_active_map(self, _map: str) -> None: + self.active_map = _map + + def get_active_map(self) -> str: + return self.active_map diff --git a/dzgui/model/servers.py b/dzgui/model/servers.py index c8ac7a2..c9887c5 100644 --- a/dzgui/model/servers.py +++ b/dzgui/model/servers.py @@ -24,7 +24,7 @@ API_TIMEOUT = 3 if TYPE_CHECKING: from dzgui.controllers.mc import Controller - from dzgui.model.filtered_model import FilteredModelManager + from dzgui.model.proxy_model import ProxyModelManager logger = logging.getLogger(__name__) @@ -46,7 +46,7 @@ class ServerModelManager: self.jobs = 1 # NOTE: store filter man for access inside thread - self.filter_man = tv.get_filter_man() + self.proxy_man = tv.get_proxy_man() # FIXME: change WaitDialog to use parent window only self.thread_man = ThreadingManager(parent=controller) @@ -195,8 +195,8 @@ class ServerModelManager: record = Servers.parse_json([res]) - filter_man = self._get_filter_man() - raw_model = filter_man.get_control() + proxy_man = self._get_proxy_man() + raw_model = proxy_man.get_control() fqip = Servers.response_to_fq_ip(res) config_man = self.controller.get_config_man() @@ -209,7 +209,7 @@ class ServerModelManager: raw_model.append(record[0]) # TODO: if all filters are already applied, strange behavior may occur # -> need to insert and update per current filters - filter_man.filter(FilterMode.INITIAL) + proxy_man.filter(FilterMode.INITIAL) self.thread_man.set_cleanup_func(StoredFunc(self._cleanup_single_ip)) @@ -249,7 +249,7 @@ class ServerModelManager: self._dump_ips(ips) def _cleanup_single_ip(self) -> None: - proxy = self._get_filter_man().get_proxy_model() + proxy = self._get_proxy_man().get_proxy_model() self.tv.set_model(proxy) # TODO: if current tab != self.saved, add label @@ -307,7 +307,7 @@ class ServerModelManager: # FIXME: filterman calls GTK methods in thread # if mode == FilterMode.INITIAL: - manager = self._get_filter_man() + manager = self._get_proxy_man() manager.wipe_cache() manager.set_control(data) manager.filter(FilterMode.INITIAL) @@ -324,13 +324,13 @@ class ServerModelManager: def _get_new_maps(self) -> list[str]: return self.new_maps - def _get_filter_man(self) -> "FilteredModelManager": - return self.filter_man + def _get_proxy_man(self) -> "ProxyModelManager": + return self.proxy_man @call_on_thread(dialog.filtering) def refilter(self, mode: FilterMode, label: str) -> None: self.first_iteration = False - filter_man = self._get_filter_man() - filter_man.filter(mode, label) - self.to_insert = filter_man.get_proxy_model() + proxy_man = self._get_proxy_man() + proxy_man.filter(mode, label) + self.to_insert = proxy_man.get_proxy_model() self.thread_man.set_cleanup_func(StoredFunc(self._cleanup_on_success)) diff --git a/dzgui/views/components/filter_panel.py b/dzgui/views/components/filter_panel.py index 38e9359..5ec8678 100644 --- a/dzgui/views/components/filter_panel.py +++ b/dzgui/views/components/filter_panel.py @@ -4,7 +4,6 @@ from typing import Literal, TYPE_CHECKING from dzgui.const.enum import FilterMode from dzgui.const.constants import EXPAND, NO_EXPAND, NO_FILL, NO_PADDING, SEARCH_ICON from dzgui.model.servers import ServerModelManager -from dzgui.util.margins import set_surrounding_margins from dzgui.views.components.labels import BoldLabel import gi @@ -128,12 +127,24 @@ class KeywordEntry(Gtk.Entry): class FilterPanel(Gtk.Box): def __init__(self, controller: "Controller") -> None: - super().__init__(spacing=6, vexpand=False, orientation=Gtk.Orientation.VERTICAL) + super().__init__( + spacing=6, + vexpand=False, + orientation=Gtk.Orientation.VERTICAL, + margin_top=1, + margin_bottom=10, + margin_left=10, + margin_right=10, + ) self.controller = controller self.controller.register_widget("filters", self) self.emitter = self.controller.get_emitter() + # TODO: strings + self.sel_map = "All maps" + + # TODO: each treeview for that tab context has its own filter manager, independent of this # self.selected_map: str = strings.all_maps # self.prior_map: str = strings.all_maps # self.set_orientation(Gtk.Orientation.VERTICAL) @@ -147,11 +158,6 @@ class FilterPanel(Gtk.Box): self.keyword_entry = KeywordEntry(self.controller) self.button_grid = ButtonGrid(self.controller, defaults) - # TODO: unintended legacy behavior? - self.connect("button-release-event", lambda *args: True) - set_surrounding_margins(self, 10) - self.set_margin_top(1) - # TODO: strings self.filters_label = BoldLabel("Filters") @@ -201,11 +207,13 @@ class FilterPanel(Gtk.Box): model = self.maps_combo.get_model() if text is None: return + if text == self.sel_map: + return for i, row in enumerate(model): if text == row[0]: self.maps_combo.set_active(i) - self._on_map_changed(self.maps_combo) - self.controller.set_active_map(i) + # self.controller.set_active_map(i) + # print("HERE3") def _on_maps_loaded(self, emitter: "Emitter", store: Gtk.ListStore) -> None: self.maps_combo.set_model(store) @@ -272,8 +280,8 @@ class FilterPanel(Gtk.Box): store = self.controller.get_map_store() if len(text) >= completion.get_minimum_key_length(): completion.set_model(store) - ind = self.get_active_combo() - self.controller.set_active_map(ind) + # ind = self.get_active_combo() + # self.controller.set_active_map(ind) def restore_focus_to_treeview(self) -> Literal[False]: view = self.controller.get_active_treeview() @@ -282,17 +290,17 @@ class FilterPanel(Gtk.Box): def _on_combo_keypress(self, combo: Gtk.ComboBox, event: Gdk.EventKey) -> bool: match event.keyval: - case Gdk.KEY_Down: + case Gdk.KEY_Down | Gdk.KEY_Up: self.maps_combo.popup() return True case _: return False - 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 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_by_key(self, emitter: "Emitter", keyval: int) -> bool: mappings = { @@ -322,16 +330,13 @@ class FilterPanel(Gtk.Box): check.set_active(not state) def _on_map_changed(self, combo: Gtk.ComboBox) -> None: - old_sel = self.controller.get_selected_map() - model = combo.get_model() - tree_iter = combo.get_active_iter() - if tree_iter is None: + ind = combo.get_active() + if ind < 0: return - selection = model[tree_iter][0] - if selection == old_sel: - return - if not selection: - return - self.maps_entry.set_text(selection) - logger.info(f"User selected map '{selection}'") + selection = self.maps_entry.get_text() + self.sel_map = selection self.emitter.emit("map_selection_changed", selection) + + tv = self.controller.get_active_treeview() + proxy_man = tv.get_proxy_man() + proxy_man.set_active_map(selection) diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index 20341b4..79f79b3 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -9,7 +9,7 @@ from dzgui.views.mixins.context_mixin import ContextMixin from dzgui.const.enum import ContextMenu, ContextMenuGroup, ServerTab from dzgui.api.servers import Record from dzgui.model.map_model import MapManager -from dzgui.model.filtered_model import FilteredModelManager +from dzgui.model.proxy_model import ProxyModelManager from dzgui.util.dist import CalcDist from dzgui.util import strings from typing import Literal, TYPE_CHECKING @@ -45,8 +45,8 @@ class ServerTreeView(ContextMixin, TreeView): self.loaded = False - self.filter_man = FilteredModelManager(controller) - model = self.filter_man.get_proxy_model() + self.proxy_man = ProxyModelManager(controller) + model = self.proxy_man.get_proxy_model() self.set_model(model) # NOTE: each tab context has its own unique maps @@ -132,8 +132,8 @@ class ServerTreeView(ContextMixin, TreeView): def get_map_man(self) -> MapManager: return self.map_man - def get_filter_man(self) -> FilteredModelManager: - return self.filter_man + def get_proxy_man(self) -> ProxyModelManager: + return self.proxy_man # def shrink_to_fit(self) -> None: # cols = self.get_columns() @@ -372,6 +372,6 @@ class ServerTreeView(ContextMixin, TreeView): return if row_index >= start[0] <= end[0]: # NOTE: fetch raw data rows - real_model = self.filter_man.get_control() + real_model = self.proxy_man.get_control() value = real_model[row_index][col_index] cell.set_property("text", str(value)) From 5bcb2b279db2d701b54d627f5c15a0a86e67df27 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Mon, 23 Feb 2026 21:58:10 +0900 Subject: [PATCH 112/309] chore: rename MapManager to FilterManager --- dzgui/controllers/mc.py | 34 +++++++++---------- .../map_model.py => managers/filter_man.py} | 3 +- dzgui/model/servers.py | 8 ++--- dzgui/views/components/filter_panel.py | 6 ++-- dzgui/views/trees/tree_servers.py | 11 +++--- 5 files changed, 30 insertions(+), 32 deletions(-) rename dzgui/{model/map_model.py => managers/filter_man.py} (98%) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index b555fb3..c9874fd 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -62,7 +62,7 @@ logger = logging.getLogger(__name__) if TYPE_CHECKING: from dzgui.const.enum import ServerTab - from dzgui.model.map_model import MapManager + from dzgui.managers.filter_man import FilterManager from dzgui.util.dist import Haversine from dzgui.views.base import Notebook, Grid, OuterWindow from dzgui.views.components.buttonbox import ContextualButton @@ -492,8 +492,8 @@ class Controller(GObject.GObject): model[path][4] = None self.mediator.modtreeview.set_cursor(0) - def get_map_man(self) -> "MapManager": - return self.get_active_treeview().get_map_man() + def get_filter_man(self) -> "FilterManager": + return self.get_active_treeview().get_filter_man() def highlight_stale_cleanup(self, stale_mods: list) -> None: """Manipulates attached ListStore in the main event loop""" @@ -628,26 +628,26 @@ class Controller(GObject.GObject): return proxy_man.get_keyword_filter() def get_map_store(self) -> Gtk.ListStore: - map_man = self.get_map_man() - return map_man.get_map_store() + filter_man = self.get_filter_man() + return filter_man.get_map_store() def get_selected_map(self) -> str: - map_man = self.get_map_man() - return map_man.get_selected_map() + filter_man = self.get_filter_man() + return filter_man.get_selected_map() def get_enabled_filters(self) -> dict: - map_man = self.get_map_man() - return map_man.get_filters() + filter_man = self.get_filter_man() + return filter_man.get_filters() def get_prior_map(self) -> str: - map_man = self.get_map_man() - return map_man.get_prior_map() + filter_man = self.get_filter_man() + return filter_man.get_prior_map() def get_active_map(self) -> None: - return self.get_map_man().get_active_map() + return self.get_filter_man().get_active_map() def set_active_map(self, ind: int) -> None: - self.get_map_man().set_active_map(ind) + self.get_filter_man().set_active_map(ind) def _on_servers_loaded_init(self, emitter: "Emitter") -> None: # FIXME: wipe maps store when changing tabs if model is none @@ -663,8 +663,8 @@ class Controller(GObject.GObject): # TODO: this could be internal to filter panel now def _on_check_toggled(self, emitter: Emitter, label: str, state: bool) -> None: - map_man = self.get_map_man() - map_man.set_filter(label, state) + filter_man = self.get_filter_man() + filter_man.set_filter(label, state) if state: mode = FilterMode.TOGGLE_ON @@ -674,8 +674,8 @@ class Controller(GObject.GObject): # TODO: map man should be consolidated into filter man def _on_map_selection_changed(self, emitter: Emitter, selection: str) -> None: - map_man = self.get_map_man() - map_man.set_selected_map(selection) + filter_man = self.get_filter_man() + filter_man.set_selected_map(selection) # FIXME: label should not be mandatory ServerModelManager(self, self.get_active_treeview()).refilter(FilterMode.MAP, selection) diff --git a/dzgui/model/map_model.py b/dzgui/managers/filter_man.py similarity index 98% rename from dzgui/model/map_model.py rename to dzgui/managers/filter_man.py index 42739cb..101f9e1 100644 --- a/dzgui/model/map_model.py +++ b/dzgui/managers/filter_man.py @@ -8,8 +8,7 @@ if TYPE_CHECKING: from dzgui.model.model_factory import FastInsertListStore -# TODO: rename class to MetaManager -class MapManager: +class FilterManager: def __init__(self) -> None: self.map_store = ModelFactory().make_map_store() diff --git a/dzgui/model/servers.py b/dzgui/model/servers.py index c9887c5..ed28ad6 100644 --- a/dzgui/model/servers.py +++ b/dzgui/model/servers.py @@ -259,8 +259,8 @@ class ServerModelManager: self._update_maps() def _update_maps(self) -> None: - map_man = self.tv.get_map_man() - map_man.set_unique_maps(self._get_new_maps()) + filter_man = self.tv.get_filter_man() + filter_man.set_unique_maps(self._get_new_maps()) self.emitter.emit("servers_loaded_init") self.first_iteration = False @@ -289,8 +289,8 @@ class ServerModelManager: # NOTE: used by refresh action if not self.preserve_on_fail: self.tv.set_model(None) - map_man = self.tv.get_map_man() - map_man.set_unique_maps(None) + filter_man = self.tv.get_filter_man() + filter_man.set_unique_maps(None) # TODO: emit signal to not disable widget sensitivity # TODO: distinguish signals, e.g. "servers_failed_to_load", "servers_loaded_empty" diff --git a/dzgui/views/components/filter_panel.py b/dzgui/views/components/filter_panel.py index 5ec8678..700074e 100644 --- a/dzgui/views/components/filter_panel.py +++ b/dzgui/views/components/filter_panel.py @@ -150,9 +150,9 @@ class FilterPanel(Gtk.Box): # self.set_orientation(Gtk.Orientation.VERTICAL) # TODO: initialize to empty - map_man = self.controller.get_map_man() - defaults = map_man.get_default_filters() - self.map_store = map_man.get_map_store() + filter_man = self.controller.get_filter_man() + defaults = filter_man.get_default_filters() + self.map_store = filter_man.get_map_store() self.enabled_filters = defaults self.keyword_entry = KeywordEntry(self.controller) diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index 79f79b3..427df2e 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -8,7 +8,7 @@ from warnings import deprecated from dzgui.views.mixins.context_mixin import ContextMixin from dzgui.const.enum import ContextMenu, ContextMenuGroup, ServerTab from dzgui.api.servers import Record -from dzgui.model.map_model import MapManager +from dzgui.managers.filter_man import FilterManager from dzgui.model.proxy_model import ProxyModelManager from dzgui.util.dist import CalcDist from dzgui.util import strings @@ -49,8 +49,7 @@ class ServerTreeView(ContextMixin, TreeView): model = self.proxy_man.get_proxy_model() self.set_model(model) - # NOTE: each tab context has its own unique maps - self.map_man = MapManager() + self.filter_man = FilterManager() self.set_fixed_height_mode(True) # NOTE: headers become visible on model load @@ -129,8 +128,8 @@ class ServerTreeView(ContextMixin, TreeView): def start_timeout(self) -> None: self.queue_id = GLib.timeout_add(QUEUE_CHECK_DELAY, self._check_result_queue) - def get_map_man(self) -> MapManager: - return self.map_man + def get_filter_man(self) -> FilterManager: + return self.filter_man def get_proxy_man(self) -> ProxyModelManager: return self.proxy_man @@ -165,7 +164,7 @@ class ServerTreeView(ContextMixin, TreeView): if self.get_enum() is ServerTab.LAN: self.emitter.emit("lan_tab_toggled", True) - store = self.map_man.get_map_store() + store = self.filter_man.get_map_store() # FIXME: if model is none, wipe maps self.emitter.emit("load_maps", store) self.handler_id = self.emitter.connect("statusbar_loaded", self.start_distcalc) From ed7f803d471fe47e7b9345fe87674bc93d47d7bc Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Mon, 23 Feb 2026 22:57:59 +0900 Subject: [PATCH 113/309] feat: attach FilterManager to each ServerTreeView --- dzgui/controllers/mc.py | 49 +++------------- dzgui/managers/config.py | 8 +++ dzgui/managers/filter_man.py | 57 +++++++++++------- dzgui/model/proxy_model.py | 77 ++++++++++++------------- dzgui/model/servers.py | 3 +- dzgui/views/components/connect_panel.py | 3 +- dzgui/views/components/filter_panel.py | 73 ++++++++--------------- dzgui/views/trees/tree_servers.py | 10 ++-- 8 files changed, 121 insertions(+), 159 deletions(-) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index c9874fd..e67bc07 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -204,8 +204,10 @@ class Controller(GObject.GObject): self.toggle_config(Preferences.DEBUG) def get_active_context(self) -> Gtk.TreeView: - tv = self.get_active_treeview() - return tv.get_enum() + return self.get_active_treeview().get_enum() + + def get_active_filterman(self) -> "FilterManager": + return self.get_active_treeview().get_filter_man() def get_active_treeview(self) -> "ServerTreeView": return self.mediator.notebook.servers.get_active_treeview() @@ -596,10 +598,6 @@ class Controller(GObject.GObject): def get_proxy_man(self) -> "ProxyModelManager": return self.proxy_man - # def set_proxy_man(self, proxy_man: "ProxyModelManager") -> None: - # # TODO: used when staging filter man outside of thread - # self.proxy_man = proxy_man - def populate_model(self, tv: Gtk.TreeView) -> None: # NOTE: skip on previously loaded tabs if tv.is_loaded(): @@ -607,26 +605,13 @@ class Controller(GObject.GObject): return ServerModelManager(self, tv).load() - def get_favorite(self) -> tuple[str, str] | tuple[None, None]: - fav = str(self.query_config(Preferences.FAV_LBL)) - if len(fav) < 1: - return None, None - ip = str(self.query_config(Preferences.FAV_SRV)) - addr = ip.split(":") - return fav, f"{addr[0]}:{addr[2]}" - def get_dist_cache(self) -> dict[str, "Haversine", "ServerTab"]: return self.dist_cache def get_filters(self) -> list: return self.mediator.filters.get_filters() - # TODO: filterman calls back to here, gets convoluted - def get_keyword(self) -> str: - tv = self.get_active_treeview() - proxy_man = tv.get_proxy_man() - return proxy_man.get_keyword_filter() - + # TODO: clean up routes between controller and filter panel def get_map_store(self) -> Gtk.ListStore: filter_man = self.get_filter_man() return filter_man.get_map_store() @@ -639,16 +624,7 @@ class Controller(GObject.GObject): filter_man = self.get_filter_man() return filter_man.get_filters() - def get_prior_map(self) -> str: - filter_man = self.get_filter_man() - return filter_man.get_prior_map() - - def get_active_map(self) -> None: - return self.get_filter_man().get_active_map() - - def set_active_map(self, ind: int) -> None: - self.get_filter_man().set_active_map(ind) - + # TODO: rename def _on_servers_loaded_init(self, emitter: "Emitter") -> None: # FIXME: wipe maps store when changing tabs if model is none tv = self.get_active_treeview() @@ -661,23 +637,16 @@ class Controller(GObject.GObject): treeview = self.get_active_treeview() return treeview.get_model() is not None - # TODO: this could be internal to filter panel now def _on_check_toggled(self, emitter: Emitter, label: str, state: bool) -> None: filter_man = self.get_filter_man() filter_man.set_filter(label, state) - if state: - mode = FilterMode.TOGGLE_ON - else: - mode = FilterMode.TOGGLE_OFF + mode = FilterMode.TOGGLE_ON if state else FilterMODE.TOGGLE_OFF ServerModelManager(self, self.get_active_treeview()).refilter(mode, label) - # TODO: map man should be consolidated into filter man def _on_map_selection_changed(self, emitter: Emitter, selection: str) -> None: - filter_man = self.get_filter_man() - filter_man.set_selected_map(selection) - # FIXME: label should not be mandatory - ServerModelManager(self, self.get_active_treeview()).refilter(FilterMode.MAP, selection) + smm = ServerModelManager(self, self.get_active_treeview()) + smm.refilter(FilterMode.MAP, selection) def get_notebook(self) -> "Notebook": return self.mediator.notebook diff --git a/dzgui/managers/config.py b/dzgui/managers/config.py index 470af34..ba5ea1b 100644 --- a/dzgui/managers/config.py +++ b/dzgui/managers/config.py @@ -54,6 +54,14 @@ class ConfigManager: def get_favorites(self) -> list[str]: return self.lookup(Preferences.IP_LIST) + def get_favorite(self) -> tuple[str, str] | tuple[None, None]: + fav = str(self.lookup(Preferences.FAV_LBL)) + if len(fav) < 1: + return None, None + ip = str(self.lookup(Preferences.FAV_SRV)) + addr = ip.split(":") + return fav, f"{addr[0]}:{addr[2]}" + def is_in_favs(self, record: str) -> bool: favs = self.get_favorites() if record in favs: diff --git a/dzgui/managers/filter_man.py b/dzgui/managers/filter_man.py index 101f9e1..60e0102 100644 --- a/dzgui/managers/filter_man.py +++ b/dzgui/managers/filter_man.py @@ -12,8 +12,6 @@ class FilterManager: def __init__(self) -> None: self.map_store = ModelFactory().make_map_store() - self.prior_map: str - self.selected_map = all_maps # TODO: namespace under strings.filters self.default_filters = { @@ -30,18 +28,37 @@ class FilterManager: strings.filter_unofficial: True, strings.filter_modded: True, } - self.active_map = 0 + + self.active_keyword = "" + self.active_map = (0, all_maps) + self.prior_map = all_maps + self.filters: list self.enabled_filters = dict(self.default_filters) - def reinit_filters(self) -> None: - self.enabled_filters = dict(self.default_filters) + # def reinit_filters(self) -> None: + # self.enabled_filters = dict(self.default_filters) + + def get_prior_map(self) -> str: + return self.prior_map + + def set_prior_map(self, name: str) -> None: + self.prior_map = name + + def get_active_map_name(self) -> str: + return self.active_map[1] def get_active_map(self) -> int: return self.active_map - def set_active_map(self, ind: int) -> None: - self.active_map = ind + def set_active_map(self, ind: int, name: str) -> None: + self.active_map = (ind, name) + + def get_active_keyword(self) -> str: + return self.active_keyword + + def set_active_keyword(self, word: str) -> None: + self.active_keyword = word def get_default_filters(self) -> dict: """Deep copy of defaults""" @@ -60,24 +77,14 @@ class FilterManager: model = ModelFactory().make_map_store() model.append([all_maps]) self.map_store = model - self.selected_map = all_maps + self.active_map = (0, all_maps) def get_prior_map(self) -> str: return self.prior_map - def get_selected_map(self) -> str: - return self.selected_map - - def set_selected_map(self, selection: str) -> str: - self.prior_map = self.selected_map - self.selected_map = selection - def append_map(self, row: list[str]) -> None: self.map_store.append(row) - def clear_map_store(self) -> None: - self.map_store.clear() - def set_unique_maps(self, maps: list) -> None: if maps is None: return @@ -88,6 +95,14 @@ class FilterManager: for m in maps: self.append_map([m]) - # when switching views, just grab the map store, active keyword, active map, and selected checks - # for that view and apply them to filter panel outside of thread - # initialize proxyman with access to filterman + def get_all_filters(self) -> tuple: + map_name = self.get_active_map_name() + enabled = self.get_filters() + kw = self.get_active_keyword() + filters = [] + filters.append(map_name) + filters.append(kw) + for filt in enabled: + if enabled[filt] is False: + filters.append(filt) + return tuple(filters) diff --git a/dzgui/model/proxy_model.py b/dzgui/model/proxy_model.py index 33df8e6..21f3e32 100644 --- a/dzgui/model/proxy_model.py +++ b/dzgui/model/proxy_model.py @@ -1,55 +1,57 @@ import re from typing import Optional, TYPE_CHECKING +from warnings import deprecated from dzgui.const.enum import FilterMode from dzgui.model.model_factory import ModelFactory from dzgui.util import strings -import gi - -gi.require_version("Gtk", "3.0") -from gi.repository.Gtk import ListStore # noqa E402 -from gi.repository import GObject, GLib # noqa E402 +# import gi +# +# gi.require_version("Gtk", "3.0") +# from gi.repository.Gtk import ListStore # noqa E402 +# from gi.repository import GObject, GLib # noqa E402 if TYPE_CHECKING: - from dzgui.controllers.mc import Controller - from dzgui.controllers.emitter import Emitter + # from dzgui.controllers.mc import Controller + # from dzgui.controllers.emitter import Emitter + from dzgui.managers.filter_man import FilterManager + from dzgui.model.model_factory import FastInsertListStore class ProxyModelManager: """ - Manages access to cached ListStore resources and + Manages access to cached FastInsertListStore resources and performs filtering on behalf of atomic TreeViews, which share the same column structure. A ProxyModelManager is attached to each ServerTreeView. - Raw data is cached before being packed into a ListStore, see get_control() + Raw data is cached before being packed into a FastInsertListStore, see get_control() Filtration creates a proxy of the TreeView's model, see get_proxy_model() """ - def __init__(self, controller: "Controller") -> None: - self.controller = controller - self.emitter = controller.get_emitter() - self.emitter.connect("keyword_set", self._on_keyword_set) + def __init__(self, filter_man: "FilterManager") -> None: + # self.emitter = controller.get_emitter() + # self.emitter.connect("keyword_set", self._on_keyword_set) self.filter_cache = {} self.ping_cache: dict[str, int] = {} - self.proxy_model: ListStore = None + self.proxy_model: "FastInsertListStore" = None + self.filter_man = filter_man self.control_model: list = None self.filtered: list = None self.success = True - self.keyword_filter = "" - def get_keyword_filter(self) -> str: - return self.keyword_filter + # def get_keyword_filter(self) -> str: + # return self.keyword_filter - def _on_keyword_set(self, emitter: "Emitter", keyword: str) -> None: - self.keyword_filter = keyword + # def _on_keyword_set(self, emitter: "Emitter", keyword: str) -> None: + # self.keyword_filter = keyword def append_row(self, row: list) -> None: self.proxy_model.append(row) @@ -57,19 +59,19 @@ class ProxyModelManager: def clear_proxy_model(self) -> None: self.proxy_model.clear() - def get_proxy_model(self) -> ListStore: + def get_proxy_model(self) -> "FastInsertListStore": return self.proxy_model - def new_model_from_class(self, cls: type) -> ListStore: - store = ListStore(*[ftype for field, ftype in cls.__annotations__.items()]) - return store - + # def new_model_from_class(self, cls: type) -> ListStore: + # store = ListStore(*[ftype for field, ftype in cls.__annotations__.items()]) + # return 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 = self.controller.get_filters() + filters = self.filter_man.get_all_filters() if filters in self.filter_cache: cache = self.filter_cache[filters] @@ -82,7 +84,7 @@ class ProxyModelManager: rows = self.filter_initial(filters) case FilterMode.MAP: - prior_map = self.controller.get_prior_map() + prior_map = self.filter_man.get_prior_map() if prior_map == strings.all_maps: rows = self.filter_map(filters) @@ -107,7 +109,7 @@ class ProxyModelManager: # if row[7] in self.ping_cache: # row[9] = self.ping_cache[row[7]] - # NOTE: this ListStore manipulation must remain local to the thread + # NOTE: this FastInsertListStore manipulation must remain local to the thread clone = ModelFactory().make_server_store() if len(rows) > 0: rows = self.sort_rows(rows) @@ -135,7 +137,7 @@ class ProxyModelManager: Multi-filtration for any context starts by narrowing by map """ rows = self.filtered - sel_map = self.controller.get_selected_map() + sel_map = self.filter_man.get_active_map_name() if sel_map == strings.all_maps: return rows @@ -144,7 +146,7 @@ class ProxyModelManager: return rows def filter_keyword(self, filters: tuple) -> list: - keyword = self.keyword_filter + keyword = self.filter_man.get_active_keyword() rows = self.filtered if keyword == "": @@ -219,7 +221,7 @@ class ProxyModelManager: self.set_filtered(self.filter_toggle_off(filters, f)) return self.filtered - def set_cache(self, filters: tuple, model: ListStore | None, rows: list) -> None: + def set_cache(self, filters: tuple, model: Optional["FastInsertListStore"], rows: list) -> None: self.filter_cache[filters] = (model, rows) def resync_model(self, addr: str, qport: int) -> None: @@ -233,11 +235,12 @@ class ProxyModelManager: self.control_model.remove(row) self.wipe_cache() - filters = self.controller.get_filters() + filters = self.get_filters() refiltered = self.filter_toggle_on(filters) self.set_filtered(refiltered) - def convert_model_to_list(self, model: ListStore) -> list: + @deprecated("Currently unused") + def convert_model_to_list(self, model: "FastInsertListStore") -> list: return [[el for el in row] for row in model] def set_filtered(self, rows: Optional[list]) -> None: @@ -248,9 +251,9 @@ class ProxyModelManager: def get_filtered(self) -> list: return self.filtered - def set_proxy_model(self, model: ListStore | None) -> None: + def set_proxy_model(self, model: Optional["FastInsertListStore"]) -> None: """ - ListStore representation of the raw model after filtration + FastInsertListStore representation of the raw model after filtration """ self.proxy_model = model @@ -282,9 +285,3 @@ class ProxyModelManager: self.ping_cache = {} # if full: # self.control_model = None - - def set_active_map(self, _map: str) -> None: - self.active_map = _map - - def get_active_map(self) -> str: - return self.active_map diff --git a/dzgui/model/servers.py b/dzgui/model/servers.py index ed28ad6..f5e9ddc 100644 --- a/dzgui/model/servers.py +++ b/dzgui/model/servers.py @@ -304,10 +304,9 @@ class ServerModelManager: # if data is None: # self.to_insert = None # else: - # FIXME: filterman calls GTK methods in thread - # if mode == FilterMode.INITIAL: manager = self._get_proxy_man() + # TODO: consolidate these methods manager.wipe_cache() manager.set_control(data) manager.filter(FilterMode.INITIAL) diff --git a/dzgui/views/components/connect_panel.py b/dzgui/views/components/connect_panel.py index 18f31c7..d51834c 100644 --- a/dzgui/views/components/connect_panel.py +++ b/dzgui/views/components/connect_panel.py @@ -123,7 +123,7 @@ class FavPanel(Gtk.Frame): emitter = self.controller.get_emitter() emitter.connect("fav_server_changed", self._on_fav_server_changed) - self.server_name, self.server_ip = self.controller.get_favorite() + self.server_name, self.server_ip = self.controller.get_config_man().get_favorite() #self.controller.get_favorite() server_name = ( f"{self.server_name} ({self.server_ip})" if self.server_name is not None @@ -207,6 +207,7 @@ class AddPanel(Gtk.Frame): def _submit_query(self) -> None: text = self.entry.get_text() + # TODO: grabs ServerTab.SAVED ServerTreeView saved = self.controller.get_servers().get_saved() ServerModelManager(self.controller, saved)._add_by_id_or_ip(text) diff --git a/dzgui/views/components/filter_panel.py b/dzgui/views/components/filter_panel.py index 700074e..49cd92c 100644 --- a/dzgui/views/components/filter_panel.py +++ b/dzgui/views/components/filter_panel.py @@ -4,6 +4,7 @@ from typing import Literal, TYPE_CHECKING from dzgui.const.enum import FilterMode from dzgui.const.constants import EXPAND, NO_EXPAND, NO_FILL, NO_PADDING, SEARCH_ICON from dzgui.model.servers import ServerModelManager +from dzgui.util.strings import all_maps from dzgui.views.components.labels import BoldLabel import gi @@ -82,6 +83,7 @@ class KeywordEntry(Gtk.Entry): # TODO :strings super().__init__(placeholder_text="Filter by keyword") + self.keyword = "" self.controller = controller self.emitter = controller.get_emitter() self.connect("activate", self._on_activated) @@ -112,13 +114,18 @@ class KeywordEntry(Gtk.Entry): # self.controller.mediator.window.set_keep_below(False) keyword = entry.get_text().lower() - if keyword == self.controller.get_keyword(): + if keyword == self.keyword: return if keyword.isspace(): return + self.keyword = keyword + # TODO: delegate to controller + tv = self.controller.get_active_treeview() + filter_man = tv.get_filter_man() + filter_man.set_active_keyword(keyword) + logger.info(f"User filtered by keyword '{keyword}'") - self.emitter.emit("keyword_set", keyword) ServerModelManager( self.controller, self.controller.get_active_treeview() @@ -141,15 +148,8 @@ class FilterPanel(Gtk.Box): self.controller.register_widget("filters", self) self.emitter = self.controller.get_emitter() - # TODO: strings - self.sel_map = "All maps" + self.sel_map = all_maps - # TODO: each treeview for that tab context has its own filter manager, independent of this - # self.selected_map: str = strings.all_maps - # self.prior_map: str = strings.all_maps - # self.set_orientation(Gtk.Orientation.VERTICAL) - - # TODO: initialize to empty filter_man = self.controller.get_filter_man() defaults = filter_man.get_default_filters() self.map_store = filter_man.get_map_store() @@ -191,9 +191,6 @@ class FilterPanel(Gtk.Box): self.maps_combo.connect("changed", self._on_map_changed) self.maps_combo.connect("key-press-event", self._on_combo_keypress) - # FIXME: should be a property of treeview's meta manager - self.active_map = 0 - for el in ( self.filters_label, self.keyword_entry, @@ -212,38 +209,15 @@ class FilterPanel(Gtk.Box): for i, row in enumerate(model): if text == row[0]: self.maps_combo.set_active(i) - # self.controller.set_active_map(i) - # print("HERE3") + # TODO: use same sort of signal to reinitialize keyword and checks def _on_maps_loaded(self, emitter: "Emitter", store: Gtk.ListStore) -> None: self.maps_combo.set_model(store) - ind = self.controller.get_active_map() + tv = self.controller.get_active_treeview() + ind, name = tv.filter_man.get_active_map() self.maps_combo.set_active(ind) self.button_grid.reload_filters() - # TODO: move into metamanager - def get_filters(self) -> tuple: - selected = self.controller.get_selected_map() - enabled = self.controller.get_enabled_filters() - filters = [] - filters.append(selected) - filters.append(self.controller.get_keyword()) - for filt in enabled: - if enabled[filt] is False: - filters.append(filt) - return tuple(filters) - - # TODO: should be used when switching ServerTab contexts - # use signals here - # def reinit_panel(self) -> None: - # self.keyword_entry.set_text("") - # self.keyword_filter = "" - # self.reinit_filters() - # self.set_visible(False) - # sel_panel = self.controller.mediator.grid.sel_panel - # if sel_panel.is_visible(): - # sel_panel.set_visible(False) - # TODO: this chiefly applies when clicking refresh button, etc. # and setting filters to default state # def reinit_filters(self) -> None: @@ -296,12 +270,6 @@ class FilterPanel(Gtk.Box): case _: return False - # 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_by_key(self, emitter: "Emitter", keyval: int) -> bool: mappings = { Gdk.KEY_1: 0, @@ -333,10 +301,15 @@ class FilterPanel(Gtk.Box): ind = combo.get_active() if ind < 0: return - selection = self.maps_entry.get_text() - self.sel_map = selection - self.emitter.emit("map_selection_changed", selection) + name = self.maps_entry.get_text() + self.prior_map = self.sel_map + self.sel_map = name + # TODO: abstraction into controller: + # pass ind, name tv = self.controller.get_active_treeview() - proxy_man = tv.get_proxy_man() - proxy_man.set_active_map(selection) + filter_man = tv.get_filter_man() + filter_man.set_prior_map(name) + filter_man.set_active_map(ind, name) + + self.emitter.emit("map_selection_changed", name) diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index 427df2e..bc6568c 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -45,12 +45,11 @@ class ServerTreeView(ContextMixin, TreeView): self.loaded = False - self.proxy_man = ProxyModelManager(controller) + self.filter_man = FilterManager() + self.proxy_man = ProxyModelManager(self.filter_man) model = self.proxy_man.get_proxy_model() self.set_model(model) - self.filter_man = FilterManager() - self.set_fixed_height_mode(True) # NOTE: headers become visible on model load self.set_headers_visible(False) @@ -125,7 +124,7 @@ class ServerTreeView(ContextMixin, TreeView): # TODO: why is this being saved? self.thread = None - def start_timeout(self) -> None: + def start_queue_checker(self) -> None: self.queue_id = GLib.timeout_add(QUEUE_CHECK_DELAY, self._check_result_queue) def get_filter_man(self) -> FilterManager: @@ -165,10 +164,11 @@ class ServerTreeView(ContextMixin, TreeView): self.emitter.emit("lan_tab_toggled", True) store = self.filter_man.get_map_store() + # FIXME: if model is none, wipe maps self.emitter.emit("load_maps", store) self.handler_id = self.emitter.connect("statusbar_loaded", self.start_distcalc) - self.start_timeout() + self.start_queue_checker() self.start_distcalc() def _on_unmap(self, a) -> None: From f424b019ada0237ebeafc0036ba99cf03dadb257 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Mon, 23 Feb 2026 23:20:38 +0900 Subject: [PATCH 114/309] chore: delegate config methods to ConfigManager --- dzgui/controllers/mc.py | 120 ++++++++++++--------------------------- dzgui/managers/config.py | 77 ++++++++++++++++++++++++- 2 files changed, 110 insertions(+), 87 deletions(-) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index e67bc07..ad77e94 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -155,8 +155,9 @@ class Controller(GObject.GObject): def query_config(self, key: Preferences) -> str | bool | list: # return self.config_man.lookup(key) - config = self.prefs.paths.config - return lookup(config, key) + return self.config_man.lookup(key) + # config = self.prefs.paths.config + # return lookup(config, key) def is_auto_install(self) -> bool: return self.query_config(Preferences.INSTALL) @@ -218,42 +219,8 @@ class Controller(GObject.GObject): # TODO: resolution manager or config manager def save_res_and_quit(self, *args: Any) -> None: treeview = self.get_active_treeview() - columns = treeview.get_columns() - - # columns_file = self.config_man.get_columns() - columns_file = self.prefs.paths.columns - try: - data = JSON.read_json(columns_file) - except Exception as e: - logger.critical(e) - data = {"cols": {}} - - for column in columns: - title = column.get_title() - size = column.get_width() - data["cols"][title] = size - - try: - JSON.write_json(data, columns_file) - except Exception as e: - logger.critical(e) - - logger.info("Normal user exit") - 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.config_man.get_resolution() - res_path = self.prefs.paths.resolution - try: - write_json(data, res_path) - except Exception as e: - logger.critical(e) - - Gtk.main_quit() + window = self.get_window() + self.config_man.save_res_and_quit(treeview, window) @deprecated("use statusbar internal contexts") def remove_statusbar(self, context: str) -> None: @@ -266,6 +233,7 @@ class Controller(GObject.GObject): msg_id = self.mediator.statusbar.set_text(text, context) return msg_id + # TODO: StatusBarManager def set_statusbar_dist(self, haversine: "Haversine", enum: "ServerTab") -> None: """ NOTE: prevents race condition when server tab changed, @@ -320,32 +288,34 @@ class Controller(GObject.GObject): self.cleanup_func = StoredFunc(self.load_mods_cleanup, model) - def toggle_config(self, context: Preferences) -> None: - config = self.prefs.paths.config - try: - update.toggle_config(config, context) - # NOTE: 'use_miles' key is updated dynamically for statusbar unit - if context == Preferences.DIST: - self.prefs.use_miles = not self.prefs.use_miles - except Exception as e: - logger.critical(e) - trace = traceback.format_exc() - dialog = ExceptionDialog(self, trace) - dialog.run() + def toggle_config(self, key: Preferences) -> None: + self.config_man.toggle_config(key) + #config = self.prefs.paths.config + #try: + # update.toggle_config(config, context) + # # NOTE: 'use_miles' key is updated dynamically for statusbar unit + # if context == Preferences.DIST: + # self.prefs.use_miles = not self.prefs.use_miles + #except Exception as e: + # logger.critical(e) + # trace = traceback.format_exc() + # dialog = ExceptionDialog(self, trace) + # dialog.run() 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) - trace = traceback.format_exc() - dialog = ExceptionDialog(self, trace) - dialog.run() - # TODO: suppress signals - # then reenable (or it spawns dialog twice) - # TODO: do this on demand for certain changes - self.mediator.grid.notebook.settings.populate_settings() - return + self.config_man.update_config(key, value) + #try: + # update.write_config(self.prefs.paths.config, key, value) + #except Exception as e: + # logger.critical(e) + # trace = traceback.format_exc() + # dialog = ExceptionDialog(self, trace) + # dialog.run() + # # TODO: suppress signals + # # then reenable (or it spawns dialog twice) + # # TODO: do this on demand for certain changes + # self.mediator.grid.notebook.settings.populate_settings() + # return def open_keybindings(self) -> None: notebook = self.mediator.grid.notebook @@ -537,6 +507,7 @@ class Controller(GObject.GObject): dialog = ExceptionDialog(self, str(e)) dialog.run() + # TODO: move to ConfigMan @call_on_thread(strings.dialog.working) def update_api_key(self, text: str, key: Preferences) -> None: if key is Preferences.STEAM: @@ -552,29 +523,7 @@ class Controller(GObject.GObject): ) 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) + self.config_man.set_resolution(window) def propagate_column_width(self, col: Gtk.TreeViewColumn) -> None: GLib.idle_add(self.mediator.servers.update_tab_widths, col) @@ -584,6 +533,7 @@ class Controller(GObject.GObject): treeview.set_loaded(False) ServerModelManager(self, treeview).refresh() + # TODO: move to servermodelman def get_player_count(self) -> str: treeview = self.get_active_treeview() model = treeview.get_model() diff --git a/dzgui/managers/config.py b/dzgui/managers/config.py index ba5ea1b..82cf8cd 100644 --- a/dzgui/managers/config.py +++ b/dzgui/managers/config.py @@ -3,20 +3,34 @@ import traceback from typing import Any, TYPE_CHECKING -from dzgui.const.constants import STEAM_CMD, FLATPAK_RUN_CMD, FLATPAK_SANDBOX +from dzgui.const.constants import ( + STEAM_CMD, + FLATPAK_RUN_CMD, + FLATPAK_SANDBOX, + WINDOW_DEFAULT_X, + WINDOW_DEFAULT_Y, +) from dzgui.const.enum import Preferences from dzgui.views.dialogs.generic import ExceptionDialog from dzgui.util._json import read_json, write_json +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository.Gtk import main_quit # noqa E402 + # import dzgui.api.servers as Servers if TYPE_CHECKING: from dzgui.config.userprefs import UserPrefs + from dzgui.views.base import OuterWindow + from dzgui.views.trees.tree_servers import ServerTreeView logger = logging.getLogger(__name__) class ConfigManager: def __init__(self, prefs: "UserPrefs") -> None: + self.prefs = prefs self.config = prefs.paths.config def lookup(self, enum: Preferences) -> Any: @@ -41,7 +55,6 @@ class ConfigManager: # then reenable (or it spawns dialog twice) # TODO: do this on demand if/when in options page # self.mediator.grid.notebook.settings.populate_settings() - return def get_config(self) -> dict: # TODO: is this being called twice? @@ -102,3 +115,63 @@ class ConfigManager: dialog = ExceptionDialog(self, trace) dialog.run() raise e + + def save_res_and_quit(self, tv: "ServerTreeView", window: "OuterWindow") -> None: + columns = tv.get_columns() + columns_file = self.prefs.paths.columns + try: + data = read_json(columns_file) + except Exception as e: + logger.critical(e) + data = {"cols": {}} + + for column in columns: + title = column.get_title() + size = column.get_width() + data["cols"][title] = size + + try: + write_json(data, columns_file) + except Exception as e: + logger.critical(e) + + logger.info("Normal user exit") + if window.props.is_maximized: + main_quit() + return + + w, h = 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) + + main_quit() + + def set_resolution(self, window: "OuterWindow") -> None: + if self.prefs.is_game_mode: + window.fullscreen() + return + elif self.lookup(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) From f86e37dbe117a52a76086396295c9421b6452b7e Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Tue, 24 Feb 2026 20:30:38 +0900 Subject: [PATCH 115/309] fix: separate id/ip methods --- dzgui/api/servers.py | 24 ++++++++++++++---------- dzgui/managers/connection.py | 5 ++--- dzgui/managers/contextmenu.py | 15 +++++---------- dzgui/model/servers.py | 15 +++++++++++++-- dzgui/views/components/connect_panel.py | 13 +++++++++---- 5 files changed, 43 insertions(+), 29 deletions(-) diff --git a/dzgui/api/servers.py b/dzgui/api/servers.py index 82d5fd2..923ddfe 100644 --- a/dzgui/api/servers.py +++ b/dzgui/api/servers.py @@ -460,18 +460,22 @@ def response_to_fq_ip(res: dict) -> str: return f"{ip}:{gameport}:{qport}" -def query_id_or_ip(addr: str, key: str) -> Optional[dict]: - # NOTE: Battlemetrics - if addr.isdigit(): - try: - resolved = map_id_to_record(key, addr) - res = query_direct(resolved.ip, resolved.qport) - except Exception as e: - logger.critical(e) - return None - else: +# NOTE: Battlemetrics +def query_by_id(addr: str, key: str) -> Optional[dict]: + try: + resolved = map_id_to_record(key, addr) + res = query_direct(resolved.ip, resolved.qport) + except Exception as e: + logger.critical(e) + return None + +def query_by_ip(addr: str) -> Optional[dict]: + try: record = addr.split(":") # TODO: create a Record object ip, qport = record[0], record[2] res = query_direct(ip, int(qport)) + except Exception as e: + logger.critical(e) + return None return res diff --git a/dzgui/managers/connection.py b/dzgui/managers/connection.py index 5787375..e597047 100644 --- a/dzgui/managers/connection.py +++ b/dzgui/managers/connection.py @@ -22,9 +22,8 @@ class ConnectionManager: self.thread_man = ThreadingManager(parent=controller) @call_on_thread(dialog.querying) - def connect_by_id_or_ip(self, addr: str, key: str) -> None: - res = Servers.query_id_or_ip(addr, key) - print(res) + def connect_by_ip(self, addr: str) -> None: + res = Servers.query_by_ip(addr) if res is None: self.thread_man.set_cleanup_func(StoredFunc(self._connection_failure)) # TODO: add to history if successful diff --git a/dzgui/managers/contextmenu.py b/dzgui/managers/contextmenu.py index 33c9198..ccace96 100644 --- a/dzgui/managers/contextmenu.py +++ b/dzgui/managers/contextmenu.py @@ -59,20 +59,15 @@ class ContextMenuManager: self.controller.set_fav() # THREADED + case ContextMenu.ADD_SERVER: + record = self.treeview.get_record_string() + ServerModelManager(self.controller, self.treeview).add_by_ip(record) + # TODO: update tab with ! case ContextMenu.CONNECT: record = self.treeview.get_record_string() # FIXME: context menu connections do not require BM key key = self.controller.get_config_man().lookup(Preferences.BM) - ConnectionManager(self.controller).connect_by_id_or_ip(record, key) - case ContextMenu.ADD_SERVER: - record = self.treeview.get_record_string() - ServerModelManager(self.controller, self.treeview).add_by_id_or_ip( - record - ) - # add to saved servers model verbatim and sort in place - # update tab with ! - # update config file with IP - pass + ConnectionManager(self.controller).connect_ip(record, key) case ContextMenu.REFRESH_PLAYERS: # get record # call a2s on thread diff --git a/dzgui/model/servers.py b/dzgui/model/servers.py index f5e9ddc..c947718 100644 --- a/dzgui/model/servers.py +++ b/dzgui/model/servers.py @@ -187,8 +187,19 @@ class ServerModelManager: self._push_data(parsed) @call_on_thread(dialog.querying) - def add_by_id_or_ip(self, addr: str) -> None: - res = Servers.query_id_or_ip(addr) + def add_by_id(self, addr: str) -> None: + # KEY + config_man = self.controller.get_config_man() + key = config_man.lookup(Preferences.BM) + res = Servers.query_by_id(addr, key) + self.parse_single_record(res) + + @call_on_thread(dialog.querying) + def add_by_ip(self, addr: str) -> None: + res = Servers.query_by_ip(addr) + self.parse_single_record(res) + + def parse_single_record(self, record: dict) -> None: if res is None: self.thread_man.set_cleanup_func(StoredFunc(self._cleanup_on_failure)) return diff --git a/dzgui/views/components/connect_panel.py b/dzgui/views/components/connect_panel.py index d51834c..31cf3c6 100644 --- a/dzgui/views/components/connect_panel.py +++ b/dzgui/views/components/connect_panel.py @@ -123,7 +123,9 @@ class FavPanel(Gtk.Frame): emitter = self.controller.get_emitter() emitter.connect("fav_server_changed", self._on_fav_server_changed) - self.server_name, self.server_ip = self.controller.get_config_man().get_favorite() #self.controller.get_favorite() + self.server_name, self.server_ip = ( + self.controller.get_config_man().get_favorite() + ) # self.controller.get_favorite() server_name = ( f"{self.server_name} ({self.server_ip})" if self.server_name is not None @@ -206,10 +208,13 @@ class AddPanel(Gtk.Frame): ConnectionManager(self.controller).connect_by_id_or_ip(text) def _submit_query(self) -> None: - text = self.entry.get_text() + addr = self.entry.get_text() # TODO: grabs ServerTab.SAVED ServerTreeView - saved = self.controller.get_servers().get_saved() - ServerModelManager(self.controller, saved)._add_by_id_or_ip(text) + tree = self.controller.get_servers().get_saved() + if addr.isdigit(): + ServerModelManager(self.controller, tree).add_by_id(addr) + else: + ServerModelManager(self.controller, tree).add_by_ip(addr) def _on_activate(self, entry: Gtk.Entry) -> None: if not self.add_server.is_sensitive(): From 79eab9362900031139b4c0f8597b33bb0f7e9ca2 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Tue, 24 Feb 2026 20:32:41 +0900 Subject: [PATCH 116/309] fix: typo in enum name --- dzgui/controllers/mc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index ad77e94..e34d050 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -591,7 +591,7 @@ class Controller(GObject.GObject): filter_man = self.get_filter_man() filter_man.set_filter(label, state) - mode = FilterMode.TOGGLE_ON if state else FilterMODE.TOGGLE_OFF + mode = FilterMode.TOGGLE_ON if state else FilterMode.TOGGLE_OFF ServerModelManager(self, self.get_active_treeview()).refilter(mode, label) def _on_map_selection_changed(self, emitter: Emitter, selection: str) -> None: From a763b3b9dc108d3fc1e2199b9bdf30827d3031f8 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 25 Feb 2026 22:44:49 +0900 Subject: [PATCH 117/309] fix: defer destroy call --- dzgui/managers/thread_man.py | 11 ++++++++--- dzgui/model/servers.py | 5 ++++- dzgui/views/components/buttons.py | 4 ++-- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/dzgui/managers/thread_man.py b/dzgui/managers/thread_man.py index 18a520c..bfe62f1 100644 --- a/dzgui/managers/thread_man.py +++ b/dzgui/managers/thread_man.py @@ -11,7 +11,7 @@ from dzgui.views.dialogs.generic import WaitDialog import gi gi.require_version("Gtk", "3.0") -from gi.repository import Gtk, Gdk, GLib, GObject # noqa E402 +from gi.repository import Gtk, GLib # noqa E402 logger = logging.getLogger(__name__) @@ -88,9 +88,14 @@ class ThreadingManager: def get_cleanup_func(self) -> StoredFunc: return self.cleanup_func - def _destroy_on_idle(self) -> None: - self.wait_dialog.destroy() + def _d(self) -> None: + self.wait_dialog.hide() + return False + + def _destroy_on_idle(self) -> GLib.SOURCE_REMOVE: func = self.get_cleanup_func() if func is not None: func.call() self.set_cleanup_func(None) + self.wait_dialog.destroy() + return GLib.SOURCE_REMOVE diff --git a/dzgui/model/servers.py b/dzgui/model/servers.py index c947718..1c8dbb2 100644 --- a/dzgui/model/servers.py +++ b/dzgui/model/servers.py @@ -85,6 +85,7 @@ class ServerModelManager: @call_on_thread(dialog.fetching) def _dump_api(self) -> None: + # TODO: pass api key a priori in .load() call config_man = self.controller.get_config_man() key = config_man.lookup(Preferences.STEAM) job = Servers.query_api @@ -276,6 +277,7 @@ class ServerModelManager: self.first_iteration = False def _cleanup_on_success(self) -> None: + self.tv.set_model(None) self.tv.set_model(self.to_insert) # TODO: make sure control model len is N + 1 @@ -286,7 +288,7 @@ class ServerModelManager: # cf. servers_loaded signal # TODO: servers_loaded vs servers_reloaded - self.emitter.emit("servers_loaded", self.enum) + # self.emitter.emit("servers_loaded", self.enum) if self.first_iteration: self._update_maps() @@ -339,6 +341,7 @@ class ServerModelManager: @call_on_thread(dialog.filtering) def refilter(self, mode: FilterMode, label: str) -> None: + # FIXME: causes two wait dialogs when map selection change signal emits after loading servers self.first_iteration = False proxy_man = self._get_proxy_man() proxy_man.filter(mode, label) diff --git a/dzgui/views/components/buttons.py b/dzgui/views/components/buttons.py index 32ee330..bc596b7 100644 --- a/dzgui/views/components/buttons.py +++ b/dzgui/views/components/buttons.py @@ -17,7 +17,7 @@ gi.require_version("Gtk", "3.0") from gi.repository import Gtk, GLib # noqa E402 if TYPE_CHECKING: - from GLib import SOURCE_REMOVE + from gi.repository import GLib from dzgui.controllers.mc import Controller @@ -107,7 +107,7 @@ class RefreshButton(IconTextButton): GLib.timeout_add_seconds(1, self.decrement) self.controller.refresh_tree() - def decrement(self) -> "SOURCE_REMOVE" | Literal[True]: + def decrement(self) -> "GLib.SOURCE_REMOVE" | Literal[True]: self.time -= 1 if self.time == 0: self.time = 30 From 374f7d113088b13eb05963da31f1321b3252afd7 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Fri, 27 Feb 2026 21:43:35 +0900 Subject: [PATCH 118/309] feat: copy version on click --- dzgui/views/components/right_panel.py | 34 +++++++++++++++++++++++++-- dzgui/views/components/statusbar.py | 8 ------- 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/dzgui/views/components/right_panel.py b/dzgui/views/components/right_panel.py index 386de3c..d5553b1 100644 --- a/dzgui/views/components/right_panel.py +++ b/dzgui/views/components/right_panel.py @@ -1,16 +1,17 @@ from typing import TYPE_CHECKING from dzgui.const.enum import ServerTab +from dzgui.util.clip import copy_clipboard from dzgui.views.components.buttonbox import ButtonBox from dzgui.views.components.filter_panel import FilterPanel from dzgui.views.components.mod_panel import ModSelectionPanel from dzgui.views.components.buttons import RefreshButton, KeysButton -from dzgui.const.constants import NO_EXPAND, NO_FILL, FILL, NO_PADDING +from dzgui.const.constants import NO_EXPAND, NO_FILL, FILL, NO_PADDING, EXPAND import gi gi.require_version("Gtk", "3.0") -from gi.repository import Gtk, GLib # noqa E402 +from gi.repository import Gtk, Gdk, GLib # noqa E402 if TYPE_CHECKING: from dzgui.controllers.mc import Controller @@ -35,16 +36,45 @@ class RightPanel(Gtk.Box): self.refresh_button = RefreshButton(controller) self.keys = KeysButton(controller) + self.copying = False + + version = self.controller.get_prefs().version + self.version_label = Gtk.Label( + label=version, + hexpand=True, + vexpand=True, + halign=Gtk.Align.END, + valign=Gtk.Align.END, + ) + eb = Gtk.EventBox(halign=Gtk.Align.END, valign=Gtk.Align.END) + eb.add(self.version_label) + eb.connect("button-press-event", self._on_version_clicked) + for el in self.button_vbox, self.keys, self.filters_vbox, self.refresh_button: self.pack_start(el, NO_EXPAND, FILL, NO_PADDING) self.pack_start(self.sel_panel, NO_EXPAND, NO_FILL, NO_PADDING) + self.pack_start(eb, NO_EXPAND, FILL, NO_PADDING) def _on_servers_loaded(self, emitter: "Emitter", context: "ServerTab") -> None: state = self.controller.has_server_model() for widget in (self.refresh_button, self.filters_vbox): widget.set_sensitive(state) + def _on_version_clicked(self, widget: Gtk.EventBox, event: Gdk.EventButton) -> None: + def revert() -> GLib.SOURCE_REMOVE: + self.copying = False + self.version_label.set_text(version) + return GLib.SOURCE_REMOVE + + if self.copying: + return + self.copying = True + version = self.version_label.get_text() + self.version_label.set_text("Copied!") + copy_clipboard(version) + GLib.timeout_add_seconds(0.5, revert) + # TODO: reference for ping pass # def _on_ping_clicked(self, button: Gtk.Button) -> None: # block_signals() diff --git a/dzgui/views/components/statusbar.py b/dzgui/views/components/statusbar.py index fba3bcc..a6893d6 100644 --- a/dzgui/views/components/statusbar.py +++ b/dzgui/views/components/statusbar.py @@ -29,16 +29,8 @@ class Statusbar(Gtk.Grid): self.spinner = Gtk.Spinner() - version = self.controller.get_prefs().version - self.status_right_label = Gtk.Label( - label=version, hexpand=True, halign=Gtk.Align.END - ) - self.attach(self.statusbar, 0, 0, 3, 1) self.attach_next_to(self.spinner, self.statusbar, Gtk.PositionType.RIGHT, 3, 1) - self.attach_next_to( - self.status_right_label, self.spinner, Gtk.PositionType.RIGHT, 3, 1 - ) self.players = "" From 1b1cb33a6e99ee5b1dbd726a35713ffbaa12dcd8 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Fri, 27 Feb 2026 22:21:08 +0900 Subject: [PATCH 119/309] fix: do not pass BM key during ip method --- dzgui/managers/connection.py | 1 + dzgui/managers/contextmenu.py | 4 +--- dzgui/model/servers.py | 1 - 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/dzgui/managers/connection.py b/dzgui/managers/connection.py index e597047..aa82622 100644 --- a/dzgui/managers/connection.py +++ b/dzgui/managers/connection.py @@ -27,6 +27,7 @@ class ConnectionManager: if res is None: self.thread_man.set_cleanup_func(StoredFunc(self._connection_failure)) # TODO: add to history if successful + print(res) def _connection_failure(self) -> None: # TODO: more explicit warning message, not necessarily API failure? diff --git a/dzgui/managers/contextmenu.py b/dzgui/managers/contextmenu.py index ccace96..1171b01 100644 --- a/dzgui/managers/contextmenu.py +++ b/dzgui/managers/contextmenu.py @@ -65,9 +65,7 @@ class ContextMenuManager: # TODO: update tab with ! case ContextMenu.CONNECT: record = self.treeview.get_record_string() - # FIXME: context menu connections do not require BM key - key = self.controller.get_config_man().lookup(Preferences.BM) - ConnectionManager(self.controller).connect_ip(record, key) + ConnectionManager(self.controller).connect_by_ip(record) case ContextMenu.REFRESH_PLAYERS: # get record # call a2s on thread diff --git a/dzgui/model/servers.py b/dzgui/model/servers.py index 1c8dbb2..e6eb3ec 100644 --- a/dzgui/model/servers.py +++ b/dzgui/model/servers.py @@ -189,7 +189,6 @@ class ServerModelManager: @call_on_thread(dialog.querying) def add_by_id(self, addr: str) -> None: - # KEY config_man = self.controller.get_config_man() key = config_man.lookup(Preferences.BM) res = Servers.query_by_id(addr, key) From 672c8f9e9efbd12293d7a39e3203bddb13e0e512 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Fri, 27 Feb 2026 22:53:31 +0900 Subject: [PATCH 120/309] fix: pass correct number of records --- dzgui/api/servers.py | 2 +- dzgui/model/servers.py | 11 ++++++----- dzgui/views/components/connect_panel.py | 1 + dzgui/views/trees/tree_log.py | 21 ++++++++++++++------- 4 files changed, 22 insertions(+), 13 deletions(-) diff --git a/dzgui/api/servers.py b/dzgui/api/servers.py index 923ddfe..918aa01 100644 --- a/dzgui/api/servers.py +++ b/dzgui/api/servers.py @@ -473,7 +473,7 @@ def query_by_ip(addr: str) -> Optional[dict]: try: record = addr.split(":") # TODO: create a Record object - ip, qport = record[0], record[2] + ip, qport = record[0], record[1] res = query_direct(ip, int(qport)) except Exception as e: logger.critical(e) diff --git a/dzgui/model/servers.py b/dzgui/model/servers.py index e6eb3ec..1b4e69b 100644 --- a/dzgui/model/servers.py +++ b/dzgui/model/servers.py @@ -197,19 +197,19 @@ class ServerModelManager: @call_on_thread(dialog.querying) def add_by_ip(self, addr: str) -> None: res = Servers.query_by_ip(addr) - self.parse_single_record(res) + self._parse_single_record(res) - def parse_single_record(self, record: dict) -> None: - if res is None: + def _parse_single_record(self, response: dict) -> None: + if response is None: self.thread_man.set_cleanup_func(StoredFunc(self._cleanup_on_failure)) return - record = Servers.parse_json([res]) + record = Servers.parse_json([response]) proxy_man = self._get_proxy_man() raw_model = proxy_man.get_control() - fqip = Servers.response_to_fq_ip(res) + fqip = Servers.response_to_fq_ip(response) config_man = self.controller.get_config_man() config_man.add_saved_server(fqip) @@ -324,6 +324,7 @@ class ServerModelManager: manager.filter(FilterMode.INITIAL) self.to_insert = manager.get_proxy_model() + # TODO: abstract for all methods u_maps = set([row[1] for row in data]) self._set_new_maps(sorted(u_maps)) diff --git a/dzgui/views/components/connect_panel.py b/dzgui/views/components/connect_panel.py index 31cf3c6..2530c27 100644 --- a/dzgui/views/components/connect_panel.py +++ b/dzgui/views/components/connect_panel.py @@ -217,6 +217,7 @@ class AddPanel(Gtk.Frame): ServerModelManager(self.controller, tree).add_by_ip(addr) def _on_activate(self, entry: Gtk.Entry) -> None: + # NOTE: default action is to add a record, not connect if not self.add_server.is_sensitive(): return self._submit_query() diff --git a/dzgui/views/trees/tree_log.py b/dzgui/views/trees/tree_log.py index 026db11..29268a5 100644 --- a/dzgui/views/trees/tree_log.py +++ b/dzgui/views/trees/tree_log.py @@ -50,15 +50,22 @@ class LogTreeView(ContextMixin, TreeView): def _on_log_buttonpress(self, widget: Gtk.Widget, event: Gdk.EventButton) -> None: if event.button == Gdk.BUTTON_SECONDARY: - path, col, x, y = self.get_path_at_pos(int(event.x), int(event.y)) - if path: - selection = self.get_selection() - model, selected_paths = selection.get_selected_rows() - if path not in selected_paths: - selection.select_path(path) self.present_menu(widget, event) return True - return False + #selection = self.get_selection() + #model, selected_paths = selection.get_selected_rows() + #if len(selected_paths) > 1: + # for path in selected_paths: + # selection.select_path(path) + # path, col, x, y = self.get_path_at_pos(int(event.x), int(event.y)) + # if path: + # selection = self.get_selection() + # model, selected_paths = selection.get_selected_rows() + # if path not in selected_paths: + # selection.select_path(path) + # self.present_menu(widget, event) + # return True + #return False def _on_log_keypress(self, widget: Gtk.Widget, event: Gdk.EventKey) -> None: self.present_menu(widget, event) From 940dd41fdf960f4b00401a83fe7d1709340c764b Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Fri, 27 Feb 2026 23:06:22 +0900 Subject: [PATCH 121/309] fix: emit missing signal --- dzgui/model/servers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dzgui/model/servers.py b/dzgui/model/servers.py index 1b4e69b..0b3a7c1 100644 --- a/dzgui/model/servers.py +++ b/dzgui/model/servers.py @@ -287,7 +287,7 @@ class ServerModelManager: # cf. servers_loaded signal # TODO: servers_loaded vs servers_reloaded - # self.emitter.emit("servers_loaded", self.enum) + self.emitter.emit("servers_loaded", self.enum) if self.first_iteration: self._update_maps() From 3b40ea184626ab6641e59c687da1f04d15244597 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Fri, 27 Feb 2026 23:24:30 +0900 Subject: [PATCH 122/309] feat: _sort_unique_maps() --- dzgui/model/servers.py | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/dzgui/model/servers.py b/dzgui/model/servers.py index 0b3a7c1..0df89f0 100644 --- a/dzgui/model/servers.py +++ b/dzgui/model/servers.py @@ -204,7 +204,7 @@ class ServerModelManager: self.thread_man.set_cleanup_func(StoredFunc(self._cleanup_on_failure)) return - record = Servers.parse_json([response]) + records = Servers.parse_json([response]) proxy_man = self._get_proxy_man() raw_model = proxy_man.get_control() @@ -217,11 +217,16 @@ class ServerModelManager: if raw_model is None: return - raw_model.append(record[0]) + # NOTE: expected to only contain one item + raw_model.append(records[0]) + # TODO: if all filters are already applied, strange behavior may occur - # -> need to insert and update per current filters + # -> need to insert and reupdate tre per current filters + # for example, non-empty will only show up in empty because it is not cached proxy_man.filter(FilterMode.INITIAL) + self._sort_unique_maps(records) + self.thread_man.set_cleanup_func(StoredFunc(self._cleanup_single_ip)) def _dump_history(self) -> None: @@ -276,8 +281,10 @@ class ServerModelManager: self.first_iteration = False def _cleanup_on_success(self) -> None: - self.tv.set_model(None) - self.tv.set_model(self.to_insert) + proxy = self._get_proxy_man().get_proxy_model() + self.tv.set_model(proxy) + # self.tv.set_model(None) + # self.tv.set_model(self.to_insert) # TODO: make sure control model len is N + 1 # inserting a row serializes file on disk, updates control model for that tab, and updates model @@ -325,11 +332,14 @@ class ServerModelManager: self.to_insert = manager.get_proxy_model() # TODO: abstract for all methods - u_maps = set([row[1] for row in data]) - self._set_new_maps(sorted(u_maps)) + self._sort_unique_maps(data) self.thread_man.set_cleanup_func(StoredFunc(self._cleanup_on_success)) + def _sort_unique_maps(self, data: list) -> None: + u_maps = set([row[1] for row in data]) + self._set_new_maps(u_maps) + def _set_new_maps(self, maps: list[str]) -> None: self.new_maps = maps From 2fa51c80f4dea946774a3a7be56cfa25ae6194bf Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 28 Feb 2026 22:34:25 +0900 Subject: [PATCH 123/309] chore: reroute connect method --- dzgui/api/servers.py | 3 ++- dzgui/controllers/mc.py | 29 ++++++++++++++++++++----- dzgui/managers/connection.py | 10 +++++++++ dzgui/model/proxy_model.py | 1 + dzgui/views/components/connect_panel.py | 22 +++++++------------ 5 files changed, 45 insertions(+), 20 deletions(-) diff --git a/dzgui/api/servers.py b/dzgui/api/servers.py index 918aa01..568ae75 100644 --- a/dzgui/api/servers.py +++ b/dzgui/api/servers.py @@ -464,11 +464,12 @@ def response_to_fq_ip(res: dict) -> str: def query_by_id(addr: str, key: str) -> Optional[dict]: try: resolved = map_id_to_record(key, addr) - res = query_direct(resolved.ip, resolved.qport) + return query_direct(resolved.ip, resolved.qport) except Exception as e: logger.critical(e) return None + def query_by_ip(addr: str) -> Optional[dict]: try: record = addr.split(":") diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index e34d050..3453abc 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -12,6 +12,7 @@ from warnings import deprecated import dzgui.api.pefile as PeFile import dzgui.util._json as JSON # noqa + from dzgui.api.mods import ( get_delimited_mods, get_local_mod_path, @@ -40,6 +41,7 @@ from dzgui.config.query import lookup from dzgui.config.userprefs import UserPrefs from dzgui.controllers.emitter import Emitter from dzgui.managers.config import ConfigManager +from dzgui.managers.connection import ConnectionManager from dzgui.managers.contextmenu import ContextMenuManager from dzgui.model.proxy_model import ProxyModelManager from dzgui.model.servers import ServerModelManager @@ -290,13 +292,13 @@ class Controller(GObject.GObject): def toggle_config(self, key: Preferences) -> None: self.config_man.toggle_config(key) - #config = self.prefs.paths.config - #try: + # config = self.prefs.paths.config + # try: # update.toggle_config(config, context) # # NOTE: 'use_miles' key is updated dynamically for statusbar unit # if context == Preferences.DIST: # self.prefs.use_miles = not self.prefs.use_miles - #except Exception as e: + # except Exception as e: # logger.critical(e) # trace = traceback.format_exc() # dialog = ExceptionDialog(self, trace) @@ -304,9 +306,9 @@ class Controller(GObject.GObject): def update_config(self, key: Preferences, value: str) -> None: self.config_man.update_config(key, value) - #try: + # try: # update.write_config(self.prefs.paths.config, key, value) - #except Exception as e: + # except Exception as e: # logger.critical(e) # trace = traceback.format_exc() # dialog = ExceptionDialog(self, trace) @@ -588,6 +590,7 @@ class Controller(GObject.GObject): return treeview.get_model() is not None def _on_check_toggled(self, emitter: Emitter, label: str, state: bool) -> None: + print('check toggled, refiltering') filter_man = self.get_filter_man() filter_man.set_filter(label, state) @@ -595,6 +598,7 @@ class Controller(GObject.GObject): ServerModelManager(self, self.get_active_treeview()).refilter(mode, label) def _on_map_selection_changed(self, emitter: Emitter, selection: str) -> None: + print("map sel changed, refiltering") smm = ServerModelManager(self, self.get_active_treeview()) smm.refilter(FilterMode.MAP, selection) @@ -615,3 +619,18 @@ class Controller(GObject.GObject): def get_config_man(self) -> ConfigManager: return self.config_man + + def add_server(self, addr: str) -> None: + saved_tree = self.get_servers().get_saved() + if addr.isdigit(): + ServerModelManager(self, saved).add_by_id(addr) + else: + ServerModelManager(self, saved).add_by_ip(addr) + + def connect_server(self, addr: str) -> None: + if addr.isdigit(): + config_man = self.get_config_man() + key = config_man.lookup(Preferences.BM) + ConnectionManager(self).connect_by_id(addr, key) + else: + ConnectionManager(self).connect_by_ip(addr) diff --git a/dzgui/managers/connection.py b/dzgui/managers/connection.py index aa82622..b66ee8e 100644 --- a/dzgui/managers/connection.py +++ b/dzgui/managers/connection.py @@ -21,12 +21,22 @@ class ConnectionManager: self.controller = controller self.thread_man = ThreadingManager(parent=controller) + @call_on_thread(dialog.querying) + def connect_by_id(self, addr: str, key: str) -> None: + res = Servers.query_by_id(addr, key) + if res is None: + self.thread_man.set_cleanup_func(StoredFunc(self._connection_failure)) + # TODO: add to history if successful + print("CONNECT") + print(res) + @call_on_thread(dialog.querying) def connect_by_ip(self, addr: str) -> None: res = Servers.query_by_ip(addr) if res is None: self.thread_man.set_cleanup_func(StoredFunc(self._connection_failure)) # TODO: add to history if successful + print("CONNECT") print(res) def _connection_failure(self) -> None: diff --git a/dzgui/model/proxy_model.py b/dzgui/model/proxy_model.py index 21f3e32..82bbd26 100644 --- a/dzgui/model/proxy_model.py +++ b/dzgui/model/proxy_model.py @@ -67,6 +67,7 @@ class ProxyModelManager: # return store # def filter(self, mode: FilterMode, *args, **kwargs) -> None: + print("proxy model filtering now") """ Native Gtk.TreeView.refilter() method was not performant enough when running in the main loop with 40k+ records diff --git a/dzgui/views/components/connect_panel.py b/dzgui/views/components/connect_panel.py index 2530c27..391e938 100644 --- a/dzgui/views/components/connect_panel.py +++ b/dzgui/views/components/connect_panel.py @@ -1,6 +1,5 @@ from typing import TYPE_CHECKING -from dzgui.managers.connection import ConnectionManager from dzgui.model.servers import ServerModelManager from dzgui.util.strings import connect_panel, fav_panel, lan_panel from dzgui.views.components.buttons import ( @@ -125,7 +124,7 @@ class FavPanel(Gtk.Frame): self.server_name, self.server_ip = ( self.controller.get_config_man().get_favorite() - ) # self.controller.get_favorite() + ) server_name = ( f"{self.server_name} ({self.server_ip})" if self.server_name is not None @@ -204,26 +203,21 @@ class AddPanel(Gtk.Frame): self.add(self.grid) def _on_connect_clicked(self, button: Gtk.Button) -> None: - text = self.entry.get_text() - ConnectionManager(self.controller).connect_by_id_or_ip(text) - - def _submit_query(self) -> None: addr = self.entry.get_text() - # TODO: grabs ServerTab.SAVED ServerTreeView - tree = self.controller.get_servers().get_saved() - if addr.isdigit(): - ServerModelManager(self.controller, tree).add_by_id(addr) - else: - ServerModelManager(self.controller, tree).add_by_ip(addr) + self.controller.connect_server(addr) + + def _add_server(self) -> None: + addr = self.entry.get_text() + self.controller.add_server(addr) def _on_activate(self, entry: Gtk.Entry) -> None: # NOTE: default action is to add a record, not connect if not self.add_server.is_sensitive(): return - self._submit_query() + self._add_server() def _on_add_clicked(self, button: Gtk.Button) -> None: - self._submit_query() + self._add_server() def _on_ip_validated(self, entry: Gtk.Entry, state: bool) -> None: self.conn_server.set_sensitive(state) From e568814a11b6470a0913afa897273a18f9046ba2 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 1 Mar 2026 00:16:43 +0900 Subject: [PATCH 124/309] feat: add/connect by record or str --- dzgui/api/servers.py | 25 +++++++++++++++++++----- dzgui/controllers/mc.py | 26 ++++++++++++------------- dzgui/managers/connection.py | 13 ++++++++++--- dzgui/managers/contextmenu.py | 13 ++++++------- dzgui/model/servers.py | 14 +++++++++++-- dzgui/views/components/connect_panel.py | 4 ++-- 6 files changed, 63 insertions(+), 32 deletions(-) diff --git a/dzgui/api/servers.py b/dzgui/api/servers.py index 568ae75..1181312 100644 --- a/dzgui/api/servers.py +++ b/dzgui/api/servers.py @@ -471,12 +471,27 @@ def query_by_id(addr: str, key: str) -> Optional[dict]: def query_by_ip(addr: str) -> Optional[dict]: + record = short_ip_to_record(addr) + return query_by_record(record) + + +def query_by_record(record: Record) -> Optional[dict]: try: - record = addr.split(":") - # TODO: create a Record object - ip, qport = record[0], record[1] - res = query_direct(ip, int(qport)) + return query_direct(record.ip, record.qport) except Exception as e: logger.critical(e) return None - return res + + +def short_ip_to_record(addr: str) -> Optional[Record]: + r = addr.split(":") + if len(r) != 2: + return None + return Record(r[0], 0, int(r[1])) + + +def fqip_to_record(addr: str) -> Optional[Record]: + r = addr.split(":") + if len(r) != 3: + return None + return Record(r[0], int(r[1]), int(r[2])) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 3453abc..28aa54b 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -2,11 +2,10 @@ import inspect import logging import shutil import threading -import traceback from functools import wraps from pathlib import Path -from typing import Any, Callable, TYPE_CHECKING +from typing import Any, Callable, Union, TYPE_CHECKING from warnings import deprecated import dzgui.api.pefile as PeFile @@ -25,8 +24,6 @@ from dzgui.const.constants import ( APPID_DAYZ, APPID_DAYZ_EXP, HEX_RED, - WINDOW_DEFAULT_X, - WINDOW_DEFAULT_Y, ) from dzgui.const.enum import ( FilterMode, @@ -36,7 +33,6 @@ from dzgui.const.enum import ( ContextMenu, ) -from dzgui.config import update from dzgui.config.query import lookup from dzgui.config.userprefs import UserPrefs from dzgui.controllers.emitter import Emitter @@ -47,7 +43,6 @@ from dzgui.model.proxy_model import ProxyModelManager from dzgui.model.servers import ServerModelManager from dzgui.model.model_factory import ModelFactory from dzgui.util import strings -from dzgui.util._json import read_json, write_json from dzgui.util.diag import write_diagnostic from dzgui.util.format import format_mods, format_player_count from dzgui.util.localize import number @@ -63,6 +58,7 @@ from gi.repository import Gtk, Gdk, GLib, GObject # noqa E402 logger = logging.getLogger(__name__) if TYPE_CHECKING: + from dzgui.api.servers import Record from dzgui.const.enum import ServerTab from dzgui.managers.filter_man import FilterManager from dzgui.util.dist import Haversine @@ -590,7 +586,7 @@ class Controller(GObject.GObject): return treeview.get_model() is not None def _on_check_toggled(self, emitter: Emitter, label: str, state: bool) -> None: - print('check toggled, refiltering') + print("check toggled, refiltering") filter_man = self.get_filter_man() filter_man.set_filter(label, state) @@ -620,17 +616,21 @@ class Controller(GObject.GObject): def get_config_man(self) -> ConfigManager: return self.config_man - def add_server(self, addr: str) -> None: + def add_by_str(self, addr: str) -> None: saved_tree = self.get_servers().get_saved() - if addr.isdigit(): - ServerModelManager(self, saved).add_by_id(addr) - else: - ServerModelManager(self, saved).add_by_ip(addr) + ServerModelManager(self, saved_tree).add_by_str(addr) - def connect_server(self, addr: str) -> None: + def add_by_record(self, record: "Record") -> None: + saved_tree = self.get_servers().get_saved() + ServerModelManager(self, saved_tree).add_by_record(record) + + def connect_by_str(self, addr: str) -> None: if addr.isdigit(): config_man = self.get_config_man() key = config_man.lookup(Preferences.BM) ConnectionManager(self).connect_by_id(addr, key) else: ConnectionManager(self).connect_by_ip(addr) + + def connect_by_record(self, record: "Record") -> None: + ConnectionManager(self).connect_by_record(record) diff --git a/dzgui/managers/connection.py b/dzgui/managers/connection.py index b66ee8e..a855f4d 100644 --- a/dzgui/managers/connection.py +++ b/dzgui/managers/connection.py @@ -26,8 +26,7 @@ class ConnectionManager: res = Servers.query_by_id(addr, key) if res is None: self.thread_man.set_cleanup_func(StoredFunc(self._connection_failure)) - # TODO: add to history if successful - print("CONNECT") + print(res) @call_on_thread(dialog.querying) @@ -35,8 +34,16 @@ class ConnectionManager: res = Servers.query_by_ip(addr) if res is None: self.thread_man.set_cleanup_func(StoredFunc(self._connection_failure)) + + print(res) + + @call_on_thread(dialog.querying) + def connect_by_record(self, record: Servers.Record) -> None: + res = Servers.query_by_record(record) + if res is None: + self.thread_man.set_cleanup_func(StoredFunc(self._connection_failure)) + # TODO: add to history if successful - print("CONNECT") print(res) def _connection_failure(self) -> None: diff --git a/dzgui/managers/contextmenu.py b/dzgui/managers/contextmenu.py index 1171b01..d79c25a 100644 --- a/dzgui/managers/contextmenu.py +++ b/dzgui/managers/contextmenu.py @@ -1,9 +1,8 @@ import gi import logging -from typing import Union, TYPE_CHECKING +from typing import TYPE_CHECKING from dzgui.const.enum import ContextMenu, Preferences -from dzgui.managers.connection import ConnectionManager from dzgui.managers.thread_man import ThreadingManager from dzgui.model.servers import ServerModelManager from dzgui.util import strings @@ -60,12 +59,12 @@ class ContextMenuManager: # THREADED case ContextMenu.ADD_SERVER: - record = self.treeview.get_record_string() - ServerModelManager(self.controller, self.treeview).add_by_ip(record) - # TODO: update tab with ! + record = self.treeview.get_record() + self.controller.add_by_record(record) case ContextMenu.CONNECT: - record = self.treeview.get_record_string() - ConnectionManager(self.controller).connect_by_ip(record) + record = self.treeview.get_record() + self.controller.connect_by_record(record) + case ContextMenu.REFRESH_PLAYERS: # get record # call a2s on thread diff --git a/dzgui/model/servers.py b/dzgui/model/servers.py index 0df89f0..14876c3 100644 --- a/dzgui/model/servers.py +++ b/dzgui/model/servers.py @@ -2,7 +2,7 @@ import logging import threading from concurrent.futures import as_completed from concurrent.futures import ThreadPoolExecutor -from typing import Optional, TYPE_CHECKING +from typing import Union, TYPE_CHECKING import dzgui.api.servers as Servers from dzgui.const.enum import FilterMode, Preferences, ServerTab @@ -192,13 +192,23 @@ class ServerModelManager: config_man = self.controller.get_config_man() key = config_man.lookup(Preferences.BM) res = Servers.query_by_id(addr, key) - self.parse_single_record(res) + self._parse_single_record(res) @call_on_thread(dialog.querying) def add_by_ip(self, addr: str) -> None: res = Servers.query_by_ip(addr) self._parse_single_record(res) + def add_by_record(self, record: Servers.Record) -> None: + res = Servers.query_by_record(record) + self._parse_single_record(res) + + def add_by_str(self, addr: str) -> None: + if addr.isdigit(): + self.add_by_id(addr) + else: + self.add_by_ip(addr) + def _parse_single_record(self, response: dict) -> None: if response is None: self.thread_man.set_cleanup_func(StoredFunc(self._cleanup_on_failure)) diff --git a/dzgui/views/components/connect_panel.py b/dzgui/views/components/connect_panel.py index 391e938..a5c06b3 100644 --- a/dzgui/views/components/connect_panel.py +++ b/dzgui/views/components/connect_panel.py @@ -204,11 +204,11 @@ class AddPanel(Gtk.Frame): def _on_connect_clicked(self, button: Gtk.Button) -> None: addr = self.entry.get_text() - self.controller.connect_server(addr) + self.controller.connect_by_str(addr) def _add_server(self) -> None: addr = self.entry.get_text() - self.controller.add_server(addr) + self.controller.add_by_str(addr) def _on_activate(self, entry: Gtk.Entry) -> None: # NOTE: default action is to add a record, not connect From 9fa93f5938d3aa03692bef25f7465e26e8ed2bae Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 1 Mar 2026 00:43:27 +0900 Subject: [PATCH 125/309] feat: toggle radio from inside Gtk.Entry --- dzgui/views/base.py | 1 + dzgui/views/components/connect_panel.py | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/dzgui/views/base.py b/dzgui/views/base.py index c5194f7..530d0bf 100644 --- a/dzgui/views/base.py +++ b/dzgui/views/base.py @@ -232,6 +232,7 @@ class Notebook(ScrollableMixin, Gtk.Notebook): # type: ignore def _on_keypress(self, widget: Gtk.Widget, event: Gdk.EventKey) -> None: match event.keyval: case Gdk.KEY_Right | Gdk.KEY_l: + # FIXME: propagation bool if event.state is Gdk.ModifierType.CONTROL_MASK: return MainController.get_emitter().emit("request_button_box_focus") diff --git a/dzgui/views/components/connect_panel.py b/dzgui/views/components/connect_panel.py index a5c06b3..801eafd 100644 --- a/dzgui/views/components/connect_panel.py +++ b/dzgui/views/components/connect_panel.py @@ -42,6 +42,7 @@ class LanPanel(Gtk.Frame): self.entry = PortEntry(controller) self.scan = Gtk.Button(label=lan_panel.scan_button) self.scan.connect("clicked", self._on_scan_clicked) + # TODO: strings self.early_abort = Gtk.CheckButton(label="Stop scanning on first hit") self.early_abort.set_active(True) @@ -51,6 +52,7 @@ class LanPanel(Gtk.Frame): self.entry.connect("activate", self._on_entry_activated) self.entry.connect("string_validated", self._on_port_validated) + self.entry.connect("key-release-event", self._on_lan_keypress) self.emitter.connect("request_custom_port_focus", self._on_custom_port_binding) self.emitter.connect( "request_default_port_focus", lambda _: self.default_radio.set_active(True) @@ -76,6 +78,14 @@ class LanPanel(Gtk.Frame): self.entry.set_sensitive(False) + def _on_lan_keypress(self, widget: Gtk.Entry, event: Gdk.EventKey) -> None: + if event.state & Gdk.ModifierType.CONTROL_MASK: + if event.keyval == Gdk.KEY_d: + self.default_radio.set_active(True) + self.controller.grab_active_treeview() + return True + return False + def _on_custom_port_binding(self, emitter: "Emitter") -> None: self.custom_radio.set_active(True) self.entry.grab_focus() From 3bb30887ca3e2ec4e0d67e2f24ae36c255045873 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 1 Mar 2026 00:49:49 +0900 Subject: [PATCH 126/309] chore: update changelog --- CHANGELOG.md | 6 ++++-- dzgui/data/CHANGELOG.md | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 197922c..6ac452d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ - Show hidden server count after filtering - Atomic map filters per server context - Use concurrency when checking stale mods (performance uplift) +- Copy version by clicking label ## Changed - Reduce padding on keys button @@ -40,10 +41,11 @@ - Moved dialogs out of threads ## Unreleased +- Load offline mods +- Choose to jump into splash screen instead of server - Setup wizard -- Local documentation -- Offline mods - Move debug mode to developers only +- Local documentation ## Devs - Show deprecation warnings diff --git a/dzgui/data/CHANGELOG.md b/dzgui/data/CHANGELOG.md index 197922c..6ac452d 100644 --- a/dzgui/data/CHANGELOG.md +++ b/dzgui/data/CHANGELOG.md @@ -25,6 +25,7 @@ - Show hidden server count after filtering - Atomic map filters per server context - Use concurrency when checking stale mods (performance uplift) +- Copy version by clicking label ## Changed - Reduce padding on keys button @@ -40,10 +41,11 @@ - Moved dialogs out of threads ## Unreleased +- Load offline mods +- Choose to jump into splash screen instead of server - Setup wizard -- Local documentation -- Offline mods - Move debug mode to developers only +- Local documentation ## Devs - Show deprecation warnings From 5391cdde1e1b8dc2c487f24a7e8f4e4122a0619e Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 4 Mar 2026 11:36:30 +0900 Subject: [PATCH 127/309] fix: reapply filters when map changed --- dzgui/controllers/mc.py | 10 +++--- dzgui/managers/filter_man.py | 9 ++--- dzgui/model/proxy_model.py | 50 +++++++------------------- dzgui/model/servers.py | 25 +++++++------ dzgui/views/components/filter_panel.py | 2 +- dzgui/views/components/right_panel.py | 2 +- 6 files changed, 39 insertions(+), 59 deletions(-) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 28aa54b..8b9e7e6 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -551,6 +551,8 @@ class Controller(GObject.GObject): if tv.is_loaded(): self.emitter.emit("servers_loaded", tv.get_enum()) return + # TODO: placeholder logic, wipe statusbar when changing page + self.mediator.statusbar.set_text("", "") ServerModelManager(self, tv).load() def get_dist_cache(self) -> dict[str, "Haversine", "ServerTab"]: @@ -586,17 +588,17 @@ class Controller(GObject.GObject): return treeview.get_model() is not None def _on_check_toggled(self, emitter: Emitter, label: str, state: bool) -> None: - print("check toggled, refiltering") + print("DEBUG: check toggled, refiltering") filter_man = self.get_filter_man() filter_man.set_filter(label, state) mode = FilterMode.TOGGLE_ON if state else FilterMode.TOGGLE_OFF - ServerModelManager(self, self.get_active_treeview()).refilter(mode, label) + ServerModelManager(self, self.get_active_treeview()).refilter(mode) def _on_map_selection_changed(self, emitter: Emitter, selection: str) -> None: - print("map sel changed, refiltering") + print("DEBUG: map sel changed, refiltering") smm = ServerModelManager(self, self.get_active_treeview()) - smm.refilter(FilterMode.MAP, selection) + smm.refilter(FilterMode.MAP) def get_notebook(self) -> "Notebook": return self.mediator.notebook diff --git a/dzgui/managers/filter_man.py b/dzgui/managers/filter_man.py index 60e0102..4047604 100644 --- a/dzgui/managers/filter_man.py +++ b/dzgui/managers/filter_man.py @@ -39,9 +39,6 @@ class FilterManager: # def reinit_filters(self) -> None: # self.enabled_filters = dict(self.default_filters) - def get_prior_map(self) -> str: - return self.prior_map - def set_prior_map(self, name: str) -> None: self.prior_map = name @@ -79,9 +76,6 @@ class FilterManager: self.map_store = model self.active_map = (0, all_maps) - def get_prior_map(self) -> str: - return self.prior_map - def append_map(self, row: list[str]) -> None: self.map_store.append(row) @@ -95,6 +89,9 @@ class FilterManager: for m in maps: self.append_map([m]) + def get_unique_maps(self) -> list[str]: + return [row[0] for row in self.map_store] + def get_all_filters(self) -> tuple: map_name = self.get_active_map_name() enabled = self.get_filters() diff --git a/dzgui/model/proxy_model.py b/dzgui/model/proxy_model.py index 82bbd26..bf3394f 100644 --- a/dzgui/model/proxy_model.py +++ b/dzgui/model/proxy_model.py @@ -7,15 +7,7 @@ from dzgui.const.enum import FilterMode from dzgui.model.model_factory import ModelFactory from dzgui.util import strings -# import gi -# -# gi.require_version("Gtk", "3.0") -# from gi.repository.Gtk import ListStore # noqa E402 -# from gi.repository import GObject, GLib # noqa E402 - if TYPE_CHECKING: - # from dzgui.controllers.mc import Controller - # from dzgui.controllers.emitter import Emitter from dzgui.managers.filter_man import FilterManager from dzgui.model.model_factory import FastInsertListStore @@ -33,9 +25,6 @@ class ProxyModelManager: """ def __init__(self, filter_man: "FilterManager") -> None: - # self.emitter = controller.get_emitter() - # self.emitter.connect("keyword_set", self._on_keyword_set) - self.filter_cache = {} self.ping_cache: dict[str, int] = {} @@ -46,13 +35,6 @@ class ProxyModelManager: self.filtered: list = None self.success = True - - # def get_keyword_filter(self) -> str: - # return self.keyword_filter - - # def _on_keyword_set(self, emitter: "Emitter", keyword: str) -> None: - # self.keyword_filter = keyword - def append_row(self, row: list) -> None: self.proxy_model.append(row) @@ -62,16 +44,14 @@ class ProxyModelManager: def get_proxy_model(self) -> "FastInsertListStore": return self.proxy_model - # def new_model_from_class(self, cls: type) -> ListStore: - # store = ListStore(*[ftype for field, ftype in cls.__annotations__.items()]) - # return store - # def filter(self, mode: FilterMode, *args, **kwargs) -> None: - print("proxy model filtering now") + # TODO: proxy model can fetch args directly from filter man, no need to process extra input """ Native Gtk.TreeView.refilter() method was not performant enough when running in the main loop with 40k+ records """ + # TODO: return a dataclass object with clearly enumerated map, keyword, and filter values + # instead of just a serial list of strings filters = self.filter_man.get_all_filters() if filters in self.filter_cache: @@ -84,24 +64,18 @@ class ProxyModelManager: case FilterMode.INITIAL: rows = self.filter_initial(filters) - case FilterMode.MAP: - prior_map = self.filter_man.get_prior_map() - - if prior_map == strings.all_maps: - rows = self.filter_map(filters) - else: - rows = self.filter_toggle_on(filters, *args) - - case FilterMode.KEYWORD: - rows = self.filter_toggle_on(filters, *args) + # TODO: consolidate into one enum + case FilterMode.MAP | FilterMode.KEYWORD | FilterMode.TOGGLE_ON: + rows = self.filter_toggle_on(filters) 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: - rows = self.filter_toggle_on(filters, *args) + # case FilterMode.TOGGLE_ON: + # rows = self.filter_toggle_on(filters) + # # rows = self.filter_toggle_on(filters, *args) # TODO: unimplemented # just write pings into control model instead @@ -212,7 +186,7 @@ class ProxyModelManager: rows = [row for row in rows if not row[11]] return rows - def filter_toggle_on(self, filters: tuple, *args: str) -> list: + def filter_toggle_on(self, filters: tuple) -> list: """Effectively applies all filters""" self.set_filtered(self.control_model) self.set_filtered(self.filter_map(filters)) @@ -222,7 +196,9 @@ class ProxyModelManager: self.set_filtered(self.filter_toggle_off(filters, f)) return self.filtered - def set_cache(self, filters: tuple, model: Optional["FastInsertListStore"], rows: list) -> None: + def set_cache( + self, filters: tuple, model: Optional["FastInsertListStore"], rows: list + ) -> None: self.filter_cache[filters] = (model, rows) def resync_model(self, addr: str, qport: int) -> None: diff --git a/dzgui/model/servers.py b/dzgui/model/servers.py index 14876c3..42efe8a 100644 --- a/dzgui/model/servers.py +++ b/dzgui/model/servers.py @@ -235,8 +235,11 @@ class ServerModelManager: # for example, non-empty will only show up in empty because it is not cached proxy_man.filter(FilterMode.INITIAL) - self._sort_unique_maps(records) - + filter_man = self.tv.get_filter_man() + old_maps = filter_man.get_unique_maps() + cur_map = records[0][1] + if cur_map not in old_maps: + self._set_new_maps([cur_map]) self.thread_man.set_cleanup_func(StoredFunc(self._cleanup_single_ip)) def _dump_history(self) -> None: @@ -280,9 +283,12 @@ class ServerModelManager: # TODO: if current tab != self.saved, add label self.emitter.emit("servers_loaded", self.enum) - # NOTE: adding a row may update available maps - # TODO: test this - self._update_maps() + + # TODO: consolidate methods and handle multi/single map addition + filter_man = self.tv.get_filter_man() + filter_man.append_map(self._get_new_maps()) + self.emitter.emit("servers_loaded_init") + self.first_iteration = False def _update_maps(self) -> None: filter_man = self.tv.get_filter_man() @@ -309,8 +315,6 @@ class ServerModelManager: if self.first_iteration: self._update_maps() - # def _cleanup_on_empty(): - def _cleanup_on_failure(self, show_dialog=True) -> None: # TODO: disable map, keyword, and filter widgets if model is None # -> signal driven (servers_empty, servers_failed_to_load) @@ -348,7 +352,7 @@ class ServerModelManager: def _sort_unique_maps(self, data: list) -> None: u_maps = set([row[1] for row in data]) - self._set_new_maps(u_maps) + self._set_new_maps(sorted(u_maps)) def _set_new_maps(self, maps: list[str]) -> None: self.new_maps = maps @@ -360,10 +364,11 @@ class ServerModelManager: return self.proxy_man @call_on_thread(dialog.filtering) - def refilter(self, mode: FilterMode, label: str) -> None: + def refilter(self, mode: FilterMode) -> None: # FIXME: causes two wait dialogs when map selection change signal emits after loading servers self.first_iteration = False proxy_man = self._get_proxy_man() - proxy_man.filter(mode, label) + proxy_man.filter(mode) + # proxy_man.filter(mode, label) self.to_insert = proxy_man.get_proxy_model() self.thread_man.set_cleanup_func(StoredFunc(self._cleanup_on_success)) diff --git a/dzgui/views/components/filter_panel.py b/dzgui/views/components/filter_panel.py index 49cd92c..6508c0b 100644 --- a/dzgui/views/components/filter_panel.py +++ b/dzgui/views/components/filter_panel.py @@ -129,7 +129,7 @@ class KeywordEntry(Gtk.Entry): ServerModelManager( self.controller, self.controller.get_active_treeview() - ).refilter(FilterMode.KEYWORD, keyword) + ).refilter(FilterMode.KEYWORD) class FilterPanel(Gtk.Box): diff --git a/dzgui/views/components/right_panel.py b/dzgui/views/components/right_panel.py index d5553b1..6a9ff40 100644 --- a/dzgui/views/components/right_panel.py +++ b/dzgui/views/components/right_panel.py @@ -6,7 +6,7 @@ from dzgui.views.components.buttonbox import ButtonBox from dzgui.views.components.filter_panel import FilterPanel from dzgui.views.components.mod_panel import ModSelectionPanel from dzgui.views.components.buttons import RefreshButton, KeysButton -from dzgui.const.constants import NO_EXPAND, NO_FILL, FILL, NO_PADDING, EXPAND +from dzgui.const.constants import NO_EXPAND, NO_FILL, FILL, NO_PADDING import gi From ae291eff41cfa3277a11d51045e22acd2b108507 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 29 Mar 2026 19:50:09 +0900 Subject: [PATCH 128/309] fix: do not refilter on page map --- dzgui/controllers/mc.py | 26 -------------------------- dzgui/managers/contextmenu.py | 9 +++++++-- dzgui/model/proxy_model.py | 2 +- dzgui/model/servers.py | 11 ++++++----- dzgui/util/strings.py | 2 +- dzgui/views/components/filter_panel.py | 6 ++++++ dzgui/views/trees/tree_servers.py | 1 + 7 files changed, 22 insertions(+), 35 deletions(-) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 8b9e7e6..daddfc5 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -152,10 +152,7 @@ class Controller(GObject.GObject): self.prefs = prefs def query_config(self, key: Preferences) -> str | bool | list: - # return self.config_man.lookup(key) return self.config_man.lookup(key) - # config = self.prefs.paths.config - # return lookup(config, key) def is_auto_install(self) -> bool: return self.query_config(Preferences.INSTALL) @@ -288,32 +285,9 @@ class Controller(GObject.GObject): def toggle_config(self, key: Preferences) -> None: self.config_man.toggle_config(key) - # config = self.prefs.paths.config - # try: - # update.toggle_config(config, context) - # # NOTE: 'use_miles' key is updated dynamically for statusbar unit - # if context == Preferences.DIST: - # self.prefs.use_miles = not self.prefs.use_miles - # except Exception as e: - # logger.critical(e) - # trace = traceback.format_exc() - # dialog = ExceptionDialog(self, trace) - # dialog.run() def update_config(self, key: Preferences, value: str) -> None: self.config_man.update_config(key, value) - # try: - # update.write_config(self.prefs.paths.config, key, value) - # except Exception as e: - # logger.critical(e) - # trace = traceback.format_exc() - # dialog = ExceptionDialog(self, trace) - # dialog.run() - # # TODO: suppress signals - # # then reenable (or it spawns dialog twice) - # # TODO: do this on demand for certain changes - # self.mediator.grid.notebook.settings.populate_settings() - # return def open_keybindings(self) -> None: notebook = self.mediator.grid.notebook diff --git a/dzgui/managers/contextmenu.py b/dzgui/managers/contextmenu.py index d79c25a..727d28a 100644 --- a/dzgui/managers/contextmenu.py +++ b/dzgui/managers/contextmenu.py @@ -4,7 +4,6 @@ from typing import TYPE_CHECKING from dzgui.const.enum import ContextMenu, Preferences from dzgui.managers.thread_man import ThreadingManager -from dzgui.model.servers import ServerModelManager from dzgui.util import strings from dzgui.util.clip import copy_clipboard from dzgui.util.open_links import open_workshop_page @@ -34,8 +33,9 @@ class ContextMenuManager: def process(self, action: ContextMenu) -> None: match action: - # NON THREADED + # UNTHREADED case ContextMenu.ADD_NOTE: + print("DEBUG: unimplemented") # spawn edit dialog and update cache, notes file pass case ContextMenu.COPY_LOG_CLIPBOARD: @@ -66,19 +66,24 @@ class ContextMenuManager: self.controller.connect_by_record(record) case ContextMenu.REFRESH_PLAYERS: + print("DEBUG: unimplemented") # get record # call a2s on thread pass case ContextMenu.REMOVE_HISTORY: + print("DEBUG: unimplemented") # update history model, update tab label, pop off of queue, write new list into file # see dq.py pass case ContextMenu.REMOVE_SERVER: + print("DEBUG: unimplemented") # reverse of ADD_SERVER pass case ContextMenu.SHOW_DETAILS: + print("DEBUG: unimplemented") pass case ContextMenu.SHOW_MODS: + print("DEBUG: unimplemented") pass def copy_server_ip(self) -> None: diff --git a/dzgui/model/proxy_model.py b/dzgui/model/proxy_model.py index bf3394f..a50708c 100644 --- a/dzgui/model/proxy_model.py +++ b/dzgui/model/proxy_model.py @@ -44,7 +44,7 @@ class ProxyModelManager: def get_proxy_model(self) -> "FastInsertListStore": return self.proxy_model - def filter(self, mode: FilterMode, *args, **kwargs) -> None: + def filter(self, mode: FilterMode) -> None: # TODO: proxy model can fetch args directly from filter man, no need to process extra input """ Native Gtk.TreeView.refilter() method was not performant enough diff --git a/dzgui/model/servers.py b/dzgui/model/servers.py index 42efe8a..d87d664 100644 --- a/dzgui/model/servers.py +++ b/dzgui/model/servers.py @@ -133,7 +133,7 @@ class ServerModelManager: try: res = future.result(timeout=LAN_TIMEOUT) if res is not None and early_abort is True: - # NOTE: first non-empty hit, flag pending threads to close + # NOTE: on first non-empty hit, flag pending threads to close event.set() servers.append(res) self.thread_man.set_cleanup_func( @@ -215,6 +215,7 @@ class ServerModelManager: return records = Servers.parse_json([response]) + record = records[0] proxy_man = self._get_proxy_man() raw_model = proxy_man.get_control() @@ -223,21 +224,21 @@ class ServerModelManager: config_man = self.controller.get_config_man() config_man.add_saved_server(fqip) - # NOTE: if tab was not instantiated yet + # NOTE: if tab contents were not loaded yet if raw_model is None: return # NOTE: expected to only contain one item - raw_model.append(records[0]) + raw_model.append(record) # TODO: if all filters are already applied, strange behavior may occur - # -> need to insert and reupdate tre per current filters + # -> need to insert and reupdate tree per current filters # for example, non-empty will only show up in empty because it is not cached proxy_man.filter(FilterMode.INITIAL) filter_man = self.tv.get_filter_man() old_maps = filter_man.get_unique_maps() - cur_map = records[0][1] + cur_map = record[1] if cur_map not in old_maps: self._set_new_maps([cur_map]) self.thread_man.set_cleanup_func(StoredFunc(self._cleanup_single_ip)) diff --git a/dzgui/util/strings.py b/dzgui/util/strings.py index 77959c2..4ef3159 100644 --- a/dzgui/util/strings.py +++ b/dzgui/util/strings.py @@ -544,7 +544,7 @@ connect_panel = ConnectPanel( connect="Connect", add="Add", add_con="Add/connect", - placeholder="Enter IP or Battlemetrics ID", + placeholder="Enter IP (IP:Query port) or Battlemetrics ID (numerical id)", entry_tooltip=( "- IP: format as IP:Query port\ne.g. 192.168.1.1:27016\n" "- Battlemetrics: numeric server ID\ne.g. 123456" diff --git a/dzgui/views/components/filter_panel.py b/dzgui/views/components/filter_panel.py index 6508c0b..1c01885 100644 --- a/dzgui/views/components/filter_panel.py +++ b/dzgui/views/components/filter_panel.py @@ -212,6 +212,7 @@ class FilterPanel(Gtk.Box): # TODO: use same sort of signal to reinitialize keyword and checks def _on_maps_loaded(self, emitter: "Emitter", store: Gtk.ListStore) -> None: + self.is_first_run = True self.maps_combo.set_model(store) tv = self.controller.get_active_treeview() ind, name = tv.filter_man.get_active_map() @@ -312,4 +313,9 @@ class FilterPanel(Gtk.Box): filter_man.set_prior_map(name) filter_man.set_active_map(ind, name) + # TODO: 2026-03-29 + # don't signal controller to refilter if we are switching pages + if self.is_first_run: + self.is_first_run = False + return self.emitter.emit("map_selection_changed", name) diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index bc6568c..e9ba1f8 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -166,6 +166,7 @@ class ServerTreeView(ContextMixin, TreeView): store = self.filter_man.get_map_store() # FIXME: if model is none, wipe maps + # distinguish this signal from changing map combo in-situ self.emitter.emit("load_maps", store) self.handler_id = self.emitter.connect("statusbar_loaded", self.start_distcalc) self.start_queue_checker() From 4022b4c6b6a09e6b8eccea3d846f4cc9a78cfae9 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Mon, 30 Mar 2026 21:42:19 +0900 Subject: [PATCH 129/309] feat: reimplement details dialog --- dzgui/api/servers.py | 16 +++-- dzgui/controllers/mc.py | 5 ++ dzgui/managers/connection.py | 45 ++++++++++--- dzgui/managers/contextmenu.py | 6 +- dzgui/managers/thread_man.py | 31 ++++++--- dzgui/model/servers.py | 4 +- dzgui/util/strings.py | 2 +- dzgui/views/dialogs/generic.py | 7 -- dzgui/views/dialogs/server_details.py | 92 +++++++++++++-------------- dzgui/views/pages/servers.py | 4 -- dzgui/views/trees/tree_servers.py | 3 +- 11 files changed, 126 insertions(+), 89 deletions(-) diff --git a/dzgui/api/servers.py b/dzgui/api/servers.py index 1181312..84e2e1d 100644 --- a/dzgui/api/servers.py +++ b/dzgui/api/servers.py @@ -234,6 +234,7 @@ class Ping: @dataclass(slots=True, frozen=True) class Details: + name: Union[str, None] data: Union[list, None] description: str success: bool @@ -272,22 +273,25 @@ def get_prereqs(ip: str, qport: int) -> Prereqs: return Prereqs(is_password, gameport, appid, version) -def details(ip: str, qport: int) -> Details: +def details(record: Record) -> Details: + ip = record.ip + qport = record.qport default_str = strings.none_provided try: info = a2s.info((ip, qport)) + name = info.server_name except TimeoutError: - return Details(None, default_str, False) + return Details(None, None, default_str, False) try: - rules = dayzquery.dayz_rules((ip, int(qport))) + rules = dayzquery.dayz_rules((ip, qport)) except TimeoutError: - return Details(None, default_str, False) + return Details(None, None, default_str, False) try: keywords = info.keywords.split(",") except AttributeError: - return Details(None, default_str, False) + return Details(None, None, default_str, False) battleye = strings.disabled if "battleye" in keywords: @@ -362,7 +366,7 @@ def details(ip: str, qport: int) -> Details: ["Version", version], ] - return Details(rows, description, True) + return Details(name, rows, description, True) def ping(iteration: int, addr: list, qport: int, ping: int) -> Ping: diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index daddfc5..728bdc2 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -449,6 +449,7 @@ class Controller(GObject.GObject): model[path][4] = True self.emitter.emit("mods_highlighted") + # TODO: delegate to mod manager @call_on_thread(strings.dialog.working) def highlight_stale(self) -> None: # TODO: set progress bar for number of mods @@ -609,4 +610,8 @@ class Controller(GObject.GObject): ConnectionManager(self).connect_by_ip(addr) def connect_by_record(self, record: "Record") -> None: + # NOTE: calls on thread ConnectionManager(self).connect_by_record(record) + + def get_details(self, record: "Record") -> None: + ConnectionManager(self).query_details(record) diff --git a/dzgui/managers/connection.py b/dzgui/managers/connection.py index a855f4d..bbd35ba 100644 --- a/dzgui/managers/connection.py +++ b/dzgui/managers/connection.py @@ -2,9 +2,9 @@ from typing import TYPE_CHECKING import dzgui.api.servers as Servers from dzgui.managers.thread_man import call_on_thread, StoredFunc, ThreadingManager -from dzgui.util.strings import api_warn_msg, dialog +from dzgui.util.strings import api_warn_msg, dialog, server_timeout from dzgui.views.dialogs.generic import ExceptionDialog - +from dzgui.views.dialogs.server_details import ServerDetailsDialog import gi @@ -25,28 +25,57 @@ class ConnectionManager: def connect_by_id(self, addr: str, key: str) -> None: res = Servers.query_by_id(addr, key) if res is None: - self.thread_man.set_cleanup_func(StoredFunc(self._connection_failure)) + self.thread_man.set_cleanup_func( + StoredFunc(self._server_timeout), destroy_first=True + ) + print("DEBUG") print(res) @call_on_thread(dialog.querying) def connect_by_ip(self, addr: str) -> None: res = Servers.query_by_ip(addr) if res is None: - self.thread_man.set_cleanup_func(StoredFunc(self._connection_failure)) + self.thread_man.set_cleanup_func( + StoredFunc(self._server_timeout), destroy_first=True + ) + print("DEBUG") print(res) @call_on_thread(dialog.querying) def connect_by_record(self, record: Servers.Record) -> None: res = Servers.query_by_record(record) if res is None: - self.thread_man.set_cleanup_func(StoredFunc(self._connection_failure)) + self.thread_man.set_cleanup_func( + StoredFunc(self._server_timeout), destroy_first=True + ) # TODO: add to history if successful + print("DEBUG") print(res) - def _connection_failure(self) -> None: - # TODO: more explicit warning message, not necessarily API failure? - dialog = ExceptionDialog(self.controller, api_warn_msg) + @call_on_thread(dialog.querying) + def query_details(self, record: Servers.Record) -> None: + details = Servers.details(record) + if details.success is False: + self.thread_man.set_cleanup_func( + StoredFunc(self._server_timeout), destroy_first=True + ) + return + self.thread_man.set_cleanup_func( + StoredFunc(self._present_details_dialog, details), destroy_first=True + ) + + def _present_details_dialog(self, details: Servers.Details) -> None: + dialog = ServerDetailsDialog(self.controller, details) dialog.run() + + def _server_timeout(self) -> None: + dialog = ExceptionDialog(self.controller, server_timeout) + dialog.run() + + # def _connection_failure(self) -> None: + # # FIXME: returns api warning, but this is more likely a localized server issue + # dialog = ExceptionDialog(self.controller, api_warn_msg) + # dialog.run() diff --git a/dzgui/managers/contextmenu.py b/dzgui/managers/contextmenu.py index 727d28a..771e0e3 100644 --- a/dzgui/managers/contextmenu.py +++ b/dzgui/managers/contextmenu.py @@ -3,7 +3,6 @@ import logging from typing import TYPE_CHECKING from dzgui.const.enum import ContextMenu, Preferences -from dzgui.managers.thread_man import ThreadingManager from dzgui.util import strings from dzgui.util.clip import copy_clipboard from dzgui.util.open_links import open_workshop_page @@ -29,7 +28,6 @@ class ContextMenuManager: ) -> None: self.controller = controller self.treeview = treeview - self.thread_man = ThreadingManager(parent=controller) def process(self, action: ContextMenu) -> None: match action: @@ -80,8 +78,8 @@ class ContextMenuManager: # reverse of ADD_SERVER pass case ContextMenu.SHOW_DETAILS: - print("DEBUG: unimplemented") - pass + record = self.treeview.get_record() + self.controller.get_details(record) case ContextMenu.SHOW_MODS: print("DEBUG: unimplemented") pass diff --git a/dzgui/managers/thread_man.py b/dzgui/managers/thread_man.py index bfe62f1..3aa1283 100644 --- a/dzgui/managers/thread_man.py +++ b/dzgui/managers/thread_man.py @@ -50,7 +50,9 @@ class ThreadingManager: self.parent = parent self.jobs = 1 self.cleanup_func = None - self.alternate_statusbar = None + self.destroy_first = False + + # self.alternate_statusbar = None def call_on_thread(self, dialog_str: str, func: StoredFunc) -> None: def callback() -> None: @@ -72,30 +74,39 @@ class ThreadingManager: GLib.idle_add(lambda: self.wait_dialog.increment(text)) # TODO: this should not be delegated here, set in cleanup func - def set_alternate_statusbar(self, msg: str) -> None: - self.alternate_statusbar = msg + # def set_alternate_statusbar(self, msg: str) -> None: + # self.alternate_statusbar = msg - def get_alternate_statusbar(self) -> Optional[str]: - return self.alternate_statusbar + # def get_alternate_statusbar(self) -> Optional[str]: + # return self.alternate_statusbar - def set_cleanup_func(self, func: StoredFunc) -> None: + def set_cleanup_func(self, func: StoredFunc, destroy_first: bool = False) -> None: if type(func) not in (StoredFunc, type(None)): msg = f"Callback function '{func}' is not of type StoredFunc or None" logger.critical(msg) raise TypeError(msg) + self.destroy_first = destroy_first self.cleanup_func = func def get_cleanup_func(self) -> StoredFunc: return self.cleanup_func - def _d(self) -> None: - self.wait_dialog.hide() - return False + # def _d(self) -> None: + # self.wait_dialog.hide() + # return False def _destroy_on_idle(self) -> GLib.SOURCE_REMOVE: + if self.destroy_first: + self.wait_dialog.destroy() + func = self.get_cleanup_func() if func is not None: func.call() self.set_cleanup_func(None) - self.wait_dialog.destroy() + if not self.destroy_first: + self.wait_dialog.destroy() + return GLib.SOURCE_REMOVE + + def get_wait_dialog(self) -> WaitDialog: + return self.wait_dialog diff --git a/dzgui/model/servers.py b/dzgui/model/servers.py index d87d664..01c6acc 100644 --- a/dzgui/model/servers.py +++ b/dzgui/model/servers.py @@ -320,7 +320,7 @@ class ServerModelManager: # TODO: disable map, keyword, and filter widgets if model is None # -> signal driven (servers_empty, servers_failed_to_load) - # NOTE: used by refresh action + # NOTE: used by refresh button action if not self.preserve_on_fail: self.tv.set_model(None) filter_man = self.tv.get_filter_man() @@ -330,6 +330,8 @@ class ServerModelManager: # TODO: distinguish signals, e.g. "servers_failed_to_load", "servers_loaded_empty" # customize statusbar and dialog accordingly self.emitter.emit("servers_loaded", self.enum) + # TODO: destroy wait dialog first + # see threadman.set_cleanup_func(_, destroy_first=True) if show_dialog: dialog = ExceptionDialog(self.controller, api_warn_msg) dialog.run() diff --git a/dzgui/util/strings.py b/dzgui/util/strings.py index 4ef3159..a737c30 100644 --- a/dzgui/util/strings.py +++ b/dzgui/util/strings.py @@ -102,7 +102,7 @@ build_corrupted = ( api_warn_msg = """No servers returned. Please wait and try again. If this issue persists, your API key may be defunct or your network is blocking requests. """ -server_timeout = "Timed out when querying server, check IP or try again later" +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." ) diff --git a/dzgui/views/dialogs/generic.py b/dzgui/views/dialogs/generic.py index c1897a5..062e839 100644 --- a/dzgui/views/dialogs/generic.py +++ b/dzgui/views/dialogs/generic.py @@ -19,13 +19,6 @@ if TYPE_CHECKING: # 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 -# TODO: unused -# def update_label(self, text: str) -> None: -# self.format_secondary_text(text) class GenericDialog(Gtk.MessageDialog): diff --git a/dzgui/views/dialogs/server_details.py b/dzgui/views/dialogs/server_details.py index 18aa3d1..3ef6948 100644 --- a/dzgui/views/dialogs/server_details.py +++ b/dzgui/views/dialogs/server_details.py @@ -1,20 +1,36 @@ import gi + +import dzgui.api.servers as Servers +from dzgui.const.constants import EXPAND, FILL, NO_PADDING +from dzgui.const.enum import Popup +from dzgui.util import css +from dzgui.util import strings +from dzgui.util.format import format_hyperlinks +from dzgui.views.dialogs.generic import GenericDialog + +from typing import Self, TYPE_CHECKING + gi.require_version("Gtk", "3.0") from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa -from dzgui.const.constants import EXPAND, FILL, NO_PADDING -from dzgui.const.enum import Popup +if TYPE_CHECKING: + from dzgui.api.servers import Details + class ServerDetailsDialog(GenericDialog): - def __init__(self, server_name: str, ip: str, qport: int): - super().__init__(server_name, Popup.DETAILS) + def __init__(self, controller, details: "Details"): + super().__init__( + controller=controller, + text=details.name, + buttons=Gtk.ButtonsType.OK, + mtype=Gtk.MessageType.INFO, + secondary=strings.server_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( @@ -24,13 +40,12 @@ class ServerDetailsDialog(GenericDialog): fixed_height_mode=True, ) self.view.connect("row-activated", self._on_row_activated) + self.view.connect("key-press-event", self._on_keypress) 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 - ) + 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) @@ -43,15 +58,12 @@ class ServerDetailsDialog(GenericDialog): scrollable_tree.add(self.view) scrollable_tree.set_size_request(700, 200) + # TODO: center header text 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 - ) + 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: @@ -61,12 +73,24 @@ class ServerDetailsDialog(GenericDialog): dialog_box.pack_start(scrollable_tree, EXPAND, FILL, NO_PADDING) dialog_box.pack_start(scrollable_message, EXPAND, FILL, NO_PADDING) - 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() + for row in details.data: + self.store.append(row + [Pango.Weight.BOLD]) + self.view.set_model(self.store) + text = details.description + text = format_hyperlinks(text) + self.description.set_markup(text) + + self.connect("response", self._on_response) + self.show_all() + + def _on_keypress(self, view: Gtk.TreeView, event: Gdk.EventKey) -> None: + if event.keyval == Gdk.KEY_Escape: + self.destroy() + return True + return False + + def _on_response(self, dialog: Self, response: Gtk.ResponseType) -> None: + self.destroy() def _on_row_activated( self, @@ -75,29 +99,3 @@ class ServerDetailsDialog(GenericDialog): 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) diff --git a/dzgui/views/pages/servers.py b/dzgui/views/pages/servers.py index cd85620..61eecbd 100644 --- a/dzgui/views/pages/servers.py +++ b/dzgui/views/pages/servers.py @@ -63,7 +63,6 @@ class ServerNotebook(Gtk.ScrolledWindow): tv = self.get_active_treeview() state = False if tv.get_model() is None else True tv.set_headers_visible(state) - # tv.set_headers_clickable(state) tv.set_loaded(True) tv.grab_focus() @@ -151,9 +150,6 @@ class ServerNotebook(Gtk.ScrolledWindow): tab, col, "_on_col_width_changed", False ) - # def get_tabs(self) -> tuple: - # return (self.browser, self.saved, self.recent, self.lan) - def get_browser(self) -> ServerTreeView: return self.browser diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index e9ba1f8..3e2c380 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -283,7 +283,8 @@ class ServerTreeView(ContextMixin, TreeView): def _parent_row_activated( self, tree: TreeView, path: Gtk.TreePath, column: Gtk.TreeViewColumn ) -> None: - print(self.get_value_at_index(0)) + record = self.get_record() + self.controller.connect_by_record(record) def _parent_selection_changed(self, base_class: TreeView, sel: Gtk.TreeSelection): if self.loaded is False: From 6393265935b1a9c26f358f4162bb7638d7091d1c Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Mon, 30 Mar 2026 22:51:45 +0900 Subject: [PATCH 130/309] feat: reimplement server mod dialog --- dzgui/api/mods.py | 1 + dzgui/api/servers.py | 8 +- dzgui/controllers/mc.py | 3 + dzgui/managers/connection.py | 34 ++++-- dzgui/managers/contextmenu.py | 4 +- dzgui/model/model_factory.py | 2 +- dzgui/views/dialogs/server_mods.py | 88 ++++++++------- .../dialogs/{server_details.py => servers.py} | 101 +++++++++++++++++- 8 files changed, 179 insertions(+), 62 deletions(-) rename dzgui/views/dialogs/{server_details.py => servers.py} (52%) diff --git a/dzgui/api/mods.py b/dzgui/api/mods.py index 5e833ad..4a37fbd 100644 --- a/dzgui/api/mods.py +++ b/dzgui/api/mods.py @@ -109,6 +109,7 @@ def get_missing_mods(local: list, remote: list) -> list: return [mod for mod in remote if mod not in local] +# FIXME: steam path is missing def get_server_modlist(server: Record, steam: Path) -> list: try: rules = dayzquery.dayz_rules((server.ip, server.qport)) diff --git a/dzgui/api/servers.py b/dzgui/api/servers.py index 84e2e1d..5aee04f 100644 --- a/dzgui/api/servers.py +++ b/dzgui/api/servers.py @@ -273,7 +273,7 @@ def get_prereqs(ip: str, qport: int) -> Prereqs: return Prereqs(is_password, gameport, appid, version) -def details(record: Record) -> Details: +def get_details(record: Record) -> Details: ip = record.ip qport = record.qport default_str = strings.none_provided @@ -448,9 +448,11 @@ def validate_ip(addr: str) -> Record: return record -def get_rules(ip: str, qport: int) -> list[int]: +def get_rules(record: Record) -> list[int]: + ip = record.ip + qport = record.qport try: - mods = dayzquery.dayz_rules((ip, int(qport))).mods + mods = dayzquery.dayz_rules((ip, qport)).mods return [mod for mod in mods] except Exception as e: logger.critical(e) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 728bdc2..a77bf53 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -615,3 +615,6 @@ class Controller(GObject.GObject): def get_details(self, record: "Record") -> None: ConnectionManager(self).query_details(record) + + def get_modlist(self, record: "Record") -> None: + ConnectionManager(self).query_modlist(record) diff --git a/dzgui/managers/connection.py b/dzgui/managers/connection.py index bbd35ba..5c677de 100644 --- a/dzgui/managers/connection.py +++ b/dzgui/managers/connection.py @@ -4,7 +4,7 @@ import dzgui.api.servers as Servers from dzgui.managers.thread_man import call_on_thread, StoredFunc, ThreadingManager from dzgui.util.strings import api_warn_msg, dialog, server_timeout from dzgui.views.dialogs.generic import ExceptionDialog -from dzgui.views.dialogs.server_details import ServerDetailsDialog +from dzgui.views.dialogs.servers import ServerDetailsDialog, ServerModDialog import gi @@ -57,7 +57,7 @@ class ConnectionManager: @call_on_thread(dialog.querying) def query_details(self, record: Servers.Record) -> None: - details = Servers.details(record) + details = Servers.get_details(record) if details.success is False: self.thread_man.set_cleanup_func( StoredFunc(self._server_timeout), destroy_first=True @@ -67,6 +67,31 @@ class ConnectionManager: StoredFunc(self._present_details_dialog, details), destroy_first=True ) + @call_on_thread(dialog.querying) + def query_modlist(self, record: Servers.Record) -> None: + mods = Servers.get_rules(record) + # TODO: test if locally installed + # see api.mods for this same logic + # call controller to look up steam path from ConfigManager + if len(mods) == 0: + # TODO: message for no mods + # TODO: message for actual timeout + self.thread_man.set_cleanup_func( + StoredFunc(self._server_timeout), destroy_first=True + ) + return + alpha_mods = [[mod.name, str(mod.workshop_id), "Test"] for mod in mods] + alpha_mods.sort(key=lambda x: x[0]) + + self.thread_man.set_cleanup_func( + StoredFunc(self._present_modlist_dialog, alpha_mods), destroy_first=True + ) + + # TODO: data type is dayzquery.DayzMod + def _present_modlist_dialog(self, mods: list[str]) -> None: + dialog = ServerModDialog(self.controller, mods) + dialog.run() + def _present_details_dialog(self, details: Servers.Details) -> None: dialog = ServerDetailsDialog(self.controller, details) dialog.run() @@ -74,8 +99,3 @@ class ConnectionManager: def _server_timeout(self) -> None: dialog = ExceptionDialog(self.controller, server_timeout) dialog.run() - - # def _connection_failure(self) -> None: - # # FIXME: returns api warning, but this is more likely a localized server issue - # dialog = ExceptionDialog(self.controller, api_warn_msg) - # dialog.run() diff --git a/dzgui/managers/contextmenu.py b/dzgui/managers/contextmenu.py index 771e0e3..a2c54f6 100644 --- a/dzgui/managers/contextmenu.py +++ b/dzgui/managers/contextmenu.py @@ -81,8 +81,8 @@ class ContextMenuManager: record = self.treeview.get_record() self.controller.get_details(record) case ContextMenu.SHOW_MODS: - print("DEBUG: unimplemented") - pass + record = self.treeview.get_record() + self.controller.get_modlist(record) def copy_server_ip(self) -> None: if not isinstance(self.treeview, ServerTreeView): diff --git a/dzgui/model/model_factory.py b/dzgui/model/model_factory.py index 0c62ba8..4ac37fc 100644 --- a/dzgui/model/model_factory.py +++ b/dzgui/model/model_factory.py @@ -53,7 +53,7 @@ class LogCols: @dataclass(slots=True, frozen=True) class ServerModCols: name: str - uid: int # GObject.TYPE_INT64 + uid: str installed: str diff --git a/dzgui/views/dialogs/server_mods.py b/dzgui/views/dialogs/server_mods.py index 1a86a81..c00c16b 100644 --- a/dzgui/views/dialogs/server_mods.py +++ b/dzgui/views/dialogs/server_mods.py @@ -1,25 +1,48 @@ import gi +import textwrap +from typing import TYPE_CHECKING + +from dzgui.const.constants import EXPAND, FILL +from dzgui.model.model_factory import ModelFactory +from dzgui.util import strings +from dzgui.views.dialogs.generic import GenericDialog + gi.require_version("Gtk", "3.0") from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa -class ModDialog(GenericDialog): - def __init__(self, record: str): +if TYPE_CHECKING: + from dzgui.api.servers import Record + + +class ServerModDialog(GenericDialog): + def __init__(self, controller, mods: list[str]): + # TODO: center secondary text - msg = strings.workshop - super().__init__(textwrap.dedent(msg), Popup.MODLIST) + msg = textwrap.dedent(strings.workshop) + super().__init__( + controller=controller, + text=msg, + buttons="Gtk.ButtonsType.OK", + mtype="Gtk.MessageType.INFO", + secondary="", + ) + + self.mod_store = ModelFactory().make_server_mod_store() dialogBox = self.get_content_area() self.set_default_response(Gtk.ResponseType.OK) - self.set_size_request(800, 500) + self.set_size_request(800, 700) 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) + # set_surrounding_margins(self.scrollable, 20) + self.connect("response", self._on_response) self.view.connect("row-activated", self._on_row_activated) + self.view.set_model(self.mod_store) for i, column_title in enumerate(strings.server_mod_cols): renderer = Gtk.CellRendererText(ellipsize=Pango.EllipsizeMode.END) @@ -36,46 +59,20 @@ class ModDialog(GenericDialog): 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() + mod_count = len(mods) + self.set_markup(f"Modlist ({mod_count} mods") + for mod in mods: + self.mod_store.append(mod) - # 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.show_all() + + # TODO: handle response a la details dialog + + def _on_keypress(self, view: Gtk.TreeView, event: Gdk.EventKey) -> None: + if event.keyval == Gdk.KEY_Escape: 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) + return True + return False def _on_row_activated( self, @@ -91,4 +88,5 @@ class ModDialog(GenericDialog): 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) + print(mod_id) + # call_bash_func("open_workshop_page", mod_id) diff --git a/dzgui/views/dialogs/server_details.py b/dzgui/views/dialogs/servers.py similarity index 52% rename from dzgui/views/dialogs/server_details.py rename to dzgui/views/dialogs/servers.py index 3ef6948..d0abf8e 100644 --- a/dzgui/views/dialogs/server_details.py +++ b/dzgui/views/dialogs/servers.py @@ -1,14 +1,14 @@ -import gi +import textwrap +from typing import Self, TYPE_CHECKING -import dzgui.api.servers as Servers from dzgui.const.constants import EXPAND, FILL, NO_PADDING -from dzgui.const.enum import Popup +from dzgui.model.model_factory import ModelFactory from dzgui.util import css from dzgui.util import strings from dzgui.util.format import format_hyperlinks from dzgui.views.dialogs.generic import GenericDialog -from typing import Self, TYPE_CHECKING +import gi gi.require_version("Gtk", "3.0") from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa @@ -17,6 +17,7 @@ if TYPE_CHECKING: from dzgui.api.servers import Details +# TODO: make a more generic base class class ServerDetailsDialog(GenericDialog): def __init__(self, controller, details: "Details"): super().__init__( @@ -99,3 +100,95 @@ class ServerDetailsDialog(GenericDialog): col: Gtk.TreeViewColumn, ) -> None: self.destroy() + + def __init__(self, controller, details: "Details"): + super().__init__( + controller=controller, + text=details.name, + buttons=Gtk.ButtonsType.OK, + mtype=Gtk.MessageType.INFO, + secondary=strings.server_details, + ) + + +# TODO: data type is dayzquery.DayzMod + + +class ServerModDialog(GenericDialog): + def __init__(self, controller, mods: list[str]): + + # TODO: center secondary text + msg = textwrap.dedent(strings.workshop) + super().__init__( + controller=controller, + text=msg, + buttons=Gtk.ButtonsType.OK, + mtype=Gtk.MessageType.INFO, + secondary="", + ) + + self.mod_store = ModelFactory().make_server_mod_store() + + dialogBox = self.get_content_area() + self.set_default_response(Gtk.ResponseType.OK) + self.set_size_request(800, 700) + + 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.connect("response", self._on_response) + self.view.connect("row-activated", self._on_row_activated) + self.view.set_model(self.mod_store) + + 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) + # FIXME: do not recycle generic string vars + 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) + + mod_count = len(mods) + self.set_markup(f"Modlist ({mod_count} mods)") + for mod in mods: + self.mod_store.append(mod) + + self.show_all() + + def _on_response(self, dialog: Self, response: Gtk.ResponseType) -> None: + self.destroy() + + def _on_keypress(self, view: Gtk.TreeView, event: Gdk.EventKey) -> None: + if event.keyval == Gdk.KEY_Escape: + self.destroy() + return True + return False + + 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) + print(mod_id) + # call_bash_func("open_workshop_page", mod_id) From 55e6af8fe285455681bac8fc5692c61cb91881f0 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Tue, 31 Mar 2026 23:58:10 +0900 Subject: [PATCH 131/309] feat: abstract base server dialog --- CHANGELOG.md | 2 +- dzgui/api/mods.py | 4 +- dzgui/api/servers.py | 11 +-- dzgui/controllers/mc.py | 10 +- dzgui/data/CHANGELOG.md | 2 +- dzgui/main.py | 1 + dzgui/managers/connection.py | 28 ++++-- dzgui/util/format.py | 11 ++- dzgui/util/strings.py | 2 +- dzgui/views/dialogs/servers.py | 148 ++++++++++++------------------ dzgui/views/trees/tree_servers.py | 3 + 11 files changed, 110 insertions(+), 112 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ac452d..17bbf3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ - Redact API key in log table - Integrate add/connect widgets into main menu - Favorite/connect/LAN panels integrated with server views -- Colorized IP/ID validation +- Colorized IP/ID validation fields - Copy favorite server IP to clipboard - Set favorite server from tables - Detailed/copyable trace in critical error dialogs diff --git a/dzgui/api/mods.py b/dzgui/api/mods.py index 4a37fbd..c63819a 100644 --- a/dzgui/api/mods.py +++ b/dzgui/api/mods.py @@ -40,7 +40,7 @@ def get_local_mod_ids(steam_path: Path) -> list[int]: def get_local_mod_path(steam_path: Path) -> Path: - p = PeFile.get_app_path(steam_path / LIBRARYFOLDERS_PATH, APPID_DAYZ) + p = PeFile.get_app_path(steam_path / Path(LIBRARYFOLDERS_PATH), APPID_DAYZ) workshop_path = p / WORKSHOP_PATH return workshop_path @@ -109,7 +109,7 @@ def get_missing_mods(local: list, remote: list) -> list: return [mod for mod in remote if mod not in local] -# FIXME: steam path is missing +# FIXME: steam path is missing when comparing to local mods def get_server_modlist(server: Record, steam: Path) -> list: try: rules = dayzquery.dayz_rules((server.ip, server.qport)) diff --git a/dzgui/api/servers.py b/dzgui/api/servers.py index 5aee04f..ce5f6f5 100644 --- a/dzgui/api/servers.py +++ b/dzgui/api/servers.py @@ -234,7 +234,6 @@ class Ping: @dataclass(slots=True, frozen=True) class Details: - name: Union[str, None] data: Union[list, None] description: str success: bool @@ -282,16 +281,16 @@ def get_details(record: Record) -> Details: info = a2s.info((ip, qport)) name = info.server_name except TimeoutError: - return Details(None, None, default_str, False) + return Details(None, default_str, False) try: rules = dayzquery.dayz_rules((ip, qport)) - except TimeoutError: - return Details(None, None, default_str, False) + except Exception: + return Details(None, default_str, False) try: keywords = info.keywords.split(",") except AttributeError: - return Details(None, None, default_str, False) + return Details(None, default_str, False) battleye = strings.disabled if "battleye" in keywords: @@ -366,7 +365,7 @@ def get_details(record: Record) -> Details: ["Version", version], ] - return Details(name, rows, description, True) + return Details(rows, description, True) def ping(iteration: int, addr: list, qport: int, ping: int) -> Ping: diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index a77bf53..b1ae307 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -46,7 +46,7 @@ from dzgui.util import strings from dzgui.util.diag import write_diagnostic from dzgui.util.format import format_mods, format_player_count from dzgui.util.localize import number -from dzgui.util.open_links import open_user_workshop +from dzgui.util.open_links import open_user_workshop, open_workshop_page from dzgui.views.dialogs.filepicker import FilePicker from dzgui.views.dialogs.generic import ExceptionDialog, WaitDialog @@ -618,3 +618,11 @@ class Controller(GObject.GObject): def get_modlist(self, record: "Record") -> None: ConnectionManager(self).query_modlist(record) + + def get_server_name(self) -> str: + tv = self.get_active_treeview() + return tv.get_name() + + def open_workshop_page(self, mod: str) -> None: + cmd = self.query_config(Preferences.CLIENT) + open_workshop_page(mod, cmd) diff --git a/dzgui/data/CHANGELOG.md b/dzgui/data/CHANGELOG.md index 6ac452d..17bbf3a 100644 --- a/dzgui/data/CHANGELOG.md +++ b/dzgui/data/CHANGELOG.md @@ -14,7 +14,7 @@ - Redact API key in log table - Integrate add/connect widgets into main menu - Favorite/connect/LAN panels integrated with server views -- Colorized IP/ID validation +- Colorized IP/ID validation fields - Copy favorite server IP to clipboard - Set favorite server from tables - Detailed/copyable trace in critical error dialogs diff --git a/dzgui/main.py b/dzgui/main.py index 41b284b..2f8127c 100644 --- a/dzgui/main.py +++ b/dzgui/main.py @@ -91,6 +91,7 @@ def main() -> None: if has_new_config(XDG.config) is False: migrate_legacy_conf(XDG.config) migrate_cols_file(XDG.columns) + # TODO: copy notes file copy_state_files(xdg_paths["XDG_STATE_HOME"]) _format = ( diff --git a/dzgui/managers/connection.py b/dzgui/managers/connection.py index 5c677de..0cefaab 100644 --- a/dzgui/managers/connection.py +++ b/dzgui/managers/connection.py @@ -1,8 +1,11 @@ from typing import TYPE_CHECKING import dzgui.api.servers as Servers + +from dzgui.api.mods import get_local_mod_ids +from dzgui.const.enum import Preferences from dzgui.managers.thread_man import call_on_thread, StoredFunc, ThreadingManager -from dzgui.util.strings import api_warn_msg, dialog, server_timeout +from dzgui.util.strings import api_warn_msg, dialog, server_timeout, checkmark from dzgui.views.dialogs.generic import ExceptionDialog from dzgui.views.dialogs.servers import ServerDetailsDialog, ServerModDialog @@ -69,25 +72,32 @@ class ConnectionManager: @call_on_thread(dialog.querying) def query_modlist(self, record: Servers.Record) -> None: + mods = Servers.get_rules(record) - # TODO: test if locally installed - # see api.mods for this same logic - # call controller to look up steam path from ConfigManager + steam_path = self.controller.query_config(Preferences.DEFAULT) + local = get_local_mod_ids(steam_path) if len(mods) == 0: - # TODO: message for no mods - # TODO: message for actual timeout + # TODO: separate message for no mods + # TODO: separate message for actual timeout self.thread_man.set_cleanup_func( StoredFunc(self._server_timeout), destroy_first=True ) return - alpha_mods = [[mod.name, str(mod.workshop_id), "Test"] for mod in mods] + alpha_mods = [ + [ + mod.name, + str(mod.workshop_id), + checkmark if mod.workshop_id in local else "" + ] + for mod in mods + ] alpha_mods.sort(key=lambda x: x[0]) self.thread_man.set_cleanup_func( - StoredFunc(self._present_modlist_dialog, alpha_mods), destroy_first=True + StoredFunc(self._present_modlist_dialog, alpha_mods), + destroy_first=True, ) - # TODO: data type is dayzquery.DayzMod def _present_modlist_dialog(self, mods: list[str]) -> None: dialog = ServerModDialog(self.controller, mods) dialog.run() diff --git a/dzgui/util/format.py b/dzgui/util/format.py index 3d462f5..78c02bf 100644 --- a/dzgui/util/format.py +++ b/dzgui/util/format.py @@ -1,7 +1,7 @@ import re from dzgui.util.localize import number -from dzgui.util.strings import no_mods, no_servers +from dzgui.util.strings import no_mods, no_servers, workshop import gi @@ -51,6 +51,11 @@ def format_mods(size: int, mods: int) -> str: return f"Found {mods:n} {plural} taking up {l_size} MiB. {suffix}" +def format_server_mods(mods: int) -> str: + plural = pluralize("mods", mods) + return f"Found {mods:n} {plural}. {workshop}" + + def format_player_count(model: Gtk.TreeModel | None, control: list) -> str: players = 0 hits: int @@ -66,7 +71,9 @@ def format_player_count(model: Gtk.TreeModel | None, control: list) -> str: players_pretty = pluralize("players", players) hidden = control_total - hits hidden_pretty = f" ({hidden:n} hidden)" if hidden > 0 else "" - status = f"Showing {hits:n} {hits_pretty}{hidden_pretty}, {players:n} {players_pretty}." + status = ( + f"Showing {hits:n} {hits_pretty}{hidden_pretty}, {players:n} {players_pretty}." + ) return status diff --git a/dzgui/util/strings.py b/dzgui/util/strings.py index a737c30..a4d5145 100644 --- a/dzgui/util/strings.py +++ b/dzgui/util/strings.py @@ -48,7 +48,7 @@ _id = "ID" add_note = "Add note" edit_note = "Edit note" show_mods = "Show server-side mods" -show_details = "Server details" +show_details = "Show server details" refresh_players = "Refresh player count" open_workshop = "Open in Steam Workshop" delete_mod = "Delete mod" diff --git a/dzgui/views/dialogs/servers.py b/dzgui/views/dialogs/servers.py index d0abf8e..a74a9ba 100644 --- a/dzgui/views/dialogs/servers.py +++ b/dzgui/views/dialogs/servers.py @@ -1,12 +1,12 @@ -import textwrap from typing import Self, TYPE_CHECKING from dzgui.const.constants import EXPAND, FILL, NO_PADDING from dzgui.model.model_factory import ModelFactory from dzgui.util import css from dzgui.util import strings -from dzgui.util.format import format_hyperlinks +from dzgui.util.format import format_hyperlinks, format_server_mods from dzgui.views.dialogs.generic import GenericDialog +from dzgui.views.trees.tree_base import TreeView import gi @@ -15,34 +15,57 @@ from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa if TYPE_CHECKING: from dzgui.api.servers import Details + from dzgui.controllers.mc import Controller -# TODO: make a more generic base class -class ServerDetailsDialog(GenericDialog): - def __init__(self, controller, details: "Details"): +class ServerDialog(GenericDialog): + def __init__(self, controller: "Controller", title: str, secondary: str): super().__init__( controller=controller, - text=details.name, + text=title, buttons=Gtk.ButtonsType.OK, mtype=Gtk.MessageType.INFO, - secondary=strings.server_details, + secondary=secondary, ) - dialog_box = self.get_content_area() self.set_default_response(Gtk.ResponseType.OK) self.set_size_request(800, 700) - self.store = Gtk.ListStore(str, str, Pango.Weight) + self.view = TreeView(controller) + self.view.set_fixed_height_mode(True) - 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) + self.connect("response", self._on_response) self.view.connect("key-press-event", self._on_keypress) + self.scrollable_tree = Gtk.ScrolledWindow() + self.scrollable_tree.add(self.view) + self.scrollable_tree.set_size_request(700, 400) + + self.content = self.get_content_area() + self.content.pack_start(self.scrollable_tree, EXPAND, FILL, 0) + + def pack(self, widget: Gtk.Widget) -> None: + self.content.pack_start(widget, EXPAND, FILL, NO_PADDING) + + def _on_keypress(self, view: Gtk.TreeView, event: Gdk.EventKey) -> None: + # NOTE: ESC normally unfocuses treeview instead of destroying dialog + if event.keyval == Gdk.KEY_Escape: + self.destroy() + return True + return False + + def _on_response(self, dialog: Self, response: Gtk.ResponseType) -> None: + self.destroy() + + +class ServerDetailsDialog(ServerDialog): + def __init__(self, controller, details: "Details"): + name = controller.get_server_name() + super().__init__(controller, strings.server_details, name) + + self.store = Gtk.ListStore(str, str, Pango.Weight) + 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: @@ -55,11 +78,7 @@ class ServerDetailsDialog(GenericDialog): 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) - - # TODO: center header text + # TODO: make "Server message" text boldface scrollable_message = Gtk.ScrolledWindow() desc = Gtk.Label(label=strings.server_message, valign=Gtk.Align.START) css.add_class(desc, "details-heading") @@ -71,8 +90,7 @@ class ServerDetailsDialog(GenericDialog): box.add(el) scrollable_message.add(box) - dialog_box.pack_start(scrollable_tree, EXPAND, FILL, NO_PADDING) - dialog_box.pack_start(scrollable_message, EXPAND, FILL, NO_PADDING) + self.pack(scrollable_message) for row in details.data: self.store.append(row + [Pango.Weight.BOLD]) @@ -81,18 +99,8 @@ class ServerDetailsDialog(GenericDialog): text = format_hyperlinks(text) self.description.set_markup(text) - self.connect("response", self._on_response) self.show_all() - def _on_keypress(self, view: Gtk.TreeView, event: Gdk.EventKey) -> None: - if event.keyval == Gdk.KEY_Escape: - self.destroy() - return True - return False - - def _on_response(self, dialog: Self, response: Gtk.ResponseType) -> None: - self.destroy() - def _on_row_activated( self, treeview: Gtk.TreeView, @@ -101,48 +109,19 @@ class ServerDetailsDialog(GenericDialog): ) -> None: self.destroy() - def __init__(self, controller, details: "Details"): - super().__init__( - controller=controller, - text=details.name, - buttons=Gtk.ButtonsType.OK, - mtype=Gtk.MessageType.INFO, - secondary=strings.server_details, - ) - -# TODO: data type is dayzquery.DayzMod - - -class ServerModDialog(GenericDialog): +class ServerModDialog(ServerDialog): def __init__(self, controller, mods: list[str]): - # TODO: center secondary text - msg = textwrap.dedent(strings.workshop) - super().__init__( - controller=controller, - text=msg, - buttons=Gtk.ButtonsType.OK, - mtype=Gtk.MessageType.INFO, - secondary="", - ) + name = controller.get_server_name() + super().__init__(controller, strings.modlist, name) + self.controller = controller self.mod_store = ModelFactory().make_server_mod_store() - dialogBox = self.get_content_area() - self.set_default_response(Gtk.ResponseType.OK) - self.set_size_request(800, 700) - - 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.connect("response", self._on_response) - self.view.connect("row-activated", self._on_row_activated) + self.view.set_headers_visible(True) self.view.set_model(self.mod_store) + 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) @@ -158,37 +137,28 @@ class ServerModDialog(GenericDialog): column.set_fixed_width(200) case _: pass - dialogBox.pack_end(self.scrollable, EXPAND, FILL, 0) mod_count = len(mods) - self.set_markup(f"Modlist ({mod_count} mods)") + self._set_footer(mod_count) + for mod in mods: self.mod_store.append(mod) self.show_all() - def _on_response(self, dialog: Self, response: Gtk.ResponseType) -> None: - self.destroy() - - def _on_keypress(self, view: Gtk.TreeView, event: Gdk.EventKey) -> None: - if event.keyval == Gdk.KEY_Escape: - self.destroy() - return True - return False + def _set_footer(self, mods: int) -> None: + footer = Gtk.Label( + valign=Gtk.Align.START, justify=Gtk.Justification.CENTER, wrap=True + ) + footer_text = format_server_mods(mods) + footer.set_text(footer_text) + self.pack(footer) def _on_row_activated( self, treeview: Gtk.TreeView, - tree_iter: Gtk.TreeIter, + path: Gtk.TreePath, 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) - print(mod_id) - # call_bash_func("open_workshop_page", mod_id) + mod = self.view.get_value_at_index(1) + self.controller.open_workshop_page(mod) diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index 3e2c380..3e58422 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -291,6 +291,9 @@ class ServerTreeView(ContextMixin, TreeView): return self.start_distcalc() + def get_name(self) -> str: + return self.get_value_at_index(0) + def get_simplified_ip(self) -> str: addr = self.get_value_at_index(7) qport = self.get_value_at_index(8) From 560349e16bca46218df18e958d6d07e757c68900 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 1 Apr 2026 00:14:48 +0900 Subject: [PATCH 132/309] feat: reimplement notes dialog (WIP) --- CHANGELOG.md | 2 ++ dzgui/data/CHANGELOG.md | 2 ++ dzgui/managers/contextmenu.py | 5 +++++ dzgui/views/dialogs/generic.py | 7 +++++-- dzgui/views/dialogs/servers.py | 4 ++-- 5 files changed, 16 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 17bbf3a..5c4b911 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ - Atomic map filters per server context - Use concurrency when checking stale mods (performance uplift) - Copy version by clicking label +- Add server name to server mod dialogs ## Changed - Reduce padding on keys button @@ -37,6 +38,7 @@ ## Fixed - Longstanding issue with left clicks not registering as tree selection changes after spamming keyboard input +- Center server title text on server dialogs - Rare segfaults when changing maps (threading) - Moved dialogs out of threads diff --git a/dzgui/data/CHANGELOG.md b/dzgui/data/CHANGELOG.md index 17bbf3a..5c4b911 100644 --- a/dzgui/data/CHANGELOG.md +++ b/dzgui/data/CHANGELOG.md @@ -26,6 +26,7 @@ - Atomic map filters per server context - Use concurrency when checking stale mods (performance uplift) - Copy version by clicking label +- Add server name to server mod dialogs ## Changed - Reduce padding on keys button @@ -37,6 +38,7 @@ ## Fixed - Longstanding issue with left clicks not registering as tree selection changes after spamming keyboard input +- Center server title text on server dialogs - Rare segfaults when changing maps (threading) - Moved dialogs out of threads diff --git a/dzgui/managers/contextmenu.py b/dzgui/managers/contextmenu.py index a2c54f6..80f2d2e 100644 --- a/dzgui/managers/contextmenu.py +++ b/dzgui/managers/contextmenu.py @@ -33,7 +33,12 @@ class ContextMenuManager: match action: # UNTHREADED case ContextMenu.ADD_NOTE: + from dzgui.views.dialogs.note import NoteDialog + + dialog = NoteDialog(self.controller) + dialog.run() print("DEBUG: unimplemented") + # dialog = NoteDialog() # spawn edit dialog and update cache, notes file pass case ContextMenu.COPY_LOG_CLIPBOARD: diff --git a/dzgui/views/dialogs/generic.py b/dzgui/views/dialogs/generic.py index 062e839..606eb7b 100644 --- a/dzgui/views/dialogs/generic.py +++ b/dzgui/views/dialogs/generic.py @@ -1,7 +1,6 @@ from typing import Literal, Self, TYPE_CHECKING -from dzgui.const.constants import NO_EXPAND, NO_FILL, EXPAND, FILL -from dzgui.const.enum import Popup, ButtonType, NotebookPage +from dzgui.const.constants import NO_EXPAND, NO_FILL, NO_PADDING, EXPAND, FILL from dzgui.util import strings from dzgui.views.components.buttons import ClipboardButton @@ -51,6 +50,10 @@ class GenericDialog(Gtk.MessageDialog): self.outer.set_margin_start(30) self.outer.set_margin_end(30) + def pack(self, widget: Gtk.Widget) -> None: + content = self.get_content_area() + content.pack_start(widget, EXPAND, FILL, NO_PADDING) + class ConfirmationDialog(GenericDialog): def __init__(self, controller: "Controller", secondary: str): diff --git a/dzgui/views/dialogs/servers.py b/dzgui/views/dialogs/servers.py index a74a9ba..7b1a883 100644 --- a/dzgui/views/dialogs/servers.py +++ b/dzgui/views/dialogs/servers.py @@ -44,8 +44,8 @@ class ServerDialog(GenericDialog): self.content = self.get_content_area() self.content.pack_start(self.scrollable_tree, EXPAND, FILL, 0) - def pack(self, widget: Gtk.Widget) -> None: - self.content.pack_start(widget, EXPAND, FILL, NO_PADDING) + # def pack(self, widget: Gtk.Widget) -> None: + # self.content.pack_start(widget, EXPAND, FILL, NO_PADDING) def _on_keypress(self, view: Gtk.TreeView, event: Gdk.EventKey) -> None: # NOTE: ESC normally unfocuses treeview instead of destroying dialog From fa12fc36f9c5fb8030091f765f89d0ff5479e290 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 8 Apr 2026 01:24:17 +0900 Subject: [PATCH 133/309] feat: dynamic notes dialog (WIP) --- dzgui/controllers/mc.py | 21 ++++++++++++++++++++- dzgui/managers/contextmenu.py | 7 ++----- dzgui/model/servers.py | 7 +++++++ dzgui/views/base.py | 1 + dzgui/views/components/entry.py | 27 +++++++++++++++++++++------ 5 files changed, 51 insertions(+), 12 deletions(-) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index b1ae307..6b636f8 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -103,6 +103,7 @@ class StoredFunc: class Controller(GObject.GObject): def __init__(self) -> None: self.dist_cache: dict[str, "Haversine", "ServerTab"] = {} + self.notes_cache: dict[str, str] = {} self.mediator = AppNavigation() self.prefs: UserPrefs self.cleanup_func: StoredFunc = None @@ -610,7 +611,6 @@ class Controller(GObject.GObject): ConnectionManager(self).connect_by_ip(addr) def connect_by_record(self, record: "Record") -> None: - # NOTE: calls on thread ConnectionManager(self).connect_by_record(record) def get_details(self, record: "Record") -> None: @@ -626,3 +626,22 @@ class Controller(GObject.GObject): def open_workshop_page(self, mod: str) -> None: cmd = self.query_config(Preferences.CLIENT) open_workshop_page(mod, cmd) + + def get_note(self) -> None: + # TODO: load from file + tv = self.get_active_treeview() + record = tv.get_record_string() + try: + text = self.notes_cache[record] + return text + except Exception: + return "" + + def add_note(self, note: str) -> None: + print(note) + + def delete_note(self) -> None: + tv = self.get_active_treeview() + record = tv.get_record_string() + del self.notes_cache[record] + # TODO: serialize into file diff --git a/dzgui/managers/contextmenu.py b/dzgui/managers/contextmenu.py index 80f2d2e..590fd18 100644 --- a/dzgui/managers/contextmenu.py +++ b/dzgui/managers/contextmenu.py @@ -7,6 +7,7 @@ from dzgui.util import strings from dzgui.util.clip import copy_clipboard from dzgui.util.open_links import open_workshop_page +from dzgui.views.dialogs.note import NoteDialog from dzgui.views.trees.tree_servers import ServerTreeView @@ -33,14 +34,9 @@ class ContextMenuManager: match action: # UNTHREADED case ContextMenu.ADD_NOTE: - from dzgui.views.dialogs.note import NoteDialog - dialog = NoteDialog(self.controller) dialog.run() print("DEBUG: unimplemented") - # dialog = NoteDialog() - # spawn edit dialog and update cache, notes file - pass case ContextMenu.COPY_LOG_CLIPBOARD: self.copy_log() case ContextMenu.COPY_SERVER_IP: @@ -48,6 +44,7 @@ class ContextMenuManager: case ContextMenu.COPY_SERVER_NAME: self.copy_server_name() case ContextMenu.DELETE_MOD: + print("DEBUG: unimplemented") # self.delete_single_mod() # TODO: connect to emitter automatically # Gtk.TreeModel, row-inserted/row-deleted diff --git a/dzgui/model/servers.py b/dzgui/model/servers.py index 01c6acc..39be124 100644 --- a/dzgui/model/servers.py +++ b/dzgui/model/servers.py @@ -187,6 +187,8 @@ class ServerModelManager: parsed = Servers.parse_json(servers) self._push_data(parsed) + # TODO: add cleanup? + # FIXME: use "adding server" string @call_on_thread(dialog.querying) def add_by_id(self, addr: str) -> None: config_man = self.controller.get_config_man() @@ -194,11 +196,16 @@ class ServerModelManager: res = Servers.query_by_id(addr, key) self._parse_single_record(res) + # TODO: add cleanup? + # FIXME: use "adding server" string @call_on_thread(dialog.querying) def add_by_ip(self, addr: str) -> None: res = Servers.query_by_ip(addr) self._parse_single_record(res) + # TODO: add cleanup? + # FIXME: use "adding server" string + @call_on_thread(dialog.querying) def add_by_record(self, record: Servers.Record) -> None: res = Servers.query_by_record(record) self._parse_single_record(res) diff --git a/dzgui/views/base.py b/dzgui/views/base.py index 530d0bf..22ecab6 100644 --- a/dzgui/views/base.py +++ b/dzgui/views/base.py @@ -366,6 +366,7 @@ class Grid(Gtk.Grid): class App(Gtk.Application): def __init__(self, prefs: "UserPrefs") -> None: + GLib.set_prgname(APP_NAME) MainController.set_prefs(prefs) diff --git a/dzgui/views/components/entry.py b/dzgui/views/components/entry.py index ac2fcc6..3f0e592 100644 --- a/dzgui/views/components/entry.py +++ b/dzgui/views/components/entry.py @@ -36,11 +36,16 @@ def validate_ip_or_id(text: str) -> bool: class ValidatedEntry(Gtk.Entry): - def __init__(self, controller: "Controller", func: Callable) -> None: + def __init__( + self, + controller: "Controller", + func: Callable, + placeholder_text: str = "", + tooltip_text: str = "", + ) -> None: + # TODO: tooltip text should not be hardcoded super().__init__( - hexpand=True, - placeholder_text=connect_panel.placeholder, - tooltip_text=connect_panel.entry_tooltip, + hexpand=True, placeholder_text=placeholder_text, tooltip_text=tooltip_text ) self.func: Callable @@ -116,13 +121,23 @@ class ValidatedEntry(Gtk.Entry): class IpEntry(ValidatedEntry): def __init__(self, controller: "Controller") -> None: - super().__init__(controller, func=validate_ip_or_id) + super().__init__( + controller, + func=validate_ip_or_id, + placeholder_text=connect_panel.placeholder, + tooltip_text=connect_panel.entry_tooltip, + ) self.set_placeholder_text(connect_panel.placeholder) self.set_tooltip_text(connect_panel.entry_tooltip) class PortEntry(ValidatedEntry): def __init__(self, controller: "Controller") -> None: - super().__init__(controller, func=validate_port) + super().__init__( + controller, + func=validate_port, + placeholder_text=connect_panel.placeholder, + tooltip_text=connect_panel.entry_tooltip, + ) self.set_placeholder_text(lan_panel.placeholder) self.set_tooltip_text(lan_panel.entry_tooltip) From 600fea8a823a8f8640cc39fcf2d1f804e6ce195b Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 11 Apr 2026 22:35:26 +0900 Subject: [PATCH 134/309] feat: notes manager (WIP) --- dzgui/managers/notes.py | 40 +++++++++++++++++++ dzgui/views/dialogs/note.py | 77 +++++++++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 dzgui/managers/notes.py create mode 100644 dzgui/views/dialogs/note.py diff --git a/dzgui/managers/notes.py b/dzgui/managers/notes.py new file mode 100644 index 0000000..942c68f --- /dev/null +++ b/dzgui/managers/notes.py @@ -0,0 +1,40 @@ +import json +import logging + +from typing import TYPE_CHECKING +from dzgui.util._json import read_json + +import gi + +gi.require_version("Gtk", "3.0") + +if TYPE_CHECKING: + from dzgui.controllers.mc import Controller + from pathlib import Path + +logger = logging.getLogger(__name__) + + +class NoteManager: + def __init__(self, controller: "Controller", path: "Path") -> None: + self.notes_path = path + self.cache: dict[str, str] = read_json(self.notes_path) + + def add_note(self, server: str, note: str) -> None: + self.cache[server] = note + self.write_file() + + def delete_note(self, server: str) -> None: + del self.cache[server] + self.write_file() + + def get_note(self, server: str) -> str: + try: + text = self.cache[server] + return text + except Exception: + return "" + + def write_file(self) -> None: + j = json.dumps(self.cache, indent=2) + self.notes_path.write_text(j) diff --git a/dzgui/views/dialogs/note.py b/dzgui/views/dialogs/note.py new file mode 100644 index 0000000..3f3acb4 --- /dev/null +++ b/dzgui/views/dialogs/note.py @@ -0,0 +1,77 @@ +from typing import Self, TYPE_CHECKING + +from dzgui.views.components.entry import ValidatedEntry +from dzgui.views.dialogs.generic import GenericDialog + +import gi # noqa E402 + +gi.require_version("Gtk", "3.0") +from gi.repository import Gdk, Gtk # noqa E402 + +if TYPE_CHECKING: + from dzgui.controllers.mc import Controller + + +class NoteDialog(GenericDialog): + def __init__(self, controller: "Controller"): + # TODO: add to strings + title = "Add a short note/reminder." + secondary = "Limit 30 chars" + super().__init__( + controller=controller, + text=title, + buttons=Gtk.ButtonsType.NONE, + mtype=Gtk.MessageType.INFO, + secondary=secondary, + ) + + self.controller = controller + + delete_button = Gtk.Button(label="Delete note") + delete_button.set_sensitive(False) + self.add_action_widget(delete_button, Gtk.ResponseType.CANCEL) + self.add_button("OK", Gtk.ResponseType.OK) + self.ok = self.get_widget_for_response(Gtk.ResponseType.OK) + self.ok.grab_default() + + self.entry = ValidatedEntry(controller, self.validate) + self.entry.set_max_length(30) + self.entry.set_size_request(250, 0) + self.pack(self.entry) + + note = self.controller.get_note() + self.entry.set_text(note) + if len(note) > 0: + delete_button.set_sensitive(True) + + self.show_all() + # NOTE: explicitly deselect region and move to end of line + self.entry.set_position(-1) + + self.entry.set_activates_default(True) + self.connect("response", self._on_response) + + def validate(self, text: str) -> None: + # FIXME: not checking for text deletion, e.g., ctrl-a, ctrl-x + if text.isspace(): + sensitive = False + else: + sensitive = True + self.ok.set_sensitive(sensitive) + return sensitive + + def _on_response(self, dialog: Self, response: Gtk.ResponseType) -> None: + match response: + case Gtk.ResponseType.DELETE_EVENT: + self.destroy() + case Gtk.ResponseType.OK: + note = self.entry.get_text() + # FIXME: handle deleting existing note, going from N chars to 0 chars and clicking OK + if len(note) > 0: + self.controller.add_note(note) + self.destroy() + case Gtk.ResponseType.CANCEL: + self.controller.delete_note() + self.destroy() + case Gtk.ResponseType.CANCEL | Gtk.ResponseType.CLOSE: + self.destroy() From 82cddf261e71bf767c700dd684441c18c99c20d7 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 11 Apr 2026 22:35:52 +0900 Subject: [PATCH 135/309] feat: dynamic tooltip --- dzgui/controllers/mc.py | 29 ++++++++++++++++---------- dzgui/managers/connection.py | 4 ++-- dzgui/managers/contextmenu.py | 1 - dzgui/managers/thread_man.py | 2 +- dzgui/model/model_factory.py | 2 +- dzgui/model/servers.py | 2 +- dzgui/util/_json.py | 8 +------- dzgui/views/mixins/context_mixin.py | 9 ++++---- dzgui/views/trees/tree_servers.py | 32 +++++++++++++++++++++++++++++ 9 files changed, 61 insertions(+), 28 deletions(-) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 6b636f8..165dbd7 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -39,6 +39,7 @@ from dzgui.controllers.emitter import Emitter from dzgui.managers.config import ConfigManager from dzgui.managers.connection import ConnectionManager from dzgui.managers.contextmenu import ContextMenuManager +from dzgui.managers.notes import NoteManager from dzgui.model.proxy_model import ProxyModelManager from dzgui.model.servers import ServerModelManager from dzgui.model.model_factory import ModelFactory @@ -103,8 +104,8 @@ class StoredFunc: class Controller(GObject.GObject): def __init__(self) -> None: self.dist_cache: dict[str, "Haversine", "ServerTab"] = {} - self.notes_cache: dict[str, str] = {} self.mediator = AppNavigation() + self.prefs: UserPrefs self.cleanup_func: StoredFunc = None @@ -150,6 +151,7 @@ class Controller(GObject.GObject): def set_prefs(self, prefs: UserPrefs) -> None: self.config_man = ConfigManager(prefs) + self.notes_man = NoteManager(self, prefs.paths.notes) self.prefs = prefs def query_config(self, key: Preferences) -> str | bool | list: @@ -627,21 +629,26 @@ class Controller(GObject.GObject): cmd = self.query_config(Preferences.CLIENT) open_workshop_page(mod, cmd) - def get_note(self) -> None: - # TODO: load from file + def has_note(self) -> bool: + note = self.get_note() + if len(note) > 0: + return True + return False + + def get_note_by_record(self, record: str) -> str: + return self.notes_man.get_note(record) + + def get_note(self) -> str: tv = self.get_active_treeview() record = tv.get_record_string() - try: - text = self.notes_cache[record] - return text - except Exception: - return "" + return self.notes_man.get_note(record) def add_note(self, note: str) -> None: - print(note) + tv = self.get_active_treeview() + record = tv.get_record_string() + self.notes_man.add_note(record, note) def delete_note(self) -> None: tv = self.get_active_treeview() record = tv.get_record_string() - del self.notes_cache[record] - # TODO: serialize into file + self.notes_man.delete_note(record) diff --git a/dzgui/managers/connection.py b/dzgui/managers/connection.py index 0cefaab..b46f6c5 100644 --- a/dzgui/managers/connection.py +++ b/dzgui/managers/connection.py @@ -5,7 +5,7 @@ import dzgui.api.servers as Servers from dzgui.api.mods import get_local_mod_ids from dzgui.const.enum import Preferences from dzgui.managers.thread_man import call_on_thread, StoredFunc, ThreadingManager -from dzgui.util.strings import api_warn_msg, dialog, server_timeout, checkmark +from dzgui.util.strings import dialog, server_timeout, checkmark from dzgui.views.dialogs.generic import ExceptionDialog from dzgui.views.dialogs.servers import ServerDetailsDialog, ServerModDialog @@ -87,7 +87,7 @@ class ConnectionManager: [ mod.name, str(mod.workshop_id), - checkmark if mod.workshop_id in local else "" + checkmark if mod.workshop_id in local else "", ] for mod in mods ] diff --git a/dzgui/managers/contextmenu.py b/dzgui/managers/contextmenu.py index 590fd18..1f698ba 100644 --- a/dzgui/managers/contextmenu.py +++ b/dzgui/managers/contextmenu.py @@ -36,7 +36,6 @@ class ContextMenuManager: case ContextMenu.ADD_NOTE: dialog = NoteDialog(self.controller) dialog.run() - print("DEBUG: unimplemented") case ContextMenu.COPY_LOG_CLIPBOARD: self.copy_log() case ContextMenu.COPY_SERVER_IP: diff --git a/dzgui/managers/thread_man.py b/dzgui/managers/thread_man.py index 3aa1283..c3997c7 100644 --- a/dzgui/managers/thread_man.py +++ b/dzgui/managers/thread_man.py @@ -4,7 +4,7 @@ import threading from typing import Any from functools import wraps -from typing import Callable, Optional +from typing import Callable from dzgui.views.dialogs.generic import WaitDialog diff --git a/dzgui/model/model_factory.py b/dzgui/model/model_factory.py index 4ac37fc..163116c 100644 --- a/dzgui/model/model_factory.py +++ b/dzgui/model/model_factory.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Any, Sequence, Union +from typing import Any, Sequence from dzgui.const.enum import HELP_MENU_ROWS from dzgui.util.redact import redact_log diff --git a/dzgui/model/servers.py b/dzgui/model/servers.py index 39be124..69e3345 100644 --- a/dzgui/model/servers.py +++ b/dzgui/model/servers.py @@ -2,7 +2,7 @@ import logging import threading from concurrent.futures import as_completed from concurrent.futures import ThreadPoolExecutor -from typing import Union, TYPE_CHECKING +from typing import TYPE_CHECKING import dzgui.api.servers as Servers from dzgui.const.enum import FilterMode, Preferences, ServerTab diff --git a/dzgui/util/_json.py b/dzgui/util/_json.py index 9961c53..4a46bdf 100644 --- a/dzgui/util/_json.py +++ b/dzgui/util/_json.py @@ -16,12 +16,6 @@ def read_json(path: Path) -> dict: def write_json(data: dict, path: Path) -> None: try: j = json.dumps(data, indent=2) + path.write_text(j) 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 diff --git a/dzgui/views/mixins/context_mixin.py b/dzgui/views/mixins/context_mixin.py index bcb9c72..a8d2ef6 100644 --- a/dzgui/views/mixins/context_mixin.py +++ b/dzgui/views/mixins/context_mixin.py @@ -1,6 +1,7 @@ import logging from dzgui.const.enum import ContextMenu from dzgui.util.keys import is_navkey +from dzgui.util.strings import edit_note from dzgui.views.trees.tree_base import TreeView import gi @@ -65,11 +66,11 @@ class ContextMixin(TreeView): if row == ContextMenu.SHOW_MODS: item.set_sensitive(self.is_modded()) + if row == ContextMenu.ADD_NOTE: + if self.controller.has_note(): + item.set_label(edit_note) + return item - # TODO: notes cache - # if row == ContextMenu.ADD_NOTE: - # if self.get_record_string() in notes_cache: - # item.set_label(strings.edit_note) def _process_button_event(self, event: Gdk.EventButton) -> bool: try: diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index 3e58422..8ba718a 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -121,9 +121,41 @@ class ServerTreeView(ContextMixin, TreeView): self.connect("map", self._on_map) self.connect("unmap", self._on_unmap) + self.set_has_tooltip(True) + self.connect("query-tooltip", self._on_tooltip) + # TODO: why is this being saved? self.thread = None + def _on_tooltip( + self, + widget: Gtk.Widget, + x: int, + y: int, + keyboard_mode: bool, + tooltip: Gtk.Tooltip, + ) -> bool: + """ + Present record data for the hovered row even if it is unfocused + """ + coords = widget.convert_widget_to_bin_window_coords(x, y) + path = self.get_path_at_pos(coords.bx, coords.by) + if path is None: + return False + + model = self.get_model() + tree_iter = model.get_iter(path[0]) + ip = model.get_value(tree_iter, 7) + qport = model.get_value(tree_iter, 8) + addr = ip + ":" + str(qport) + note = self.controller.get_note_by_record(addr) + + if len(note) > 0: + tooltip.set_text(note) + self.set_tooltip_row(tooltip, path[0]) + return True + return False + def start_queue_checker(self) -> None: self.queue_id = GLib.timeout_add(QUEUE_CHECK_DELAY, self._check_result_queue) From 933c3b19b0b32209359475240144c93df530eb18 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 11 Apr 2026 22:51:16 +0900 Subject: [PATCH 136/309] chore: drop debug logic from treeview --- dzgui/views/base.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/dzgui/views/base.py b/dzgui/views/base.py index 22ecab6..de6476b 100644 --- a/dzgui/views/base.py +++ b/dzgui/views/base.py @@ -160,9 +160,6 @@ class OuterWindow(Gtk.Window): # TODO: POC, trigger page change here MainController.loaded = True - # TODO: debug flag, perhaps drop - if MainController.get_prefs().is_debug: - return MainController.populate_model(MainController.get_active_treeview()) def _on_delete_event(self, window: "OuterWindow", event: Gdk.EventKey) -> None: From 39abbf3a02a29cc5e064046acfd928e4ba3f7a86 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 12 Apr 2026 02:20:50 +0900 Subject: [PATCH 137/309] feat: lazy load ping --- CHANGELOG.md | 1 + dzgui/api/servers.py | 38 ++++------------- dzgui/data/CHANGELOG.md | 1 + dzgui/model/servers.py | 4 ++ dzgui/views/trees/tree_servers.py | 71 +++++++++++++++++-------------- 5 files changed, 52 insertions(+), 63 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c4b911..c77d067 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ - Sidebar buttos do not steal focus - Copy IP copies IP:queryport only instead of IP:gameport:queryport, mimics syntax needed by add by ip method - Load new model into view without flushing +- Cull servers with abnormal queue values (integer overflow: 2147483647) ## Fixed - Longstanding issue with left clicks not registering as tree selection changes after spamming keyboard input diff --git a/dzgui/api/servers.py b/dzgui/api/servers.py index ce5f6f5..4886118 100644 --- a/dzgui/api/servers.py +++ b/dzgui/api/servers.py @@ -140,6 +140,8 @@ def parse_json(json: list) -> list: try: r = row["gametype"].split("lqs") queue = r[1].split(",")[0] + if int(queue) > 255: + continue except IndexError: queue = 0 @@ -225,13 +227,6 @@ class Res: 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] @@ -279,7 +274,6 @@ def get_details(record: Record) -> Details: try: info = a2s.info((ip, qport)) - name = info.server_name except TimeoutError: return Details(None, default_str, False) try: @@ -368,31 +362,15 @@ def get_details(record: Record) -> Details: return Details(rows, description, True) -def ping(iteration: int, addr: list, qport: int, ping: int) -> Ping: - # addr = row[7] - # qport = row[8] - - res = None - - if ping != 9999: - return Ping(addr, iteration, ping) - - try: - ip = addr.split(":")[0] - except IndexError: - ping = 9999 - +def ping(ip: str, qport: int) -> int: try: res = query_direct(ip, qport, 0.5) + if res is None: + return 9999 + else: + return res["ping"] except Exception: - pass - - if res is None: - ping = 9999 - else: - ping = res["ping"] - - return Ping(addr, iteration, ping) + return 9999 def query_api(key: str, appid: int, param: str) -> Res: diff --git a/dzgui/data/CHANGELOG.md b/dzgui/data/CHANGELOG.md index 5c4b911..c77d067 100644 --- a/dzgui/data/CHANGELOG.md +++ b/dzgui/data/CHANGELOG.md @@ -35,6 +35,7 @@ - Sidebar buttos do not steal focus - Copy IP copies IP:queryport only instead of IP:gameport:queryport, mimics syntax needed by add by ip method - Load new model into view without flushing +- Cull servers with abnormal queue values (integer overflow: 2147483647) ## Fixed - Longstanding issue with left clicks not registering as tree selection changes after spamming keyboard input diff --git a/dzgui/model/servers.py b/dzgui/model/servers.py index 69e3345..aa88a99 100644 --- a/dzgui/model/servers.py +++ b/dzgui/model/servers.py @@ -118,6 +118,10 @@ class ServerModelManager: j = res.json servers += j["response"]["servers"] + import json + + with open("test.txt", "w") as f: + json.dump(servers, f) parsed = Servers.parse_json(servers) self._push_data(parsed) diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index 8ba718a..e7415c4 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -58,6 +58,8 @@ class ServerTreeView(ContextMixin, TreeView): self.handler_id: int self.queue = Queue() + self.seen_cache = [] + prefs = self.controller.get_prefs() columns = prefs.paths.columns try: @@ -93,10 +95,10 @@ class ServerTreeView(ContextMixin, TreeView): else: w = width_map[column_title] column.set_fixed_width(w) - # if column_title == "Ping": - # self.fancy_col = column - # self.fancy_rend = renderer - # column.set_cell_data_func(renderer, self._get_ping) + if column_title == "Ping": + # self.fancy_col = column + # self.fancy_rend = renderer + column.set_cell_data_func(renderer, self._get_ping) # if column_title == "Name": # column.set_fixed_width(800) # if column_title == "Map": @@ -351,39 +353,42 @@ class ServerTreeView(ContextMixin, TreeView): def set_loaded(self, status: bool) -> None: self.loaded = status - # @deprecated("currently unused") - # def _get_ping( - # self, - # column: Gtk.TreeViewColumn, - # cell: Gtk.CellRendererText, - # model: Gtk.TreeModel, - # it: Gtk.TreeIter, - # data: Any, - # ): - # def ping_server(model, _iter, ip: str, qport: int, ping: int): - # res = Ping(0, ip, qport, ping) - # ping = res.ping - # GLib.idle_add(lambda: model.set(_iter, ping_column, ping)) + def _get_ping( + self, + column: Gtk.TreeViewColumn, + cell: Gtk.CellRendererText, + model: Gtk.TreeModel, + _iter: Gtk.TreeIter, + data: Any, + ): + def ping_server(model, _iter: Gtk.TreeIter, ip: str, qport: int, ping: int): + from dzgui.api.servers import ping - # addr_column = 7 - # qport_column = 8 - # ping_column = 9 + _ping = ping(ip, qport, ping) + GLib.idle_add(lambda: model.set(_iter, ping_column, _ping)) - # addr = model.get_value(it, addr_column).split(":")[0] - # qport = model.get_value(it, qport_column) - # ping = model.get_value(it, ping_column) - # ip = f"{addr}:{qport}" + addr_column = 7 + qport_column = 8 + ping_column = 9 - # if ip in self.seen_cache: - # return - # self.seen_cache.append(ip) + addr = model.get_value(_iter, addr_column).split(":") + ip = addr[0] + gameport = addr[1] + qport = model.get_value(_iter, qport_column) + # ping = model.get_value(_iter, ping_column) + record = f"{addr}:{gameport}:{qport}" - # thread = threading.Thread( - # daemon=True, - # target=ping_server, - # args=(model, it, addr, qport, ping), - # ) - # thread.start() + if record in self.seen_cache: + return + self.seen_cache.append(record) + + # TODO: use thread manager + thread = threading.Thread( + daemon=True, + target=ping_server, + args=(model, _iter, ip, qport), + ) + thread.start() @deprecated("Currently unused") def _lazy_load( From 81a6d03a59d55aed5a145cf84811572f55b3eeb1 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 12 Apr 2026 02:22:09 +0900 Subject: [PATCH 138/309] fix: remove unused arguments --- dzgui/views/trees/tree_servers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index e7415c4..5631ee1 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -361,10 +361,10 @@ class ServerTreeView(ContextMixin, TreeView): _iter: Gtk.TreeIter, data: Any, ): - def ping_server(model, _iter: Gtk.TreeIter, ip: str, qport: int, ping: int): + def ping_server(model, _iter: Gtk.TreeIter, ip: str, qport: int): from dzgui.api.servers import ping - _ping = ping(ip, qport, ping) + _ping = ping(ip, qport) GLib.idle_add(lambda: model.set(_iter, ping_column, _ping)) addr_column = 7 From 0b6ca7b6fd26bd6c777860eb28b1eaac49e3ccaa Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 12 Apr 2026 02:23:17 +0900 Subject: [PATCH 139/309] chore: update changelog --- CHANGELOG.md | 1 + dzgui/data/CHANGELOG.md | 1 + 2 files changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c77d067..9c1fa63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ - Use concurrency when checking stale mods (performance uplift) - Copy version by clicking label - Add server name to server mod dialogs +- Lazy load ping cell data func ## Changed - Reduce padding on keys button diff --git a/dzgui/data/CHANGELOG.md b/dzgui/data/CHANGELOG.md index c77d067..9c1fa63 100644 --- a/dzgui/data/CHANGELOG.md +++ b/dzgui/data/CHANGELOG.md @@ -27,6 +27,7 @@ - Use concurrency when checking stale mods (performance uplift) - Copy version by clicking label - Add server name to server mod dialogs +- Lazy load ping cell data func ## Changed - Reduce padding on keys button From a323c4fc753a3ef2adfad6365d05efc6899763dd Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 12 Apr 2026 02:36:40 +0900 Subject: [PATCH 140/309] fix: change button sensitivity on empty --- dzgui/views/dialogs/note.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/dzgui/views/dialogs/note.py b/dzgui/views/dialogs/note.py index 3f3acb4..1057bf5 100644 --- a/dzgui/views/dialogs/note.py +++ b/dzgui/views/dialogs/note.py @@ -30,7 +30,7 @@ class NoteDialog(GenericDialog): delete_button = Gtk.Button(label="Delete note") delete_button.set_sensitive(False) self.add_action_widget(delete_button, Gtk.ResponseType.CANCEL) - self.add_button("OK", Gtk.ResponseType.OK) + self.add_button("Update note", Gtk.ResponseType.OK) self.ok = self.get_widget_for_response(Gtk.ResponseType.OK) self.ok.grab_default() @@ -43,6 +43,9 @@ class NoteDialog(GenericDialog): self.entry.set_text(note) if len(note) > 0: delete_button.set_sensitive(True) + self.format_secondary_text(f"Current note: {note}") + elif len(note) == 0: + self.ok.set_sensitive(False) self.show_all() # NOTE: explicitly deselect region and move to end of line @@ -50,9 +53,16 @@ class NoteDialog(GenericDialog): self.entry.set_activates_default(True) self.connect("response", self._on_response) + self.entry.connect("changed", self._on_text_changed) + + def _on_text_changed(self, entry: Gtk.Entry) -> None: + """ + Called in conjunction with signal callback in the parent class + """ + if len(entry.get_text()) == 0: + self.ok.set_sensitive(False) def validate(self, text: str) -> None: - # FIXME: not checking for text deletion, e.g., ctrl-a, ctrl-x if text.isspace(): sensitive = False else: @@ -66,9 +76,7 @@ class NoteDialog(GenericDialog): self.destroy() case Gtk.ResponseType.OK: note = self.entry.get_text() - # FIXME: handle deleting existing note, going from N chars to 0 chars and clicking OK - if len(note) > 0: - self.controller.add_note(note) + self.controller.add_note(note) self.destroy() case Gtk.ResponseType.CANCEL: self.controller.delete_note() From e8fca224196a880c1e4b7b023e0790d25f429a21 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Fri, 17 Apr 2026 22:27:59 +0900 Subject: [PATCH 141/309] feat: skip_cache arg on FilterMan.filter() --- dzgui/init/update.py | 13 ++++++++----- dzgui/managers/filter_man.py | 10 +++++----- dzgui/model/proxy_model.py | 18 +++++++++++------- dzgui/model/servers.py | 28 ++++++++++++---------------- 4 files changed, 36 insertions(+), 33 deletions(-) diff --git a/dzgui/init/update.py b/dzgui/init/update.py index 889b557..7adb93f 100644 --- a/dzgui/init/update.py +++ b/dzgui/init/update.py @@ -12,6 +12,7 @@ from dzgui.init.prefix import is_prefix_writeable logger = logging.getLogger(__name__) + def get_latest_release() -> str | None: tag = None # TODO: check order; github often has gateway errors @@ -26,18 +27,20 @@ def get_latest_release() -> str | None: continue 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: + latest = get_latest_release() + prefix = sys.prefix + if latest is None: + return if Version(version) >= Version(latest): return @@ -51,5 +54,5 @@ def check_updates(version: str) -> None: # TODO: pop a dialog pass sys.exit(proc) - except InvalidVersion: + except Exception: return diff --git a/dzgui/managers/filter_man.py b/dzgui/managers/filter_man.py index 4047604..4dfefcf 100644 --- a/dzgui/managers/filter_man.py +++ b/dzgui/managers/filter_man.py @@ -13,7 +13,7 @@ class FilterManager: self.map_store = ModelFactory().make_map_store() - # TODO: namespace under strings.filters + # TODO: namespace under own file: strings.filters self.default_filters = { strings.filter_1pp: True, strings.filter_day: True, @@ -36,9 +36,6 @@ class FilterManager: self.filters: list self.enabled_filters = dict(self.default_filters) - # def reinit_filters(self) -> None: - # self.enabled_filters = dict(self.default_filters) - def set_prior_map(self, name: str) -> None: self.prior_map = name @@ -86,11 +83,14 @@ class FilterManager: return self.reinit_map_store() + maps.sort() for m in maps: + if m == "All maps": + continue self.append_map([m]) def get_unique_maps(self) -> list[str]: - return [row[0] for row in self.map_store] + return [row[0] for row in self.map_store if row != "All maps"] def get_all_filters(self) -> tuple: map_name = self.get_active_map_name() diff --git a/dzgui/model/proxy_model.py b/dzgui/model/proxy_model.py index a50708c..cf4d092 100644 --- a/dzgui/model/proxy_model.py +++ b/dzgui/model/proxy_model.py @@ -44,21 +44,25 @@ class ProxyModelManager: def get_proxy_model(self) -> "FastInsertListStore": return self.proxy_model - def filter(self, mode: FilterMode) -> None: + def filter(self, mode: FilterMode, skip_cache: bool = False) -> None: # TODO: proxy model can fetch args directly from filter man, no need to process extra input """ Native Gtk.TreeView.refilter() method was not performant enough when running in the main loop with 40k+ records + + skip_cache: used when updating a record on Saved Servers/History and forcing a refilter """ - # TODO: return a dataclass object with clearly enumerated map, keyword, and filter values + # TODO: filter cache: return a dataclass object with clearly enumerated map, keyword, and filter values # instead of just a serial list of strings filters = self.filter_man.get_all_filters() - if filters in self.filter_cache: - cache = self.filter_cache[filters] - self.set_proxy_model(cache[0]) - self.set_filtered(cache[1]) - return + if skip_cache is False: + if filters in self.filter_cache: + print("already in cache, not updating proxy model") + cache = self.filter_cache[filters] + self.set_proxy_model(cache[0]) + self.set_filtered(cache[1]) + return match mode: case FilterMode.INITIAL: diff --git a/dzgui/model/servers.py b/dzgui/model/servers.py index aa88a99..61a5554 100644 --- a/dzgui/model/servers.py +++ b/dzgui/model/servers.py @@ -235,7 +235,7 @@ class ServerModelManager: config_man = self.controller.get_config_man() config_man.add_saved_server(fqip) - # NOTE: if tab contents were not loaded yet + # NOTE: abort early if Saved Servers tab was not loaded yet if raw_model is None: return @@ -245,13 +245,16 @@ class ServerModelManager: # TODO: if all filters are already applied, strange behavior may occur # -> need to insert and reupdate tree per current filters # for example, non-empty will only show up in empty because it is not cached - proxy_man.filter(FilterMode.INITIAL) + + # FIXME: new results are not being shown when tabbing over to Saved Servers + proxy_man.filter(FilterMode.INITIAL, skip_cache=True) filter_man = self.tv.get_filter_man() old_maps = filter_man.get_unique_maps() cur_map = record[1] if cur_map not in old_maps: - self._set_new_maps([cur_map]) + old_maps.append(cur_map) + self._set_new_maps(old_maps) self.thread_man.set_cleanup_func(StoredFunc(self._cleanup_single_ip)) def _dump_history(self) -> None: @@ -293,12 +296,14 @@ class ServerModelManager: proxy = self._get_proxy_man().get_proxy_model() self.tv.set_model(proxy) - # TODO: if current tab != self.saved, add label + # TODO: animate saved servers tab if we are on other tab self.emitter.emit("servers_loaded", self.enum) # TODO: consolidate methods and handle multi/single map addition filter_man = self.tv.get_filter_man() - filter_man.append_map(self._get_new_maps()) + maps = self._get_new_maps() + filter_man.set_unique_maps(maps) + self.emitter.emit("servers_loaded_init") self.first_iteration = False @@ -311,8 +316,6 @@ class ServerModelManager: def _cleanup_on_success(self) -> None: proxy = self._get_proxy_man().get_proxy_model() self.tv.set_model(proxy) - # self.tv.set_model(None) - # self.tv.set_model(self.to_insert) # TODO: make sure control model len is N + 1 # inserting a row serializes file on disk, updates control model for that tab, and updates model @@ -348,16 +351,11 @@ class ServerModelManager: dialog.run() def _push_data(self, data: list) -> None: - # if data is None: - # self.to_insert = None - # else: - - manager = self._get_proxy_man() # TODO: consolidate these methods + manager = self._get_proxy_man() manager.wipe_cache() manager.set_control(data) manager.filter(FilterMode.INITIAL) - self.to_insert = manager.get_proxy_model() # TODO: abstract for all methods self._sort_unique_maps(data) @@ -369,7 +367,7 @@ class ServerModelManager: self._set_new_maps(sorted(u_maps)) def _set_new_maps(self, maps: list[str]) -> None: - self.new_maps = maps + self.new_maps: list[str] = maps def _get_new_maps(self) -> list[str]: return self.new_maps @@ -383,6 +381,4 @@ class ServerModelManager: self.first_iteration = False proxy_man = self._get_proxy_man() proxy_man.filter(mode) - # proxy_man.filter(mode, label) - self.to_insert = proxy_man.get_proxy_model() self.thread_man.set_cleanup_func(StoredFunc(self._cleanup_on_success)) From 7298fc75666821e220ae6bd60705079169602cb4 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Fri, 17 Apr 2026 22:37:40 +0900 Subject: [PATCH 142/309] chore: document block_map_change_propagation flag --- dzgui/views/components/filter_panel.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/dzgui/views/components/filter_panel.py b/dzgui/views/components/filter_panel.py index 1c01885..8dfd412 100644 --- a/dzgui/views/components/filter_panel.py +++ b/dzgui/views/components/filter_panel.py @@ -212,10 +212,11 @@ class FilterPanel(Gtk.Box): # TODO: use same sort of signal to reinitialize keyword and checks def _on_maps_loaded(self, emitter: "Emitter", store: Gtk.ListStore) -> None: - self.is_first_run = True self.maps_combo.set_model(store) tv = self.controller.get_active_treeview() ind, name = tv.filter_man.get_active_map() + # NOTE: setting active index triggers a 'changed' signal on self.maps_combo + self.block_map_change_propagation = True self.maps_combo.set_active(ind) self.button_grid.reload_filters() @@ -313,9 +314,7 @@ class FilterPanel(Gtk.Box): filter_man.set_prior_map(name) filter_man.set_active_map(ind, name) - # TODO: 2026-03-29 - # don't signal controller to refilter if we are switching pages - if self.is_first_run: - self.is_first_run = False + if self.block_map_change_propagation: + self.block_map_change_propagation = False return self.emitter.emit("map_selection_changed", name) From 06450ddadc43076cb7e18a051024f08bab6ab2a3 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Fri, 17 Apr 2026 22:41:47 +0900 Subject: [PATCH 143/309] chore: drop test document write --- dzgui/managers/thread_man.py | 4 ---- dzgui/model/servers.py | 4 ---- 2 files changed, 8 deletions(-) diff --git a/dzgui/managers/thread_man.py b/dzgui/managers/thread_man.py index c3997c7..6a1c4d0 100644 --- a/dzgui/managers/thread_man.py +++ b/dzgui/managers/thread_man.py @@ -91,10 +91,6 @@ class ThreadingManager: def get_cleanup_func(self) -> StoredFunc: return self.cleanup_func - # def _d(self) -> None: - # self.wait_dialog.hide() - # return False - def _destroy_on_idle(self) -> GLib.SOURCE_REMOVE: if self.destroy_first: self.wait_dialog.destroy() diff --git a/dzgui/model/servers.py b/dzgui/model/servers.py index 61a5554..fbaf3dd 100644 --- a/dzgui/model/servers.py +++ b/dzgui/model/servers.py @@ -118,10 +118,6 @@ class ServerModelManager: j = res.json servers += j["response"]["servers"] - import json - - with open("test.txt", "w") as f: - json.dump(servers, f) parsed = Servers.parse_json(servers) self._push_data(parsed) From e1f54bd75db495284a6f13850ac669a608691a84 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 18 Apr 2026 18:15:27 +0900 Subject: [PATCH 144/309] feat: refresh player count --- dzgui/api/servers.py | 15 ++++++- dzgui/controllers/mc.py | 9 +++++ dzgui/managers/config.py | 5 +++ dzgui/managers/contextmenu.py | 10 +++-- dzgui/model/proxy_model.py | 23 +++++++++++ dzgui/model/servers.py | 75 ++++++++++++++++++++++------------- 6 files changed, 105 insertions(+), 32 deletions(-) diff --git a/dzgui/api/servers.py b/dzgui/api/servers.py index 4886118..7aae579 100644 --- a/dzgui/api/servers.py +++ b/dzgui/api/servers.py @@ -453,11 +453,24 @@ def query_by_id(addr: str, key: str) -> Optional[dict]: return None +def query_playercount(record: Record) -> Optional[tuple[int]]: + try: + res = query_direct(record.ip, record.qport) + players = int(res["players"]) + r = res["gametype"].split("lqs") + try: + queue = int(r[1].split(",")[0]) + except IndexError: + queue = 0 + return (players, queue) + except Exception as e: + logger.critical(e) + return None + def query_by_ip(addr: str) -> Optional[dict]: record = short_ip_to_record(addr) return query_by_record(record) - def query_by_record(record: Record) -> Optional[dict]: try: return query_direct(record.ip, record.qport) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 165dbd7..73116f0 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -604,6 +604,10 @@ class Controller(GObject.GObject): saved_tree = self.get_servers().get_saved() ServerModelManager(self, saved_tree).add_by_record(record) + def remove_by_record(self, record: "Record") -> None: + saved_tree = self.get_servers().get_saved() + ServerModelManager(self, saved_tree).remove_by_record(record) + def connect_by_str(self, addr: str) -> None: if addr.isdigit(): config_man = self.get_config_man() @@ -652,3 +656,8 @@ class Controller(GObject.GObject): tv = self.get_active_treeview() record = tv.get_record_string() self.notes_man.delete_note(record) + + def refresh_players(self, record: "Record") -> None: + treeview = self.get_active_treeview() + model, treeiter = treeview.get_selection().get_selected() + ServerModelManager(self, treeview).update_playercount(treeiter, record) diff --git a/dzgui/managers/config.py b/dzgui/managers/config.py index 82cf8cd..679a39d 100644 --- a/dzgui/managers/config.py +++ b/dzgui/managers/config.py @@ -48,6 +48,11 @@ class ConfigManager: ips.append(record) self.update_config(Preferences.IP_LIST, ips) + def remove_saved_server(self, record: str) -> None: + ips = self.lookup(Preferences.IP_LIST) + ips.remove(record) + self.update_config(Preferences.IP_LIST, ips) + # TODO: drop/reroute def update_config(self, key: Preferences, value: str) -> None: self.write_config(key, value) diff --git a/dzgui/managers/contextmenu.py b/dzgui/managers/contextmenu.py index 1f698ba..3a654b9 100644 --- a/dzgui/managers/contextmenu.py +++ b/dzgui/managers/contextmenu.py @@ -66,7 +66,10 @@ class ContextMenuManager: case ContextMenu.REFRESH_PLAYERS: print("DEBUG: unimplemented") - # get record + record = self.treeview.get_record() + self.controller.refresh_players(record) + + # TODO: just updates proxy model, this info is ephemeral # call a2s on thread pass case ContextMenu.REMOVE_HISTORY: @@ -75,9 +78,8 @@ class ContextMenuManager: # see dq.py pass case ContextMenu.REMOVE_SERVER: - print("DEBUG: unimplemented") - # reverse of ADD_SERVER - pass + record = self.treeview.get_record() + self.controller.remove_by_record(record) case ContextMenu.SHOW_DETAILS: record = self.treeview.get_record() self.controller.get_details(record) diff --git a/dzgui/model/proxy_model.py b/dzgui/model/proxy_model.py index cf4d092..d5c9ac7 100644 --- a/dzgui/model/proxy_model.py +++ b/dzgui/model/proxy_model.py @@ -10,7 +10,12 @@ from dzgui.util import strings if TYPE_CHECKING: from dzgui.managers.filter_man import FilterManager from dzgui.model.model_factory import FastInsertListStore + from dzgui.model.servers import NewPlayerCount +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository.Gtk import TreeIter class ProxyModelManager: """ @@ -35,9 +40,27 @@ class ProxyModelManager: self.filtered: list = None self.success = True + def has_control_model(self) -> bool: + pass + def append_row(self, row: list) -> None: self.proxy_model.append(row) + def append_row_to_control(self, row: list) -> None: + self.control_model.append(row) + self.filter(FilterMode.INITIAL, skip_cache=True) + + def remove_row_from_control(self, row: list) -> None: + for record in self.control_model: + if row[7] == record[7] and row[8] == record[8]: + self.control_model.remove(record) + self.filter(FilterMode.INITIAL, skip_cache=True) + + def update_playercount(self, playercount: "NewPlayerCount") -> None: + treeiter = playercount.treeiter + self.proxy_model[treeiter][4] = playercount.players + self.proxy_model[treeiter][6] = playercount.queue + def clear_proxy_model(self) -> None: self.proxy_model.clear() diff --git a/dzgui/model/servers.py b/dzgui/model/servers.py index fbaf3dd..5b13b86 100644 --- a/dzgui/model/servers.py +++ b/dzgui/model/servers.py @@ -1,7 +1,9 @@ import logging import threading + from concurrent.futures import as_completed from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass from typing import TYPE_CHECKING import dzgui.api.servers as Servers @@ -29,9 +31,15 @@ if TYPE_CHECKING: logger = logging.getLogger(__name__) -# TODO: failure: spawns error dialog +# TODO: failure should spawn error dialog # TODO: non failure with empty model: updates statusbar with help text +@dataclass +class NewPlayerCount: + treeiter: Gtk.TreeIter + players: int + queue: int + class ServerModelManager: def __init__(self, controller: "Controller", tv: Gtk.TreeView) -> None: @@ -207,50 +215,65 @@ class ServerModelManager: # FIXME: use "adding server" string @call_on_thread(dialog.querying) def add_by_record(self, record: Servers.Record) -> None: + """ + Record as shown in server browser may resolve to a different IP + """ res = Servers.query_by_record(record) self._parse_single_record(res) + @call_on_thread(dialog.querying) + def remove_by_record(self, record: Servers.Record) -> None: + res = Servers.query_by_record(record) + self._parse_single_record(res, delete=True) + def add_by_str(self, addr: str) -> None: if addr.isdigit(): self.add_by_id(addr) else: self.add_by_ip(addr) - def _parse_single_record(self, response: dict) -> None: + @call_on_thread(dialog.querying) + def update_playercount(self, treeiter: Gtk.TreeIter, record: Servers.Record) -> None: + proxy_man = self._get_proxy_man() + res = Servers.query_playercount(record) + if res is None: + return + players, queue = res + + self.playercount = NewPlayerCount(treeiter, players, queue) + self.thread_man.set_cleanup_func(StoredFunc(self._push_playercount)) + + def _push_playercount(self) -> None: + proxy_man = self._get_proxy_man() + proxy_man.update_playercount(self.playercount) + + def _parse_single_record(self, response: dict, delete: bool = False) -> None: if response is None: self.thread_man.set_cleanup_func(StoredFunc(self._cleanup_on_failure)) return + # NOTE: expected to only contain one item records = Servers.parse_json([response]) record = records[0] proxy_man = self._get_proxy_man() - raw_model = proxy_man.get_control() - fqip = Servers.response_to_fq_ip(response) config_man = self.controller.get_config_man() - config_man.add_saved_server(fqip) - # NOTE: abort early if Saved Servers tab was not loaded yet - if raw_model is None: - return + if delete: + # NOTE: abort early if Saved Servers tab was not loaded yet + config_man.remove_saved_server(fqip) + if proxy_man.has_control_model() is False: + return + proxy_man.remove_row_from_control(record) + else: + config_man.add_saved_server(fqip) + if proxy_man.has_control_model() is False: + return + proxy_man.append_row_to_control(record) - # NOTE: expected to only contain one item - raw_model.append(record) - - # TODO: if all filters are already applied, strange behavior may occur - # -> need to insert and reupdate tree per current filters - # for example, non-empty will only show up in empty because it is not cached - - # FIXME: new results are not being shown when tabbing over to Saved Servers - proxy_man.filter(FilterMode.INITIAL, skip_cache=True) - - filter_man = self.tv.get_filter_man() - old_maps = filter_man.get_unique_maps() - cur_map = record[1] - if cur_map not in old_maps: - old_maps.append(cur_map) - self._set_new_maps(old_maps) + control_model = proxy_man.get_control() + self._sort_unique_maps(control_model) self.thread_man.set_cleanup_func(StoredFunc(self._cleanup_single_ip)) def _dump_history(self) -> None: @@ -295,8 +318,8 @@ class ServerModelManager: # TODO: animate saved servers tab if we are on other tab self.emitter.emit("servers_loaded", self.enum) - # TODO: consolidate methods and handle multi/single map addition filter_man = self.tv.get_filter_man() + # NOTE: maps are set outside of thread because it triggers map changed signals maps = self._get_new_maps() filter_man.set_unique_maps(maps) @@ -314,8 +337,6 @@ class ServerModelManager: self.tv.set_model(proxy) # TODO: make sure control model len is N + 1 - # inserting a row serializes file on disk, updates control model for that tab, and updates model - # NOTE: when inserting new rows, the entire control model is wiped and rebuilt, then proxy model is swapped in # TODO: signals or other approach to deferring map # model insertion after thread closes # cf. servers_loaded signal From 0949a0d8a9b1b1caa7d46fe299c0b3cba3c65b37 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 18 Apr 2026 18:16:16 +0900 Subject: [PATCH 145/309] chore: remove comments --- dzgui/managers/contextmenu.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/dzgui/managers/contextmenu.py b/dzgui/managers/contextmenu.py index 3a654b9..4c92a9f 100644 --- a/dzgui/managers/contextmenu.py +++ b/dzgui/managers/contextmenu.py @@ -65,13 +65,8 @@ class ContextMenuManager: self.controller.connect_by_record(record) case ContextMenu.REFRESH_PLAYERS: - print("DEBUG: unimplemented") record = self.treeview.get_record() self.controller.refresh_players(record) - - # TODO: just updates proxy model, this info is ephemeral - # call a2s on thread - pass case ContextMenu.REMOVE_HISTORY: print("DEBUG: unimplemented") # update history model, update tab label, pop off of queue, write new list into file From af4d79f190b5e01c3ea0d779906561b40b144195 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 18 Apr 2026 20:08:32 +0900 Subject: [PATCH 146/309] feat: update saved tab label --- dzgui/controllers/emitter.py | 5 +++++ dzgui/model/proxy_model.py | 4 +++- dzgui/model/servers.py | 6 ++++++ dzgui/views/pages/servers.py | 16 ++++++++++------ 4 files changed, 24 insertions(+), 7 deletions(-) diff --git a/dzgui/controllers/emitter.py b/dzgui/controllers/emitter.py index c84fd70..4a46311 100644 --- a/dzgui/controllers/emitter.py +++ b/dzgui/controllers/emitter.py @@ -22,6 +22,11 @@ class Emitter(GObject.GObject): """User invoked Ctrl-f keybinding from ServerTreeView""" pass + @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=()) + def saved_servers_changed(self) -> None: + """Record was added/removed from Saved Servers model""" + pass + @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=()) def request_maps_focus(self) -> None: """User invoked Ctrl-m keybinding from ServerTreeView""" diff --git a/dzgui/model/proxy_model.py b/dzgui/model/proxy_model.py index d5c9ac7..e39e830 100644 --- a/dzgui/model/proxy_model.py +++ b/dzgui/model/proxy_model.py @@ -41,7 +41,9 @@ class ProxyModelManager: self.success = True def has_control_model(self) -> bool: - pass + if self.control_model is None: + return False + return True def append_row(self, row: list) -> None: self.proxy_model.append(row) diff --git a/dzgui/model/servers.py b/dzgui/model/servers.py index 5b13b86..3e7be14 100644 --- a/dzgui/model/servers.py +++ b/dzgui/model/servers.py @@ -264,11 +264,13 @@ class ServerModelManager: # NOTE: abort early if Saved Servers tab was not loaded yet config_man.remove_saved_server(fqip) if proxy_man.has_control_model() is False: + self.thread_man.set_cleanup_func(StoredFunc(self._cleanup_when_no_model)) return proxy_man.remove_row_from_control(record) else: config_man.add_saved_server(fqip) if proxy_man.has_control_model() is False: + self.thread_man.set_cleanup_func(StoredFunc(self._cleanup_when_no_model)) return proxy_man.append_row_to_control(record) @@ -276,6 +278,9 @@ class ServerModelManager: self._sort_unique_maps(control_model) self.thread_man.set_cleanup_func(StoredFunc(self._cleanup_single_ip)) + def _cleanup_when_no_model(self) -> None: + self.emitter.emit("saved_servers_changed") + def _dump_history(self) -> None: history = self.controller.get_prefs().paths.history try: @@ -325,6 +330,7 @@ class ServerModelManager: self.emitter.emit("servers_loaded_init") self.first_iteration = False + self.emitter.emit("saved_servers_changed") def _update_maps(self) -> None: filter_man = self.tv.get_filter_man() diff --git a/dzgui/views/pages/servers.py b/dzgui/views/pages/servers.py index 61eecbd..b8f1599 100644 --- a/dzgui/views/pages/servers.py +++ b/dzgui/views/pages/servers.py @@ -56,6 +56,15 @@ class ServerNotebook(Gtk.ScrolledWindow): self.connect("unmap", self._on_unmap) self.emitter.connect("servers_loaded", self._on_servers_loaded) + self.emitter.connect("saved_servers_changed", self._on_saved_servers_changed) + + def _on_saved_servers_changed(self, emitter: "Emitter") -> None: + saved = 1 + if self.notebook.get_current_page() == saved: + return + page = self.notebook.get_nth_page(saved) + label = self.notebook.get_tab_label(page) + label.set_text(f"{server_labels.saved}*") def _on_servers_loaded(self, emitter: "Emitter", tab: "ServerTab") -> None: # NOTE: workaround for GTK bug where fullscreen causes headers to vanish when model is None @@ -101,13 +110,8 @@ class ServerNotebook(Gtk.ScrolledWindow): if self.controller.loaded is False: return - # TODO: wipe statusbar when changing pages - label = self.notebook.get_tab_label_text(child) - if label is None: - return - - # TODO: strings, tab activity notifier + # TODO: strings text = label.strip("*") self.notebook.set_tab_label_text(child, text) From 24d054ca1627d27eb6f21a0ccc751a72db7095e5 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 18 Apr 2026 20:56:29 +0900 Subject: [PATCH 147/309] fix: do not add server if already in config --- dzgui/managers/connection.py | 8 +++++--- dzgui/model/servers.py | 4 ++++ dzgui/views/components/connect_panel.py | 4 ++++ dzgui/views/components/right_panel.py | 2 +- dzgui/views/components/statusbar.py | 8 ++++++++ 5 files changed, 22 insertions(+), 4 deletions(-) diff --git a/dzgui/managers/connection.py b/dzgui/managers/connection.py index b46f6c5..a05e3d3 100644 --- a/dzgui/managers/connection.py +++ b/dzgui/managers/connection.py @@ -32,7 +32,8 @@ class ConnectionManager: StoredFunc(self._server_timeout), destroy_first=True ) - print("DEBUG") + # TODO: resolve real ip + print("UNIMPLEMENTED: get rules") print(res) @call_on_thread(dialog.querying) @@ -43,7 +44,8 @@ class ConnectionManager: StoredFunc(self._server_timeout), destroy_first=True ) - print("DEBUG") + # TODO: resolve real ip + print("UNIMPLEMENTED: get rules") print(res) @call_on_thread(dialog.querying) @@ -55,7 +57,7 @@ class ConnectionManager: ) # TODO: add to history if successful - print("DEBUG") + print("UNIMPLEMENTED: get rules") print(res) @call_on_thread(dialog.querying) diff --git a/dzgui/model/servers.py b/dzgui/model/servers.py index 3e7be14..e945c77 100644 --- a/dzgui/model/servers.py +++ b/dzgui/model/servers.py @@ -260,6 +260,7 @@ class ServerModelManager: fqip = Servers.response_to_fq_ip(response) config_man = self.controller.get_config_man() + # TODO: less convoluted if delete: # NOTE: abort early if Saved Servers tab was not loaded yet config_man.remove_saved_server(fqip) @@ -268,6 +269,9 @@ class ServerModelManager: return proxy_man.remove_row_from_control(record) else: + if config_man.is_in_favs(fqip): + self.thread_man.set_cleanup_func(StoredFunc(self._cleanup_when_no_model)) + return config_man.add_saved_server(fqip) if proxy_man.has_control_model() is False: self.thread_man.set_cleanup_func(StoredFunc(self._cleanup_when_no_model)) diff --git a/dzgui/views/components/connect_panel.py b/dzgui/views/components/connect_panel.py index 801eafd..a29f580 100644 --- a/dzgui/views/components/connect_panel.py +++ b/dzgui/views/components/connect_panel.py @@ -151,6 +151,7 @@ class FavPanel(Gtk.Frame): scrollable_label.add(self.fav_label) self.fav_button = SteamConnectButton() + self.fav_button.connect("clicked", self._on_connect_clicked) if server_name is None: self.fav_button.set_sensitive(False) copy = ClipboardButton(self.controller, self.get_fav_ip) @@ -162,6 +163,9 @@ class FavPanel(Gtk.Frame): self.add(grid) + def _on_connect_clicked(self, button: Gtk.Button) -> None: + self.controller.connect_by_str(self.server_ip) + def get_fav_ip(self) -> str: return self.server_ip diff --git a/dzgui/views/components/right_panel.py b/dzgui/views/components/right_panel.py index 6a9ff40..47f0ad6 100644 --- a/dzgui/views/components/right_panel.py +++ b/dzgui/views/components/right_panel.py @@ -6,7 +6,7 @@ from dzgui.views.components.buttonbox import ButtonBox from dzgui.views.components.filter_panel import FilterPanel from dzgui.views.components.mod_panel import ModSelectionPanel from dzgui.views.components.buttons import RefreshButton, KeysButton -from dzgui.const.constants import NO_EXPAND, NO_FILL, FILL, NO_PADDING +from dzgui.const.constants import EXPAND, NO_EXPAND, NO_FILL, FILL, NO_PADDING import gi diff --git a/dzgui/views/components/statusbar.py b/dzgui/views/components/statusbar.py index a6893d6..4e8f789 100644 --- a/dzgui/views/components/statusbar.py +++ b/dzgui/views/components/statusbar.py @@ -32,6 +32,14 @@ class Statusbar(Gtk.Grid): self.attach(self.statusbar, 0, 0, 3, 1) self.attach_next_to(self.spinner, self.statusbar, Gtk.PositionType.RIGHT, 3, 1) + # TODO: pack version event box in right panel into hbox with update button + # TODO: spawns a modal or just jumps right to install page + #from dzgui.views.components.buttons import IconTextButton + #b = IconTextButton("dialog-information-symbolic", label="Updates available") + #b.set_halign(Gtk.Align.END) + #b.set_hexpand(True) + #self.attach_next_to(b, self.spinner, Gtk.PositionType.RIGHT, 3, 1) + self.players = "" controller.get_menu().connect("generic_treesel_changed", self._help_row_changed) From 107c672b1cdc55dcebc2fb732f539e1e7d30bc9a Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 18 Apr 2026 21:17:12 +0900 Subject: [PATCH 148/309] fix: update UserPrefs object --- dzgui/controllers/mc.py | 4 +++- dzgui/util/dist.py | 3 +-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 73116f0..e3f7ca3 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -250,7 +250,6 @@ class Controller(GObject.GObject): if haversine is None: dist = "Unknown" else: - # self.config_man.use_miles() if self.prefs.use_miles: raw = round(haversine.as_miles()) separated = number(raw) @@ -287,6 +286,9 @@ class Controller(GObject.GObject): self.cleanup_func = StoredFunc(self.load_mods_cleanup, model) def toggle_config(self, key: Preferences) -> None: + # NOTE: Preferences.DIST is dynamic + if key == Preferences.DIST: + self.prefs.use_miles = not self.prefs.use_miles self.config_man.toggle_config(key) def update_config(self, key: Preferences, value: str) -> None: diff --git a/dzgui/util/dist.py b/dzgui/util/dist.py index 8a60cc9..1cd61a5 100644 --- a/dzgui/util/dist.py +++ b/dzgui/util/dist.py @@ -54,9 +54,8 @@ class CalcDist: self.controller = controller self.result_queue = result_queue self.addr = addr - self.ip = self.addr # .split(":")[0] + self.ip = self.addr - # def run(self) -> None: cache = self.controller.get_dist_cache() if self.addr in cache: logger.info(f"Address '{self.addr}' already in cache") From 891abcc0495306c31c855b01dcb3bb2f51787e7a Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 19 Apr 2026 18:58:05 +0900 Subject: [PATCH 149/309] chore: drop unused code --- dzgui/api/probe.py | 13 +++---- dzgui/api/servers.py | 5 ++- dzgui/main.py | 1 + dzgui/views/base.py | 81 ++------------------------------------------ 4 files changed, 14 insertions(+), 86 deletions(-) diff --git a/dzgui/api/probe.py b/dzgui/api/probe.py index d2ce200..0280502 100644 --- a/dzgui/api/probe.py +++ b/dzgui/api/probe.py @@ -8,18 +8,22 @@ 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}", + "filter": r"\appid" + rf"\{appid}", "limit": 10, "key": key, } try: - res = requests.get(endpoints.STEAM_SERVERS, params=payload, timeout=REQUEST_TIMEOUT) + res = requests.get( + endpoints.STEAM_SERVERS, params=payload, timeout=REQUEST_TIMEOUT + ) return is_remote_up(res) except Exception as e: logger.critical(e) @@ -44,10 +48,7 @@ def test_bm_api(key: str) -> bool: hdr = {"Authorization": "Bearer " + key} try: res = requests.get( - endpoints.BM_SERVERS, - params=payload, - headers=hdr, - timeout=REQUEST_TIMEOUT + endpoints.BM_SERVERS, params=payload, headers=hdr, timeout=REQUEST_TIMEOUT ) return is_remote_up(res) except Exception as e: diff --git a/dzgui/api/servers.py b/dzgui/api/servers.py index 7aae579..4a34a55 100644 --- a/dzgui/api/servers.py +++ b/dzgui/api/servers.py @@ -21,9 +21,10 @@ import dayzquery logger = logging.getLogger(__name__) +logging.getLogger("a2s").setLevel(logging.ERROR) # TODO: confirm that patches from testing are incorporated here -# particularly around malformed values. check commit log +# particularly around malformed values in JSON response. check commit log params = [ r"\nor\1\map\chernarusplus\nor\1\map\sakhal\nor\1\map\enoch\empty\1\nor\1\map\namalsk", # noqa @@ -467,10 +468,12 @@ def query_playercount(record: Record) -> Optional[tuple[int]]: logger.critical(e) return None + def query_by_ip(addr: str) -> Optional[dict]: record = short_ip_to_record(addr) return query_by_record(record) + def query_by_record(record: Record) -> Optional[dict]: try: return query_direct(record.ip, record.qport) diff --git a/dzgui/main.py b/dzgui/main.py index 2f8127c..6d991dc 100644 --- a/dzgui/main.py +++ b/dzgui/main.py @@ -37,6 +37,7 @@ from dzgui.views.base import App from dzgui.views.dialogs.early_alert import EarlyAlertDialog, EarlyIgnoreDialog logger = logging.getLogger(__name__) + parser = argparse.ArgumentParser(description=flags.description) parser.add_argument("-v", "--version", action="store_true", help=flags.version) diff --git a/dzgui/views/base.py b/dzgui/views/base.py index de6476b..e54c3e6 100644 --- a/dzgui/views/base.py +++ b/dzgui/views/base.py @@ -4,7 +4,6 @@ import warnings from typing import TYPE_CHECKING, Literal -# TODO: import notebook only and add components there? from dzgui.const.constants import APP_NAME, APP_NAME_LOWER, STEAM_ICON from dzgui.const.enum import NotebookPage from dzgui.controllers.emitter import Emitter @@ -15,6 +14,8 @@ from dzgui.views.components.crumbs import Breadcrumbs from dzgui.views.components.right_panel import RightPanel from dzgui.views.components.statusbar import Statusbar from dzgui.views.mixins.scrollable_mixin import ScrollableMixin + +# TODO: import notebook only and add components there? from dzgui.views.pages.changelog import Changelog from dzgui.views.pages.devs import Developers from dzgui.views.pages.help import Help @@ -38,10 +39,6 @@ logger = logging.getLogger(__name__) warnings.filterwarnings("ignore", ".*g_value_get_int", Warning) -# TODO: move to controller -# kilometer cache; note, user may change measurement it partway through, flush cache -# cache: dict[str, int] = {} - ## TODO: move to configs/servers # def query_history() -> list | None: # history_file = MainController.get_prefs().paths.history @@ -52,38 +49,6 @@ warnings.filterwarnings("ignore", ".*g_value_get_int", Warning) # rows = None # return rows # -# def process_tree_option(choice: RowType) -> None: -# # server tables -# if command == RowType.RESOLVE_IP: -# record = 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 == "": -# spawn_dialog("No favorite server currently set", Popup.NOTIFY) -# return -# -# record = str_to_record(record) -# thread_new_with_dialog( -# 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(":") @@ -96,44 +61,6 @@ warnings.filterwarnings("ignore", ".*g_value_get_int", Warning) # 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 = 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 = treeview.prepare_connection(record) -# # return proc -# -# #thread_new_with_dialog( -# # _prep, parse_shell_output, "Querying API", enum, [key, response] -# #) -# #return -# -# - - class OuterWindow(Gtk.Window): def __init__(self) -> None: super().__init__(title=APP_NAME, border_width=10, icon_name=APP_NAME_LOWER) @@ -304,10 +231,6 @@ class Grid(Gtk.Grid): MainController.register_widget("grid", self) - # self.bu = Gtk.Button(label="Shrink to fit", halign=Gtk.Align.END) - # self.bu.connect("clicked", self._shrink) - # self.crumb_box.add(self.bu) - self.emitter = MainController.get_emitter() self.notebook = Notebook() From 1715d4c110fae5cf3fb7e0ebb6ce4b48d21590f8 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 19 Apr 2026 19:18:56 +0900 Subject: [PATCH 150/309] chore: drop shrink to fit --- dzgui/api/servers.py | 5 ++- dzgui/controllers/mc.py | 3 ++ dzgui/managers/connection.py | 2 +- dzgui/managers/thread_man.py | 17 ++------- dzgui/model/proxy_model.py | 8 ++++- dzgui/model/servers.py | 34 +++++++++--------- dzgui/views/trees/tree_servers.py | 58 +++++-------------------------- 7 files changed, 42 insertions(+), 85 deletions(-) diff --git a/dzgui/api/servers.py b/dzgui/api/servers.py index 4a34a55..0061dee 100644 --- a/dzgui/api/servers.py +++ b/dzgui/api/servers.py @@ -215,9 +215,8 @@ def query_direct(ip: str, qport: int, TIMEOUT: float = 3.0) -> dict | None: res["gameport"] = gameport res["ping"] = ping return res - except TimeoutError: - return None - except KeyError: + except Exception as e: + logger.critical(e) return None diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index e3f7ca3..27696a8 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -663,3 +663,6 @@ class Controller(GObject.GObject): treeview = self.get_active_treeview() model, treeiter = treeview.get_selection().get_selected() ServerModelManager(self, treeview).update_playercount(treeiter, record) + + def get_window(self) -> "OuterWindow": + return self.mediator.window diff --git a/dzgui/managers/connection.py b/dzgui/managers/connection.py index a05e3d3..1ac8a05 100644 --- a/dzgui/managers/connection.py +++ b/dzgui/managers/connection.py @@ -22,7 +22,7 @@ class ConnectionManager: def __init__(self, controller: "Controller") -> None: self.controller = controller - self.thread_man = ThreadingManager(parent=controller) + self.thread_man = ThreadingManager(controller) @call_on_thread(dialog.querying) def connect_by_id(self, addr: str, key: str) -> None: diff --git a/dzgui/managers/thread_man.py b/dzgui/managers/thread_man.py index 6a1c4d0..41d72bf 100644 --- a/dzgui/managers/thread_man.py +++ b/dzgui/managers/thread_man.py @@ -29,9 +29,7 @@ def call_on_thread(dialog_str: str) -> Callable: "Attribute 'thread_man' must be of type 'ThreadingManager'" ) self.thread_man.call_on_thread(dialog_str, stored) - return wrapper - return decorator @@ -46,20 +44,18 @@ class StoredFunc: class ThreadingManager: - def __init__(self, parent: Gtk.Window) -> None: - self.parent = parent + def __init__(self, controller: "Controller") -> None: + self.controller = controller self.jobs = 1 self.cleanup_func = None self.destroy_first = False - # self.alternate_statusbar = None - def call_on_thread(self, dialog_str: str, func: StoredFunc) -> None: def callback() -> None: func.call() GLib.idle_add(self._destroy_on_idle) - self.wait_dialog = WaitDialog(self.parent, dialog_str, jobs=self.jobs) + self.wait_dialog = WaitDialog(self.controller, dialog_str, jobs=self.jobs) self.wait_dialog.show_all() thread = threading.Thread(target=callback) thread.start() @@ -73,13 +69,6 @@ class ThreadingManager: def increment_dialog_with_str(self, text: str) -> None: GLib.idle_add(lambda: self.wait_dialog.increment(text)) - # TODO: this should not be delegated here, set in cleanup func - # def set_alternate_statusbar(self, msg: str) -> None: - # self.alternate_statusbar = msg - - # def get_alternate_statusbar(self) -> Optional[str]: - # return self.alternate_statusbar - def set_cleanup_func(self, func: StoredFunc, destroy_first: bool = False) -> None: if type(func) not in (StoredFunc, type(None)): msg = f"Callback function '{func}' is not of type StoredFunc or None" diff --git a/dzgui/model/proxy_model.py b/dzgui/model/proxy_model.py index e39e830..c132925 100644 --- a/dzgui/model/proxy_model.py +++ b/dzgui/model/proxy_model.py @@ -1,6 +1,6 @@ import re -from typing import Optional, TYPE_CHECKING +from typing import Any, Optional, TYPE_CHECKING from warnings import deprecated from dzgui.const.enum import FilterMode @@ -17,6 +17,7 @@ import gi gi.require_version("Gtk", "3.0") from gi.repository.Gtk import TreeIter + class ProxyModelManager: """ Manages access to cached FastInsertListStore resources and @@ -291,3 +292,8 @@ class ProxyModelManager: self.ping_cache = {} # if full: # self.control_model = None + + def push(self, data: list[Any]) -> None: + self.wipe_cache() + self.set_control(data) + self.filter(FilterMode.INITIAL) diff --git a/dzgui/model/servers.py b/dzgui/model/servers.py index e945c77..447bb62 100644 --- a/dzgui/model/servers.py +++ b/dzgui/model/servers.py @@ -4,7 +4,7 @@ import threading from concurrent.futures import as_completed from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass -from typing import TYPE_CHECKING +from typing import Any, TYPE_CHECKING import dzgui.api.servers as Servers from dzgui.const.enum import FilterMode, Preferences, ServerTab @@ -34,6 +34,7 @@ logger = logging.getLogger(__name__) # TODO: failure should spawn error dialog # TODO: non failure with empty model: updates statusbar with help text + @dataclass class NewPlayerCount: treeiter: Gtk.TreeIter @@ -55,9 +56,7 @@ class ServerModelManager: # NOTE: store filter man for access inside thread self.proxy_man = tv.get_proxy_man() - - # FIXME: change WaitDialog to use parent window only - self.thread_man = ThreadingManager(parent=controller) + self.thread_man = ThreadingManager(self.controller) # TODO: if first iteration, clear filter man control model # literal first load: iteration 1 @@ -233,7 +232,9 @@ class ServerModelManager: self.add_by_ip(addr) @call_on_thread(dialog.querying) - def update_playercount(self, treeiter: Gtk.TreeIter, record: Servers.Record) -> None: + def update_playercount( + self, treeiter: Gtk.TreeIter, record: Servers.Record + ) -> None: proxy_man = self._get_proxy_man() res = Servers.query_playercount(record) if res is None: @@ -265,16 +266,22 @@ class ServerModelManager: # NOTE: abort early if Saved Servers tab was not loaded yet config_man.remove_saved_server(fqip) if proxy_man.has_control_model() is False: - self.thread_man.set_cleanup_func(StoredFunc(self._cleanup_when_no_model)) + self.thread_man.set_cleanup_func( + StoredFunc(self._cleanup_when_no_model) + ) return proxy_man.remove_row_from_control(record) else: if config_man.is_in_favs(fqip): - self.thread_man.set_cleanup_func(StoredFunc(self._cleanup_when_no_model)) + self.thread_man.set_cleanup_func( + StoredFunc(self._cleanup_when_no_model) + ) return config_man.add_saved_server(fqip) if proxy_man.has_control_model() is False: - self.thread_man.set_cleanup_func(StoredFunc(self._cleanup_when_no_model)) + self.thread_man.set_cleanup_func( + StoredFunc(self._cleanup_when_no_model) + ) return proxy_man.append_row_to_control(record) @@ -377,16 +384,9 @@ class ServerModelManager: dialog = ExceptionDialog(self.controller, api_warn_msg) dialog.run() - def _push_data(self, data: list) -> None: - # TODO: consolidate these methods - manager = self._get_proxy_man() - manager.wipe_cache() - manager.set_control(data) - manager.filter(FilterMode.INITIAL) - - # TODO: abstract for all methods + def _push_data(self, data: list[Any]) -> None: + self._get_proxy_man().push(data) self._sort_unique_maps(data) - self.thread_man.set_cleanup_func(StoredFunc(self._cleanup_on_success)) def _sort_unique_maps(self, data: list) -> None: diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index 5631ee1..87b530f 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -6,8 +6,8 @@ from typing import Any, Optional, Self from warnings import deprecated from dzgui.views.mixins.context_mixin import ContextMixin -from dzgui.const.enum import ContextMenu, ContextMenuGroup, ServerTab -from dzgui.api.servers import Record +from dzgui.const.enum import ContextMenu, ContextMenuGroup, Preferences, ServerTab +from dzgui.api.servers import ping, Record from dzgui.managers.filter_man import FilterManager from dzgui.model.proxy_model import ProxyModelManager from dzgui.util.dist import CalcDist @@ -96,21 +96,7 @@ class ServerTreeView(ContextMixin, TreeView): w = width_map[column_title] column.set_fixed_width(w) if column_title == "Ping": - # self.fancy_col = column - # self.fancy_rend = renderer column.set_cell_data_func(renderer, self._get_ping) - # if column_title == "Name": - # column.set_fixed_width(800) - # if column_title == "Map": - # column.set_fixed_width(300) - - # TODO: standardize widths based on column title and longest content - # if column_title == "Name": - # column.set_fixed_width(500) - # if column_title == "Map": - # column.set_fixed_width(200) - # if column_title == "IP": - # column.set_fixed_width(240) column.connect("notify::fixed-width", self._on_col_width_changed) self.append_column(column) @@ -167,28 +153,6 @@ class ServerTreeView(ContextMixin, TreeView): def get_proxy_man(self) -> ProxyModelManager: return self.proxy_man - # def shrink_to_fit(self) -> None: - # cols = self.get_columns() - # # TODO: run on only one treeview and propagate results - # # TODO: does not shrink name, map, ip fields to fit - # # TODO: col width changed signal is buggy on current treeview - # for col in cols: - # title = col.get_title() - # if title == "Name": - # continue - # if title == "Map": - # continue - # if title == "IP": - # continue - # label = Gtk.Label(label=title) - # pango = label.get_layout() - # size = pango.get_pixel_size() - # if size.width > 50: - # width = size.width * 1.30 - # else: - # width = size.width * 1.65 - # col.set_fixed_width(width) - def get_enum(self) -> None: return self.enum @@ -306,8 +270,6 @@ class ServerTreeView(ContextMixin, TreeView): return sels[0] def is_in_favs(self) -> bool: - from dzgui.const.enum import Preferences - record = self.get_record_string() ips = self.controller.get_config_man().lookup(Preferences.IP_LIST) if record in ips: @@ -353,6 +315,11 @@ class ServerTreeView(ContextMixin, TreeView): def set_loaded(self, status: bool) -> None: self.loaded = status + @staticmethod + def ping_server(model, _iter: Gtk.TreeIter, ip: str, qport: int, ping_column: int): + _ping = ping(ip, qport) + GLib.idle_add(lambda: model.set(_iter, ping_column, _ping)) + def _get_ping( self, column: Gtk.TreeViewColumn, @@ -361,11 +328,6 @@ class ServerTreeView(ContextMixin, TreeView): _iter: Gtk.TreeIter, data: Any, ): - def ping_server(model, _iter: Gtk.TreeIter, ip: str, qport: int): - from dzgui.api.servers import ping - - _ping = ping(ip, qport) - GLib.idle_add(lambda: model.set(_iter, ping_column, _ping)) addr_column = 7 qport_column = 8 @@ -375,18 +337,16 @@ class ServerTreeView(ContextMixin, TreeView): ip = addr[0] gameport = addr[1] qport = model.get_value(_iter, qport_column) - # ping = model.get_value(_iter, ping_column) record = f"{addr}:{gameport}:{qport}" if record in self.seen_cache: return self.seen_cache.append(record) - # TODO: use thread manager thread = threading.Thread( daemon=True, - target=ping_server, - args=(model, _iter, ip, qport), + target=self.ping_server, + args=(model, _iter, ip, qport, ping_column), ) thread.start() From 699c1037118c2d428fb25e9628fbecd930cb9c5d Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 19 Apr 2026 19:31:40 +0900 Subject: [PATCH 151/309] feat: add MapsCombo --- dzgui/controllers/mc.py | 1 + dzgui/views/components/filter_panel.py | 8 ++------ dzgui/views/components/maps_combo.py | 11 +++++++++++ dzgui/views/trees/tree_servers.py | 1 - 4 files changed, 14 insertions(+), 7 deletions(-) create mode 100644 dzgui/views/components/maps_combo.py diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 27696a8..65e818a 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -442,6 +442,7 @@ class Controller(GObject.GObject): self.mediator.modtreeview.set_cursor(0) def get_filter_man(self) -> "FilterManager": + """Each ServerTreeView has an atomic FilterManager""" return self.get_active_treeview().get_filter_man() def highlight_stale_cleanup(self, stale_mods: list) -> None: diff --git a/dzgui/views/components/filter_panel.py b/dzgui/views/components/filter_panel.py index 8dfd412..497237a 100644 --- a/dzgui/views/components/filter_panel.py +++ b/dzgui/views/components/filter_panel.py @@ -5,6 +5,7 @@ from dzgui.const.enum import FilterMode from dzgui.const.constants import EXPAND, NO_EXPAND, NO_FILL, NO_PADDING, SEARCH_ICON from dzgui.model.servers import ServerModelManager from dzgui.util.strings import all_maps +from dzgui.views.components.maps_combo import MapsCombo from dzgui.views.components.labels import BoldLabel import gi @@ -170,10 +171,7 @@ class FilterPanel(Gtk.Box): self.emitter.connect("check_button_pressed", self.toggle_check_by_key) self.emitter.connect("load_maps", self._on_maps_loaded) - # TODO: break into MapsCombo class - completion = Gtk.EntryCompletion(inline_completion=True) - completion.set_text_column(0) - completion.set_minimum_key_length(1) + completion = MapsCombo() completion.connect("match_selected", self._on_completer_match) renderer_text = Gtk.CellRendererText(ellipsize=Pango.EllipsizeMode.END) @@ -256,8 +254,6 @@ class FilterPanel(Gtk.Box): store = self.controller.get_map_store() if len(text) >= completion.get_minimum_key_length(): completion.set_model(store) - # ind = self.get_active_combo() - # self.controller.set_active_map(ind) def restore_focus_to_treeview(self) -> Literal[False]: view = self.controller.get_active_treeview() diff --git a/dzgui/views/components/maps_combo.py b/dzgui/views/components/maps_combo.py new file mode 100644 index 0000000..dfbc948 --- /dev/null +++ b/dzgui/views/components/maps_combo.py @@ -0,0 +1,11 @@ +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk, GLib # noqa E402 + + +class MapsCombo(Gtk.EntryCompletion): + def __init__(self): + super().__init__(inline_completion=True) + self.set_text_column(0) + self.set_minimum_key_length(1) diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index 87b530f..98ad2cd 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -254,7 +254,6 @@ class ServerTreeView(ContextMixin, TreeView): case _: self.emitter.emit("check_button_pressed", event.keyval) - # TODO: unimplemented def is_modded(self) -> bool: select = self.get_selection() sels = select.get_selected_rows() From 95948e3e40b303b9e754a172bf39a40438a4c941 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 19 Apr 2026 20:22:58 +0900 Subject: [PATCH 152/309] fix: refresh keywords per page --- dzgui/controllers/mc.py | 10 +++++----- dzgui/views/components/filter_panel.py | 17 ++++++++--------- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 65e818a..47a0d0e 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -205,9 +205,6 @@ class Controller(GObject.GObject): def get_active_context(self) -> Gtk.TreeView: return self.get_active_treeview().get_enum() - def get_active_filterman(self) -> "FilterManager": - return self.get_active_treeview().get_filter_man() - def get_active_treeview(self) -> "ServerTreeView": return self.mediator.notebook.servers.get_active_treeview() @@ -665,5 +662,8 @@ class Controller(GObject.GObject): model, treeiter = treeview.get_selection().get_selected() ServerModelManager(self, treeview).update_playercount(treeiter, record) - def get_window(self) -> "OuterWindow": - return self.mediator.window + def get_active_keyword(self) -> str: + return self.get_filter_man().get_active_keyword() + + def set_active_keyword(self, keyword: str) -> None: + self.get_filter_man().set_active_keyword(keyword) diff --git a/dzgui/views/components/filter_panel.py b/dzgui/views/components/filter_panel.py index 497237a..e05e8dd 100644 --- a/dzgui/views/components/filter_panel.py +++ b/dzgui/views/components/filter_panel.py @@ -94,6 +94,13 @@ class KeywordEntry(Gtk.Entry): self.set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, SEARCH_ICON) self.set_icon_activatable(Gtk.EntryIconPosition.SECONDARY, True) + self.emitter.connect("load_maps", self._on_maps_loaded) + + def _on_maps_loaded(self, emitter: "Emitter", store: Gtk.ListStore) -> None: + kw = self.controller.get_active_keyword() + self.keyword = kw + self.set_text(kw) + def _on_keypress(self, entry: Gtk.Entry, event: Gdk.EventKey) -> bool: match event.keyval: case Gdk.KEY_Up: @@ -111,9 +118,6 @@ class KeywordEntry(Gtk.Entry): return False def _on_activated(self, entry: Gtk.Entry) -> None: - # TODO: investigate this method - # self.controller.mediator.window.set_keep_below(False) - keyword = entry.get_text().lower() if keyword == self.keyword: return @@ -121,11 +125,7 @@ class KeywordEntry(Gtk.Entry): return self.keyword = keyword - # TODO: delegate to controller - tv = self.controller.get_active_treeview() - filter_man = tv.get_filter_man() - filter_man.set_active_keyword(keyword) - + self.controller.set_active_keyword(keyword) logger.info(f"User filtered by keyword '{keyword}'") ServerModelManager( @@ -208,7 +208,6 @@ class FilterPanel(Gtk.Box): if text == row[0]: self.maps_combo.set_active(i) - # TODO: use same sort of signal to reinitialize keyword and checks def _on_maps_loaded(self, emitter: "Emitter", store: Gtk.ListStore) -> None: self.maps_combo.set_model(store) tv = self.controller.get_active_treeview() From d92a0010e2483f075711e2274552d3dafddd2bd2 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 19 Apr 2026 20:42:36 +0900 Subject: [PATCH 153/309] chore: clean up unused methods --- dzgui/api/servers.py | 4 +++- dzgui/controllers/mc.py | 33 ++------------------------- dzgui/model/servers.py | 9 +------- dzgui/views/components/right_panel.py | 25 -------------------- 4 files changed, 6 insertions(+), 65 deletions(-) diff --git a/dzgui/api/servers.py b/dzgui/api/servers.py index 0061dee..9c728da 100644 --- a/dzgui/api/servers.py +++ b/dzgui/api/servers.py @@ -443,8 +443,10 @@ def response_to_fq_ip(res: dict) -> str: return f"{ip}:{gameport}:{qport}" -# NOTE: Battlemetrics def query_by_id(addr: str, key: str) -> Optional[dict]: + """ + Used with numeric Battlemetrics IDs + """ try: resolved = map_id_to_record(key, addr) return query_direct(resolved.ip, resolved.qport) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 47a0d0e..8838770 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -5,7 +5,7 @@ import threading from functools import wraps from pathlib import Path -from typing import Any, Callable, Union, TYPE_CHECKING +from typing import Any, Callable, TYPE_CHECKING from warnings import deprecated import dzgui.api.pefile as PeFile @@ -160,34 +160,6 @@ class Controller(GObject.GObject): def is_auto_install(self) -> bool: return self.query_config(Preferences.INSTALL) - # def unblock_signals(self) -> None: - # self.block_signals(False) - - # @deprecated("Currently unused") - # def block_signals(self, state: bool = True) -> None: - # self.suppress_signal( - # self.mediator.filters, - # self.mediator.filters.maps_combo, - # "_on_map_changed", - # state, - # ) - # self.suppress_signal( - # self.mediator.menu, - # self.mediator.menu.selected_row, - # "_on_tree_selection_changed", - # state, - # ) - # self.suppress_signal( - # self.mediator.menu, self.mediator.menu, "_on_keypress", state - # ) - # for check in self.mediator.filters.checks: - # self.suppress_signal( - # self.mediator.filters, - # check, - # "_on_check_toggled", - # state, - # ) - def suppress_signal( self, owner: Gtk.Widget, widget: Gtk.Widget, func_name: str, state: bool ) -> None: @@ -199,7 +171,6 @@ class Controller(GObject.GObject): widget.handler_unblock_by_func(func) def toggle_debug_mode(self) -> None: - # self.config_man().toggle_config(Preferences.DEBUG) self.toggle_config(Preferences.DEBUG) def get_active_context(self) -> Gtk.TreeView: @@ -211,7 +182,6 @@ class Controller(GObject.GObject): def grab_active_treeview(self) -> None: self.get_active_treeview().grab_focus() - # TODO: resolution manager or config manager def save_res_and_quit(self, *args: Any) -> None: treeview = self.get_active_treeview() window = self.get_window() @@ -223,6 +193,7 @@ class Controller(GObject.GObject): self.mediator.statusbar.statusbar.pop(c) # TODO: refactor any modules using this + # cf. eventbox.py @deprecated("use set_by_context") def set_statusbar(self, text: str, context: str) -> int: msg_id = self.mediator.statusbar.set_text(text, context) diff --git a/dzgui/model/servers.py b/dzgui/model/servers.py index 447bb62..f8275a5 100644 --- a/dzgui/model/servers.py +++ b/dzgui/model/servers.py @@ -194,8 +194,6 @@ class ServerModelManager: parsed = Servers.parse_json(servers) self._push_data(parsed) - # TODO: add cleanup? - # FIXME: use "adding server" string @call_on_thread(dialog.querying) def add_by_id(self, addr: str) -> None: config_man = self.controller.get_config_man() @@ -203,19 +201,15 @@ class ServerModelManager: res = Servers.query_by_id(addr, key) self._parse_single_record(res) - # TODO: add cleanup? - # FIXME: use "adding server" string @call_on_thread(dialog.querying) def add_by_ip(self, addr: str) -> None: res = Servers.query_by_ip(addr) self._parse_single_record(res) - # TODO: add cleanup? - # FIXME: use "adding server" string @call_on_thread(dialog.querying) def add_by_record(self, record: Servers.Record) -> None: """ - Record as shown in server browser may resolve to a different IP + Rationale: a Record as shown in server browser may resolve to a different IP """ res = Servers.query_by_record(record) self._parse_single_record(res) @@ -235,7 +229,6 @@ class ServerModelManager: def update_playercount( self, treeiter: Gtk.TreeIter, record: Servers.Record ) -> None: - proxy_man = self._get_proxy_man() res = Servers.query_playercount(record) if res is None: return diff --git a/dzgui/views/components/right_panel.py b/dzgui/views/components/right_panel.py index 47f0ad6..9901e6a 100644 --- a/dzgui/views/components/right_panel.py +++ b/dzgui/views/components/right_panel.py @@ -74,28 +74,3 @@ class RightPanel(Gtk.Box): self.version_label.set_text("Copied!") copy_clipboard(version) GLib.timeout_add_seconds(0.5, revert) - - # TODO: reference for ping pass - # def _on_ping_clicked(self, button: Gtk.Button) -> None: - # block_signals() - # def _update_pings(): - # 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 - # treeview.set_model(temp_model) - # treeview.wait_dialog.destroy() - # treeview.grab_focus() - # unblock_signals() - # temp_model = self.AppNav.treeview.get_model() - # treeview.set_model(None) - # thread = threading.Thread(target=_update_pings, args=()) - # thread.start() From 8e6cff4bf094e2cf95045a8000149d32056614e9 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Tue, 21 Apr 2026 01:22:56 +0900 Subject: [PATCH 154/309] fix: start filters insensitive if tree is empty --- dzgui/controllers/emitter.py | 5 +++++ dzgui/views/components/right_panel.py | 10 ++++++++++ dzgui/views/pages/log.py | 14 +++++++++++++- dzgui/views/pages/servers.py | 22 ++++++++++++++++------ dzgui/views/trees/tree_log.py | 14 -------------- 5 files changed, 44 insertions(+), 21 deletions(-) diff --git a/dzgui/controllers/emitter.py b/dzgui/controllers/emitter.py index 4a46311..557e9d7 100644 --- a/dzgui/controllers/emitter.py +++ b/dzgui/controllers/emitter.py @@ -60,6 +60,11 @@ class Emitter(GObject.GObject): def check_button_pressed(self, keyval: int) -> None: """User toggled filter panel check button via keyboard""" + @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object,)) + def server_page_changed(self, keyval: int) -> None: + """Triggered on 'switch-page' signal within tabs of NotebookPage.SERVERS.""" + pass + @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(bool,)) def server_page_toggled(self, keyval: int) -> None: """Triggered on map/unmap signal from NotebookPage.SERVERS. Shows/hides grid panels.""" diff --git a/dzgui/views/components/right_panel.py b/dzgui/views/components/right_panel.py index 9901e6a..daccc5b 100644 --- a/dzgui/views/components/right_panel.py +++ b/dzgui/views/components/right_panel.py @@ -16,6 +16,7 @@ from gi.repository import Gtk, Gdk, GLib # noqa E402 if TYPE_CHECKING: from dzgui.controllers.mc import Controller from dzgui.controllers.emitter import Emitter + from dzgui.controllers.views.trees.tree_servers import ServerTreeView class RightPanel(Gtk.Box): @@ -27,6 +28,7 @@ class RightPanel(Gtk.Box): emitter = controller.get_emitter() emitter.connect("servers_loaded", self._on_servers_loaded) + emitter.connect("server_page_changed", self._on_server_page_changed) self.button_vbox = ButtonBox(controller) self.filters_vbox = FilterPanel(controller) @@ -56,7 +58,15 @@ class RightPanel(Gtk.Box): self.pack_start(self.sel_panel, NO_EXPAND, NO_FILL, NO_PADDING) self.pack_start(eb, NO_EXPAND, FILL, NO_PADDING) + def _on_server_page_changed(self, emitter: "Emitter", page: "ServerTreeView") -> None: + """Initially set filter area to disabled""" + if page.loaded is True: + return + self.filters_vbox.set_sensitive(False) + def _on_servers_loaded(self, emitter: "Emitter", context: "ServerTab") -> None: + # TODO: similar logic on notebook page change + print('servers loaded') state = self.controller.has_server_model() for widget in (self.refresh_button, self.filters_vbox): widget.set_sensitive(state) diff --git a/dzgui/views/pages/log.py b/dzgui/views/pages/log.py index f216327..88f15da 100644 --- a/dzgui/views/pages/log.py +++ b/dzgui/views/pages/log.py @@ -17,7 +17,19 @@ class Log(CursorMixin, HelpMenuMixin, Gtk.ScrolledWindow): # type: ignore def __init__(self, controller: "Controller") -> None: super().__init__() self.treeview = LogTreeView(controller) - self.add(self.treeview) + self.box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + self.box.add(self.treeview) + + #self.add(self.treeview) + # TODO proxymodel into this + + self.check_bar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + for check in ("Warning", "Critical", "Debug", "Info"): + c = Gtk.CheckButton(label=check) + self.check_bar.pack_start(c, False, False, 0) + # c.set_active(True) + self.box.add(self.check_bar) + self.add(self.box) self.controller = controller self.controller.register_widget("logtreeview", self.treeview) diff --git a/dzgui/views/pages/servers.py b/dzgui/views/pages/servers.py index b8f1599..0b632f9 100644 --- a/dzgui/views/pages/servers.py +++ b/dzgui/views/pages/servers.py @@ -18,6 +18,17 @@ if TYPE_CHECKING: from dzgui.controllers.mc import Emitter +class ScrollableTree(Gtk.ScrolledWindow): + def __init__(self, tree: "ServerTreeView") -> None: + super().__init__() + + self.tree = tree + self.add(tree) + + def get_tree(self) -> "ServerTreeView": + return self.tree + + class ServerNotebook(Gtk.ScrolledWindow): def __init__(self, controller: "Controller"): super().__init__() @@ -45,8 +56,7 @@ class ServerNotebook(Gtk.ScrolledWindow): ] for tree, label in tabs: - scrolled = Gtk.ScrolledWindow() - scrolled.add(tree) + scrolled = ScrollableTree(tree) self.notebook.append_page(scrolled, Gtk.Label(label=label)) self.add(self.notebook) @@ -77,9 +87,6 @@ class ServerNotebook(Gtk.ScrolledWindow): def _on_map(self, widget: Self) -> None: self.emitter.emit("server_page_toggled", True) - # FIXME: only applies to server notebook, not atomic page - # this can be delegated to the map/unmap signal of the ServerTreeView itself - # self.get_active_treeview().start_timeout() def _on_unmap(self, widget: Self) -> None: self.emitter.emit("server_page_toggled", False) @@ -105,7 +112,7 @@ class ServerNotebook(Gtk.ScrolledWindow): return self.notebook.get_tab_label_text(child) def _on_page_changed( - self, notebook: Gtk.Notebook, child: Gtk.Widget, index: int + self, notebook: Gtk.Notebook, child: ScrollableTree, index: int ) -> None: if self.controller.loaded is False: return @@ -115,6 +122,9 @@ class ServerNotebook(Gtk.ScrolledWindow): text = label.strip("*") self.notebook.set_tab_label_text(child, text) + tree = child.get_tree() + self.emitter.emit("server_page_changed", tree) + # NOTE: spawns a thread self.controller.populate_model(self.get_active_treeview()) diff --git a/dzgui/views/trees/tree_log.py b/dzgui/views/trees/tree_log.py index 29268a5..3bc0866 100644 --- a/dzgui/views/trees/tree_log.py +++ b/dzgui/views/trees/tree_log.py @@ -52,20 +52,6 @@ class LogTreeView(ContextMixin, TreeView): if event.button == Gdk.BUTTON_SECONDARY: self.present_menu(widget, event) return True - #selection = self.get_selection() - #model, selected_paths = selection.get_selected_rows() - #if len(selected_paths) > 1: - # for path in selected_paths: - # selection.select_path(path) - # path, col, x, y = self.get_path_at_pos(int(event.x), int(event.y)) - # if path: - # selection = self.get_selection() - # model, selected_paths = selection.get_selected_rows() - # if path not in selected_paths: - # selection.select_path(path) - # self.present_menu(widget, event) - # return True - #return False def _on_log_keypress(self, widget: Gtk.Widget, event: Gdk.EventKey) -> None: self.present_menu(widget, event) From d45d0a8da0aa34410cc5c84a1ae4b0fb06ea7df4 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Tue, 21 Apr 2026 01:37:38 +0900 Subject: [PATCH 155/309] chore: drop unused code --- dzgui/model/proxy_model.py | 33 --------------------------- dzgui/views/components/right_panel.py | 8 ++++--- 2 files changed, 5 insertions(+), 36 deletions(-) diff --git a/dzgui/model/proxy_model.py b/dzgui/model/proxy_model.py index c132925..5454b17 100644 --- a/dzgui/model/proxy_model.py +++ b/dzgui/model/proxy_model.py @@ -12,11 +12,6 @@ if TYPE_CHECKING: from dzgui.model.model_factory import FastInsertListStore from dzgui.model.servers import NewPlayerCount -import gi - -gi.require_version("Gtk", "3.0") -from gi.repository.Gtk import TreeIter - class ProxyModelManager: """ @@ -32,7 +27,6 @@ class ProxyModelManager: def __init__(self, filter_man: "FilterManager") -> None: self.filter_cache = {} - self.ping_cache: dict[str, int] = {} self.proxy_model: "FastInsertListStore" = None self.filter_man = filter_man @@ -103,17 +97,6 @@ class ProxyModelManager: self.set_filtered(self.filter_toggle_off(filters, f)) rows = self.filtered - # case FilterMode.TOGGLE_ON: - # rows = self.filter_toggle_on(filters) - # # rows = self.filter_toggle_on(filters, *args) - - # TODO: unimplemented - # just write pings into control model instead - # if mode is not FilterMode.INITIAL: - # for row in rows: - # if row[7] in self.ping_cache: - # row[9] = self.ping_cache[row[7]] - # NOTE: this FastInsertListStore manipulation must remain local to the thread clone = ModelFactory().make_server_store() if len(rows) > 0: @@ -273,25 +256,9 @@ class ProxyModelManager: def get_control(self) -> list: return self.control_model - # @deprecated("Legacy code") - # def set_success(self, result: bool) -> None: - # self.success = result - - # @deprecated("Legacy code") - # def get_success(self) -> bool: - # return self.success - - # NOTE: used when adding/removing rows in-situ in the proxy model - # and syncing changes to control model, but ignored for player count/ping updates - # cf. remove_from_history(), remove_server() - # NOTE: this can most likely be simplified for v7 def wipe_cache(self, full=False) -> None: - # self.set_success(True) self.filtered = None self.filter_cache = {} - self.ping_cache = {} - # if full: - # self.control_model = None def push(self, data: list[Any]) -> None: self.wipe_cache() diff --git a/dzgui/views/components/right_panel.py b/dzgui/views/components/right_panel.py index daccc5b..084634d 100644 --- a/dzgui/views/components/right_panel.py +++ b/dzgui/views/components/right_panel.py @@ -6,7 +6,7 @@ from dzgui.views.components.buttonbox import ButtonBox from dzgui.views.components.filter_panel import FilterPanel from dzgui.views.components.mod_panel import ModSelectionPanel from dzgui.views.components.buttons import RefreshButton, KeysButton -from dzgui.const.constants import EXPAND, NO_EXPAND, NO_FILL, FILL, NO_PADDING +from dzgui.const.constants import NO_EXPAND, NO_FILL, FILL, NO_PADDING import gi @@ -58,7 +58,9 @@ class RightPanel(Gtk.Box): self.pack_start(self.sel_panel, NO_EXPAND, NO_FILL, NO_PADDING) self.pack_start(eb, NO_EXPAND, FILL, NO_PADDING) - def _on_server_page_changed(self, emitter: "Emitter", page: "ServerTreeView") -> None: + def _on_server_page_changed( + self, emitter: "Emitter", page: "ServerTreeView" + ) -> None: """Initially set filter area to disabled""" if page.loaded is True: return @@ -66,7 +68,7 @@ class RightPanel(Gtk.Box): def _on_servers_loaded(self, emitter: "Emitter", context: "ServerTab") -> None: # TODO: similar logic on notebook page change - print('servers loaded') + print("servers loaded") state = self.controller.has_server_model() for widget in (self.refresh_button, self.filters_vbox): widget.set_sensitive(state) From a2231e3cb90dc51fba3eaf34168ddb9c50be5462 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Tue, 21 Apr 2026 22:20:06 +0900 Subject: [PATCH 156/309] feat: toggle log levels --- dzgui/managers/thread_man.py | 6 +++++- dzgui/views/pages/log.py | 27 ++++++++++++++++----------- dzgui/views/trees/tree_log.py | 25 +++++++++++++++++++++++-- 3 files changed, 44 insertions(+), 14 deletions(-) diff --git a/dzgui/managers/thread_man.py b/dzgui/managers/thread_man.py index 41d72bf..e3f6013 100644 --- a/dzgui/managers/thread_man.py +++ b/dzgui/managers/thread_man.py @@ -1,12 +1,14 @@ import inspect import logging import threading -from typing import Any +from typing import Any, TYPE_CHECKING from functools import wraps from typing import Callable from dzgui.views.dialogs.generic import WaitDialog +if TYPE_CHECKING: + from dzgui.controllers.mc import Controller import gi @@ -29,7 +31,9 @@ def call_on_thread(dialog_str: str) -> Callable: "Attribute 'thread_man' must be of type 'ThreadingManager'" ) self.thread_man.call_on_thread(dialog_str, stored) + return wrapper + return decorator diff --git a/dzgui/views/pages/log.py b/dzgui/views/pages/log.py index 88f15da..dcd717a 100644 --- a/dzgui/views/pages/log.py +++ b/dzgui/views/pages/log.py @@ -13,29 +13,34 @@ if TYPE_CHECKING: from dzgui.controllers.mc import Controller -class Log(CursorMixin, HelpMenuMixin, Gtk.ScrolledWindow): # type: ignore +class Log(CursorMixin, HelpMenuMixin, Gtk.Box): # type: ignore def __init__(self, controller: "Controller") -> None: - super().__init__() + super().__init__(orientation=Gtk.Orientation.VERTICAL) + self.scrolled = Gtk.ScrolledWindow() self.treeview = LogTreeView(controller) - self.box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) - self.box.add(self.treeview) - - #self.add(self.treeview) - # TODO proxymodel into this + self.scrolled.add(self.treeview) self.check_bar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) - for check in ("Warning", "Critical", "Debug", "Info"): + # TODO: strings + active = ("WARNING", "CRITICAL", "INFO") + for check in ("WARNING", "CRITICAL", "INFO", "DEBUG"): c = Gtk.CheckButton(label=check) self.check_bar.pack_start(c, False, False, 0) - # c.set_active(True) - self.box.add(self.check_bar) - self.add(self.box) + if check in active: + c.set_active(True) + c.connect("clicked", self._on_checkbox_clicked) + self.add(self.scrolled) + self.add(self.check_bar) self.controller = controller self.controller.register_widget("logtreeview", self.treeview) self.connect("key-press-event", self._on_esc_keypress) + def _on_checkbox_clicked(self, checkbox: Gtk.CheckButton) -> None: + label = checkbox.get_label() + self.treeview.toggle_filter(label) + def get_treeview(self) -> LogTreeView: return self.treeview diff --git a/dzgui/views/trees/tree_log.py b/dzgui/views/trees/tree_log.py index 3bc0866..3a479de 100644 --- a/dzgui/views/trees/tree_log.py +++ b/dzgui/views/trees/tree_log.py @@ -1,6 +1,6 @@ import logging -from typing import TYPE_CHECKING +from typing import Any, TYPE_CHECKING from dzgui.const.enum import ContextMenuGroup from dzgui.model.model_factory import ModelFactory @@ -28,9 +28,13 @@ class LogTreeView(ContextMixin, TreeView): self.set_headers_visible(True) self.set_fixed_height_mode(True) + self.set_vexpand(True) self.get_selection().set_mode(Gtk.SelectionMode.MULTIPLE) self.set_model(None) + self.filter: Gtk.TreeModelFilter + # TODO: strings + self.filters = ["INFO", "CRITICAL", "WARNING"] for i, column_title in enumerate(strings.log_cols): renderer = Gtk.CellRendererText() @@ -45,9 +49,26 @@ class LogTreeView(ContextMixin, TreeView): def populate_log(self, filepath: str) -> None: model = ModelFactory().new_model_from_logfile(filepath) - self.set_model(model) + self.filter = model.filter_new() + self.filter.set_visible_func(self._filter_rows) + self.set_model(self.filter) + self.filter.refilter() self.set_cursor(0) + def toggle_filter(self, _filter: str) -> None: + if _filter in self.filters: + self.filters.remove(_filter) + else: + self.filters.append(_filter) + self.filter.refilter() + + def _filter_rows( + self, model: Gtk.ListStore, _iter: Gtk.TreeIter, data: Any + ) -> None: + if len(self.filters) < 1: + return False + return model[_iter][1] in self.filters + def _on_log_buttonpress(self, widget: Gtk.Widget, event: Gdk.EventButton) -> None: if event.button == Gdk.BUTTON_SECONDARY: self.present_menu(widget, event) From d9ec01d9b040e34221fefe8f17a71c31c2275f16 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 22 Apr 2026 22:43:07 +0900 Subject: [PATCH 157/309] feat: alert button --- dzgui/const/constants.py | 9 ++++---- dzgui/controllers/mc.py | 9 ++++++++ dzgui/util/strings.py | 2 ++ dzgui/views/components/buttons.py | 27 ++++++++++++++++++++++- dzgui/views/components/statusbar.py | 33 ++++++++++++++++++----------- dzgui/views/pages/log.py | 5 ++++- dzgui/views/trees/tree_log.py | 7 ++++-- 7 files changed, 72 insertions(+), 20 deletions(-) diff --git a/dzgui/const/constants.py b/dzgui/const/constants.py index ab43f3f..ed3f578 100644 --- a/dzgui/const/constants.py +++ b/dzgui/const/constants.py @@ -31,14 +31,15 @@ HEX_RED = "#FF0000" HEX_ORANGE = "#FFAC1C" CLIPBOARD = "edit-copy-symbolic" +HELP_BUBBLE = "help-about-symbolic" +INPUT_KEYBOARD = "input-keyboard-symbolic" LIST_ADD = "list-add-symbolic" +REFRESH_ICON = "view-refresh-symbolic" +SEARCH_ICON = "system-search-symbolic" STEAM_ICON = "steam_tray_mono" VIEW_CONCEAL = "view-conceal-symbolic" -SEARCH_ICON = "system-search-symbolic" VIEW_REVEAL = "view-reveal-symbolic" -INPUT_KEYBOARD = "input-keyboard-symbolic" -HELP_BUBBLE = "help-about-symbolic" -REFRESH_ICON = "view-refresh-symbolic" +WARNING = "dialog-warning-symbolic" WEB_BROWSER = "web-browser-symbolic" SEPARATOR = "SEPARATOR" diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 8838770..13891dc 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -36,6 +36,7 @@ from dzgui.const.enum import ( from dzgui.config.query import lookup from dzgui.config.userprefs import UserPrefs from dzgui.controllers.emitter import Emitter +from dzgui.managers.log import LogManager from dzgui.managers.config import ConfigManager from dzgui.managers.connection import ConnectionManager from dzgui.managers.contextmenu import ContextMenuManager @@ -638,3 +639,11 @@ class Controller(GObject.GObject): def set_active_keyword(self, keyword: str) -> None: self.get_filter_man().set_active_keyword(keyword) + + def set_alerts(self, count: tuple[int]) -> None: + self.alerts = count + + def get_alerts(self) -> tuple[int]: + log_man = LogManager() + alerts = log_man.get_alerts(self.prefs.paths.debug) + return alerts diff --git a/dzgui/util/strings.py b/dzgui/util/strings.py index a4d5145..9f0ef25 100644 --- a/dzgui/util/strings.py +++ b/dzgui/util/strings.py @@ -613,3 +613,5 @@ steam_icon_missing = "Steam icon not found in IconTheme" missing_changelog = "Error: failed to read changelog" esc_to_return = "Press ESC to return" question_to_return = "Press ? to return" + +alert_button_tooltip = "Click to open debug log" diff --git a/dzgui/views/components/buttons.py b/dzgui/views/components/buttons.py index bc596b7..8a08e63 100644 --- a/dzgui/views/components/buttons.py +++ b/dzgui/views/components/buttons.py @@ -1,13 +1,15 @@ from typing import Callable, Literal, Self, TYPE_CHECKING from dzgui.util.clip import copy_clipboard -from dzgui.util.strings import atomic_buttons, connect_panel +from dzgui.util.format import pluralize +from dzgui.util.strings import alert_button_tooltip, atomic_buttons, connect_panel from dzgui.const.constants import ( CLIPBOARD, INPUT_KEYBOARD, LIST_ADD, REFRESH_ICON, STEAM_ICON, + WARNING, WEB_BROWSER, ) @@ -157,3 +159,26 @@ class AddButton(IconTextButton): def __init__(self) -> None: super().__init__(icon=LIST_ADD, label=connect_panel.add) self.set_tooltip_text(connect_panel.add_tooltip) + + +class LoggerAlertsButton(IconTextButton): + def __init__(self, warnings: int, errors: int) -> None: + warnings_text = "" + errors_text = "" + separator = "" + if warnings > 0: + warnings_plural = pluralize("warnings", warnings) + warnings_text = f"{warnings} {warnings_plural}" + if errors > 0: + errors_plural = pluralize("errors", errors) + errors_text = f" {errors} {errors_plural}" + if warnings + errors > 1: + separator = "," + concat = warnings_text + separator + errors_text + super().__init__( + icon=WARNING, + label=f"Loaded with{concat}", + ) + self.set_halign(Gtk.Align.END) + self.set_hexpand(True) + self.set_tooltip_text(alert_button_tooltip) diff --git a/dzgui/views/components/statusbar.py b/dzgui/views/components/statusbar.py index 4e8f789..7b5682a 100644 --- a/dzgui/views/components/statusbar.py +++ b/dzgui/views/components/statusbar.py @@ -2,6 +2,7 @@ from typing import Self, Union, TYPE_CHECKING from dzgui.const.enum import NotebookPage, ServerTab from dzgui.util.strings import esc_to_return, question_to_return +from dzgui.views.components.buttons import LoggerAlertsButton import gi @@ -32,13 +33,22 @@ class Statusbar(Gtk.Grid): self.attach(self.statusbar, 0, 0, 3, 1) self.attach_next_to(self.spinner, self.statusbar, Gtk.PositionType.RIGHT, 3, 1) + warnings, errors = self.controller.get_alerts() + + if warnings + errors > 0: + alert_button = LoggerAlertsButton(warnings, errors) + self.attach_next_to( + alert_button, self.spinner, Gtk.PositionType.RIGHT, 3, 1 + ) + alert_button.connect("clicked", self._on_alerts_clicked) + # TODO: pack version event box in right panel into hbox with update button # TODO: spawns a modal or just jumps right to install page - #from dzgui.views.components.buttons import IconTextButton - #b = IconTextButton("dialog-information-symbolic", label="Updates available") - #b.set_halign(Gtk.Align.END) - #b.set_hexpand(True) - #self.attach_next_to(b, self.spinner, Gtk.PositionType.RIGHT, 3, 1) + # from dzgui.views.components.buttons import IconTextButton + # b = IconTextButton("dialog-information-symbolic", label="Updates available") + # b.set_halign(Gtk.Align.END) + # b.set_hexpand(True) + # self.attach_next_to(b, self.spinner, Gtk.PositionType.RIGHT, 3, 1) self.players = "" @@ -52,6 +62,12 @@ class Statusbar(Gtk.Grid): self.emitter.connect("servers_loaded", self._on_servers_loaded) self.emitter.connect("mod_page_loaded", self._on_mod_page_loaded) + def _on_alerts_clicked(self, button: LoggerAlertsButton) -> None: + button.hide() + # FIXME: hitting ESC from here goes to help list instead of main menu + # need some kind of flag + self.controller.populate_log() + def _on_mod_page_loaded(self, emitter: "Emitter") -> None: msg = self.controller.format_mod_statusbar() self.set_by_context(NotebookPage.MODS, msg) @@ -80,13 +96,6 @@ class Statusbar(Gtk.Grid): match enum: case NotebookPage.HELP: bar = self.controller.get_help_row() - # TODO: drop - # case NotebookPage.MODS: - # return - # case NotebookPage.SERVERS: - # context = self.controller.get_active_context() - # # self.emitter.emit("servers_loaded", context) - # return case NotebookPage.KEYS: bar = question_to_return case _: diff --git a/dzgui/views/pages/log.py b/dzgui/views/pages/log.py index dcd717a..837435f 100644 --- a/dzgui/views/pages/log.py +++ b/dzgui/views/pages/log.py @@ -22,7 +22,10 @@ class Log(CursorMixin, HelpMenuMixin, Gtk.Box): # type: ignore self.check_bar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) # TODO: strings - active = ("WARNING", "CRITICAL", "INFO") + active = ( + "WARNING", + "CRITICAL", + ) for check in ("WARNING", "CRITICAL", "INFO", "DEBUG"): c = Gtk.CheckButton(label=check) self.check_bar.pack_start(c, False, False, 0) diff --git a/dzgui/views/trees/tree_log.py b/dzgui/views/trees/tree_log.py index 3a479de..34e64a6 100644 --- a/dzgui/views/trees/tree_log.py +++ b/dzgui/views/trees/tree_log.py @@ -33,15 +33,18 @@ class LogTreeView(ContextMixin, TreeView): self.set_model(None) self.filter: Gtk.TreeModelFilter - # TODO: strings - self.filters = ["INFO", "CRITICAL", "WARNING"] + # TODO: strings + # NOTE: default filters that should be shown on startup + self.filters = ["CRITICAL", "WARNING"] 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) + if i == 1: + column.set_fixed_width(100) self.append_column(column) self.connect("button-press-event", self._on_log_buttonpress) From c6f1d62c730c28a489313b8d11609b213148757d Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 22 Apr 2026 23:11:40 +0900 Subject: [PATCH 158/309] fixme: decrement button after thread closes --- dzgui/controllers/emitter.py | 2 ++ dzgui/controllers/mc.py | 1 + dzgui/main.py | 1 + dzgui/model/servers.py | 2 -- dzgui/views/components/buttons.py | 24 ++++++++++++++++-------- dzgui/views/components/right_panel.py | 1 - dzgui/views/pages/log.py | 2 ++ 7 files changed, 22 insertions(+), 11 deletions(-) diff --git a/dzgui/controllers/emitter.py b/dzgui/controllers/emitter.py index 557e9d7..b344a92 100644 --- a/dzgui/controllers/emitter.py +++ b/dzgui/controllers/emitter.py @@ -139,3 +139,5 @@ class Emitter(GObject.GObject): @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(str,)) def keyword_set(self, keyword: str) -> None: pass + + # TODO: if servers fail to load, may leave dangling widgets waiting for a signal diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 13891dc..23075fb 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -526,6 +526,7 @@ class Controller(GObject.GObject): # TODO: rename def _on_servers_loaded_init(self, emitter: "Emitter") -> None: + """Triggered after servers load but prior to maps loading""" # FIXME: wipe maps store when changing tabs if model is none tv = self.get_active_treeview() if tv.loaded is False: diff --git a/dzgui/main.py b/dzgui/main.py index 6d991dc..fa174b6 100644 --- a/dzgui/main.py +++ b/dzgui/main.py @@ -143,4 +143,5 @@ def main() -> None: use_miles, ) print("All OK. Loading UI...") + logger.critical("TEST") App(prefs) diff --git a/dzgui/model/servers.py b/dzgui/model/servers.py index f8275a5..cf0cfa2 100644 --- a/dzgui/model/servers.py +++ b/dzgui/model/servers.py @@ -87,8 +87,6 @@ class ServerModelManager: def refresh(self) -> None: self.preserve_on_fail = True self.load() - # TODO: if refresh is active, callback to button decrement signal - # and do not mark refresh as sensitive @call_on_thread(dialog.fetching) def _dump_api(self) -> None: diff --git a/dzgui/views/components/buttons.py b/dzgui/views/components/buttons.py index 8a08e63..bf85c21 100644 --- a/dzgui/views/components/buttons.py +++ b/dzgui/views/components/buttons.py @@ -21,6 +21,8 @@ from gi.repository import Gtk, GLib # noqa E402 if TYPE_CHECKING: from gi.repository import GLib from dzgui.controllers.mc import Controller + from dzgui.controllers.emitter import Emitter + from dzgui.const.enum import ServerTab class Icon(Gtk.Image): @@ -88,6 +90,9 @@ class RefreshButton(IconTextButton): label=atomic_buttons.refresh, ) self.controller = controller + emitter = self.controller.get_emitter() + self.loading = False + self.time = 30 self.set_margin_top(10) @@ -96,18 +101,21 @@ class RefreshButton(IconTextButton): self.set_tooltip_text(atomic_buttons.refresh_tooltip) self.connect("clicked", self._on_refresh_clicked) + emitter.connect("servers_loaded", self.start_decrement) def _on_refresh_clicked(self, button: Self) -> None: + """Spawned in a thread""" + self.loading = True + self.controller.refresh_tree() # TODO: get server tab enum # if LAN tab, reload existing entries in place - self.set_sensitive(False) - self.show_time(True) - # FIXME: do this after threaded call fully finishes - # FIXME: other signals cause refresh button to become sensitive - # after model is loaded - # TODO: put in smm cleanup func, use signal - GLib.timeout_add_seconds(1, self.decrement) - self.controller.refresh_tree() + + def start_decrement(self, emitter: "Emitter", tab: "ServerTab") -> None: + if self.loading: + self.set_sensitive(False) + self.loading = False + self.show_time(True) + GLib.timeout_add_seconds(1, self.decrement) def decrement(self) -> "GLib.SOURCE_REMOVE" | Literal[True]: self.time -= 1 diff --git a/dzgui/views/components/right_panel.py b/dzgui/views/components/right_panel.py index 084634d..a03211b 100644 --- a/dzgui/views/components/right_panel.py +++ b/dzgui/views/components/right_panel.py @@ -68,7 +68,6 @@ class RightPanel(Gtk.Box): def _on_servers_loaded(self, emitter: "Emitter", context: "ServerTab") -> None: # TODO: similar logic on notebook page change - print("servers loaded") state = self.controller.has_server_model() for widget in (self.refresh_button, self.filters_vbox): widget.set_sensitive(state) diff --git a/dzgui/views/pages/log.py b/dzgui/views/pages/log.py index 837435f..f1c48e1 100644 --- a/dzgui/views/pages/log.py +++ b/dzgui/views/pages/log.py @@ -16,6 +16,8 @@ if TYPE_CHECKING: class Log(CursorMixin, HelpMenuMixin, Gtk.Box): # type: ignore def __init__(self, controller: "Controller") -> None: super().__init__(orientation=Gtk.Orientation.VERTICAL) + # NOTE: LogTreeView is kept in a separate ScrolledWindow so that + # checkboxes will be flush on bottom self.scrolled = Gtk.ScrolledWindow() self.treeview = LogTreeView(controller) self.scrolled.add(self.treeview) From 106b2018049fedbf2271f1076635286f567a3fd9 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 22 Apr 2026 23:13:11 +0900 Subject: [PATCH 159/309] chore: drop test logging --- dzgui/main.py | 1 - 1 file changed, 1 deletion(-) diff --git a/dzgui/main.py b/dzgui/main.py index fa174b6..6d991dc 100644 --- a/dzgui/main.py +++ b/dzgui/main.py @@ -143,5 +143,4 @@ def main() -> None: use_miles, ) print("All OK. Loading UI...") - logger.critical("TEST") App(prefs) From 978daa7a287fc8134eee98df0a03317042696fdf Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 22 Apr 2026 23:13:26 +0900 Subject: [PATCH 160/309] feat: LogManager --- dzgui/managers/log.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 dzgui/managers/log.py diff --git a/dzgui/managers/log.py b/dzgui/managers/log.py new file mode 100644 index 0000000..46933f9 --- /dev/null +++ b/dzgui/managers/log.py @@ -0,0 +1,27 @@ +import logging +from pathlib import Path +from dzgui.util.strings import delimiter + +logger = logging.getLogger(__name__) + + +class LogManager: + def __init__(self) -> None: + self.CRITICAL = "CRITICAL" + self.WARNING = "WARNING" + + # TODO: more of a static method + def get_alerts(self, log_path: Path) -> tuple[int]: + try: + with open(log_path, "r") as f: + lines = f.read().splitlines() + errors = [ + line for line in lines if line.split(delimiter)[1] == self.CRITICAL + ] + warnings = [ + line for line in lines if line.split(delimiter)[1] == self.WARNING + ] + return len(warnings), len(errors) + except Exception as e: + logger.critical(e) + return 0, 0 From dff9b884a849e4d66966b779dee37d7d5f8de24d Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 22 Apr 2026 23:20:29 +0900 Subject: [PATCH 161/309] chore: reword tooltip --- dzgui/util/strings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dzgui/util/strings.py b/dzgui/util/strings.py index 9f0ef25..aa081b5 100644 --- a/dzgui/util/strings.py +++ b/dzgui/util/strings.py @@ -549,7 +549,7 @@ connect_panel = ConnectPanel( "- IP: format as IP:Query port\ne.g. 192.168.1.1:27016\n" "- Battlemetrics: numeric server ID\ne.g. 123456" ), - add_tooltip="Add to my servers", + add_tooltip="Add to Saved Servers", connect_tooltip="Connect to this server", ) From 8665292ff87d7b6b132447b510820d36ba073d07 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 22 Apr 2026 23:21:06 +0900 Subject: [PATCH 162/309] chore: update changelog --- CHANGELOG.md | 2 ++ dzgui/data/CHANGELOG.md | 2 ++ 2 files changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c1fa63..c07b2d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,8 @@ - Copy version by clicking label - Add server name to server mod dialogs - Lazy load ping cell data func +- Refresh servers button +- Early load alerts button ## Changed - Reduce padding on keys button diff --git a/dzgui/data/CHANGELOG.md b/dzgui/data/CHANGELOG.md index 9c1fa63..c07b2d4 100644 --- a/dzgui/data/CHANGELOG.md +++ b/dzgui/data/CHANGELOG.md @@ -28,6 +28,8 @@ - Copy version by clicking label - Add server name to server mod dialogs - Lazy load ping cell data func +- Refresh servers button +- Early load alerts button ## Changed - Reduce padding on keys button From 2f6a78fc65d25bc2ac0daa374b587847004f836f Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 23 Apr 2026 00:41:26 +0900 Subject: [PATCH 163/309] feat: wrap TreeModelFilter in TreeModelSort --- dzgui/api/servers.py | 2 +- dzgui/controllers/emitter.py | 4 ++++ dzgui/controllers/mc.py | 3 +++ dzgui/main.py | 1 + dzgui/views/base.py | 3 +++ dzgui/views/components/connect_panel.py | 2 +- dzgui/views/components/statusbar.py | 13 +++++++------ dzgui/views/mixins/help_menu_mixin.py | 3 ++- dzgui/views/pages/log.py | 2 ++ dzgui/views/trees/tree_log.py | 14 ++++++++------ 10 files changed, 32 insertions(+), 15 deletions(-) diff --git a/dzgui/api/servers.py b/dzgui/api/servers.py index 9c728da..5255e9f 100644 --- a/dzgui/api/servers.py +++ b/dzgui/api/servers.py @@ -216,7 +216,7 @@ def query_direct(ip: str, qport: int, TIMEOUT: float = 3.0) -> dict | None: res["ping"] = ping return res except Exception as e: - logger.critical(e) + logger.critical(f"{type(e).__name__}: {e}") return None diff --git a/dzgui/controllers/emitter.py b/dzgui/controllers/emitter.py index b344a92..58684e9 100644 --- a/dzgui/controllers/emitter.py +++ b/dzgui/controllers/emitter.py @@ -140,4 +140,8 @@ class Emitter(GObject.GObject): def keyword_set(self, keyword: str) -> None: pass + @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=()) + def log_page_loaded(self) -> None: + pass + # TODO: if servers fail to load, may leave dangling widgets waiting for a signal diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 23075fb..a413c22 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -554,6 +554,9 @@ class Controller(GObject.GObject): def get_notebook(self) -> "Notebook": return self.mediator.notebook + def get_prior_page(self) -> NotebookPage: + return self.mediator.notebook.get_prior_page() + def get_servers(self) -> "Notebook": return self.mediator.servers diff --git a/dzgui/main.py b/dzgui/main.py index 6d991dc..eb4000c 100644 --- a/dzgui/main.py +++ b/dzgui/main.py @@ -143,4 +143,5 @@ def main() -> None: use_miles, ) print("All OK. Loading UI...") + logger.critical("e") App(prefs) diff --git a/dzgui/views/base.py b/dzgui/views/base.py index e54c3e6..d165541 100644 --- a/dzgui/views/base.py +++ b/dzgui/views/base.py @@ -180,6 +180,9 @@ class Notebook(ScrollableMixin, Gtk.Notebook): # type: ignore return self.set_page_by_enum(self.prior_page) + def get_prior_page(self) -> NotebookPage: + return self.prior_page + def get_page_by_enum(self) -> NotebookPage | None: for k, v in self.indexes.items(): if v == self.get_current_page(): diff --git a/dzgui/views/components/connect_panel.py b/dzgui/views/components/connect_panel.py index a29f580..71e7fff 100644 --- a/dzgui/views/components/connect_panel.py +++ b/dzgui/views/components/connect_panel.py @@ -78,7 +78,7 @@ class LanPanel(Gtk.Frame): self.entry.set_sensitive(False) - def _on_lan_keypress(self, widget: Gtk.Entry, event: Gdk.EventKey) -> None: + def _on_lan_keypress(self, widget: Gtk.Entry, event: Gdk.EventKey) -> bool: if event.state & Gdk.ModifierType.CONTROL_MASK: if event.keyval == Gdk.KEY_d: self.default_radio.set_active(True) diff --git a/dzgui/views/components/statusbar.py b/dzgui/views/components/statusbar.py index 7b5682a..ceb1c14 100644 --- a/dzgui/views/components/statusbar.py +++ b/dzgui/views/components/statusbar.py @@ -36,11 +36,11 @@ class Statusbar(Gtk.Grid): warnings, errors = self.controller.get_alerts() if warnings + errors > 0: - alert_button = LoggerAlertsButton(warnings, errors) + self.alert_button = LoggerAlertsButton(warnings, errors) self.attach_next_to( - alert_button, self.spinner, Gtk.PositionType.RIGHT, 3, 1 + self.alert_button, self.spinner, Gtk.PositionType.RIGHT, 3, 1 ) - alert_button.connect("clicked", self._on_alerts_clicked) + self.alert_button.connect("clicked", self._on_alerts_clicked) # TODO: pack version event box in right panel into hbox with update button # TODO: spawns a modal or just jumps right to install page @@ -61,13 +61,14 @@ class Statusbar(Gtk.Grid): self.emitter.connect("distcalc_ended", self._on_distcalc_ended) self.emitter.connect("servers_loaded", self._on_servers_loaded) self.emitter.connect("mod_page_loaded", self._on_mod_page_loaded) + self.emitter.connect("log_page_loaded", self._on_log_page_loaded) def _on_alerts_clicked(self, button: LoggerAlertsButton) -> None: - button.hide() - # FIXME: hitting ESC from here goes to help list instead of main menu - # need some kind of flag self.controller.populate_log() + def _on_log_page_loaded(self, emitter: "Emitter") -> None: + self.alert_button.hide() + def _on_mod_page_loaded(self, emitter: "Emitter") -> None: msg = self.controller.format_mod_statusbar() self.set_by_context(NotebookPage.MODS, msg) diff --git a/dzgui/views/mixins/help_menu_mixin.py b/dzgui/views/mixins/help_menu_mixin.py index 9fa3225..3b2870e 100644 --- a/dzgui/views/mixins/help_menu_mixin.py +++ b/dzgui/views/mixins/help_menu_mixin.py @@ -13,5 +13,6 @@ class HelpMenuMixin: self, widget: Gtk.Widget, event: Gdk.EventKey ) -> Literal[True]: if event.keyval == Gdk.KEY_Escape: - self.controller.open_page(NotebookPage.HELP) # type: ignore + prior = self.controller.get_prior_page() + self.controller.open_page(prior) # type: ignore return True diff --git a/dzgui/views/pages/log.py b/dzgui/views/pages/log.py index f1c48e1..2400b2f 100644 --- a/dzgui/views/pages/log.py +++ b/dzgui/views/pages/log.py @@ -39,8 +39,10 @@ class Log(CursorMixin, HelpMenuMixin, Gtk.Box): # type: ignore self.controller = controller self.controller.register_widget("logtreeview", self.treeview) + self.emitter = controller.get_emitter() self.connect("key-press-event", self._on_esc_keypress) + self.connect("map", lambda _: self.emitter.emit("log_page_loaded")) def _on_checkbox_clicked(self, checkbox: Gtk.CheckButton) -> None: label = checkbox.get_label() diff --git a/dzgui/views/trees/tree_log.py b/dzgui/views/trees/tree_log.py index 34e64a6..123e925 100644 --- a/dzgui/views/trees/tree_log.py +++ b/dzgui/views/trees/tree_log.py @@ -32,7 +32,7 @@ class LogTreeView(ContextMixin, TreeView): self.get_selection().set_mode(Gtk.SelectionMode.MULTIPLE) self.set_model(None) - self.filter: Gtk.TreeModelFilter + # self.filter: Gtk.TreeModelFilter # TODO: strings # NOTE: default filters that should be shown on startup @@ -52,10 +52,11 @@ class LogTreeView(ContextMixin, TreeView): def populate_log(self, filepath: str) -> None: model = ModelFactory().new_model_from_logfile(filepath) - self.filter = model.filter_new() - self.filter.set_visible_func(self._filter_rows) - self.set_model(self.filter) - self.filter.refilter() + _filter = model.filter_new() + _filter.set_visible_func(self._filter_rows) + sortable = Gtk.TreeModelSort(_filter) + self.set_model(sortable) + _filter.refilter() self.set_cursor(0) def toggle_filter(self, _filter: str) -> None: @@ -63,7 +64,8 @@ class LogTreeView(ContextMixin, TreeView): self.filters.remove(_filter) else: self.filters.append(_filter) - self.filter.refilter() + # NOTE: unwrap TreeModelSort and TreeModelFilter + self.get_model().get_model().refilter() def _filter_rows( self, model: Gtk.ListStore, _iter: Gtk.TreeIter, data: Any From b86cac37095c291cdb32188a1f1ae680065c49c1 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Fri, 24 Apr 2026 19:56:58 +0900 Subject: [PATCH 164/309] chore: clear typehinting errors --- dzgui/controllers/mc.py | 6 +++--- dzgui/views/base.py | 4 ---- dzgui/views/components/right_panel.py | 2 +- 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index a413c22..c5a08e3 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -490,13 +490,13 @@ class Controller(GObject.GObject): count = format_player_count(model, control_model) return count - def get_statusbar(self) -> None: + def get_statusbar(self) -> "Statusbar": return self.mediator.statusbar def get_proxy_man(self) -> "ProxyModelManager": return self.proxy_man - def populate_model(self, tv: Gtk.TreeView) -> None: + def populate_model(self, tv: "ServerTreeView") -> None: # NOTE: skip on previously loaded tabs if tv.is_loaded(): self.emitter.emit("servers_loaded", tv.get_enum()) @@ -557,7 +557,7 @@ class Controller(GObject.GObject): def get_prior_page(self) -> NotebookPage: return self.mediator.notebook.get_prior_page() - def get_servers(self) -> "Notebook": + def get_servers(self) -> "ServerNotebook": return self.mediator.servers def get_server_notebook(self) -> "Notebook": diff --git a/dzgui/views/base.py b/dzgui/views/base.py index d165541..fae6294 100644 --- a/dzgui/views/base.py +++ b/dzgui/views/base.py @@ -277,10 +277,6 @@ class Grid(Gtk.Grid): self.conpan.set_lan_visible(False) self.right_panel.sel_panel.hide() - def _shrink(self, button: Gtk.Button) -> None: - tv = MainController.get_active_treeview() - tv.shrink_to_fit() - def toggle_filter_panels(self, emitter: "Emitter", state: bool) -> None: self.right_panel.filters_vbox.set_visible(state) self.conpan.set_visible(state) diff --git a/dzgui/views/components/right_panel.py b/dzgui/views/components/right_panel.py index a03211b..5e97e8c 100644 --- a/dzgui/views/components/right_panel.py +++ b/dzgui/views/components/right_panel.py @@ -84,4 +84,4 @@ class RightPanel(Gtk.Box): version = self.version_label.get_text() self.version_label.set_text("Copied!") copy_clipboard(version) - GLib.timeout_add_seconds(0.5, revert) + GLib.timeout_add_seconds(1, revert) From afcc50e8c36ac3cf1d4d96b87df36755b40aa725 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Fri, 24 Apr 2026 20:00:20 +0900 Subject: [PATCH 165/309] chore: update gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 7063a6a..3f9aba7 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ build/ .pytest_cache/ .mypy_cache/ notes.otl +uv.lock From 979e616c4fdb2e701ad5b96a6c7e6ed071dbe5e8 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Fri, 24 Apr 2026 20:02:15 +0900 Subject: [PATCH 166/309] fix: GLibUnix namespace patch from DZGUI 6 --- dzgui/views/base.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/dzgui/views/base.py b/dzgui/views/base.py index fae6294..af2273d 100644 --- a/dzgui/views/base.py +++ b/dzgui/views/base.py @@ -32,7 +32,8 @@ if TYPE_CHECKING: import gi gi.require_version("Gtk", "3.0") -from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa E402 +gi.require_version("GLibUnix", "2.0") +from gi.repository import Gtk, GLib, GLibUnix, Gdk, GObject, Pango # noqa E402 logger = logging.getLogger(__name__) # https://bugzilla.gnome.org/show_bug.cgi?id=708676 @@ -300,9 +301,23 @@ class App(Gtk.Application): ) self.win.add_accel_group(accel) - GLib.unix_signal_add(GLib.PRIORITY_DEFAULT, signal.SIGINT, self._catch_sigint) + self.setup_signals() Gtk.main() + def _setup_signals(self) -> None: + SIGNAL_ADD = "signal_add" + SIGNAL_ADD_FULL = "signal_add_full" + try: + if SIGNAL_ADD in dir(GLibUnix): + func = GLibUnix.signal_add + elif SIGNAL_ADD_FULL in dir(GLibUnix): + func = GLibUnix.signal_add_full + else: + func = GLib.unix_signal_add + func(GLib.PRIORITY_DEFAULT, signal.SIGINT, self._catch_sigint) + except Exception as e: + logger.critical(e) + def _catch_sigint(self) -> Literal[True]: self.win.halt_proc_and_quit() return True From 07c98594e155e69f7483889465c6237926d63b8d Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Fri, 24 Apr 2026 20:06:44 +0900 Subject: [PATCH 167/309] fixme: private function name --- dzgui/views/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dzgui/views/base.py b/dzgui/views/base.py index af2273d..b361e96 100644 --- a/dzgui/views/base.py +++ b/dzgui/views/base.py @@ -301,7 +301,7 @@ class App(Gtk.Application): ) self.win.add_accel_group(accel) - self.setup_signals() + self._setup_signals() Gtk.main() def _setup_signals(self) -> None: From c4878ad97cec8f27a79b79a2cf6ee9d0cda49426 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Fri, 24 Apr 2026 20:08:08 +0900 Subject: [PATCH 168/309] fix: indentation level --- dzgui/views/base.py | 26 +++++++++++++------------- dzgui/views/components/right_panel.py | 2 +- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/dzgui/views/base.py b/dzgui/views/base.py index b361e96..b4fcb25 100644 --- a/dzgui/views/base.py +++ b/dzgui/views/base.py @@ -304,19 +304,19 @@ class App(Gtk.Application): self._setup_signals() Gtk.main() - def _setup_signals(self) -> None: - SIGNAL_ADD = "signal_add" - SIGNAL_ADD_FULL = "signal_add_full" - try: - if SIGNAL_ADD in dir(GLibUnix): - func = GLibUnix.signal_add - elif SIGNAL_ADD_FULL in dir(GLibUnix): - func = GLibUnix.signal_add_full - else: - func = GLib.unix_signal_add - func(GLib.PRIORITY_DEFAULT, signal.SIGINT, self._catch_sigint) - except Exception as e: - logger.critical(e) + def _setup_signals(self) -> None: + SIGNAL_ADD = "signal_add" + SIGNAL_ADD_FULL = "signal_add_full" + try: + if SIGNAL_ADD in dir(GLibUnix): + func = GLibUnix.signal_add + elif SIGNAL_ADD_FULL in dir(GLibUnix): + func = GLibUnix.signal_add_full + else: + func = GLib.unix_signal_add + func(GLib.PRIORITY_DEFAULT, signal.SIGINT, self._catch_sigint) + except Exception as e: + logger.critical(e) def _catch_sigint(self) -> Literal[True]: self.win.halt_proc_and_quit() diff --git a/dzgui/views/components/right_panel.py b/dzgui/views/components/right_panel.py index 5e97e8c..e9602cb 100644 --- a/dzgui/views/components/right_panel.py +++ b/dzgui/views/components/right_panel.py @@ -69,7 +69,7 @@ class RightPanel(Gtk.Box): def _on_servers_loaded(self, emitter: "Emitter", context: "ServerTab") -> None: # TODO: similar logic on notebook page change state = self.controller.has_server_model() - for widget in (self.refresh_button, self.filters_vbox): + for widget in self.filters_vbox: widget.set_sensitive(state) def _on_version_clicked(self, widget: Gtk.EventBox, event: Gdk.EventButton) -> None: From 66909b668bc2dc22ec68551e61322016670725c1 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 25 Apr 2026 00:24:13 +0900 Subject: [PATCH 169/309] fix: duplicate records in saved servers --- dzgui/model/servers.py | 15 +++++---------- dzgui/views/components/right_panel.py | 3 +-- dzgui/views/pages/servers.py | 4 +++- 3 files changed, 9 insertions(+), 13 deletions(-) diff --git a/dzgui/model/servers.py b/dzgui/model/servers.py index cf0cfa2..baaa65e 100644 --- a/dzgui/model/servers.py +++ b/dzgui/model/servers.py @@ -240,6 +240,7 @@ class ServerModelManager: proxy_man.update_playercount(self.playercount) def _parse_single_record(self, response: dict, delete: bool = False) -> None: + self.preserve_on_fail = True if response is None: self.thread_man.set_cleanup_func(StoredFunc(self._cleanup_on_failure)) return @@ -264,17 +265,12 @@ class ServerModelManager: proxy_man.remove_row_from_control(record) else: if config_man.is_in_favs(fqip): - self.thread_man.set_cleanup_func( - StoredFunc(self._cleanup_when_no_model) - ) return config_man.add_saved_server(fqip) if proxy_man.has_control_model() is False: - self.thread_man.set_cleanup_func( - StoredFunc(self._cleanup_when_no_model) - ) - return - proxy_man.append_row_to_control(record) + self._get_proxy_man().push(records) + else: + proxy_man.append_row_to_control(record) control_model = proxy_man.get_control() self._sort_unique_maps(control_model) @@ -359,8 +355,7 @@ class ServerModelManager: # TODO: disable map, keyword, and filter widgets if model is None # -> signal driven (servers_empty, servers_failed_to_load) - # NOTE: used by refresh button action - if not self.preserve_on_fail: + if self.preserve_on_fail is False: self.tv.set_model(None) filter_man = self.tv.get_filter_man() filter_man.set_unique_maps(None) diff --git a/dzgui/views/components/right_panel.py b/dzgui/views/components/right_panel.py index e9602cb..73610cb 100644 --- a/dzgui/views/components/right_panel.py +++ b/dzgui/views/components/right_panel.py @@ -69,8 +69,7 @@ class RightPanel(Gtk.Box): def _on_servers_loaded(self, emitter: "Emitter", context: "ServerTab") -> None: # TODO: similar logic on notebook page change state = self.controller.has_server_model() - for widget in self.filters_vbox: - widget.set_sensitive(state) + self.filters_vbox.set_sensitive(state) def _on_version_clicked(self, widget: Gtk.EventBox, event: Gdk.EventButton) -> None: def revert() -> GLib.SOURCE_REMOVE: diff --git a/dzgui/views/pages/servers.py b/dzgui/views/pages/servers.py index 0b632f9..92da14d 100644 --- a/dzgui/views/pages/servers.py +++ b/dzgui/views/pages/servers.py @@ -69,8 +69,10 @@ class ServerNotebook(Gtk.ScrolledWindow): self.emitter.connect("saved_servers_changed", self._on_saved_servers_changed) def _on_saved_servers_changed(self, emitter: "Emitter") -> None: + # TODO: can be dropped/consolidated? saved = 1 - if self.notebook.get_current_page() == saved: + cur_page = self.notebook.get_current_page() + if cur_page == saved: return page = self.notebook.get_nth_page(saved) label = self.notebook.get_tab_label(page) From ed508c67641657d098bb35037b9ece8d3bd33a92 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 25 Apr 2026 01:04:14 +0900 Subject: [PATCH 170/309] feat: popover bubble --- dzgui/controllers/emitter.py | 4 ++++ dzgui/controllers/mc.py | 3 +++ dzgui/model/servers.py | 1 + dzgui/views/components/connect_panel.py | 20 +++++++++++++++++--- 4 files changed, 25 insertions(+), 3 deletions(-) diff --git a/dzgui/controllers/emitter.py b/dzgui/controllers/emitter.py index 58684e9..c58f4d7 100644 --- a/dzgui/controllers/emitter.py +++ b/dzgui/controllers/emitter.py @@ -136,6 +136,10 @@ class Emitter(GObject.GObject): def api_change_failed(self) -> None: pass + @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=()) + def already_saved_server(self) -> None: + pass + @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(str,)) def keyword_set(self, keyword: str) -> None: pass diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index c5a08e3..63c0967 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -569,6 +569,9 @@ class Controller(GObject.GObject): def get_menu(self) -> "MenuTreeView": return self.mediator.menu + def is_in_favs(self) -> bool: + return self.config_man.is_in_favs() + def get_config_man(self) -> ConfigManager: return self.config_man diff --git a/dzgui/model/servers.py b/dzgui/model/servers.py index baaa65e..814e81d 100644 --- a/dzgui/model/servers.py +++ b/dzgui/model/servers.py @@ -265,6 +265,7 @@ class ServerModelManager: proxy_man.remove_row_from_control(record) else: if config_man.is_in_favs(fqip): + self.emitter.emit("already_saved_server") return config_man.add_saved_server(fqip) if proxy_man.has_control_model() is False: diff --git a/dzgui/views/components/connect_panel.py b/dzgui/views/components/connect_panel.py index 71e7fff..e75c45c 100644 --- a/dzgui/views/components/connect_panel.py +++ b/dzgui/views/components/connect_panel.py @@ -204,6 +204,7 @@ class AddPanel(Gtk.Frame): self.emitter.connect( "request_ip_entry_focus", lambda _: self.entry.grab_focus() ) + self.emitter.connect("already_saved_server", self._on_duplicate_server) self.grid = Gtk.Grid(margin=10, vexpand=False, column_spacing=15, row_spacing=5) self.grid.attach(self.entry, 0, 0, COLS, ROWS) @@ -216,6 +217,21 @@ class AddPanel(Gtk.Frame): self.add(self.grid) + # TODO: dynamic popover text + self.pop = Gtk.Popover() + self.pop_label = Gtk.Label( + label="This address is already in your Saved Servers", + margin_start=10, + margin_end=10, + ) + self.pop.add(self.pop_label) + self.pop.show_all() + self.pop.set_margin_start(10) + self.pop.set_relative_to(self.entry) + + def _on_duplicate_server(self, emitter: "Emitter") -> None: + self.pop.popup() + def _on_connect_clicked(self, button: Gtk.Button) -> None: addr = self.entry.get_text() self.controller.connect_by_str(addr) @@ -226,9 +242,7 @@ class AddPanel(Gtk.Frame): def _on_activate(self, entry: Gtk.Entry) -> None: # NOTE: default action is to add a record, not connect - if not self.add_server.is_sensitive(): - return - self._add_server() + self.add_server.emit("clicked") def _on_add_clicked(self, button: Gtk.Button) -> None: self._add_server() From 61735d20cf1f02ae0cd52b66fb73200ec6663929 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 25 Apr 2026 19:17:32 +0900 Subject: [PATCH 171/309] fix: wipe cache when removing row --- dzgui/controllers/mc.py | 16 +++++++++++++--- dzgui/main.py | 1 - dzgui/model/proxy_model.py | 2 ++ dzgui/model/servers.py | 8 +------- dzgui/views/components/buttons.py | 5 +++-- dzgui/views/components/statusbar.py | 1 + 6 files changed, 20 insertions(+), 13 deletions(-) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 63c0967..8c7e533 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -536,10 +536,15 @@ class Controller(GObject.GObject): def has_server_model(self) -> bool: treeview = self.get_active_treeview() - return treeview.get_model() is not None + proxy_man = treeview.get_proxy_man() + control = proxy_man.get_control() + if control is None: + return False + if len(control) < 1: + return False + return True def _on_check_toggled(self, emitter: Emitter, label: str, state: bool) -> None: - print("DEBUG: check toggled, refiltering") filter_man = self.get_filter_man() filter_man.set_filter(label, state) @@ -547,7 +552,6 @@ class Controller(GObject.GObject): ServerModelManager(self, self.get_active_treeview()).refilter(mode) def _on_map_selection_changed(self, emitter: Emitter, selection: str) -> None: - print("DEBUG: map sel changed, refiltering") smm = ServerModelManager(self, self.get_active_treeview()) smm.refilter(FilterMode.MAP) @@ -569,6 +573,12 @@ class Controller(GObject.GObject): def get_menu(self) -> "MenuTreeView": return self.mediator.menu + def has_favorites(self) -> bool: + favs = self.config_man.get_favorites() + if len(favs) < 1: + return False + return True + def is_in_favs(self) -> bool: return self.config_man.is_in_favs() diff --git a/dzgui/main.py b/dzgui/main.py index eb4000c..6d991dc 100644 --- a/dzgui/main.py +++ b/dzgui/main.py @@ -143,5 +143,4 @@ def main() -> None: use_miles, ) print("All OK. Loading UI...") - logger.critical("e") App(prefs) diff --git a/dzgui/model/proxy_model.py b/dzgui/model/proxy_model.py index 5454b17..bc816a7 100644 --- a/dzgui/model/proxy_model.py +++ b/dzgui/model/proxy_model.py @@ -51,6 +51,7 @@ class ProxyModelManager: for record in self.control_model: if row[7] == record[7] and row[8] == record[8]: self.control_model.remove(record) + self.wipe_cache() self.filter(FilterMode.INITIAL, skip_cache=True) def update_playercount(self, playercount: "NewPlayerCount") -> None: @@ -214,6 +215,7 @@ class ProxyModelManager: ) -> None: self.filter_cache[filters] = (model, rows) + @deprecated("legacy logic") def resync_model(self, addr: str, qport: int) -> None: """ Handle in-situ updates to model during diff --git a/dzgui/model/servers.py b/dzgui/model/servers.py index 814e81d..1149a0d 100644 --- a/dzgui/model/servers.py +++ b/dzgui/model/servers.py @@ -54,15 +54,9 @@ class ServerModelManager: self.preserve_on_fail = False self.jobs = 1 - # NOTE: store filter man for access inside thread self.proxy_man = tv.get_proxy_man() self.thread_man = ThreadingManager(self.controller) - # TODO: if first iteration, clear filter man control model - # literal first load: iteration 1 - # refresh: should be functionally identical to iteration 1 - # filter: iteration N+1 - def load(self) -> None: """ There may be cases where you want to instantiate this class without dumping servers, @@ -327,8 +321,8 @@ class ServerModelManager: maps = self._get_new_maps() filter_man.set_unique_maps(maps) - self.emitter.emit("servers_loaded_init") self.first_iteration = False + self.emitter.emit("servers_loaded_init") self.emitter.emit("saved_servers_changed") def _update_maps(self) -> None: diff --git a/dzgui/views/components/buttons.py b/dzgui/views/components/buttons.py index bf85c21..c1bd3b1 100644 --- a/dzgui/views/components/buttons.py +++ b/dzgui/views/components/buttons.py @@ -105,8 +105,9 @@ class RefreshButton(IconTextButton): def _on_refresh_clicked(self, button: Self) -> None: """Spawned in a thread""" - self.loading = True - self.controller.refresh_tree() + if self.controller.has_favorites(): + self.loading = True + self.controller.refresh_tree() # TODO: get server tab enum # if LAN tab, reload existing entries in place diff --git a/dzgui/views/components/statusbar.py b/dzgui/views/components/statusbar.py index ceb1c14..7b4dd05 100644 --- a/dzgui/views/components/statusbar.py +++ b/dzgui/views/components/statusbar.py @@ -67,6 +67,7 @@ class Statusbar(Gtk.Grid): self.controller.populate_log() def _on_log_page_loaded(self, emitter: "Emitter") -> None: + # FIXME: fails if button was not previously drawn self.alert_button.hide() def _on_mod_page_loaded(self, emitter: "Emitter") -> None: From 48f69d79e0a61e34b45f2d6b64d3dfcb9148b340 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 25 Apr 2026 19:23:53 +0900 Subject: [PATCH 172/309] chore: consolidate legacy methods --- dzgui/model/proxy_model.py | 16 ---------------- dzgui/views/components/label.py | 12 ------------ dzgui/views/components/labels.py | 11 +++++++++++ dzgui/views/pages/options.py | 2 +- 4 files changed, 12 insertions(+), 29 deletions(-) delete mode 100644 dzgui/views/components/label.py diff --git a/dzgui/model/proxy_model.py b/dzgui/model/proxy_model.py index bc816a7..d7cea56 100644 --- a/dzgui/model/proxy_model.py +++ b/dzgui/model/proxy_model.py @@ -215,22 +215,6 @@ class ProxyModelManager: ) -> None: self.filter_cache[filters] = (model, rows) - @deprecated("legacy logic") - def resync_model(self, addr: str, qport: int) -> None: - """ - Handle in-situ updates to model during - row deletion/insertion 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 = self.get_filters() - refiltered = self.filter_toggle_on(filters) - self.set_filtered(refiltered) - @deprecated("Currently unused") def convert_model_to_list(self, model: "FastInsertListStore") -> list: return [[el for el in row] for row in model] diff --git a/dzgui/views/components/label.py b/dzgui/views/components/label.py deleted file mode 100644 index 4100a01..0000000 --- a/dzgui/views/components/label.py +++ /dev/null @@ -1,12 +0,0 @@ -import gi -gi.require_version("Gtk", "3.0") -from gi.repository import Gtk # noqa E402 - -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 diff --git a/dzgui/views/components/labels.py b/dzgui/views/components/labels.py index df63009..3a5c243 100644 --- a/dzgui/views/components/labels.py +++ b/dzgui/views/components/labels.py @@ -1,12 +1,23 @@ from dzgui.util.format import embolden import gi + gi.require_version("Gtk", "3.0") from gi.repository import Gtk # noqa E402 + class BoldLabel(Gtk.Label): def __init__(self, text: str): super().__init__() label = embolden(text) self.set_markup(label) + + +class LeftLabel(Gtk.Label): + def __init__(self, text: str, tooltip: str = ""): + super().__init__( + label=text, + halign=Gtk.Align.START, + ) + self.set_tooltip_text(tooltip) diff --git a/dzgui/views/pages/options.py b/dzgui/views/pages/options.py index 19327df..4214233 100644 --- a/dzgui/views/pages/options.py +++ b/dzgui/views/pages/options.py @@ -6,7 +6,7 @@ from dzgui.api.steam import find_user_id from dzgui.config import query from dzgui.util import strings, css, open_links -from dzgui.views.components.label import LeftLabel +from dzgui.views.components.labels import LeftLabel from dzgui.views.components.eventbox import InfoEventBox from dzgui.views.components.buttons import WebButton from dzgui.views.dialogs.generic import ExceptionDialog From 9bd286a9245e7d252d8f959076066aae58024d0b Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 25 Apr 2026 20:28:43 +0900 Subject: [PATCH 173/309] fix: remove hardcoded argument --- dzgui/views/trees/tree_servers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index 98ad2cd..4edaeed 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -37,7 +37,7 @@ class ServerTreeView(ContextMixin, TreeView): def __init__( self, controller: "Controller", enum: ServerTab, menu: ContextMenuGroup ) -> None: - super().__init__(controller, menu=ContextMenuGroup.SERVER_BROWSER) + super().__init__(controller, menu=menu) self.controller = controller self.emitter = controller.get_emitter() From 5834f392897aee75105b0200b8aa548f09dd191f Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 25 Apr 2026 20:29:38 +0900 Subject: [PATCH 174/309] fix: do not check favorites on other tabs --- dzgui/views/components/buttons.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/dzgui/views/components/buttons.py b/dzgui/views/components/buttons.py index c1bd3b1..bf85c21 100644 --- a/dzgui/views/components/buttons.py +++ b/dzgui/views/components/buttons.py @@ -105,9 +105,8 @@ class RefreshButton(IconTextButton): def _on_refresh_clicked(self, button: Self) -> None: """Spawned in a thread""" - if self.controller.has_favorites(): - self.loading = True - self.controller.refresh_tree() + self.loading = True + self.controller.refresh_tree() # TODO: get server tab enum # if LAN tab, reload existing entries in place From 6b50b1e9333587136272158ccaa0ad92dafe10a9 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 25 Apr 2026 20:29:55 +0900 Subject: [PATCH 175/309] fix: capitalize tab name --- dzgui/util/strings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dzgui/util/strings.py b/dzgui/util/strings.py index aa081b5..52f0e12 100644 --- a/dzgui/util/strings.py +++ b/dzgui/util/strings.py @@ -55,7 +55,7 @@ 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" +add = "Add to Saved Servers" add_fav = "Set as favorite" remove = "Remove from my servers" remove_history = "Remove from history" From 4bccaec427d8118d30808b7c006ecdd9ea790217 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 25 Apr 2026 20:30:15 +0900 Subject: [PATCH 176/309] feat: remove history entries (WIP) --- dzgui/controllers/mc.py | 4 ++++ dzgui/managers/config.py | 4 ++++ dzgui/managers/contextmenu.py | 6 ++---- dzgui/model/proxy_model.py | 19 +++++++++++++++++++ dzgui/model/servers.py | 27 +++++++++++++++++++++++++++ 5 files changed, 56 insertions(+), 4 deletions(-) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 8c7e533..f22d1f8 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -597,6 +597,10 @@ class Controller(GObject.GObject): saved_tree = self.get_servers().get_saved() ServerModelManager(self, saved_tree).remove_by_record(record) + def remove_from_history(self, record: "Record") -> None: + tv = self.get_active_treeview() + ServerModelManager(self, tv).remove_from_history(record) + def connect_by_str(self, addr: str) -> None: if addr.isdigit(): config_man = self.get_config_man() diff --git a/dzgui/managers/config.py b/dzgui/managers/config.py index 679a39d..2dfa6d1 100644 --- a/dzgui/managers/config.py +++ b/dzgui/managers/config.py @@ -48,6 +48,10 @@ class ConfigManager: ips.append(record) self.update_config(Preferences.IP_LIST, ips) + def remove_history_server(self, record: str) -> None: + print(record) + pass + def remove_saved_server(self, record: str) -> None: ips = self.lookup(Preferences.IP_LIST) ips.remove(record) diff --git a/dzgui/managers/contextmenu.py b/dzgui/managers/contextmenu.py index 4c92a9f..a062c6d 100644 --- a/dzgui/managers/contextmenu.py +++ b/dzgui/managers/contextmenu.py @@ -68,10 +68,8 @@ class ContextMenuManager: record = self.treeview.get_record() self.controller.refresh_players(record) case ContextMenu.REMOVE_HISTORY: - print("DEBUG: unimplemented") - # update history model, update tab label, pop off of queue, write new list into file - # see dq.py - pass + record = self.treeview.get_record() + self.controller.remove_from_history(record) case ContextMenu.REMOVE_SERVER: record = self.treeview.get_record() self.controller.remove_by_record(record) diff --git a/dzgui/model/proxy_model.py b/dzgui/model/proxy_model.py index d7cea56..4334768 100644 --- a/dzgui/model/proxy_model.py +++ b/dzgui/model/proxy_model.py @@ -1,5 +1,6 @@ import re +from collections import deque from typing import Any, Optional, TYPE_CHECKING from warnings import deprecated @@ -51,6 +52,7 @@ class ProxyModelManager: for record in self.control_model: if row[7] == record[7] and row[8] == record[8]: self.control_model.remove(record) + break self.wipe_cache() self.filter(FilterMode.INITIAL, skip_cache=True) @@ -59,6 +61,23 @@ class ProxyModelManager: self.proxy_model[treeiter][4] = playercount.players self.proxy_model[treeiter][6] = playercount.queue + # def append_to_history(self, row: list) -> None: + # if len(self.control_model) == 10: + # del self.control_model[0] + # self.control_model.append(row) + + # TODO: consolidate with remove_row_from_control() + def remove_from_history(self, record: "Record") -> None: + print(record) + addr = f"{record.ip}:{record.gameport}" + qport = record.qport + for record in self.control_model: + if addr == record[7] and qport == record[8]: + self.control_model.remove(record) + break + self.wipe_cache() + self.filter(FilterMode.INITIAL, skip_cache=True) + def clear_proxy_model(self) -> None: self.proxy_model.clear() diff --git a/dzgui/model/servers.py b/dzgui/model/servers.py index 1149a0d..8be2c2b 100644 --- a/dzgui/model/servers.py +++ b/dzgui/model/servers.py @@ -211,6 +211,33 @@ class ServerModelManager: res = Servers.query_by_record(record) self._parse_single_record(res, delete=True) + def remove_from_history(self, record: Servers.Record) -> None: + """Fully unthreaded, just removes a row""" + port = record.ip + gport = record.gameport + qport = record.qport + + fqip = f"{port}:{gport}:{qport}" + config_man = self.controller.get_config_man() + config_man.remove_history_server(fqip) + + proxy_man = self._get_proxy_man() + proxy_man.remove_from_history(record) + control_model = proxy_man.get_control() + + self._sort_unique_maps(control_model) + proxy = proxy_man.get_proxy_model() + self.tv.set_model(proxy) + + self.emitter.emit("servers_loaded", self.enum) + + filter_man = self.tv.get_filter_man() + maps = self._get_new_maps() + filter_man.set_unique_maps(maps) + + self.first_iteration = False + self.emitter.emit("servers_loaded_init") + def add_by_str(self, addr: str) -> None: if addr.isdigit(): self.add_by_id(addr) From 7b8388376c39c2533e1f7002791cc90648cf9a0c Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 25 Apr 2026 21:17:47 +0900 Subject: [PATCH 177/309] chore: consolidate filter enums --- dzgui/const/enum.py | 8 +++----- dzgui/controllers/mc.py | 2 +- dzgui/managers/config.py | 10 +++++----- dzgui/model/proxy_model.py | 5 ++--- dzgui/model/servers.py | 11 +++-------- dzgui/views/components/filter_panel.py | 2 +- 6 files changed, 15 insertions(+), 23 deletions(-) diff --git a/dzgui/const/enum.py b/dzgui/const/enum.py index 656782e..e1189c6 100644 --- a/dzgui/const/enum.py +++ b/dzgui/const/enum.py @@ -41,11 +41,9 @@ class CursorPosition(Enum): class FilterMode(Enum): - KEYWORD = 1 - MAP = 2 - INITIAL = 3 - TOGGLE_OFF = 4 - TOGGLE_ON = 5 + INITIAL = 1 + TOGGLE_OFF = 2 + TOGGLE_ON = 3 class EnumWithAttrs(Enum): diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index f22d1f8..b15b3f9 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -553,7 +553,7 @@ class Controller(GObject.GObject): def _on_map_selection_changed(self, emitter: Emitter, selection: str) -> None: smm = ServerModelManager(self, self.get_active_treeview()) - smm.refilter(FilterMode.MAP) + smm.refilter(FilterMode.TOGGLE_ON) def get_notebook(self) -> "Notebook": return self.mediator.notebook diff --git a/dzgui/managers/config.py b/dzgui/managers/config.py index 2dfa6d1..98f38df 100644 --- a/dzgui/managers/config.py +++ b/dzgui/managers/config.py @@ -34,8 +34,6 @@ class ConfigManager: self.config = prefs.paths.config def lookup(self, enum: Preferences) -> Any: - # if path.is_file() is False: - # raise ConfigFileError("Not a valid file") key = self.enum_to_key(enum) config = self.get_config() try: @@ -48,9 +46,11 @@ class ConfigManager: ips.append(record) self.update_config(Preferences.IP_LIST, ips) - def remove_history_server(self, record: str) -> None: - print(record) - pass + def update_history_file(self, records: list[Any]) -> None: + with open(self.prefs.paths.history, "w") as f: + for record in records: + addr = f"{record[7]}:{record[8]}" + f.write(f"{addr}\n") def remove_saved_server(self, record: str) -> None: ips = self.lookup(Preferences.IP_LIST) diff --git a/dzgui/model/proxy_model.py b/dzgui/model/proxy_model.py index 4334768..cad4e17 100644 --- a/dzgui/model/proxy_model.py +++ b/dzgui/model/proxy_model.py @@ -11,6 +11,7 @@ from dzgui.util import strings if TYPE_CHECKING: from dzgui.managers.filter_man import FilterManager from dzgui.model.model_factory import FastInsertListStore + from dzgui.api.servers import Record from dzgui.model.servers import NewPlayerCount @@ -68,7 +69,6 @@ class ProxyModelManager: # TODO: consolidate with remove_row_from_control() def remove_from_history(self, record: "Record") -> None: - print(record) addr = f"{record.ip}:{record.gameport}" qport = record.qport for record in self.control_model: @@ -85,7 +85,6 @@ class ProxyModelManager: return self.proxy_model def filter(self, mode: FilterMode, skip_cache: bool = False) -> None: - # TODO: proxy model can fetch args directly from filter man, no need to process extra input """ Native Gtk.TreeView.refilter() method was not performant enough when running in the main loop with 40k+ records @@ -109,7 +108,7 @@ class ProxyModelManager: rows = self.filter_initial(filters) # TODO: consolidate into one enum - case FilterMode.MAP | FilterMode.KEYWORD | FilterMode.TOGGLE_ON: + case FilterMode.TOGGLE_ON: rows = self.filter_toggle_on(filters) case FilterMode.TOGGLE_OFF: diff --git a/dzgui/model/servers.py b/dzgui/model/servers.py index 8be2c2b..1ba50c8 100644 --- a/dzgui/model/servers.py +++ b/dzgui/model/servers.py @@ -213,18 +213,13 @@ class ServerModelManager: def remove_from_history(self, record: Servers.Record) -> None: """Fully unthreaded, just removes a row""" - port = record.ip - gport = record.gameport - qport = record.qport - - fqip = f"{port}:{gport}:{qport}" - config_man = self.controller.get_config_man() - config_man.remove_history_server(fqip) - proxy_man = self._get_proxy_man() proxy_man.remove_from_history(record) control_model = proxy_man.get_control() + config_man = self.controller.get_config_man() + config_man.update_history_file(control_model) + self._sort_unique_maps(control_model) proxy = proxy_man.get_proxy_model() self.tv.set_model(proxy) diff --git a/dzgui/views/components/filter_panel.py b/dzgui/views/components/filter_panel.py index e05e8dd..796de21 100644 --- a/dzgui/views/components/filter_panel.py +++ b/dzgui/views/components/filter_panel.py @@ -130,7 +130,7 @@ class KeywordEntry(Gtk.Entry): ServerModelManager( self.controller, self.controller.get_active_treeview() - ).refilter(FilterMode.KEYWORD) + ).refilter(FilterMode.TOGGLE_ON) class FilterPanel(Gtk.Box): From 39e3bb8d2d2dfb87d45f568633647b6eb08c9052 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 25 Apr 2026 21:59:32 +0900 Subject: [PATCH 178/309] chore: move log parse action to LogTreeView --- dzgui/managers/contextmenu.py | 17 ++++------------- dzgui/views/trees/tree_log.py | 14 ++++++++++++++ 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/dzgui/managers/contextmenu.py b/dzgui/managers/contextmenu.py index a062c6d..0e9de69 100644 --- a/dzgui/managers/contextmenu.py +++ b/dzgui/managers/contextmenu.py @@ -63,7 +63,6 @@ class ContextMenuManager: case ContextMenu.CONNECT: record = self.treeview.get_record() self.controller.connect_by_record(record) - case ContextMenu.REFRESH_PLAYERS: record = self.treeview.get_record() self.controller.refresh_players(record) @@ -97,16 +96,8 @@ class ContextMenuManager: cmd = self.controller.query_config(Preferences.CLIENT) open_workshop_page(mod, cmd) - def copy_log(self) -> str: - # NOTE: ModTreeView uses Gtk.SelectionMode.MULTIPLE - model, records = self.treeview.get_selection().get_selected_rows() - if len(records) < 1: + def copy_log(self) -> None: + log = self.treeview.concatenate_rows() + if log is None: return - final = [] - for record in records: - record = self.treeview.get_model()[record] - r = [el for el in record] - concat = strings.delimiter.join(r) - final.append(concat) - text = "\n".join(final) - copy_clipboard(text) + copy_clipboard(log) diff --git a/dzgui/views/trees/tree_log.py b/dzgui/views/trees/tree_log.py index 123e925..6208393 100644 --- a/dzgui/views/trees/tree_log.py +++ b/dzgui/views/trees/tree_log.py @@ -81,3 +81,17 @@ class LogTreeView(ContextMixin, TreeView): def _on_log_keypress(self, widget: Gtk.Widget, event: Gdk.EventKey) -> None: self.present_menu(widget, event) + + def concatenate_rows(self) -> str | None: + # NOTE: LogTreeView uses Gtk.SelectionMode.MULTIPLE + final = [] + model, records = self.get_selection().get_selected_rows() + if len(records) < 1: + return None + for record in records: + raw_record = model[record] + r = [el for el in raw_record] + concat = strings.delimiter.join(r) + final.append(concat) + text = "\n".join(final) + return text From 4535a190201a1ba57d5a226fa45340bae41dbe2a Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 25 Apr 2026 22:00:14 +0900 Subject: [PATCH 179/309] chore: move mod actions into ModManager (WIP) --- dzgui/controllers/mc.py | 160 +++++++++++++------------- dzgui/model/proxy_model.py | 7 +- dzgui/model/servers.py | 9 -- dzgui/views/mixins/help_menu_mixin.py | 2 - 4 files changed, 81 insertions(+), 97 deletions(-) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index b15b3f9..4344f92 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -147,7 +147,6 @@ class Controller(GObject.GObject): logger.critical(f"{attr} is not a valid AppNavigation attribute.") def get_prefs(self) -> UserPrefs: - # return self.config_man.get_prefs() return self.prefs def set_prefs(self, prefs: UserPrefs) -> None: @@ -200,7 +199,7 @@ class Controller(GObject.GObject): msg_id = self.mediator.statusbar.set_text(text, context) return msg_id - # TODO: StatusBarManager + # TODO: StatusBarManager, move out of here def set_statusbar_dist(self, haversine: "Haversine", enum: "ServerTab") -> None: """ NOTE: prevents race condition when server tab changed, @@ -230,30 +229,6 @@ class Controller(GObject.GObject): self.emitter.emit("distcalc_ended", dist, context) - def delete_multiple_mods(self) -> None: - sel = self.mediator.modtreeview.get_selection() - model, pathlist = sel.get_selected_rows() - # NOTE: reverse when multiple selection - 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_cleanup(self, model: Gtk.ListStore) -> None: - self.mediator.modtreeview.set_model(model) - self.emitter.emit("mod_page_loaded") - - # TODO: delegate to threadmanager - @call_on_thread(strings.dialog.modlist) - def load_mods(self) -> None: - model = ModelFactory().make_mod_store() - path = self.query_config(Preferences.DEFAULT) - mods = get_delimited_mods(Path(path)) - model.extend(mods) - - self.cleanup_func = StoredFunc(self.load_mods_cleanup, model) - def toggle_config(self, key: Preferences) -> None: # NOTE: Preferences.DIST is dynamic if key == Preferences.DIST: @@ -303,6 +278,80 @@ class Controller(GObject.GObject): client = self.query_config(Preferences.CLIENT) open_user_workshop(uid, client) + ### START LOAD MODS LOGIC + + def uncolorize_mods(self) -> None: + model = self.get_mod_store() + for mod in model: + it = mod.iter + path = model.get_path(it) + model[path][4] = None + self.mediator.modtreeview.set_cursor(0) + + def highlight_stale_cleanup(self, stale_mods: list) -> None: + """Manipulates attached ListStore in the main event loop""" + model = self.get_mod_store() + for mod in model: + it = mod.iter + path = model.get_path(it) + if int(mod[2]) in stale_mods: + model[path][4] = True + self.emitter.emit("mods_highlighted") + + # TODO: delegate to mod manager + @call_on_thread(strings.dialog.working) + def highlight_stale(self) -> None: + # TODO: set progress bar for number of mods + stale = find_stale_mods(self.prefs.paths.config) + self.cleanup_func = StoredFunc(self.highlight_stale_cleanup, stale) + + def set_cleanup_func(self, func: StoredFunc) -> None: + self.cleanup_func = func + + def get_cleanup_func(self) -> StoredFunc: + return self.cleanup_func + + def _destroy_on_idle(self) -> None: + self.wait_dialog.destroy() + func = self.get_cleanup_func() + if func is not None: + func.call() + self.set_cleanup_func(None) + self.mediator.window.set_sensitive(True) + + def toggle_mod_selection(self, state: bool) -> None: + sel = self.mediator.modtreeview.get_selection() + if state: + sel.select_all() + else: + sel.unselect_all() + # TODO: signal + self.mediator.modtreeview.grab_focus() + + def load_mods_cleanup(self, model: Gtk.ListStore) -> None: + self.mediator.modtreeview.set_model(model) + self.emitter.emit("mod_page_loaded") + + # TODO: delegate to threadmanager + @call_on_thread(strings.dialog.modlist) + def load_mods(self) -> None: + model = ModelFactory().make_mod_store() + path = self.query_config(Preferences.DEFAULT) + mods = get_delimited_mods(Path(path)) + model.extend(mods) + + self.cleanup_func = StoredFunc(self.load_mods_cleanup, model) + + def delete_multiple_mods(self) -> None: + sel = self.mediator.modtreeview.get_selection() + model, pathlist = sel.get_selected_rows() + # NOTE: reverse when multiple selection + for path in reversed(pathlist): + self.delete_single_mod(path) + + total_mods, total_size = self.calc_mod_size() + self.update_mod_statusbar() + def delete_single_mod_cleanup(self, _iter: Gtk.TreeIter) -> None: self.get_mod_store().remove(_iter) remove_stale_signatures(self.prefs.paths.config, self.prefs.paths.version) @@ -335,9 +384,6 @@ class Controller(GObject.GObject): self.cleanup_func = StoredFunc(self.delete_single_mod_cleanup, _iter) - def get_mod_store(self) -> Gtk.ListStore: - return self.mediator.modtreeview.get_model() - def format_mod_statusbar(self) -> str: total_mods, total_size = self.calc_mod_size() msg = format_mods(total_size, total_mods) @@ -353,6 +399,11 @@ class Controller(GObject.GObject): total_size += mod[3] return total_mods, total_size + ### END LOAD MODS LOGIC + + def get_mod_store(self) -> Gtk.ListStore: + return self.mediator.modtreeview.get_model() + def set_fav(self) -> None: treeview = self.get_active_treeview() name = treeview.get_value_at_index(0) @@ -373,15 +424,6 @@ class Controller(GObject.GObject): context_man = ContextMenuManager(tree, self) context_man.process(action) - def toggle_mod_selection(self, state: bool) -> None: - sel = self.mediator.modtreeview.get_selection() - if state: - sel.select_all() - else: - sel.unselect_all() - # TODO: signal - self.mediator.modtreeview.grab_focus() - def populate_log(self) -> None: log = self.prefs.paths.debug try: @@ -400,51 +442,10 @@ class Controller(GObject.GObject): if mod[4] == HEX_RED: sel.select_path(path) - # TODO: make as method of tree? - # TODO: could have a LocalModManager that accepts button enums - def uncolorize_mods(self) -> None: - model = self.get_mod_store() - for mod in model: - it = mod.iter - path = model.get_path(it) - model[path][4] = None - self.mediator.modtreeview.set_cursor(0) - def get_filter_man(self) -> "FilterManager": """Each ServerTreeView has an atomic FilterManager""" return self.get_active_treeview().get_filter_man() - def highlight_stale_cleanup(self, stale_mods: list) -> None: - """Manipulates attached ListStore in the main event loop""" - model = self.get_mod_store() - for mod in model: - it = mod.iter - path = model.get_path(it) - if int(mod[2]) in stale_mods: - model[path][4] = True - self.emitter.emit("mods_highlighted") - - # TODO: delegate to mod manager - @call_on_thread(strings.dialog.working) - def highlight_stale(self) -> None: - # TODO: set progress bar for number of mods - stale = find_stale_mods(self.prefs.paths.config) - self.cleanup_func = StoredFunc(self.highlight_stale_cleanup, stale) - - def set_cleanup_func(self, func: StoredFunc) -> None: - self.cleanup_func = func - - def get_cleanup_func(self) -> StoredFunc: - return self.cleanup_func - - def _destroy_on_idle(self) -> None: - self.wait_dialog.destroy() - func = self.get_cleanup_func() - if func is not None: - func.call() - self.set_cleanup_func(None) - self.mediator.window.set_sensitive(True) - def dump_diagnostics(self) -> None: picker = FilePicker(self.mediator.window) file = picker.pick_file() @@ -511,7 +512,6 @@ class Controller(GObject.GObject): def get_filters(self) -> list: return self.mediator.filters.get_filters() - # TODO: clean up routes between controller and filter panel def get_map_store(self) -> Gtk.ListStore: filter_man = self.get_filter_man() return filter_man.get_map_store() diff --git a/dzgui/model/proxy_model.py b/dzgui/model/proxy_model.py index cad4e17..13e89e2 100644 --- a/dzgui/model/proxy_model.py +++ b/dzgui/model/proxy_model.py @@ -1,6 +1,5 @@ import re -from collections import deque from typing import Any, Optional, TYPE_CHECKING from warnings import deprecated @@ -97,7 +96,6 @@ class ProxyModelManager: if skip_cache is False: if filters in self.filter_cache: - print("already in cache, not updating proxy model") cache = self.filter_cache[filters] self.set_proxy_model(cache[0]) self.set_filtered(cache[1]) @@ -106,17 +104,14 @@ class ProxyModelManager: match mode: case FilterMode.INITIAL: rows = self.filter_initial(filters) - - # TODO: consolidate into one enum case FilterMode.TOGGLE_ON: rows = self.filter_toggle_on(filters) - case FilterMode.TOGGLE_OFF: for f in filters[2:]: self.set_filtered(self.filter_toggle_off(filters, f)) rows = self.filtered - # NOTE: this FastInsertListStore manipulation must remain local to the thread + # NOTE: FastInsertListStore manipulation must remain local to the thread clone = ModelFactory().make_server_store() if len(rows) > 0: rows = self.sort_rows(rows) diff --git a/dzgui/model/servers.py b/dzgui/model/servers.py index 1ba50c8..6cd978f 100644 --- a/dzgui/model/servers.py +++ b/dzgui/model/servers.py @@ -31,10 +31,6 @@ if TYPE_CHECKING: logger = logging.getLogger(__name__) -# TODO: failure should spawn error dialog -# TODO: non failure with empty model: updates statusbar with help text - - @dataclass class NewPlayerCount: treeiter: Gtk.TreeIter @@ -357,11 +353,6 @@ class ServerModelManager: proxy = self._get_proxy_man().get_proxy_model() self.tv.set_model(proxy) - # TODO: make sure control model len is N + 1 - # TODO: signals or other approach to deferring map - # model insertion after thread closes - # cf. servers_loaded signal - # TODO: servers_loaded vs servers_reloaded self.emitter.emit("servers_loaded", self.enum) diff --git a/dzgui/views/mixins/help_menu_mixin.py b/dzgui/views/mixins/help_menu_mixin.py index 3b2870e..1af135d 100644 --- a/dzgui/views/mixins/help_menu_mixin.py +++ b/dzgui/views/mixins/help_menu_mixin.py @@ -1,7 +1,5 @@ from typing import Literal -from dzgui.const.enum import NotebookPage - import gi gi.require_version("Gtk", "3.0") From 75459515ea309f92c1cdba4411ee136f5c693f94 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 25 Apr 2026 22:47:12 +0900 Subject: [PATCH 180/309] change: consolidate proxy model methods --- dzgui/api/servers.py | 21 ++++++++++++++------- dzgui/model/proxy_model.py | 11 +---------- dzgui/model/servers.py | 10 ++++++---- dzgui/views/trees/tree_log.py | 1 - 4 files changed, 21 insertions(+), 22 deletions(-) diff --git a/dzgui/api/servers.py b/dzgui/api/servers.py index 5255e9f..7debbf3 100644 --- a/dzgui/api/servers.py +++ b/dzgui/api/servers.py @@ -436,13 +436,6 @@ def get_rules(record: Record) -> list[int]: return [] -def response_to_fq_ip(res: dict) -> str: - ip = res["addr"].split(":")[0] - gameport = res["gameport"] - qport = res["addr"].split(":")[1] - return f"{ip}:{gameport}:{qport}" - - def query_by_id(addr: str, key: str) -> Optional[dict]: """ Used with numeric Battlemetrics IDs @@ -495,3 +488,17 @@ def fqip_to_record(addr: str) -> Optional[Record]: if len(r) != 3: return None return Record(r[0], int(r[1]), int(r[2])) + + +def response_to_fqip(res: dict) -> str: + ip = res["addr"].split(":")[0] + gameport = res["gameport"] + qport = res["addr"].split(":")[1] + return f"{ip}:{gameport}:{qport}" + + +def response_to_record(res: dict) -> Record: + ip = res["addr"].split(":")[0] + gameport = res["gameport"] + qport = res["addr"].split(":")[1] + return Record(ip, gameport, qport) diff --git a/dzgui/model/proxy_model.py b/dzgui/model/proxy_model.py index 13e89e2..dad8609 100644 --- a/dzgui/model/proxy_model.py +++ b/dzgui/model/proxy_model.py @@ -48,14 +48,6 @@ class ProxyModelManager: self.control_model.append(row) self.filter(FilterMode.INITIAL, skip_cache=True) - def remove_row_from_control(self, row: list) -> None: - for record in self.control_model: - if row[7] == record[7] and row[8] == record[8]: - self.control_model.remove(record) - break - self.wipe_cache() - self.filter(FilterMode.INITIAL, skip_cache=True) - def update_playercount(self, playercount: "NewPlayerCount") -> None: treeiter = playercount.treeiter self.proxy_model[treeiter][4] = playercount.players @@ -66,8 +58,7 @@ class ProxyModelManager: # del self.control_model[0] # self.control_model.append(row) - # TODO: consolidate with remove_row_from_control() - def remove_from_history(self, record: "Record") -> None: + def remove_row_from_control(self, record: "Record") -> None: addr = f"{record.ip}:{record.gameport}" qport = record.qport for record in self.control_model: diff --git a/dzgui/model/servers.py b/dzgui/model/servers.py index 6cd978f..67102a5 100644 --- a/dzgui/model/servers.py +++ b/dzgui/model/servers.py @@ -210,7 +210,7 @@ class ServerModelManager: def remove_from_history(self, record: Servers.Record) -> None: """Fully unthreaded, just removes a row""" proxy_man = self._get_proxy_man() - proxy_man.remove_from_history(record) + proxy_man.remove_row_from_control(record) control_model = proxy_man.get_control() config_man = self.controller.get_config_man() @@ -259,10 +259,12 @@ class ServerModelManager: # NOTE: expected to only contain one item records = Servers.parse_json([response]) - record = records[0] + server = records[0] proxy_man = self._get_proxy_man() - fqip = Servers.response_to_fq_ip(response) + fqip = Servers.response_to_fqip(response) + record = Servers.response_to_record(response) + config_man = self.controller.get_config_man() # TODO: less convoluted @@ -283,7 +285,7 @@ class ServerModelManager: if proxy_man.has_control_model() is False: self._get_proxy_man().push(records) else: - proxy_man.append_row_to_control(record) + proxy_man.append_row_to_control(server) control_model = proxy_man.get_control() self._sort_unique_maps(control_model) diff --git a/dzgui/views/trees/tree_log.py b/dzgui/views/trees/tree_log.py index 6208393..0cd093f 100644 --- a/dzgui/views/trees/tree_log.py +++ b/dzgui/views/trees/tree_log.py @@ -32,7 +32,6 @@ class LogTreeView(ContextMixin, TreeView): self.get_selection().set_mode(Gtk.SelectionMode.MULTIPLE) self.set_model(None) - # self.filter: Gtk.TreeModelFilter # TODO: strings # NOTE: default filters that should be shown on startup From 218856baabc9e9a71bc937f5c4e54d48885e94de Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 26 Apr 2026 22:16:01 +0900 Subject: [PATCH 181/309] chore: rename filter manager --- dzgui/controllers/mc.py | 2 +- dzgui/managers/{filter_man.py => filter.py} | 0 dzgui/model/proxy_model.py | 2 +- dzgui/views/trees/tree_servers.py | 2 +- 4 files changed, 3 insertions(+), 3 deletions(-) rename dzgui/managers/{filter_man.py => filter.py} (100%) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 4344f92..69a6454 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -62,7 +62,7 @@ logger = logging.getLogger(__name__) if TYPE_CHECKING: from dzgui.api.servers import Record from dzgui.const.enum import ServerTab - from dzgui.managers.filter_man import FilterManager + from dzgui.managers.filter import FilterManager from dzgui.util.dist import Haversine from dzgui.views.base import Notebook, Grid, OuterWindow from dzgui.views.components.buttonbox import ContextualButton diff --git a/dzgui/managers/filter_man.py b/dzgui/managers/filter.py similarity index 100% rename from dzgui/managers/filter_man.py rename to dzgui/managers/filter.py diff --git a/dzgui/model/proxy_model.py b/dzgui/model/proxy_model.py index dad8609..053f15e 100644 --- a/dzgui/model/proxy_model.py +++ b/dzgui/model/proxy_model.py @@ -8,7 +8,7 @@ from dzgui.model.model_factory import ModelFactory from dzgui.util import strings if TYPE_CHECKING: - from dzgui.managers.filter_man import FilterManager + from dzgui.managers.filter import FilterManager from dzgui.model.model_factory import FastInsertListStore from dzgui.api.servers import Record from dzgui.model.servers import NewPlayerCount diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index 4edaeed..1bd2f84 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -8,7 +8,7 @@ from warnings import deprecated from dzgui.views.mixins.context_mixin import ContextMixin from dzgui.const.enum import ContextMenu, ContextMenuGroup, Preferences, ServerTab from dzgui.api.servers import ping, Record -from dzgui.managers.filter_man import FilterManager +from dzgui.managers.filter import FilterManager from dzgui.model.proxy_model import ProxyModelManager from dzgui.util.dist import CalcDist from dzgui.util import strings From f25982ab7afe02a7e0baa81c0ab0fe635734e5ba Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 26 Apr 2026 22:17:08 +0900 Subject: [PATCH 182/309] chore: rename threading manager --- dzgui/managers/connection.py | 2 +- dzgui/managers/{thread_man.py => threading.py} | 0 dzgui/model/servers.py | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename dzgui/managers/{thread_man.py => threading.py} (100%) diff --git a/dzgui/managers/connection.py b/dzgui/managers/connection.py index 1ac8a05..a8285bb 100644 --- a/dzgui/managers/connection.py +++ b/dzgui/managers/connection.py @@ -4,7 +4,7 @@ import dzgui.api.servers as Servers from dzgui.api.mods import get_local_mod_ids from dzgui.const.enum import Preferences -from dzgui.managers.thread_man import call_on_thread, StoredFunc, ThreadingManager +from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager from dzgui.util.strings import dialog, server_timeout, checkmark from dzgui.views.dialogs.generic import ExceptionDialog from dzgui.views.dialogs.servers import ServerDetailsDialog, ServerModDialog diff --git a/dzgui/managers/thread_man.py b/dzgui/managers/threading.py similarity index 100% rename from dzgui/managers/thread_man.py rename to dzgui/managers/threading.py diff --git a/dzgui/model/servers.py b/dzgui/model/servers.py index 67102a5..61bd96c 100644 --- a/dzgui/model/servers.py +++ b/dzgui/model/servers.py @@ -12,7 +12,7 @@ from dzgui.const.constants import ( APPID_DAYZ, APPID_DAYZ_EXP, ) -from dzgui.managers.thread_man import call_on_thread, StoredFunc, ThreadingManager +from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager from dzgui.util.strings import api_warn_msg, dialog from dzgui.views.dialogs.generic import ExceptionDialog From 3d33d4f1e0e8feb9ee31396e2ffab646cb91422b Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Mon, 27 Apr 2026 00:00:14 +0900 Subject: [PATCH 183/309] chore: move method into config manager --- dzgui/controllers/mc.py | 19 ++++--------------- dzgui/managers/config.py | 22 +++++++++++++++++++++- dzgui/views/pages/options.py | 3 ++- 3 files changed, 27 insertions(+), 17 deletions(-) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 69a6454..a63166c 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -150,7 +150,7 @@ class Controller(GObject.GObject): return self.prefs def set_prefs(self, prefs: UserPrefs) -> None: - self.config_man = ConfigManager(prefs) + self.config_man = ConfigManager(prefs, self) self.notes_man = NoteManager(self, prefs.paths.notes) self.prefs = prefs @@ -404,6 +404,7 @@ class Controller(GObject.GObject): def get_mod_store(self) -> Gtk.ListStore: return self.mediator.modtreeview.get_model() + # TODO: delegate to configman def set_fav(self) -> None: treeview = self.get_active_treeview() name = treeview.get_value_at_index(0) @@ -456,20 +457,8 @@ class Controller(GObject.GObject): dialog = ExceptionDialog(self, str(e)) dialog.run() - # TODO: move to ConfigMan - @call_on_thread(strings.dialog.working) - def update_api_key(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) - else: - self.cleanup_func = StoredFunc( - lambda: self.emitter.emit("api_change_failed") - ) + def update_api_key(self, key: Preferences, text: str) -> None: + self.config_man.update_api_key(key, text) def set_resolution(self, window: "OuterWindow") -> None: self.config_man.set_resolution(window) diff --git a/dzgui/managers/config.py b/dzgui/managers/config.py index 98f38df..7437391 100644 --- a/dzgui/managers/config.py +++ b/dzgui/managers/config.py @@ -10,7 +10,9 @@ from dzgui.const.constants import ( WINDOW_DEFAULT_X, WINDOW_DEFAULT_Y, ) +from dzgui.api.probe import test_steam_api, test_bm_api from dzgui.const.enum import Preferences +from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager from dzgui.views.dialogs.generic import ExceptionDialog from dzgui.util._json import read_json, write_json @@ -22,6 +24,7 @@ from gi.repository.Gtk import main_quit # noqa E402 # import dzgui.api.servers as Servers if TYPE_CHECKING: from dzgui.config.userprefs import UserPrefs + from dzgui.controllers.mc import Controller from dzgui.views.base import OuterWindow from dzgui.views.trees.tree_servers import ServerTreeView @@ -29,9 +32,11 @@ logger = logging.getLogger(__name__) class ConfigManager: - def __init__(self, prefs: "UserPrefs") -> None: + def __init__(self, prefs: "UserPrefs", controller: "Controller") -> None: self.prefs = prefs self.config = prefs.paths.config + self.emitter = controller.get_emitter() + self.thread_man = ThreadingManager(controller) def lookup(self, enum: Preferences) -> Any: key = self.enum_to_key(enum) @@ -73,6 +78,21 @@ class ConfigManager: logger.critical(e) raise e + # TODO: strings + @call_on_thread("checking api") + def update_api_key(self, key: Preferences, text: str) -> 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) + else: + self.thread_man.set_cleanup_func(StoredFunc( + lambda: self.emitter.emit("api_change_failed") + )) + return + def get_favorites(self) -> list[str]: return self.lookup(Preferences.IP_LIST) diff --git a/dzgui/views/pages/options.py b/dzgui/views/pages/options.py index 4214233..1196d25 100644 --- a/dzgui/views/pages/options.py +++ b/dzgui/views/pages/options.py @@ -281,10 +281,11 @@ class Options(Gtk.Box): self.controller.update_config(enum, value) case Preferences.BM | Preferences.STEAM: text = "".join(entry.get_text().split()) - self.controller.update_api_key(text, enum) + self.controller.update_api_key(enum, text) def _on_api_change_failed(self, emitter: "Emitter") -> None: self.old_entry.set_text(self.old_text) + # FIXME: ambiguous error message dialog = ExceptionDialog(self.controller, strings.api_error) dialog.run() From 52d51f2bd706571806a50f0d35b01ce7c2255234 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Tue, 28 Apr 2026 21:41:07 +0900 Subject: [PATCH 184/309] chore: clear typehinting errors --- dzgui/api/servers.py | 6 +++-- dzgui/controllers/mc.py | 30 ++++++++++++------------- dzgui/managers/config.py | 8 +++---- dzgui/managers/contextmenu.py | 3 ++- dzgui/model/model_factory.py | 8 +++++-- dzgui/model/proxy_model.py | 2 +- dzgui/model/servers.py | 4 +++- dzgui/views/components/buttons.py | 2 +- dzgui/views/components/connect_panel.py | 5 +++-- dzgui/views/components/right_panel.py | 2 +- dzgui/views/dialogs/note.py | 2 +- dzgui/views/dialogs/servers.py | 2 +- dzgui/views/trees/tree_log.py | 3 ++- dzgui/views/trees/tree_servers.py | 14 ++++++------ 14 files changed, 50 insertions(+), 41 deletions(-) diff --git a/dzgui/api/servers.py b/dzgui/api/servers.py index 7debbf3..ea30a95 100644 --- a/dzgui/api/servers.py +++ b/dzgui/api/servers.py @@ -9,7 +9,7 @@ import subprocess import threading from dataclasses import dataclass -from typing import Optional, Union +from typing import Optional, TYPE_CHECKING, Union from dzgui.api.bm import map_id_to_record from dzgui.const.constants import REQUEST_TIMEOUT @@ -19,6 +19,8 @@ from dzgui.util import strings import a2s import dayzquery +if TYPE_CHECKING: + from dayzquery import DayzMod logger = logging.getLogger(__name__) logging.getLogger("a2s").setLevel(logging.ERROR) @@ -425,7 +427,7 @@ def validate_ip(addr: str) -> Record: return record -def get_rules(record: Record) -> list[int]: +def get_rules(record: Record) -> list["DayzMod"]: ip = record.ip qport = record.qport try: diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index a63166c..52ebc5a 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -104,7 +104,7 @@ class StoredFunc: class Controller(GObject.GObject): def __init__(self) -> None: - self.dist_cache: dict[str, "Haversine", "ServerTab"] = {} + self.dist_cache: dict[str, "Haversine"] = {} self.mediator = AppNavigation() self.prefs: UserPrefs @@ -154,7 +154,7 @@ class Controller(GObject.GObject): self.notes_man = NoteManager(self, prefs.paths.notes) self.prefs = prefs - def query_config(self, key: Preferences) -> str | bool | list: + def query_config(self, key: Preferences) -> Any: return self.config_man.lookup(key) def is_auto_install(self) -> bool: @@ -286,7 +286,8 @@ class Controller(GObject.GObject): it = mod.iter path = model.get_path(it) model[path][4] = None - self.mediator.modtreeview.set_cursor(0) + path = Gtk.TreePath.new_from_indices([0]) + self.mediator.modtreeview.set_cursor(path) def highlight_stale_cleanup(self, stale_mods: list) -> None: """Manipulates attached ListStore in the main event loop""" @@ -401,7 +402,7 @@ class Controller(GObject.GObject): ### END LOAD MODS LOGIC - def get_mod_store(self) -> Gtk.ListStore: + def get_mod_store(self) -> Gtk.TreeModel | None: return self.mediator.modtreeview.get_model() # TODO: delegate to configman @@ -483,8 +484,8 @@ class Controller(GObject.GObject): def get_statusbar(self) -> "Statusbar": return self.mediator.statusbar - def get_proxy_man(self) -> "ProxyModelManager": - return self.proxy_man + #def get_proxy_man(self) -> "ProxyModelManager": + # return self.proxy_man def populate_model(self, tv: "ServerTreeView") -> None: # NOTE: skip on previously loaded tabs @@ -495,19 +496,16 @@ class Controller(GObject.GObject): self.mediator.statusbar.set_text("", "") ServerModelManager(self, tv).load() - def get_dist_cache(self) -> dict[str, "Haversine", "ServerTab"]: + def get_dist_cache(self) -> dict[str, "Haversine"]: return self.dist_cache - def get_filters(self) -> list: - return self.mediator.filters.get_filters() - def get_map_store(self) -> Gtk.ListStore: filter_man = self.get_filter_man() return filter_man.get_map_store() - def get_selected_map(self) -> str: - filter_man = self.get_filter_man() - return filter_man.get_selected_map() + #def get_selected_map(self) -> str: + # filter_man = self.get_filter_man() + # return filter_man.get_selected_map() def get_enabled_filters(self) -> dict: filter_man = self.get_filter_man() @@ -553,7 +551,7 @@ class Controller(GObject.GObject): def get_servers(self) -> "ServerNotebook": return self.mediator.servers - def get_server_notebook(self) -> "Notebook": + def get_server_notebook(self) -> Gtk.Notebook: return self.mediator.servers.notebook def get_window(self) -> "OuterWindow": @@ -568,8 +566,8 @@ class Controller(GObject.GObject): return False return True - def is_in_favs(self) -> bool: - return self.config_man.is_in_favs() + #def is_in_favs(self) -> bool: + # return self.config_man.is_in_favs() def get_config_man(self) -> ConfigManager: return self.config_man diff --git a/dzgui/managers/config.py b/dzgui/managers/config.py index 7437391..7c39d2b 100644 --- a/dzgui/managers/config.py +++ b/dzgui/managers/config.py @@ -88,9 +88,9 @@ class ConfigManager: if res is True: self.update_config(key, text) else: - self.thread_man.set_cleanup_func(StoredFunc( - lambda: self.emitter.emit("api_change_failed") - )) + self.thread_man.set_cleanup_func( + StoredFunc(lambda: self.emitter.emit("api_change_failed")) + ) return def get_favorites(self) -> list[str]: @@ -113,7 +113,7 @@ class ConfigManager: def enum_to_key(self, enum: Preferences) -> str: return enum.dict["key"] - def get_client_index(client: str) -> int: + def get_client_index(self, client: str) -> int: if client == STEAM_CMD: return 0 if client == FLATPAK_RUN_CMD: diff --git a/dzgui/managers/contextmenu.py b/dzgui/managers/contextmenu.py index 0e9de69..1e2a8eb 100644 --- a/dzgui/managers/contextmenu.py +++ b/dzgui/managers/contextmenu.py @@ -19,12 +19,13 @@ logger = logging.getLogger(__name__) if TYPE_CHECKING: from dzgui.controllers.mc import Controller + from dzgui.views.trees.tree_servers import ServerTreeView class ContextMenuManager: def __init__( self, - treeview: Gtk.TreeView, + treeview: "ServerTreeView", controller: "Controller", ) -> None: self.controller = controller diff --git a/dzgui/model/model_factory.py b/dzgui/model/model_factory.py index 163116c..75e45e8 100644 --- a/dzgui/model/model_factory.py +++ b/dzgui/model/model_factory.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Any, Sequence +from typing import Any, Sequence, TYPE_CHECKING from dzgui.const.enum import HELP_MENU_ROWS from dzgui.util.redact import redact_log @@ -17,6 +17,10 @@ GTYPE_TO_PYTHON = { } +if TYPE_CHECKING: + from pathlib import Path + + @dataclass(slots=True, frozen=True) class ServerCols: name: str @@ -120,7 +124,7 @@ class ModelFactory: def __init__(self) -> None: pass - def new_model_from_logfile(self, path: str) -> FastInsertListStore: + def new_model_from_logfile(self, path: "Path") -> FastInsertListStore: store = self.make_log_store() with open(path, "r") as f: lines = [line.split(delimiter) for line in f.read().splitlines()] diff --git a/dzgui/model/proxy_model.py b/dzgui/model/proxy_model.py index 053f15e..9cbc7da 100644 --- a/dzgui/model/proxy_model.py +++ b/dzgui/model/proxy_model.py @@ -246,7 +246,7 @@ class ProxyModelManager: def get_control(self) -> list: return self.control_model - def wipe_cache(self, full=False) -> None: + def wipe_cache(self, full: bool = False) -> None: self.filtered = None self.filter_cache = {} diff --git a/dzgui/model/servers.py b/dzgui/model/servers.py index 61bd96c..2a1ba42 100644 --- a/dzgui/model/servers.py +++ b/dzgui/model/servers.py @@ -251,7 +251,9 @@ class ServerModelManager: proxy_man = self._get_proxy_man() proxy_man.update_playercount(self.playercount) - def _parse_single_record(self, response: dict, delete: bool = False) -> None: + def _parse_single_record( + self, response: dict[Any, Any] | None, delete: bool = False + ) -> None: self.preserve_on_fail = True if response is None: self.thread_man.set_cleanup_func(StoredFunc(self._cleanup_on_failure)) diff --git a/dzgui/views/components/buttons.py b/dzgui/views/components/buttons.py index bf85c21..390c0dc 100644 --- a/dzgui/views/components/buttons.py +++ b/dzgui/views/components/buttons.py @@ -117,7 +117,7 @@ class RefreshButton(IconTextButton): self.show_time(True) GLib.timeout_add_seconds(1, self.decrement) - def decrement(self) -> "GLib.SOURCE_REMOVE" | Literal[True]: + def decrement(self) -> type[GLib.SOURCE_REMOVE] | Literal[True]: self.time -= 1 if self.time == 0: self.time = 30 diff --git a/dzgui/views/components/connect_panel.py b/dzgui/views/components/connect_panel.py index e75c45c..1e9dd56 100644 --- a/dzgui/views/components/connect_panel.py +++ b/dzgui/views/components/connect_panel.py @@ -102,10 +102,10 @@ class LanPanel(Gtk.Frame): if self.default_radio.get_active(): port = 27016 else: - port = self.entry.get_text() + port = int(self.entry.get_text()) abort = self.early_abort.get_active() smm = ServerModelManager(self.controller, self.controller.get_active_treeview()) - smm.dump_lan(int(port), abort) + smm.dump_lan(port, abort) def _on_radio_toggled(self, button: Gtk.RadioButton) -> None: state = button.get_active() @@ -132,6 +132,7 @@ class FavPanel(Gtk.Frame): emitter = self.controller.get_emitter() emitter.connect("fav_server_changed", self._on_fav_server_changed) + # TODO: do not return a None, None tuple self.server_name, self.server_ip = ( self.controller.get_config_man().get_favorite() ) diff --git a/dzgui/views/components/right_panel.py b/dzgui/views/components/right_panel.py index 73610cb..79c4379 100644 --- a/dzgui/views/components/right_panel.py +++ b/dzgui/views/components/right_panel.py @@ -16,7 +16,7 @@ from gi.repository import Gtk, Gdk, GLib # noqa E402 if TYPE_CHECKING: from dzgui.controllers.mc import Controller from dzgui.controllers.emitter import Emitter - from dzgui.controllers.views.trees.tree_servers import ServerTreeView + from dzgui.views.trees.tree_servers import ServerTreeView class RightPanel(Gtk.Box): diff --git a/dzgui/views/dialogs/note.py b/dzgui/views/dialogs/note.py index 1057bf5..bda0b75 100644 --- a/dzgui/views/dialogs/note.py +++ b/dzgui/views/dialogs/note.py @@ -62,7 +62,7 @@ class NoteDialog(GenericDialog): if len(entry.get_text()) == 0: self.ok.set_sensitive(False) - def validate(self, text: str) -> None: + def validate(self, text: str) -> bool: if text.isspace(): sensitive = False else: diff --git a/dzgui/views/dialogs/servers.py b/dzgui/views/dialogs/servers.py index 7b1a883..a0382e0 100644 --- a/dzgui/views/dialogs/servers.py +++ b/dzgui/views/dialogs/servers.py @@ -47,7 +47,7 @@ class ServerDialog(GenericDialog): # def pack(self, widget: Gtk.Widget) -> None: # self.content.pack_start(widget, EXPAND, FILL, NO_PADDING) - def _on_keypress(self, view: Gtk.TreeView, event: Gdk.EventKey) -> None: + def _on_keypress(self, view: Gtk.TreeView, event: Gdk.EventKey) -> bool: # NOTE: ESC normally unfocuses treeview instead of destroying dialog if event.keyval == Gdk.KEY_Escape: self.destroy() diff --git a/dzgui/views/trees/tree_log.py b/dzgui/views/trees/tree_log.py index 0cd093f..89a240f 100644 --- a/dzgui/views/trees/tree_log.py +++ b/dzgui/views/trees/tree_log.py @@ -14,6 +14,7 @@ gi.require_version("Gtk", "3.0") from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa E402 if TYPE_CHECKING: + from pathlib import Path from dzgui.controllers.mc import Controller logger = logging.getLogger(__name__) @@ -49,7 +50,7 @@ class LogTreeView(ContextMixin, TreeView): self.connect("button-press-event", self._on_log_buttonpress) self.connect("key-press-event", self._on_log_keypress) - def populate_log(self, filepath: str) -> None: + def populate_log(self, filepath: "Path") -> None: model = ModelFactory().new_model_from_logfile(filepath) _filter = model.filter_new() _filter.set_visible_func(self._filter_rows) diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index 1bd2f84..6b96586 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -113,7 +113,7 @@ class ServerTreeView(ContextMixin, TreeView): self.connect("query-tooltip", self._on_tooltip) # TODO: why is this being saved? - self.thread = None + # self.thread = None def _on_tooltip( self, @@ -153,7 +153,7 @@ class ServerTreeView(ContextMixin, TreeView): def get_proxy_man(self) -> ProxyModelManager: return self.proxy_man - def get_enum(self) -> None: + def get_enum(self) -> ServerTab: return self.enum def _on_map(self, widget: Self) -> None: @@ -202,12 +202,12 @@ class ServerTreeView(ContextMixin, TreeView): return enum = self.get_enum() - self.thread = threading.Thread( + thread = threading.Thread( daemon=True, target=CalcDist, args=(record.ip, enum, self.queue, self.controller), ) - self.thread.start() + thread.start() def _check_result_queue(self) -> Literal[True]: latest_result = None @@ -281,7 +281,7 @@ class ServerTreeView(ContextMixin, TreeView): record = self.get_record() self.controller.connect_by_record(record) - def _parent_selection_changed(self, base_class: TreeView, sel: Gtk.TreeSelection): + def _parent_selection_changed(self, base_class: TreeView, sel: Gtk.TreeSelection) -> None: if self.loaded is False: return self.start_distcalc() @@ -315,7 +315,7 @@ class ServerTreeView(ContextMixin, TreeView): self.loaded = status @staticmethod - def ping_server(model, _iter: Gtk.TreeIter, ip: str, qport: int, ping_column: int): + def ping_server(model, _iter: Gtk.TreeIter, ip: str, qport: int, ping_column: int) -> None: _ping = ping(ip, qport) GLib.idle_add(lambda: model.set(_iter, ping_column, _ping)) @@ -326,7 +326,7 @@ class ServerTreeView(ContextMixin, TreeView): model: Gtk.TreeModel, _iter: Gtk.TreeIter, data: Any, - ): + ) -> None: addr_column = 7 qport_column = 8 From cba884da7e80836ace87154d76952b5bc185e06d Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Tue, 28 Apr 2026 22:04:17 +0900 Subject: [PATCH 185/309] chore: simplify CalcDist --- dzgui/controllers/mc.py | 12 ++++-------- dzgui/managers/config.py | 2 +- dzgui/util/dist.py | 10 ++-------- dzgui/views/components/filter_panel.py | 5 +++-- dzgui/views/trees/tree_servers.py | 15 +++++++++------ 5 files changed, 19 insertions(+), 25 deletions(-) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 52ebc5a..a7ef16b 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -484,9 +484,6 @@ class Controller(GObject.GObject): def get_statusbar(self) -> "Statusbar": return self.mediator.statusbar - #def get_proxy_man(self) -> "ProxyModelManager": - # return self.proxy_man - def populate_model(self, tv: "ServerTreeView") -> None: # NOTE: skip on previously loaded tabs if tv.is_loaded(): @@ -499,14 +496,13 @@ class Controller(GObject.GObject): def get_dist_cache(self) -> dict[str, "Haversine"]: return self.dist_cache + def set_dist_cache(self, addr: str, haversine: "Haversine") -> None: + self.dist_cache[addr] = haversine + def get_map_store(self) -> Gtk.ListStore: filter_man = self.get_filter_man() return filter_man.get_map_store() - #def get_selected_map(self) -> str: - # filter_man = self.get_filter_man() - # return filter_man.get_selected_map() - def get_enabled_filters(self) -> dict: filter_man = self.get_filter_man() return filter_man.get_filters() @@ -566,7 +562,7 @@ class Controller(GObject.GObject): return False return True - #def is_in_favs(self) -> bool: + # def is_in_favs(self) -> bool: # return self.config_man.is_in_favs() def get_config_man(self) -> ConfigManager: diff --git a/dzgui/managers/config.py b/dzgui/managers/config.py index 7c39d2b..a03a557 100644 --- a/dzgui/managers/config.py +++ b/dzgui/managers/config.py @@ -79,7 +79,7 @@ class ConfigManager: raise e # TODO: strings - @call_on_thread("checking api") + @call_on_thread("Checking api") def update_api_key(self, key: Preferences, text: str) -> None: if key is Preferences.STEAM: res = test_steam_api(text) diff --git a/dzgui/util/dist.py b/dzgui/util/dist.py index 1cd61a5..c2fb873 100644 --- a/dzgui/util/dist.py +++ b/dzgui/util/dist.py @@ -1,7 +1,7 @@ import logging from math import radians, cos, sin, asin, sqrt -from typing import TYPE_CHECKING +from typing import Self, TYPE_CHECKING from dzgui.util.ip import GeolocationError, get_coords @@ -47,6 +47,7 @@ class CalcDist: enum: "ServerTab", result_queue: "Queue", controller: "Controller", + cache: dict[str, Haversine], ) -> None: super().__init__() @@ -56,17 +57,10 @@ class CalcDist: self.addr = addr self.ip = self.addr - cache = self.controller.get_dist_cache() - if self.addr in cache: - logger.info(f"Address '{self.addr}' already in cache") - self.result_queue.put([self.addr, cache[self.addr], self.enum]) - return - dist = self.compare(self.ip) self.result_queue.put([self.addr, dist, self.enum]) def compare(self, remote: str) -> int | None: - # TODO: cache this prefs = self.controller.get_prefs() local = prefs.coords if local is None: diff --git a/dzgui/views/components/filter_panel.py b/dzgui/views/components/filter_panel.py index 796de21..bd3eb88 100644 --- a/dzgui/views/components/filter_panel.py +++ b/dzgui/views/components/filter_panel.py @@ -36,8 +36,9 @@ class ButtonGrid(Gtk.Grid): # TODO: use enumerated checks for check in defaults.keys(): checkbox = Gtk.CheckButton(label=check) - label = checkbox.get_children() - label[0].set_ellipsize(Pango.EllipsizeMode.END) + label = checkbox.get_child() + if label is not None: + label.set_ellipsize(Pango.EllipsizeMode.END) if defaults[check]: checkbox.set_active(True) diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index 6b96586..148f057 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -205,7 +205,7 @@ class ServerTreeView(ContextMixin, TreeView): thread = threading.Thread( daemon=True, target=CalcDist, - args=(record.ip, enum, self.queue, self.controller), + args=(record.ip, enum, self.queue, self.controller, cache), ) thread.start() @@ -218,10 +218,9 @@ class ServerTreeView(ContextMixin, TreeView): cache = self.controller.get_dist_cache() if latest_result: - addr = latest_result[0] - haversine = latest_result[1] + addr, haversine, tab = latest_result if addr not in cache: - cache[addr] = haversine + self.controller.set_dist_cache(addr, haversine) # TODO: should be emitting a statusbar signal here instead? self.controller.set_statusbar_dist(haversine, self.get_enum()) return True @@ -281,7 +280,9 @@ class ServerTreeView(ContextMixin, TreeView): record = self.get_record() self.controller.connect_by_record(record) - def _parent_selection_changed(self, base_class: TreeView, sel: Gtk.TreeSelection) -> None: + def _parent_selection_changed( + self, base_class: TreeView, sel: Gtk.TreeSelection + ) -> None: if self.loaded is False: return self.start_distcalc() @@ -315,7 +316,9 @@ class ServerTreeView(ContextMixin, TreeView): self.loaded = status @staticmethod - def ping_server(model, _iter: Gtk.TreeIter, ip: str, qport: int, ping_column: int) -> None: + def ping_server( + model, _iter: Gtk.TreeIter, ip: str, qport: int, ping_column: int + ) -> None: _ping = ping(ip, qport) GLib.idle_add(lambda: model.set(_iter, ping_column, _ping)) From 1be7bddbb4a2eb195ca0cee633beb7990164e02f Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Tue, 28 Apr 2026 23:28:45 +0900 Subject: [PATCH 186/309] fix: cast ports to int --- dzgui/api/bm.py | 5 +---- dzgui/api/servers.py | 2 +- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/dzgui/api/bm.py b/dzgui/api/bm.py index 1f25838..310ea70 100644 --- a/dzgui/api/bm.py +++ b/dzgui/api/bm.py @@ -1,14 +1,11 @@ import logging import requests -from typing import Optional, TYPE_CHECKING +from typing import Optional from dzgui.const.endpoints import BM_SERVERS logger = logging.getLogger(__name__) -if TYPE_CHECKING: - from dzgui.api.servers import Record - def get_attributes(key: str, uid: int) -> str: # TODO: handle if key is not set diff --git a/dzgui/api/servers.py b/dzgui/api/servers.py index ea30a95..9785d84 100644 --- a/dzgui/api/servers.py +++ b/dzgui/api/servers.py @@ -503,4 +503,4 @@ def response_to_record(res: dict) -> Record: ip = res["addr"].split(":")[0] gameport = res["gameport"] qport = res["addr"].split(":")[1] - return Record(ip, gameport, qport) + return Record(ip, int(gameport), int(qport)) From d0934723e650a4fae347179fc3482ff4a490cf07 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Tue, 28 Apr 2026 23:51:02 +0900 Subject: [PATCH 187/309] changed: delegate set_fav to ConfigManager --- dzgui/controllers/mc.py | 24 +++++++++++------------- dzgui/managers/config.py | 7 +++++++ dzgui/managers/contextmenu.py | 5 ++++- 3 files changed, 22 insertions(+), 14 deletions(-) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index a7ef16b..decac12 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -19,7 +19,6 @@ from dzgui.api.mods import ( _hash, remove_stale_signatures, ) -from dzgui.api.probe import test_steam_api, test_bm_api from dzgui.const.constants import ( APPID_DAYZ, APPID_DAYZ_EXP, @@ -122,6 +121,7 @@ class Controller(GObject.GObject): def get_emitter(self) -> Emitter: return self.emitter + @deprecated("depends on load mods logic") def call_on_thread(dialog_str: str) -> Callable: def decorator(func: Callable) -> Callable: @wraps(func) @@ -187,6 +187,7 @@ class Controller(GObject.GObject): window = self.get_window() self.config_man.save_res_and_quit(treeview, window) + ## START STATUSBAR LOGIC @deprecated("use statusbar internal contexts") def remove_statusbar(self, context: str) -> None: c = self.mediator.statusbar.statusbar.get_context_id(context) @@ -229,6 +230,8 @@ class Controller(GObject.GObject): self.emitter.emit("distcalc_ended", dist, context) + ## END STATUSBAR LOGIC + def toggle_config(self, key: Preferences) -> None: # NOTE: Preferences.DIST is dynamic if key == Preferences.DIST: @@ -333,6 +336,10 @@ class Controller(GObject.GObject): self.mediator.modtreeview.set_model(model) self.emitter.emit("mod_page_loaded") + # def load_mods(self) -> None: + # path = self.query_config(Preferences.DEFAULT) + # ModManager(path) + # TODO: delegate to threadmanager @call_on_thread(strings.dialog.modlist) def load_mods(self) -> None: @@ -405,23 +412,14 @@ class Controller(GObject.GObject): def get_mod_store(self) -> Gtk.TreeModel | None: return self.mediator.modtreeview.get_model() - # TODO: delegate to configman - def set_fav(self) -> None: - treeview = self.get_active_treeview() - name = treeview.get_value_at_index(0) - record = treeview.get_record_string() - + def set_fav(self, name: str, record: str, simple_ip: str) -> None: try: - self.config_man.write_config(Preferences.FAV_LBL, name) - self.config_man.write_config(Preferences.FAV_SRV, record) + self.config_man.set_fav(name, record) + self.emitter.emit("fav_server_changed", name, simple_ip) except Exception as e: logger.critical(e) - # TODO: add a failure dialog here return - simple_ip = treeview.get_simplified_ip() - self.emitter.emit("fav_server_changed", name, simple_ip) - def menu_action(self, action: ContextMenu, tree: Gtk.TreeView) -> None: context_man = ContextMenuManager(tree, self) context_man.process(action) diff --git a/dzgui/managers/config.py b/dzgui/managers/config.py index a03a557..0857d9f 100644 --- a/dzgui/managers/config.py +++ b/dzgui/managers/config.py @@ -132,6 +132,13 @@ class ConfigManager: logger.critical(e) raise e + def set_fav(self, name: str, record: str) -> None: + try: + self.write_config(Preferences.FAV_LBL, name) + self.write_config(Preferences.FAV_SRV, record) + except Exception: + return + def write_config(self, key: Preferences, value: str) -> None: try: real_key = self.enum_to_key(key) diff --git a/dzgui/managers/contextmenu.py b/dzgui/managers/contextmenu.py index 1e2a8eb..7e31c6d 100644 --- a/dzgui/managers/contextmenu.py +++ b/dzgui/managers/contextmenu.py @@ -55,7 +55,10 @@ class ContextMenuManager: case ContextMenu.OPEN_WORKSHOP: self.open_mod_page() case ContextMenu.SET_FAV: - self.controller.set_fav() + name = self.treeview.get_value_at_index(0) + record = self.treeview.get_record_string() + simple = self.treeview.get_simplified_ip() + self.controller.set_fav(name, record, simple) # THREADED case ContextMenu.ADD_SERVER: From 4377f9de7c6cfd844ecf1c0105e27d9c479f2aeb Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 29 Apr 2026 00:44:33 +0900 Subject: [PATCH 188/309] chore: clear typehinting errors --- dzgui/controllers/mc.py | 10 ++++++---- dzgui/managers/contextmenu.py | 1 - dzgui/model/servers.py | 3 ++- dzgui/views/components/filter_panel.py | 3 ++- dzgui/views/components/maps_combo.py | 2 +- dzgui/views/pages/servers.py | 10 +++++++--- dzgui/views/trees/tree_menu.py | 2 +- 7 files changed, 19 insertions(+), 12 deletions(-) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index decac12..851b9d9 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -158,7 +158,7 @@ class Controller(GObject.GObject): return self.config_man.lookup(key) def is_auto_install(self) -> bool: - return self.query_config(Preferences.INSTALL) + return bool(self.query_config(Preferences.INSTALL)) def suppress_signal( self, owner: Gtk.Widget, widget: Gtk.Widget, func_name: str, state: bool @@ -173,7 +173,7 @@ class Controller(GObject.GObject): def toggle_debug_mode(self) -> None: self.toggle_config(Preferences.DEBUG) - def get_active_context(self) -> Gtk.TreeView: + def get_active_context(self) -> "ServerTab": return self.get_active_treeview().get_enum() def get_active_treeview(self) -> "ServerTreeView": @@ -295,6 +295,8 @@ class Controller(GObject.GObject): def highlight_stale_cleanup(self, stale_mods: list) -> None: """Manipulates attached ListStore in the main event loop""" model = self.get_mod_store() + if model is None: + return for mod in model: it = mod.iter path = model.get_path(it) @@ -358,7 +360,7 @@ class Controller(GObject.GObject): self.delete_single_mod(path) total_mods, total_size = self.calc_mod_size() - self.update_mod_statusbar() + self.format_mod_statusbar() def delete_single_mod_cleanup(self, _iter: Gtk.TreeIter) -> None: self.get_mod_store().remove(_iter) @@ -420,7 +422,7 @@ class Controller(GObject.GObject): logger.critical(e) return - def menu_action(self, action: ContextMenu, tree: Gtk.TreeView) -> None: + def menu_action(self, action: ContextMenu, tree: "ServerTreeView") -> None: context_man = ContextMenuManager(tree, self) context_man.process(action) diff --git a/dzgui/managers/contextmenu.py b/dzgui/managers/contextmenu.py index 7e31c6d..1ecc6f8 100644 --- a/dzgui/managers/contextmenu.py +++ b/dzgui/managers/contextmenu.py @@ -3,7 +3,6 @@ import logging from typing import TYPE_CHECKING from dzgui.const.enum import ContextMenu, Preferences -from dzgui.util import strings from dzgui.util.clip import copy_clipboard from dzgui.util.open_links import open_workshop_page diff --git a/dzgui/model/servers.py b/dzgui/model/servers.py index 2a1ba42..c175d15 100644 --- a/dzgui/model/servers.py +++ b/dzgui/model/servers.py @@ -27,6 +27,7 @@ API_TIMEOUT = 3 if TYPE_CHECKING: from dzgui.controllers.mc import Controller from dzgui.model.proxy_model import ProxyModelManager + from dzgui.views.trees.tree_servers import ServerTreeView logger = logging.getLogger(__name__) @@ -39,7 +40,7 @@ class NewPlayerCount: class ServerModelManager: - def __init__(self, controller: "Controller", tv: Gtk.TreeView) -> None: + def __init__(self, controller: "Controller", tv: "ServerTreeView") -> None: self.tv = tv self.enum = tv.get_enum() diff --git a/dzgui/views/components/filter_panel.py b/dzgui/views/components/filter_panel.py index bd3eb88..0cf6b4e 100644 --- a/dzgui/views/components/filter_panel.py +++ b/dzgui/views/components/filter_panel.py @@ -227,7 +227,7 @@ class FilterPanel(Gtk.Box): # state = self.default_filters[label] # check.set_active(state) - def _on_map_entry_keypress(self, entry: Gtk.Entry, event: Gdk.EventKey) -> None: + def _on_map_entry_keypress(self, entry: Gtk.Entry, event: Gdk.EventKey) -> bool: match event.keyval: case Gdk.KEY_Escape: GLib.idle_add(self.restore_focus_to_treeview) @@ -237,6 +237,7 @@ class FilterPanel(Gtk.Box): """ text = self.maps_entry.get_text() self.maps_entry.set_position(len(text)) + return True case _: return False diff --git a/dzgui/views/components/maps_combo.py b/dzgui/views/components/maps_combo.py index dfbc948..0eea10f 100644 --- a/dzgui/views/components/maps_combo.py +++ b/dzgui/views/components/maps_combo.py @@ -5,7 +5,7 @@ from gi.repository import Gtk, GLib # noqa E402 class MapsCombo(Gtk.EntryCompletion): - def __init__(self): + def __init__(self) -> None: super().__init__(inline_completion=True) self.set_text_column(0) self.set_minimum_key_length(1) diff --git a/dzgui/views/pages/servers.py b/dzgui/views/pages/servers.py index 92da14d..e7d14c2 100644 --- a/dzgui/views/pages/servers.py +++ b/dzgui/views/pages/servers.py @@ -94,16 +94,20 @@ class ServerNotebook(Gtk.ScrolledWindow): self.emitter.emit("server_page_toggled", False) def _on_keypress(self, widget: Self, event: Gdk.EventKey) -> Optional[False]: - if event.state is Gdk.ModifierType.CONTROL_MASK: + # NOTE: abort if modifier mask is active + if event.state != 0: return False match event.keyval: case Gdk.KEY_n: self.notebook.next_page() + self.grab_content_area() + return True case Gdk.KEY_p: self.notebook.prev_page() + self.grab_content_area() + return True case _: - return - self.grab_content_area() + return False def grab_content_area(self) -> None: self.get_active_treeview().grab_focus() diff --git a/dzgui/views/trees/tree_menu.py b/dzgui/views/trees/tree_menu.py index 8292bb1..a88819e 100644 --- a/dzgui/views/trees/tree_menu.py +++ b/dzgui/views/trees/tree_menu.py @@ -70,7 +70,7 @@ class MenuTreeView(TreeView): if row_type in docs: open_link_by_rowtype(row_type) - def get_row_enum(self) -> Any: + def get_row_enum(self) -> str: # NOTE: col 1 contains a RowType enum model = self.get_model() _iter = self.get_focused_row_iter() From d6ef55a3b3ebf53b5ef8d860038a304b40176576 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 29 Apr 2026 01:04:22 +0900 Subject: [PATCH 189/309] chore: explicitly return False instead of GLib macro --- dzgui/config/query.py | 6 ++++-- dzgui/views/components/right_panel.py | 6 +++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/dzgui/config/query.py b/dzgui/config/query.py index 50f3c34..00bb522 100644 --- a/dzgui/config/query.py +++ b/dzgui/config/query.py @@ -54,7 +54,9 @@ def enum_to_key(enum: Preferences) -> str: def get_client_index(client: str) -> int: if client == STEAM_CMD: return 0 - if client == FLATPAK_RUN_CMD: + elif client == FLATPAK_RUN_CMD: return 1 - if client == FLATPAK_SANDBOX: + elif client == FLATPAK_SANDBOX: return 2 + else: + return 0 diff --git a/dzgui/views/components/right_panel.py b/dzgui/views/components/right_panel.py index 79c4379..f421369 100644 --- a/dzgui/views/components/right_panel.py +++ b/dzgui/views/components/right_panel.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING +from typing import Literal, TYPE_CHECKING from dzgui.const.enum import ServerTab from dzgui.util.clip import copy_clipboard @@ -72,10 +72,10 @@ class RightPanel(Gtk.Box): self.filters_vbox.set_sensitive(state) def _on_version_clicked(self, widget: Gtk.EventBox, event: Gdk.EventButton) -> None: - def revert() -> GLib.SOURCE_REMOVE: + def revert() -> Literal[False]: self.copying = False self.version_label.set_text(version) - return GLib.SOURCE_REMOVE + return False if self.copying: return From eba9a7bfbafa182e5112bf44f256d6c1fef6f9a9 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 29 Apr 2026 20:20:20 +0900 Subject: [PATCH 190/309] chore: restrict logs to app namespace --- dzgui/api/bm.py | 3 ++- dzgui/api/mods.py | 3 ++- dzgui/api/probe.py | 4 ++-- dzgui/api/servers.py | 16 +++++++------ dzgui/api/steam.py | 3 ++- dzgui/config/ipdb.py | 14 ++++++----- dzgui/const/enum.py | 33 -------------------------- dzgui/controllers/mc.py | 3 ++- dzgui/init/coords.py | 3 ++- dzgui/init/dayz.py | 4 ++-- dzgui/init/prereqs.py | 9 +++---- dzgui/init/update.py | 4 ++-- dzgui/main.py | 27 ++++++++++++++------- dzgui/managers/config.py | 5 ++-- dzgui/managers/contextmenu.py | 3 ++- dzgui/managers/log.py | 4 +++- dzgui/managers/notes.py | 4 +++- dzgui/managers/threading.py | 4 +++- dzgui/model/servers.py | 11 ++++----- dzgui/util/dist.py | 7 +++--- dzgui/util/symlink.py | 5 ++-- dzgui/views/base.py | 2 +- dzgui/views/components/buttonbox.py | 4 ++-- dzgui/views/components/filter_panel.py | 11 +++++++-- dzgui/views/dialogs/servers.py | 4 ++++ dzgui/views/mixins/context_mixin.py | 5 ++-- dzgui/views/pages/changelog.py | 4 ++-- dzgui/views/pages/log.py | 4 ++-- dzgui/views/pages/servers.py | 3 ++- dzgui/views/trees/tree_base.py | 4 ++-- dzgui/views/trees/tree_log.py | 3 ++- dzgui/views/trees/tree_menu.py | 5 ++-- dzgui/views/trees/tree_mods.py | 4 ++-- dzgui/views/trees/tree_servers.py | 9 +++---- 34 files changed, 119 insertions(+), 112 deletions(-) diff --git a/dzgui/api/bm.py b/dzgui/api/bm.py index 310ea70..c92a3f5 100644 --- a/dzgui/api/bm.py +++ b/dzgui/api/bm.py @@ -2,9 +2,10 @@ import logging import requests from typing import Optional +from dzgui.const.constants import APP_NAME from dzgui.const.endpoints import BM_SERVERS -logger = logging.getLogger(__name__) +logger = logging.getLogger(APP_NAME) def get_attributes(key: str, uid: int) -> str: diff --git a/dzgui/api/mods.py b/dzgui/api/mods.py index c63819a..ead3485 100644 --- a/dzgui/api/mods.py +++ b/dzgui/api/mods.py @@ -11,6 +11,7 @@ from pathlib import Path import dzgui.api.pefile as PeFile from dzgui.api.servers import Record, get_rules from dzgui.const.constants import ( + APP_NAME, APPID_DAYZ, LIBRARYFOLDERS_PATH, WORKSHOP_PATH, @@ -22,7 +23,7 @@ from dzgui.const.enum import Preferences from typing import Any -logger = logging.getLogger(__name__) +logger = logging.getLogger(APP_NAME) @dataclass diff --git a/dzgui/api/probe.py b/dzgui/api/probe.py index 0280502..6ecd788 100644 --- a/dzgui/api/probe.py +++ b/dzgui/api/probe.py @@ -3,10 +3,10 @@ import requests from typing import Union, TYPE_CHECKING -from dzgui.const.constants import APPID_DAYZ, REQUEST_TIMEOUT +from dzgui.const.constants import APPID_DAYZ, APP_NAME, REQUEST_TIMEOUT from dzgui.const import endpoints -logger = logging.getLogger(__name__) +logger = logging.getLogger(APP_NAME) if TYPE_CHECKING: diff --git a/dzgui/api/servers.py b/dzgui/api/servers.py index 9785d84..c2e4322 100644 --- a/dzgui/api/servers.py +++ b/dzgui/api/servers.py @@ -12,7 +12,7 @@ from dataclasses import dataclass from typing import Optional, TYPE_CHECKING, Union from dzgui.api.bm import map_id_to_record -from dzgui.const.constants import REQUEST_TIMEOUT +from dzgui.const.constants import APP_NAME, REQUEST_TIMEOUT from dzgui.const.endpoints import STEAM_SERVERS from dzgui.util import strings @@ -22,8 +22,7 @@ import dayzquery if TYPE_CHECKING: from dayzquery import DayzMod -logger = logging.getLogger(__name__) -logging.getLogger("a2s").setLevel(logging.ERROR) +logger = logging.getLogger(APP_NAME) # TODO: confirm that patches from testing are incorporated here # particularly around malformed values in JSON response. check commit log @@ -63,7 +62,7 @@ def get_netmask() -> str: # TODO: rename def test_ip(suffix: int, port: int, event: threading.Event) -> dict | None: if event.is_set(): - return + return None netmask = get_netmask() hostname = f"{netmask}.{str(suffix)}" ping = ["ping", "-c1", "-i", "0.1", "-w", "1"] @@ -74,6 +73,9 @@ def test_ip(suffix: int, port: int, event: threading.Event) -> dict | None: def sanitize(name: str) -> str: + """ + Strips extraneous padding and spammy junk chars in server names + """ name = re.sub(r"\r", r"", name) name = re.sub(r"\n", r"", name) name = re.sub(r"\x01", r"", name) @@ -189,9 +191,9 @@ def parse_json(json: list) -> list: return rows -def query_direct(ip: str, qport: int, TIMEOUT: float = 3.0) -> dict | None: +def query_direct(ip: str, qport: int, timeout: float = 3.0) -> dict | None: try: - info = a2s.info((ip, qport), TIMEOUT) + info = a2s.info((ip, qport), timeout) name = info.server_name mapname = info.map_name @@ -218,7 +220,7 @@ def query_direct(ip: str, qport: int, TIMEOUT: float = 3.0) -> dict | None: res["ping"] = ping return res except Exception as e: - logger.critical(f"{type(e).__name__}: {e}") + logger.critical(f"{type(e).__name__}: {e} ({ip}:{qport})") return None diff --git a/dzgui/api/steam.py b/dzgui/api/steam.py index 358941d..e0f872f 100644 --- a/dzgui/api/steam.py +++ b/dzgui/api/steam.py @@ -5,10 +5,11 @@ import subprocess from shlex import shlex from pathlib import Path +from dzgui.const.constants import APP_NAME from dzgui.util.bash import concat_bash_args -logger = logging.getLogger(__name__) +logger = logging.getLogger(APP_NAME) def query_defunct() -> None: diff --git a/dzgui/config/ipdb.py b/dzgui/config/ipdb.py index 4d64264..53c89db 100644 --- a/dzgui/config/ipdb.py +++ b/dzgui/config/ipdb.py @@ -6,10 +6,11 @@ import shutil from pathlib import Path -from dzgui.const.constants import REQUEST_TIMEOUT +from dzgui.const.constants import APP_NAME, REQUEST_TIMEOUT from dzgui.const.endpoints import DB_IP -logger = logging.getLogger(__name__) +logger = logging.getLogger(APP_NAME) + def find_download_url(url: str) -> str | None: res = requests.get(url, timeout=REQUEST_TIMEOUT) @@ -20,6 +21,7 @@ def find_download_url(url: str) -> str | None: return None return str(urls[0]) + def get_ipdb(ips_path: Path) -> None: url = find_download_url(DB_IP) if url is None: @@ -28,10 +30,10 @@ def get_ipdb(ips_path: Path) -> None: date = find_date(url) month_file = ips_path.parent / ".month" if ips_path.exists() and month_file.exists(): - old_date = month_file.read_text().rstrip("\n") - if old_date == date: - logger.info(f"IP DB date matches: {date}") - return + 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}") diff --git a/dzgui/const/enum.py b/dzgui/const/enum.py index e1189c6..254e3fd 100644 --- a/dzgui/const/enum.py +++ b/dzgui/const/enum.py @@ -112,39 +112,6 @@ class NotebookPage(EnumWithAttrs): class RowType(EnumWithAttrs): - @classmethod - def str2rowtype(cls, string: str) -> "RowType": - for member in cls: - if string == member.dict["label"]: - return member - - # TODO: add to notebook tab tooltips - # 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", - # } # TODO: strings CHANGELOG = { "label": "View changelog", diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 851b9d9..f9bab62 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -34,6 +34,7 @@ from dzgui.const.enum import ( from dzgui.config.query import lookup from dzgui.config.userprefs import UserPrefs +from dzgui.const.constants import APP_NAME from dzgui.controllers.emitter import Emitter from dzgui.managers.log import LogManager from dzgui.managers.config import ConfigManager @@ -56,7 +57,7 @@ import gi gi.require_version("Gtk", "3.0") from gi.repository import Gtk, Gdk, GLib, GObject # noqa E402 -logger = logging.getLogger(__name__) +logger = logging.getLogger(APP_NAME) if TYPE_CHECKING: from dzgui.api.servers import Record diff --git a/dzgui/init/coords.py b/dzgui/init/coords.py index 957fdbb..7ae3afe 100644 --- a/dzgui/init/coords.py +++ b/dzgui/init/coords.py @@ -1,12 +1,13 @@ import logging from typing import TYPE_CHECKING +from dzgui.const.constants import APP_NAME from dzgui.util.ip import get_local_ip, get_coords, Coords if TYPE_CHECKING: from pathlib import Path -logger = logging.getLogger(__name__) +logger = logging.getLogger(APP_NAME) def get_local_coords(path: "Path") -> Coords | None: diff --git a/dzgui/init/dayz.py b/dzgui/init/dayz.py index 7db9e16..1dbbdbc 100644 --- a/dzgui/init/dayz.py +++ b/dzgui/init/dayz.py @@ -4,13 +4,13 @@ import sys from pathlib import Path from dzgui.config.query import lookup -from dzgui.const.constants import LIBRARYFOLDERS_PATH, APPID_DAYZ +from dzgui.const.constants import APPID_DAYZ, APP_NAME, LIBRARYFOLDERS_PATH from dzgui.const.enum import Preferences from dzgui.views.dialogs.early_alert import EarlyAlertDialog import dzgui.api.pefile as PeFile -logger = logging.getLogger(__name__) +logger = logging.getLogger(APP_NAME) def is_dayz_installed(config: Path) -> None: diff --git a/dzgui/init/prereqs.py b/dzgui/init/prereqs.py index 5be0040..be92e20 100644 --- a/dzgui/init/prereqs.py +++ b/dzgui/init/prereqs.py @@ -2,9 +2,9 @@ import logging import shutil import subprocess -from dzgui.const.constants import STEAM_CMD, FLATPAK_APPID, FLATPAK_CMD +from dzgui.const.constants import APP_NAME, FLATPAK_APPID, FLATPAK_CMD, STEAM_CMD -logger = logging.getLogger(__name__) +logger = logging.getLogger(APP_NAME) def has_steam_client() -> bool: @@ -14,10 +14,7 @@ def has_steam_client() -> bool: return False try: res = subprocess.run( - [FLATPAK_CMD, "list"], - capture_output=True, - text=True, - check=True + [FLATPAK_CMD, "list"], capture_output=True, text=True, check=True ) if FLATPAK_APPID in res.stdout: return True diff --git a/dzgui/init/update.py b/dzgui/init/update.py index 7adb93f..92729cb 100644 --- a/dzgui/init/update.py +++ b/dzgui/init/update.py @@ -6,11 +6,11 @@ import sys from importlib import resources from packaging.version import Version, InvalidVersion -from dzgui.const.constants import APP_NAME_LOWER, REQUEST_TIMEOUT +from dzgui.const.constants import APP_NAME, APP_NAME_LOWER, REQUEST_TIMEOUT from dzgui.const.endpoints import GITHUB_RELEASES, CODEBERG_RELEASES from dzgui.init.prefix import is_prefix_writeable -logger = logging.getLogger(__name__) +logger = logging.getLogger(APP_NAME) def get_latest_release() -> str | None: diff --git a/dzgui/main.py b/dzgui/main.py index 6d991dc..d729e80 100644 --- a/dzgui/main.py +++ b/dzgui/main.py @@ -7,6 +7,7 @@ import warnings from typing import TYPE_CHECKING from dzgui.api.mods import remove_stale_signatures +from dzgui.const.constants import APP_NAME from dzgui.const.enum import Preferences from dzgui.config.ipdb import get_ipdb from dzgui.config.query import lookup @@ -36,18 +37,17 @@ from dzgui.util.strings import init, flags from dzgui.views.base import App from dzgui.views.dialogs.early_alert import EarlyAlertDialog, EarlyIgnoreDialog -logger = logging.getLogger(__name__) +if TYPE_CHECKING: + from pathlib import Path + +logger = logging.getLogger(APP_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) parser.add_argument("-d", "--debug", action="store_true", help=flags.debug) args = parser.parse_args() -if TYPE_CHECKING: - from pathlib import Path - # TODO: profile load time def make_parents(path: "Path") -> None: @@ -63,6 +63,18 @@ def uninstall() -> None: pass +def setup_logger(log_path: "Path") -> None: + _format = ( + "%(asctime)s␞%(levelname)s␞%(filename)s::%(funcName)s::%(lineno)s␞%(message)s" + ) + fh = logging.FileHandler(log_path) + formatter = logging.Formatter(_format) + fh.setFormatter(formatter) + fh.setLevel(logging.DEBUG) + logger.setLevel(logging.DEBUG) + logger.addHandler(fh) + + def main() -> None: lock = lock_acquire() # noqa @@ -95,10 +107,7 @@ def main() -> None: # TODO: copy notes file copy_state_files(xdg_paths["XDG_STATE_HOME"]) - _format = ( - "%(asctime)s␞%(levelname)s␞%(filename)s::%(funcName)s::%(lineno)s␞%(message)s" - ) - logging.basicConfig(filename=XDG.debug, format=_format, level=logging.DEBUG) + setup_logger(XDG.debug) with open(XDG.debug, "w") as f: f.truncate(0) diff --git a/dzgui/managers/config.py b/dzgui/managers/config.py index 0857d9f..15c0b03 100644 --- a/dzgui/managers/config.py +++ b/dzgui/managers/config.py @@ -4,9 +4,10 @@ import traceback from typing import Any, TYPE_CHECKING from dzgui.const.constants import ( - STEAM_CMD, + APP_NAME, FLATPAK_RUN_CMD, FLATPAK_SANDBOX, + STEAM_CMD, WINDOW_DEFAULT_X, WINDOW_DEFAULT_Y, ) @@ -28,7 +29,7 @@ if TYPE_CHECKING: from dzgui.views.base import OuterWindow from dzgui.views.trees.tree_servers import ServerTreeView -logger = logging.getLogger(__name__) +logger = logging.getLogger(APP_NAME) class ConfigManager: diff --git a/dzgui/managers/contextmenu.py b/dzgui/managers/contextmenu.py index 1ecc6f8..a075a6a 100644 --- a/dzgui/managers/contextmenu.py +++ b/dzgui/managers/contextmenu.py @@ -2,6 +2,7 @@ import gi import logging from typing import TYPE_CHECKING +from dzgui.const.constants import APP_NAME from dzgui.const.enum import ContextMenu, Preferences from dzgui.util.clip import copy_clipboard from dzgui.util.open_links import open_workshop_page @@ -14,7 +15,7 @@ gi.require_version("Gtk", "3.0") from gi.repository import Gtk, Gdk, GLib, GObject # noqa E402 -logger = logging.getLogger(__name__) +logger = logging.getLogger(APP_NAME) if TYPE_CHECKING: from dzgui.controllers.mc import Controller diff --git a/dzgui/managers/log.py b/dzgui/managers/log.py index 46933f9..76504d2 100644 --- a/dzgui/managers/log.py +++ b/dzgui/managers/log.py @@ -1,8 +1,10 @@ import logging from pathlib import Path + +from dzgui.const.constants import APP_NAME from dzgui.util.strings import delimiter -logger = logging.getLogger(__name__) +logger = logging.getLogger(APP_NAME) class LogManager: diff --git a/dzgui/managers/notes.py b/dzgui/managers/notes.py index 942c68f..73beaa6 100644 --- a/dzgui/managers/notes.py +++ b/dzgui/managers/notes.py @@ -2,6 +2,8 @@ import json import logging from typing import TYPE_CHECKING + +from dzgui.const.constants import APP_NAME from dzgui.util._json import read_json import gi @@ -12,7 +14,7 @@ if TYPE_CHECKING: from dzgui.controllers.mc import Controller from pathlib import Path -logger = logging.getLogger(__name__) +logger = logging.getLogger(APP_NAME) class NoteManager: diff --git a/dzgui/managers/threading.py b/dzgui/managers/threading.py index e3f6013..60406d5 100644 --- a/dzgui/managers/threading.py +++ b/dzgui/managers/threading.py @@ -5,6 +5,8 @@ from typing import Any, TYPE_CHECKING from functools import wraps from typing import Callable + +from dzgui.const.constants import APP_NAME from dzgui.views.dialogs.generic import WaitDialog if TYPE_CHECKING: @@ -15,7 +17,7 @@ import gi gi.require_version("Gtk", "3.0") from gi.repository import Gtk, GLib # noqa E402 -logger = logging.getLogger(__name__) +logger = logging.getLogger(APP_NAME) def call_on_thread(dialog_str: str) -> Callable: diff --git a/dzgui/model/servers.py b/dzgui/model/servers.py index c175d15..f008c24 100644 --- a/dzgui/model/servers.py +++ b/dzgui/model/servers.py @@ -7,11 +7,12 @@ from dataclasses import dataclass from typing import Any, TYPE_CHECKING import dzgui.api.servers as Servers -from dzgui.const.enum import FilterMode, Preferences, ServerTab from dzgui.const.constants import ( APPID_DAYZ, APPID_DAYZ_EXP, + APP_NAME, ) +from dzgui.const.enum import FilterMode, Preferences, ServerTab from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager from dzgui.util.strings import api_warn_msg, dialog from dzgui.views.dialogs.generic import ExceptionDialog @@ -21,15 +22,14 @@ import gi gi.require_version("Gtk", "3.0") from gi.repository import Gtk # noqa E402 -LAN_TIMEOUT = 0.5 -API_TIMEOUT = 3 - if TYPE_CHECKING: from dzgui.controllers.mc import Controller from dzgui.model.proxy_model import ProxyModelManager from dzgui.views.trees.tree_servers import ServerTreeView -logger = logging.getLogger(__name__) +logger = logging.getLogger(APP_NAME) +LAN_TIMEOUT = 0.5 +API_TIMEOUT = 3 @dataclass @@ -336,7 +336,6 @@ class ServerModelManager: proxy = self._get_proxy_man().get_proxy_model() self.tv.set_model(proxy) - # TODO: animate saved servers tab if we are on other tab self.emitter.emit("servers_loaded", self.enum) filter_man = self.tv.get_filter_man() diff --git a/dzgui/util/dist.py b/dzgui/util/dist.py index c2fb873..b1c7721 100644 --- a/dzgui/util/dist.py +++ b/dzgui/util/dist.py @@ -1,9 +1,10 @@ import logging from math import radians, cos, sin, asin, sqrt -from typing import Self, TYPE_CHECKING +from typing import TYPE_CHECKING -from dzgui.util.ip import GeolocationError, get_coords +from dzgui.const.constants import APP_NAME +from dzgui.util.ip import get_coords import gi @@ -15,7 +16,7 @@ if TYPE_CHECKING: from dzgui.controllers.mc import Controller from queue import Queue -logger = logging.getLogger(__name__) +logger = logging.getLogger(APP_NAME) class Haversine: diff --git a/dzgui/util/symlink.py b/dzgui/util/symlink.py index 59528bd..984a0d8 100644 --- a/dzgui/util/symlink.py +++ b/dzgui/util/symlink.py @@ -2,13 +2,14 @@ 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.constants import APPID_DAYZ, APPID_DAYZ_EXP, APP_NAME from dzgui.const.enum import Preferences from dzgui.config.query import lookup import dzgui.api.pefile as PeFile -logger = logging.getLogger(__name__) +logger = logging.getLogger(APP_NAME) + def rebuild_symlinks(config: Path) -> None: path = lookup(config, Preferences.DEFAULT) diff --git a/dzgui/views/base.py b/dzgui/views/base.py index b4fcb25..bc722ec 100644 --- a/dzgui/views/base.py +++ b/dzgui/views/base.py @@ -35,7 +35,7 @@ gi.require_version("Gtk", "3.0") gi.require_version("GLibUnix", "2.0") from gi.repository import Gtk, GLib, GLibUnix, Gdk, GObject, Pango # noqa E402 -logger = logging.getLogger(__name__) +logger = logging.getLogger(APP_NAME) # https://bugzilla.gnome.org/show_bug.cgi?id=708676 warnings.filterwarnings("ignore", ".*g_value_get_int", Warning) diff --git a/dzgui/views/components/buttonbox.py b/dzgui/views/components/buttonbox.py index cfa73ff..2e81c5b 100644 --- a/dzgui/views/components/buttonbox.py +++ b/dzgui/views/components/buttonbox.py @@ -1,15 +1,15 @@ import logging from typing import TYPE_CHECKING +from dzgui.const.constants import APP_NAME, NO_EXPAND, NO_FILL, NO_PADDING from dzgui.const.enum import ButtonType -from dzgui.const.constants import NO_EXPAND, NO_FILL, NO_PADDING import gi gi.require_version("Gtk", "3.0") from gi.repository import Gtk, Gdk # noqa E402 -logger = logging.getLogger(__name__) +logger = logging.getLogger(APP_NAME) if TYPE_CHECKING: from dzgui.controllers.emitter import Emitter diff --git a/dzgui/views/components/filter_panel.py b/dzgui/views/components/filter_panel.py index 0cf6b4e..f24e3b8 100644 --- a/dzgui/views/components/filter_panel.py +++ b/dzgui/views/components/filter_panel.py @@ -1,8 +1,15 @@ import logging from typing import Literal, TYPE_CHECKING +from dzgui.const.constants import ( + APP_NAME, + EXPAND, + NO_EXPAND, + NO_FILL, + NO_PADDING, + SEARCH_ICON, +) from dzgui.const.enum import FilterMode -from dzgui.const.constants import EXPAND, NO_EXPAND, NO_FILL, NO_PADDING, SEARCH_ICON from dzgui.model.servers import ServerModelManager from dzgui.util.strings import all_maps from dzgui.views.components.maps_combo import MapsCombo @@ -13,7 +20,7 @@ import gi gi.require_version("Gtk", "3.0") from gi.repository import Gtk, Gdk, Pango, GLib # noqa E402 -logger = logging.getLogger(__name__) +logger = logging.getLogger(APP_NAME) if TYPE_CHECKING: from dzgui.controllers.mc import Controller diff --git a/dzgui/views/dialogs/servers.py b/dzgui/views/dialogs/servers.py index a0382e0..70dd7f5 100644 --- a/dzgui/views/dialogs/servers.py +++ b/dzgui/views/dialogs/servers.py @@ -78,6 +78,10 @@ class ServerDetailsDialog(ServerDialog): column.set_sort_column_id(i) column.set_expand(True) + # NOTE: constrain tree area to logical height of metadata + # leaving room for server message + self.scrollable_tree.set_size_request(700, 150) + # TODO: make "Server message" text boldface scrollable_message = Gtk.ScrolledWindow() desc = Gtk.Label(label=strings.server_message, valign=Gtk.Align.START) diff --git a/dzgui/views/mixins/context_mixin.py b/dzgui/views/mixins/context_mixin.py index a8d2ef6..1b1d952 100644 --- a/dzgui/views/mixins/context_mixin.py +++ b/dzgui/views/mixins/context_mixin.py @@ -1,4 +1,6 @@ import logging + +from dzgui.const.constants import APP_NAME from dzgui.const.enum import ContextMenu from dzgui.util.keys import is_navkey from dzgui.util.strings import edit_note @@ -9,8 +11,7 @@ import gi gi.require_version("Gtk", "3.0") from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa E402 - -logger = logging.getLogger(__name__) +logger = logging.getLogger(APP_NAME) class ContextMixin(TreeView): diff --git a/dzgui/views/pages/changelog.py b/dzgui/views/pages/changelog.py index d429ad8..a9b7e2d 100644 --- a/dzgui/views/pages/changelog.py +++ b/dzgui/views/pages/changelog.py @@ -3,7 +3,7 @@ import logging from typing import TYPE_CHECKING from importlib import resources -from dzgui.const.constants import APP_NAME_LOWER, CHANGELOG_PATH +from dzgui.const.constants import APP_NAME, APP_NAME_LOWER, CHANGELOG_PATH from dzgui.util.strings import missing_changelog from dzgui.util.format import format_pango from dzgui.views.mixins.help_menu_mixin import HelpMenuMixin @@ -14,7 +14,7 @@ import gi gi.require_version("Gtk", "3.0") from gi.repository import Gtk # noqa E402 -logger = logging.getLogger(__name__) +logger = logging.getLogger(APP_NAME) if TYPE_CHECKING: from dzgui.controllers.mc import Controller diff --git a/dzgui/views/pages/log.py b/dzgui/views/pages/log.py index 2400b2f..f076907 100644 --- a/dzgui/views/pages/log.py +++ b/dzgui/views/pages/log.py @@ -25,10 +25,10 @@ class Log(CursorMixin, HelpMenuMixin, Gtk.Box): # type: ignore self.check_bar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) # TODO: strings active = ( - "WARNING", + "WARN", "CRITICAL", ) - for check in ("WARNING", "CRITICAL", "INFO", "DEBUG"): + for check in ("WARN", "CRITICAL", "INFO", "DEBUG"): c = Gtk.CheckButton(label=check) self.check_bar.pack_start(c, False, False, 0) if check in active: diff --git a/dzgui/views/pages/servers.py b/dzgui/views/pages/servers.py index e7d14c2..f8bc3ef 100644 --- a/dzgui/views/pages/servers.py +++ b/dzgui/views/pages/servers.py @@ -2,6 +2,7 @@ import logging from typing import Optional, Self, TYPE_CHECKING +from dzgui.cosnt.constants import APP_NAME from dzgui.const.enum import ContextMenuGroup, ServerTab from dzgui.views.trees.tree_servers import ServerTreeView from dzgui.util.strings import server_labels @@ -11,7 +12,7 @@ import gi gi.require_version("Gtk", "3.0") from gi.repository import Gtk, Gdk, GLib # noqa E402 -logger = logging.getLogger(__name__) +logger = logging.getLogger(APP_NAME) if TYPE_CHECKING: from dzgui.controllers.mc import Controller diff --git a/dzgui/views/trees/tree_base.py b/dzgui/views/trees/tree_base.py index 9fc7187..be14d36 100644 --- a/dzgui/views/trees/tree_base.py +++ b/dzgui/views/trees/tree_base.py @@ -3,7 +3,7 @@ import logging from typing import Any, Optional, TYPE_CHECKING from warnings import deprecated -from dzgui.const.constants import SEPARATOR +from dzgui.const.constants import APP_NAME, SEPARATOR from dzgui.util.keys import is_navkey from dzgui.views.mixins.cursor_mixin import CursorMixin @@ -12,7 +12,7 @@ import gi gi.require_version("Gtk", "3.0") from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa -logger = logging.getLogger(__name__) +logger = logging.getLogger(APP_NAME) if TYPE_CHECKING: from dzgui.controllers.mc import Controller diff --git a/dzgui/views/trees/tree_log.py b/dzgui/views/trees/tree_log.py index 89a240f..e8db97b 100644 --- a/dzgui/views/trees/tree_log.py +++ b/dzgui/views/trees/tree_log.py @@ -2,6 +2,7 @@ import logging from typing import Any, TYPE_CHECKING +from dzgui.const.cosntants import APP_NAME from dzgui.const.enum import ContextMenuGroup from dzgui.model.model_factory import ModelFactory from dzgui.util import strings @@ -17,7 +18,7 @@ if TYPE_CHECKING: from pathlib import Path from dzgui.controllers.mc import Controller -logger = logging.getLogger(__name__) +logger = logging.getLogger(APP_NAME) class LogTreeView(ContextMixin, TreeView): diff --git a/dzgui/views/trees/tree_menu.py b/dzgui/views/trees/tree_menu.py index a88819e..d6a3e32 100644 --- a/dzgui/views/trees/tree_menu.py +++ b/dzgui/views/trees/tree_menu.py @@ -1,6 +1,7 @@ import logging -from typing import Any, TYPE_CHECKING +from typing import TYPE_CHECKING +from dzgui.const.constants import APP_NAME from dzgui.const.enum import RowType, NotebookPage from dzgui.model.model_factory import ModelFactory from dzgui.util.open_links import open_link_by_rowtype @@ -11,7 +12,7 @@ import gi gi.require_version("Gtk", "3.0") from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa -logger = logging.getLogger(__name__) +logger = logging.getLogger(APP_NAME) if TYPE_CHECKING: from dzgui.controllers.mc import Controller diff --git a/dzgui/views/trees/tree_mods.py b/dzgui/views/trees/tree_mods.py index 766fae5..c272d40 100644 --- a/dzgui/views/trees/tree_mods.py +++ b/dzgui/views/trees/tree_mods.py @@ -1,7 +1,7 @@ import logging from typing import Any, TYPE_CHECKING -from dzgui.const.constants import HEX_RED +from dzgui.const.constants import APP_NAME, HEX_RED from dzgui.const.enum import ContextMenuGroup from dzgui.util import strings, localize from dzgui.views.mixins.context_mixin import ContextMixin @@ -18,7 +18,7 @@ if TYPE_CHECKING: from dzgui.controllers.mc import Controller from dzgui.controllers.emitter import Emitter -logger = logging.getLogger(__name__) +logger = logging.getLogger(APP_NAME) class ModTreeView(ModsMixin, ContextMixin, TreeView): diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index 148f057..3ac8f87 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -5,13 +5,14 @@ from queue import Queue from typing import Any, Optional, Self from warnings import deprecated -from dzgui.views.mixins.context_mixin import ContextMixin -from dzgui.const.enum import ContextMenu, ContextMenuGroup, Preferences, ServerTab from dzgui.api.servers import ping, Record +from dzgui.const.constants import APP_NAME +from dzgui.const.enum import ContextMenu, ContextMenuGroup, Preferences, ServerTab from dzgui.managers.filter import FilterManager from dzgui.model.proxy_model import ProxyModelManager -from dzgui.util.dist import CalcDist from dzgui.util import strings +from dzgui.util.dist import CalcDist +from dzgui.views.mixins.context_mixin import ContextMixin from typing import Literal, TYPE_CHECKING @@ -24,7 +25,7 @@ gi.require_version("Gtk", "3.0") from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa -logger = logging.getLogger(__name__) +logger = logging.getLogger(APP_NAME) if TYPE_CHECKING: from dzgui.controllers.mc import Controller From 792caf0d2d63a4ce6cdd7ba747ece5d078eaa93a Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 29 Apr 2026 20:20:48 +0900 Subject: [PATCH 191/309] fix: typo in import --- dzgui/views/trees/tree_log.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dzgui/views/trees/tree_log.py b/dzgui/views/trees/tree_log.py index e8db97b..30174c0 100644 --- a/dzgui/views/trees/tree_log.py +++ b/dzgui/views/trees/tree_log.py @@ -2,7 +2,7 @@ import logging from typing import Any, TYPE_CHECKING -from dzgui.const.cosntants import APP_NAME +from dzgui.const.constants import APP_NAME from dzgui.const.enum import ContextMenuGroup from dzgui.model.model_factory import ModelFactory from dzgui.util import strings From 804f1df6be3615c311311f6d134c1197329b62b6 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 29 Apr 2026 20:21:06 +0900 Subject: [PATCH 192/309] fix: typo in import --- dzgui/views/pages/servers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dzgui/views/pages/servers.py b/dzgui/views/pages/servers.py index f8bc3ef..0be914f 100644 --- a/dzgui/views/pages/servers.py +++ b/dzgui/views/pages/servers.py @@ -2,7 +2,7 @@ import logging from typing import Optional, Self, TYPE_CHECKING -from dzgui.cosnt.constants import APP_NAME +from dzgui.const.constants import APP_NAME from dzgui.const.enum import ContextMenuGroup, ServerTab from dzgui.views.trees.tree_servers import ServerTreeView from dzgui.util.strings import server_labels From efaaf7f434c93d03dcdc4a4562a87b61794c85a6 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 29 Apr 2026 21:44:57 +0900 Subject: [PATCH 193/309] chore: document tab navigation keys --- dzgui/util/strings.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/dzgui/util/strings.py b/dzgui/util/strings.py index 52f0e12..5f5956b 100644 --- a/dzgui/util/strings.py +++ b/dzgui/util/strings.py @@ -135,6 +135,8 @@ servers = { "Ctrl-d": "select default LAN port", "Ctrl-n": "select custom LAN port", "Ctrl-i": "jump to IP insert field", + "n": "navigate to next tab", + "p": "navigate to previous tab", "ESC": "return to table", "1-9": "toggle filter 1-9 on/off", "0": "toggle filter 10", From 4f8d397bbc99cf54c469361299e32a21846bfd6d Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 30 Apr 2026 00:21:18 +0900 Subject: [PATCH 194/309] feat: ModManager --- dzgui/api/mods.py | 13 ++- dzgui/controllers/emitter.py | 4 +- dzgui/controllers/mc.py | 154 +++------------------------- dzgui/managers/contextmenu.py | 9 +- dzgui/views/components/mod_panel.py | 2 +- dzgui/views/components/statusbar.py | 5 +- dzgui/views/trees/tree_mods.py | 4 +- 7 files changed, 26 insertions(+), 165 deletions(-) diff --git a/dzgui/api/mods.py b/dzgui/api/mods.py index ead3485..c04f1fc 100644 --- a/dzgui/api/mods.py +++ b/dzgui/api/mods.py @@ -9,7 +9,7 @@ from dataclasses import dataclass from pathlib import Path import dzgui.api.pefile as PeFile -from dzgui.api.servers import Record, get_rules +from dzgui.api.servers import Record, get_rules, fqip_to_record from dzgui.const.constants import ( APP_NAME, APPID_DAYZ, @@ -153,10 +153,8 @@ def remove_stale_signatures(config: Path, versions: Path) -> None: def find_stale_mods(config: Path) -> list[int]: def push_record(rec: str) -> list: - add = rec.split(":") - ip = add[0] - qport = add[2] - return get_rules(ip, qport) + record = fqip_to_record(rec) + return [mod.workshop_id for mod in get_rules(record)] steam = lookup(config, Preferences.DEFAULT) steam_path = Path(steam) @@ -171,7 +169,8 @@ def find_stale_mods(config: Path) -> list[int]: wait(futures) for future in futures: res = future.result() - remote_mods += res + remote_mods.extend(res) - stale = [mod for mod in local if mod not in remote_mods] + unique_mods = set(remote_mods) + stale = [mod for mod in local if mod not in unique_mods] return stale diff --git a/dzgui/controllers/emitter.py b/dzgui/controllers/emitter.py index c58f4d7..51faae2 100644 --- a/dzgui/controllers/emitter.py +++ b/dzgui/controllers/emitter.py @@ -124,8 +124,8 @@ class Emitter(GObject.GObject): def load_maps(self, store: ListStore) -> None: pass - @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=()) - def mod_page_loaded(self) -> None: + @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(str,)) + def mods_updated(self, msg: str) -> None: pass @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=()) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index f9bab62..7f822d5 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -41,7 +41,6 @@ from dzgui.managers.config import ConfigManager from dzgui.managers.connection import ConnectionManager from dzgui.managers.contextmenu import ContextMenuManager from dzgui.managers.notes import NoteManager -from dzgui.model.proxy_model import ProxyModelManager from dzgui.model.servers import ServerModelManager from dzgui.model.model_factory import ModelFactory from dzgui.util import strings @@ -122,25 +121,6 @@ class Controller(GObject.GObject): def get_emitter(self) -> Emitter: return self.emitter - @deprecated("depends on load mods logic") - def call_on_thread(dialog_str: str) -> Callable: - def decorator(func: Callable) -> Callable: - @wraps(func) - def wrapper(*args, **kwargs): - def callback() -> None: - func(*args, **kwargs) - GLib.idle_add(self._destroy_on_idle) - - self = args[0] - self.wait_dialog = WaitDialog(self, dialog_str, jobs=self.pending_jobs) - self.wait_dialog.show_all() - thread = threading.Thread(target=callback) - thread.start() - - return wrapper - - return decorator - def register_widget(self, attr: str, widget: Gtk.Widget) -> None: try: setattr(self.mediator, attr, widget) @@ -282,135 +262,22 @@ class Controller(GObject.GObject): client = self.query_config(Preferences.CLIENT) open_user_workshop(uid, client) - ### START LOAD MODS LOGIC + def load_mods(self) -> None: + from dzgui.managers.mods import ModManager + + self.mod_man = ModManager(self) def uncolorize_mods(self) -> None: - model = self.get_mod_store() - for mod in model: - it = mod.iter - path = model.get_path(it) - model[path][4] = None - path = Gtk.TreePath.new_from_indices([0]) - self.mediator.modtreeview.set_cursor(path) + self.mod_man.uncolorize_mods() - def highlight_stale_cleanup(self, stale_mods: list) -> None: - """Manipulates attached ListStore in the main event loop""" - model = self.get_mod_store() - if model is None: - return - for mod in model: - it = mod.iter - path = model.get_path(it) - if int(mod[2]) in stale_mods: - model[path][4] = True - self.emitter.emit("mods_highlighted") - - # TODO: delegate to mod manager - @call_on_thread(strings.dialog.working) def highlight_stale(self) -> None: - # TODO: set progress bar for number of mods - stale = find_stale_mods(self.prefs.paths.config) - self.cleanup_func = StoredFunc(self.highlight_stale_cleanup, stale) - - def set_cleanup_func(self, func: StoredFunc) -> None: - self.cleanup_func = func - - def get_cleanup_func(self) -> StoredFunc: - return self.cleanup_func - - def _destroy_on_idle(self) -> None: - self.wait_dialog.destroy() - func = self.get_cleanup_func() - if func is not None: - func.call() - self.set_cleanup_func(None) - self.mediator.window.set_sensitive(True) + self.mod_man.highlight_stale() def toggle_mod_selection(self, state: bool) -> None: - sel = self.mediator.modtreeview.get_selection() - if state: - sel.select_all() - else: - sel.unselect_all() - # TODO: signal - self.mediator.modtreeview.grab_focus() + self.mod_man.toggle_mod_selection(state) - def load_mods_cleanup(self, model: Gtk.ListStore) -> None: - self.mediator.modtreeview.set_model(model) - self.emitter.emit("mod_page_loaded") - - # def load_mods(self) -> None: - # path = self.query_config(Preferences.DEFAULT) - # ModManager(path) - - # TODO: delegate to threadmanager - @call_on_thread(strings.dialog.modlist) - def load_mods(self) -> None: - model = ModelFactory().make_mod_store() - path = self.query_config(Preferences.DEFAULT) - mods = get_delimited_mods(Path(path)) - model.extend(mods) - - self.cleanup_func = StoredFunc(self.load_mods_cleanup, model) - - def delete_multiple_mods(self) -> None: - sel = self.mediator.modtreeview.get_selection() - model, pathlist = sel.get_selected_rows() - # NOTE: reverse when multiple selection - for path in reversed(pathlist): - self.delete_single_mod(path) - - total_mods, total_size = self.calc_mod_size() - self.format_mod_statusbar() - - def delete_single_mod_cleanup(self, _iter: Gtk.TreeIter) -> None: - self.get_mod_store().remove(_iter) - remove_stale_signatures(self.prefs.paths.config, self.prefs.paths.version) - - # TODO: strings - # TODO: delegate to LocalModManager or api/mods.py - @call_on_thread("deleting mod") - def delete_single_mod(self, tree_path: Gtk.TreePath) -> None: - config = self.prefs.paths.config - mod, _iter = 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 - # TODO: test this with working APPID_DAYZ_EXP installation - 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 - - self.cleanup_func = StoredFunc(self.delete_single_mod_cleanup, _iter) - - def format_mod_statusbar(self) -> str: - total_mods, total_size = self.calc_mod_size() - msg = format_mods(total_size, total_mods) - return msg - - def calc_mod_size(self) -> tuple[int, int]: - model = self.get_mod_store() - if model is None: - return 0, 0 - total_mods = len(model) - total_size = 0 - for mod in model: - total_size += mod[3] - return total_mods, total_size - - ### END LOAD MODS LOGIC + def delete_mods(self) -> None: + self.mod_man.delete_mods() def get_mod_store(self) -> Gtk.TreeModel | None: return self.mediator.modtreeview.get_model() @@ -652,3 +519,6 @@ class Controller(GObject.GObject): log_man = LogManager() alerts = log_man.get_alerts(self.prefs.paths.debug) return alerts + + def get_modtreeview(self) -> "ModTreeView": + return self.mediator.modtreeview diff --git a/dzgui/managers/contextmenu.py b/dzgui/managers/contextmenu.py index a075a6a..642d645 100644 --- a/dzgui/managers/contextmenu.py +++ b/dzgui/managers/contextmenu.py @@ -44,14 +44,7 @@ class ContextMenuManager: case ContextMenu.COPY_SERVER_NAME: self.copy_server_name() case ContextMenu.DELETE_MOD: - print("DEBUG: unimplemented") - # self.delete_single_mod() - # TODO: connect to emitter automatically - # Gtk.TreeModel, row-inserted/row-deleted - # updates statusbar - # FIXME: signal should instead be emitted off of treeview when rows added/inserted - # self.update_mod_statusbar() - pass + self.controller.delete_mods() case ContextMenu.OPEN_WORKSHOP: self.open_mod_page() case ContextMenu.SET_FAV: diff --git a/dzgui/views/components/mod_panel.py b/dzgui/views/components/mod_panel.py index 5bc4552..5f05337 100644 --- a/dzgui/views/components/mod_panel.py +++ b/dzgui/views/components/mod_panel.py @@ -81,7 +81,7 @@ class ModSelectionPanel(Gtk.Box): case ModButton.UNSELECT_ALL: self.controller.toggle_mod_selection(False) case ModButton.DELETE_SELECTED: - self.controller.delete_multiple_mods() + self.controller.delete_mods() case ModButton.HIGHLIGHT_STALE: self.controller.highlight_stale() case ModButton.UNHIGHLIGHT_STALE: diff --git a/dzgui/views/components/statusbar.py b/dzgui/views/components/statusbar.py index 7b4dd05..7533cb5 100644 --- a/dzgui/views/components/statusbar.py +++ b/dzgui/views/components/statusbar.py @@ -60,7 +60,7 @@ class Statusbar(Gtk.Grid): self.emitter.connect("distcalc_started", self._on_distcalc_started) self.emitter.connect("distcalc_ended", self._on_distcalc_ended) self.emitter.connect("servers_loaded", self._on_servers_loaded) - self.emitter.connect("mod_page_loaded", self._on_mod_page_loaded) + self.emitter.connect("mods_updated", self._on_mods_updated) self.emitter.connect("log_page_loaded", self._on_log_page_loaded) def _on_alerts_clicked(self, button: LoggerAlertsButton) -> None: @@ -70,8 +70,7 @@ class Statusbar(Gtk.Grid): # FIXME: fails if button was not previously drawn self.alert_button.hide() - def _on_mod_page_loaded(self, emitter: "Emitter") -> None: - msg = self.controller.format_mod_statusbar() + def _on_mods_updated(self, emitter: "Emitter", msg: str) -> None: self.set_by_context(NotebookPage.MODS, msg) def _on_notebook_page_changed( diff --git a/dzgui/views/trees/tree_mods.py b/dzgui/views/trees/tree_mods.py index c272d40..993ee40 100644 --- a/dzgui/views/trees/tree_mods.py +++ b/dzgui/views/trees/tree_mods.py @@ -26,7 +26,7 @@ class ModTreeView(ModsMixin, ContextMixin, TreeView): super().__init__(controller, menu=ContextMenuGroup.MOD) self.controller = controller emitter = self.controller.get_emitter() - emitter.connect("mod_page_loaded", self._on_mod_page_loaded) + emitter.connect("mods_updated", self._on_mods_updated) emitter.connect("mods_highlighted", self._on_mods_highlighted) self.set_fixed_height_mode(True) @@ -62,7 +62,7 @@ class ModTreeView(ModsMixin, ContextMixin, TreeView): self.get_selection().unselect_all() # TODO: test loading with no mods - def _on_mod_page_loaded(self, emitter: "Emitter") -> None: + def _on_mods_updated(self, emitter: "Emitter", msg: str) -> None: self.set_cursor(0) def get_selected_mod(self) -> str: From 343e4024037d5fcc33debcebe24623cb4879ad4f Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 30 Apr 2026 00:33:01 +0900 Subject: [PATCH 195/309] fix: toggle button sensitivity on map --- dzgui/views/components/mod_panel.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/dzgui/views/components/mod_panel.py b/dzgui/views/components/mod_panel.py index 5f05337..c7ed63f 100644 --- a/dzgui/views/components/mod_panel.py +++ b/dzgui/views/components/mod_panel.py @@ -61,6 +61,15 @@ class ModSelectionPanel(Gtk.Box): for el in header, self.main_panel, self.stale_panel: self.pack_start(el, NO_EXPAND, FILL, NO_PADDING) + self.connect("map", self._reinit_button) + + def _reinit_button(self, s) -> None: + for child in self.stale_panel.get_children(): + if child.enum == ModButton.UNHIGHLIGHT_STALE: + child.set_sensitive(False) + else: + child.set_sensitive(True) + def _on_mods_highlighted(self, emitter: "Emitter") -> None: self.swap_sensitive(True) From 0fad9155e2ccc51e91526983afa08ff36a6645f7 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 30 Apr 2026 00:49:47 +0900 Subject: [PATCH 196/309] chore: drop unused imports --- dzgui/controllers/mc.py | 35 +++-------------------------- dzgui/views/components/mod_panel.py | 2 ++ 2 files changed, 5 insertions(+), 32 deletions(-) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 7f822d5..8cdc8a0 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -1,29 +1,12 @@ import inspect import logging -import shutil -import threading -from functools import wraps -from pathlib import Path from typing import Any, Callable, TYPE_CHECKING from warnings import deprecated -import dzgui.api.pefile as PeFile import dzgui.util._json as JSON # noqa -from dzgui.api.mods import ( - get_delimited_mods, - get_local_mod_path, - find_stale_mods, - _hash, - remove_stale_signatures, -) -from dzgui.const.constants import ( - APPID_DAYZ, - APPID_DAYZ_EXP, - HEX_RED, -) from dzgui.const.enum import ( FilterMode, Preferences, @@ -32,7 +15,6 @@ from dzgui.const.enum import ( ContextMenu, ) -from dzgui.config.query import lookup from dzgui.config.userprefs import UserPrefs from dzgui.const.constants import APP_NAME from dzgui.controllers.emitter import Emitter @@ -42,14 +24,12 @@ from dzgui.managers.connection import ConnectionManager from dzgui.managers.contextmenu import ContextMenuManager from dzgui.managers.notes import NoteManager from dzgui.model.servers import ServerModelManager -from dzgui.model.model_factory import ModelFactory -from dzgui.util import strings from dzgui.util.diag import write_diagnostic -from dzgui.util.format import format_mods, format_player_count +from dzgui.util.format import format_player_count from dzgui.util.localize import number from dzgui.util.open_links import open_user_workshop, open_workshop_page from dzgui.views.dialogs.filepicker import FilePicker -from dzgui.views.dialogs.generic import ExceptionDialog, WaitDialog +from dzgui.views.dialogs.generic import ExceptionDialog import gi @@ -151,9 +131,6 @@ class Controller(GObject.GObject): else: widget.handler_unblock_by_func(func) - def toggle_debug_mode(self) -> None: - self.toggle_config(Preferences.DEBUG) - def get_active_context(self) -> "ServerTab": return self.get_active_treeview().get_enum() @@ -304,13 +281,7 @@ class Controller(GObject.GObject): dialog.run() def select_colorized(self) -> None: - model = self.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) + self.mod_man.select_colorized() def get_filter_man(self) -> "FilterManager": """Each ServerTreeView has an atomic FilterManager""" diff --git a/dzgui/views/components/mod_panel.py b/dzgui/views/components/mod_panel.py index c7ed63f..951c641 100644 --- a/dzgui/views/components/mod_panel.py +++ b/dzgui/views/components/mod_panel.py @@ -67,6 +67,8 @@ class ModSelectionPanel(Gtk.Box): for child in self.stale_panel.get_children(): if child.enum == ModButton.UNHIGHLIGHT_STALE: child.set_sensitive(False) + elif child.enum == ModButton.SELECT_STALE: + child.set_sensitive(False) else: child.set_sensitive(True) From 34d8545c94b4fe5cdd4c30f8dbb23db6674713ad Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 30 Apr 2026 01:06:20 +0900 Subject: [PATCH 197/309] fix: handle zero mods --- dzgui/controllers/emitter.py | 10 ++++++++-- dzgui/views/components/mod_panel.py | 6 ++++++ dzgui/views/components/statusbar.py | 2 +- dzgui/views/trees/tree_mods.py | 5 +++-- 4 files changed, 18 insertions(+), 5 deletions(-) diff --git a/dzgui/controllers/emitter.py b/dzgui/controllers/emitter.py index 51faae2..9857d1b 100644 --- a/dzgui/controllers/emitter.py +++ b/dzgui/controllers/emitter.py @@ -124,8 +124,14 @@ class Emitter(GObject.GObject): def load_maps(self, store: ListStore) -> None: pass - @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(str,)) - def mods_updated(self, msg: str) -> None: + @GObject.Signal( + flags=GObject.SignalFlags.RUN_LAST, + arg_types=( + str, + int, + ), + ) + def mods_updated(self, msg: str, mods: int) -> None: pass @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=()) diff --git a/dzgui/views/components/mod_panel.py b/dzgui/views/components/mod_panel.py index 951c641..76298a6 100644 --- a/dzgui/views/components/mod_panel.py +++ b/dzgui/views/components/mod_panel.py @@ -31,6 +31,7 @@ class ModSelectionPanel(Gtk.Box): emitter = controller.get_emitter() emitter.connect("mod_page_toggled", self._on_mod_page_toggled) emitter.connect("mods_highlighted", self._on_mods_highlighted) + emitter.connect("mods_updated", self._on_mods_updated) header = BoldLabel(mod_panel.header) @@ -72,6 +73,11 @@ class ModSelectionPanel(Gtk.Box): else: child.set_sensitive(True) + def _on_mods_updated(self, emitter: "Emitter", msg: str, mods: int) -> None: + if mods < 1: + self.main_panel.set_sensitive(False) + self.stale_panel.set_sensitive(False) + def _on_mods_highlighted(self, emitter: "Emitter") -> None: self.swap_sensitive(True) diff --git a/dzgui/views/components/statusbar.py b/dzgui/views/components/statusbar.py index 7533cb5..bd1df36 100644 --- a/dzgui/views/components/statusbar.py +++ b/dzgui/views/components/statusbar.py @@ -70,7 +70,7 @@ class Statusbar(Gtk.Grid): # FIXME: fails if button was not previously drawn self.alert_button.hide() - def _on_mods_updated(self, emitter: "Emitter", msg: str) -> None: + def _on_mods_updated(self, emitter: "Emitter", msg: str, mods: int) -> None: self.set_by_context(NotebookPage.MODS, msg) def _on_notebook_page_changed( diff --git a/dzgui/views/trees/tree_mods.py b/dzgui/views/trees/tree_mods.py index 993ee40..8067343 100644 --- a/dzgui/views/trees/tree_mods.py +++ b/dzgui/views/trees/tree_mods.py @@ -61,8 +61,9 @@ class ModTreeView(ModsMixin, ContextMixin, TreeView): def _on_mods_highlighted(self, emitter: "Emitter") -> None: self.get_selection().unselect_all() - # TODO: test loading with no mods - def _on_mods_updated(self, emitter: "Emitter", msg: str) -> None: + def _on_mods_updated(self, emitter: "Emitter", msg: str, mods: int) -> None: + if mods < 1: + return self.set_cursor(0) def get_selected_mod(self) -> str: From b8ed0723e158ca4b0ddd71a56b78184d8be4c438 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 30 Apr 2026 18:48:50 +0900 Subject: [PATCH 198/309] feat: add ModManager file --- dzgui/managers/mods.py | 178 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 dzgui/managers/mods.py diff --git a/dzgui/managers/mods.py b/dzgui/managers/mods.py new file mode 100644 index 0000000..46dff80 --- /dev/null +++ b/dzgui/managers/mods.py @@ -0,0 +1,178 @@ +import logging +import shutil + +from pathlib import Path +from typing import TYPE_CHECKING + +from dzgui.api.mods import ( + get_delimited_mods, + get_local_mod_path, + find_stale_mods, + _hash, + remove_stale_signatures, +) +from dzgui.const.constants import APP_NAME, APPID_DAYZ, APPID_DAYZ_EXP +from dzgui.const.enum import Preferences +from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager +from dzgui.model.model_factory import ModelFactory +from dzgui.util.format import format_mods + +import dzgui.api.pefile as PeFile + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk # noqa E402 + +if TYPE_CHECKING: + from dzgui.controllers.mc import Controller + +logger = logging.getLogger(APP_NAME) + + +class ModManager: + """ + Because mods may be dynamically updated on the system, + this manager is instantiated each time the Mods page is opened + and persists until the next time the page is opened + """ + def __init__(self, controller: "Controller") -> None: + + self.controller = controller + self.emitter = controller.get_emitter() + self.prefs = controller.get_prefs() + self.path = controller.query_config(Preferences.DEFAULT) + self.treeview = self.controller.get_modtreeview() + + self.thread_man = ThreadingManager(controller) + self._get_mods() + + @call_on_thread("getting mods") + def _get_mods(self) -> list: + mods = get_delimited_mods(self.path) + if len(mods) < 1: + msg = self.format_mod_statusbar() + self.emitter.emit("mods_updated", msg, 0) + return + + func = StoredFunc(self._on_mods_loaded, mods) + self.thread_man.set_cleanup_func(func) + + def _on_mods_loaded(self, mods: list[int]) -> None: + model = ModelFactory().make_mod_store() + model.extend(mods) + self.treeview.set_model(model) + msg = self.format_mod_statusbar() + mods = len(model) + self.emitter.emit("mods_updated", msg, mods) + + @call_on_thread("deleting mods") + def delete_mods(self) -> None: + sel = self.treeview.get_selection() + model, pathlist = sel.get_selected_rows() + # NOTE reverse when multiple selection + iters = [] + for path in reversed(pathlist): + _iter = self.delete_single_mod(path) + iters.append(_iter) + + func = StoredFunc(self._on_mods_deleted, iters) + self.thread_man.set_cleanup_func(func) + + def get_mod_from_tree_path( + self, tree_path: Gtk.TreePath + ) -> tuple[str, Gtk.TreeIter]: + model = self.treeview.get_model() + 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: + mod, _iter = self.get_mod_from_tree_path(tree_path) + + steam_path = Path(self.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 + # TODO: test this with working APPID_DAYZ_EXP installation + 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 + return _iter + + def _on_mods_deleted(self, iters: list[Gtk.TreeIter]) -> None: + for _iter in iters: + self.treeview.get_model().remove(_iter) + remove_stale_signatures(self.prefs.paths.config, self.prefs.paths.version) + mods = len(self.treeview.get_model()) + msg = self.format_mod_statusbar() + self.emitter.emit("mods_updated", msg, mods) + + def uncolorize_mods(self) -> None: + model = self.treeview.get_model() + for mod in model: + _iter = mod.iter + path = model.get_path(_iter) + model[path][4] = None + path = Gtk.TreePath.new_from_indices([0]) + self.treeview.set_cursor(path) + + def toggle_mod_selection(self, state: bool) -> None: + sel = self.treeview.get_selection() + if state: + sel.select_all() + else: + sel.unselect_all() + self.treeview.grab_focus() + + def format_mod_statusbar(self) -> str: + total_mods, total_size = self.calc_mod_size() + msg = format_mods(total_size, total_mods) + return msg + + def calc_mod_size(self) -> tuple[int, int]: + model = self.treeview.get_model() + if model is None: + return 0, 0 + total_mods = len(model) + total_size = 0 + for mod in model: + total_size += mod[3] + return total_mods, total_size + + def _on_stale_mods_found(self, stale_mods: list) -> None: + """Manipulates attached ListStore in the main event loop""" + model = self.treeview.get_model() + if model is None: + return + for mod in model: + it = mod.iter + path = model.get_path(it) + if int(mod[2]) in stale_mods: + model[path][4] = True + self.emitter.emit("mods_highlighted") + + @call_on_thread("working") + def highlight_stale(self) -> None: + # TODO: set progress bar for number of mods + stale = find_stale_mods(self.prefs.paths.config) + func = StoredFunc(self._on_stale_mods_found, stale) + self.thread_man.set_cleanup_func(func) + + def select_colorized(self) -> None: + model = self.treeview.get_model() + sel = self.treeview.get_selection() + for mod in model: + it = mod.iter + path = model.get_path(it) + if mod[4] is True: + sel.select_path(path) From 51ccdf7caa8561a53e98da4812f2e7e0b6b1f3cf Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 30 Apr 2026 18:49:10 +0900 Subject: [PATCH 199/309] feat: version tooltip --- dzgui/views/components/right_panel.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/dzgui/views/components/right_panel.py b/dzgui/views/components/right_panel.py index f421369..a0566fc 100644 --- a/dzgui/views/components/right_panel.py +++ b/dzgui/views/components/right_panel.py @@ -40,6 +40,7 @@ class RightPanel(Gtk.Box): self.copying = False + # TODO: strings version = self.controller.get_prefs().version self.version_label = Gtk.Label( label=version, @@ -47,6 +48,7 @@ class RightPanel(Gtk.Box): vexpand=True, halign=Gtk.Align.END, valign=Gtk.Align.END, + tooltip_text="Click to copy to clipboard", ) eb = Gtk.EventBox(halign=Gtk.Align.END, valign=Gtk.Align.END) eb.add(self.version_label) From e6d3775fd04f0066f3c233a10ba348fe90302072 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Fri, 1 May 2026 20:31:49 +0900 Subject: [PATCH 200/309] chore: partially drop Union typehint --- dzgui/api/bm.py | 5 ++++- dzgui/controllers/emitter.py | 4 ++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/dzgui/api/bm.py b/dzgui/api/bm.py index c92a3f5..67dbd63 100644 --- a/dzgui/api/bm.py +++ b/dzgui/api/bm.py @@ -1,12 +1,15 @@ import logging import requests -from typing import Optional +from typing import Optional, TYPE_CHECKING from dzgui.const.constants import APP_NAME from dzgui.const.endpoints import BM_SERVERS logger = logging.getLogger(APP_NAME) +if TYPE_CHECKING: + from dzgui.api.servers import Record + def get_attributes(key: str, uid: int) -> str: # TODO: handle if key is not set diff --git a/dzgui/controllers/emitter.py b/dzgui/controllers/emitter.py index 9857d1b..fe6e202 100644 --- a/dzgui/controllers/emitter.py +++ b/dzgui/controllers/emitter.py @@ -1,4 +1,4 @@ -from typing import Union, TYPE_CHECKING +from typing import TYPE_CHECKING import gi @@ -94,7 +94,7 @@ class Emitter(GObject.GObject): ), ) def distcalc_ended( - self, dist: Union[str, None], context: Union["ServerTab", "NotebookPage"] + self, dist: str | None, context: "ServerTab | NotebookPage" ) -> None: pass From 8ec9e5abff873b9f86ee4ccb1d8db37c820530b6 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Fri, 1 May 2026 20:32:22 +0900 Subject: [PATCH 201/309] change: drop debug key --- dzgui/config/convert.py | 4 ++-- dzgui/util/strings.py | 2 -- tests/test_config.py | 52 +++++++++++++++++++++++++---------------- 3 files changed, 34 insertions(+), 24 deletions(-) diff --git a/dzgui/config/convert.py b/dzgui/config/convert.py index 60571a6..c4c886e 100644 --- a/dzgui/config/convert.py +++ b/dzgui/config/convert.py @@ -36,8 +36,8 @@ def rc2json(file: Path) -> str: d = {} ips: list[str] = [] - toggles = ["auto_install", "debug", "fullscreen"] - deprecated = ["staging_dir", "src_path", "steam_path"] + toggles = ["auto_install", "fullscreen"] + deprecated = ["staging_dir", "src_path", "steam_path", "debug", "branch"] while True: tok = lex.get_token() diff --git a/dzgui/util/strings.py b/dzgui/util/strings.py index 5f5956b..afe6c9f 100644 --- a/dzgui/util/strings.py +++ b/dzgui/util/strings.py @@ -161,8 +161,6 @@ label_main_menu = "Main menu" # buttons ping_servers = "Ping servers" -debug_mode = "Debug mode" -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" ) diff --git a/tests/test_config.py b/tests/test_config.py index a1c1a7a..0cdcb80 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -8,27 +8,29 @@ from tests.fixtures import fixture_path pytestmark = pytest.mark.config + @pytest.fixture def legacy_config(): return fixture_path("dztuirc_one") + @pytest.fixture def keys(): return [ - "bm_api", - "fav_server", - "fav_label", - "name", - "debug", - "branch", - "fullscreen", - "steam_api", - "auto_install", - "default_steam_path", - "client", - "ip_list", - "use_miles" - ] + "bm_api", + "fav_server", + "fav_label", + "name", + "branch", + "fullscreen", + "steam_api", + "auto_install", + "default_steam_path", + "client", + "ip_list", + "use_miles", + ] + @pytest.fixture def config(): @@ -55,16 +57,18 @@ def test_contains_invalid_values(keys, config): assert key in keys -@pytest.mark.parametrize("fixture, expect", [ - ("dztuirc_one", (False, True, True)), - ("dztuirc_two", (False, False, False)), -]) +@pytest.mark.parametrize( + "fixture, expect", + [ + ("dztuirc_one", (False, True, True)), + ("dztuirc_two", (False, False, False)), + ], +) def test_bool_conversion(fixture, expect): fixture = fixture_path(fixture) j = convert.rc2json(fixture) j = json.loads(j) assert j["fullscreen"] == expect[0] - assert j["debug"] == expect[1] assert j["auto_install"] == expect[2] assert not j["use_miles"] @@ -72,8 +76,16 @@ def test_bool_conversion(fixture, expect): def test_key_conversion(legacy_config): j = convert.rc2json(legacy_config) j = json.loads(j) - keys = ["api_key", "staging_dir", "src_path", "steam_path", "preferred_client"] + keys = [ + "api_key", + "staging_dir", + "src_path", + "steam_path", + "preferred_client", + "debug", + ] for key in keys: assert key not in j + # TODO: test that when a config file is created from scratch, it contains all values From 63d85b03c195819e4e55b3927c4dbf50f9509e9a Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Fri, 1 May 2026 20:37:24 +0900 Subject: [PATCH 202/309] change: drop statusbar alerts button --- dzgui/controllers/emitter.py | 4 ---- dzgui/controllers/mc.py | 8 -------- dzgui/managers/log.py | 29 ----------------------------- dzgui/views/components/statusbar.py | 14 -------------- dzgui/views/pages/log.py | 1 - 5 files changed, 56 deletions(-) delete mode 100644 dzgui/managers/log.py diff --git a/dzgui/controllers/emitter.py b/dzgui/controllers/emitter.py index fe6e202..3da22a7 100644 --- a/dzgui/controllers/emitter.py +++ b/dzgui/controllers/emitter.py @@ -150,8 +150,4 @@ class Emitter(GObject.GObject): def keyword_set(self, keyword: str) -> None: pass - @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=()) - def log_page_loaded(self) -> None: - pass - # TODO: if servers fail to load, may leave dangling widgets waiting for a signal diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 8cdc8a0..72900c6 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -483,13 +483,5 @@ class Controller(GObject.GObject): def set_active_keyword(self, keyword: str) -> None: self.get_filter_man().set_active_keyword(keyword) - def set_alerts(self, count: tuple[int]) -> None: - self.alerts = count - - def get_alerts(self) -> tuple[int]: - log_man = LogManager() - alerts = log_man.get_alerts(self.prefs.paths.debug) - return alerts - def get_modtreeview(self) -> "ModTreeView": return self.mediator.modtreeview diff --git a/dzgui/managers/log.py b/dzgui/managers/log.py deleted file mode 100644 index 76504d2..0000000 --- a/dzgui/managers/log.py +++ /dev/null @@ -1,29 +0,0 @@ -import logging -from pathlib import Path - -from dzgui.const.constants import APP_NAME -from dzgui.util.strings import delimiter - -logger = logging.getLogger(APP_NAME) - - -class LogManager: - def __init__(self) -> None: - self.CRITICAL = "CRITICAL" - self.WARNING = "WARNING" - - # TODO: more of a static method - def get_alerts(self, log_path: Path) -> tuple[int]: - try: - with open(log_path, "r") as f: - lines = f.read().splitlines() - errors = [ - line for line in lines if line.split(delimiter)[1] == self.CRITICAL - ] - warnings = [ - line for line in lines if line.split(delimiter)[1] == self.WARNING - ] - return len(warnings), len(errors) - except Exception as e: - logger.critical(e) - return 0, 0 diff --git a/dzgui/views/components/statusbar.py b/dzgui/views/components/statusbar.py index bd1df36..b873bb7 100644 --- a/dzgui/views/components/statusbar.py +++ b/dzgui/views/components/statusbar.py @@ -33,15 +33,6 @@ class Statusbar(Gtk.Grid): self.attach(self.statusbar, 0, 0, 3, 1) self.attach_next_to(self.spinner, self.statusbar, Gtk.PositionType.RIGHT, 3, 1) - warnings, errors = self.controller.get_alerts() - - if warnings + errors > 0: - self.alert_button = LoggerAlertsButton(warnings, errors) - self.attach_next_to( - self.alert_button, self.spinner, Gtk.PositionType.RIGHT, 3, 1 - ) - self.alert_button.connect("clicked", self._on_alerts_clicked) - # TODO: pack version event box in right panel into hbox with update button # TODO: spawns a modal or just jumps right to install page # from dzgui.views.components.buttons import IconTextButton @@ -61,15 +52,10 @@ class Statusbar(Gtk.Grid): self.emitter.connect("distcalc_ended", self._on_distcalc_ended) self.emitter.connect("servers_loaded", self._on_servers_loaded) self.emitter.connect("mods_updated", self._on_mods_updated) - self.emitter.connect("log_page_loaded", self._on_log_page_loaded) def _on_alerts_clicked(self, button: LoggerAlertsButton) -> None: self.controller.populate_log() - def _on_log_page_loaded(self, emitter: "Emitter") -> None: - # FIXME: fails if button was not previously drawn - self.alert_button.hide() - def _on_mods_updated(self, emitter: "Emitter", msg: str, mods: int) -> None: self.set_by_context(NotebookPage.MODS, msg) diff --git a/dzgui/views/pages/log.py b/dzgui/views/pages/log.py index f076907..50271ae 100644 --- a/dzgui/views/pages/log.py +++ b/dzgui/views/pages/log.py @@ -42,7 +42,6 @@ class Log(CursorMixin, HelpMenuMixin, Gtk.Box): # type: ignore self.emitter = controller.get_emitter() self.connect("key-press-event", self._on_esc_keypress) - self.connect("map", lambda _: self.emitter.emit("log_page_loaded")) def _on_checkbox_clicked(self, checkbox: Gtk.CheckButton) -> None: label = checkbox.get_label() From 7a9f0dc0966df1f7826e86be4afb742c38049053 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Fri, 1 May 2026 20:45:22 +0900 Subject: [PATCH 203/309] chore: use statusbar internal methods --- dzgui/controllers/mc.py | 18 ++++-------------- dzgui/views/components/eventbox.py | 7 ++++--- 2 files changed, 8 insertions(+), 17 deletions(-) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 72900c6..51e0577 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -18,7 +18,6 @@ from dzgui.const.enum import ( from dzgui.config.userprefs import UserPrefs from dzgui.const.constants import APP_NAME from dzgui.controllers.emitter import Emitter -from dzgui.managers.log import LogManager from dzgui.managers.config import ConfigManager from dzgui.managers.connection import ConnectionManager from dzgui.managers.contextmenu import ContextMenuManager @@ -145,18 +144,11 @@ class Controller(GObject.GObject): window = self.get_window() self.config_man.save_res_and_quit(treeview, window) - ## START STATUSBAR LOGIC - @deprecated("use statusbar internal contexts") - def remove_statusbar(self, context: str) -> None: - c = self.mediator.statusbar.statusbar.get_context_id(context) - self.mediator.statusbar.statusbar.pop(c) + def set_statusbar(self, enum: "NotebookPage | ServerTab", text: str) -> None: + self.mediator.statusbar.set_by_context(enum, text) - # TODO: refactor any modules using this - # cf. eventbox.py - @deprecated("use set_by_context") - def set_statusbar(self, text: str, context: str) -> int: - msg_id = self.mediator.statusbar.set_text(text, context) - return msg_id + def remove_statusbar(self, context: "NotebookPage | ServerTab") -> None: + self.mediator.statusbar.pop(context) # TODO: StatusBarManager, move out of here def set_statusbar_dist(self, haversine: "Haversine", enum: "ServerTab") -> None: @@ -188,8 +180,6 @@ class Controller(GObject.GObject): self.emitter.emit("distcalc_ended", dist, context) - ## END STATUSBAR LOGIC - def toggle_config(self, key: Preferences) -> None: # NOTE: Preferences.DIST is dynamic if key == Preferences.DIST: diff --git a/dzgui/views/components/eventbox.py b/dzgui/views/components/eventbox.py index fc05f2e..67fde6e 100644 --- a/dzgui/views/components/eventbox.py +++ b/dzgui/views/components/eventbox.py @@ -1,4 +1,6 @@ from typing import TYPE_CHECKING + +from dzgui.const.enum import NotebookPage from dzgui.const.constants import HELP_BUBBLE import gi @@ -32,14 +34,13 @@ class InfoEventBox(Gtk.EventBox): self, eventbox: Gtk.EventBox, eventcrossing: Gdk.EventCrossing ) -> None: self.icon.set_opacity(1) - self.controller.set_statusbar(self.text, "Options") + self.controller.set_statusbar(NotebookPage.OPTIONS, 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("", "Options") - self.controller.remove_statusbar("Options") + self.controller.remove_statusbar(NotebookPage.OPTIONS) def set_text(self, text: str) -> None: self.text = text From eb4ee417423a668c8b1b275cc73cd721df6b111c Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Fri, 1 May 2026 21:01:28 +0900 Subject: [PATCH 204/309] chore: simplify controller logic --- dzgui/controllers/mc.py | 26 ++++---------------------- dzgui/util/dist.py | 13 +++++++++++++ dzgui/views/trees/tree_servers.py | 6 ++++++ 3 files changed, 23 insertions(+), 22 deletions(-) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 51e0577..ee05d32 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -25,7 +25,6 @@ from dzgui.managers.notes import NoteManager from dzgui.model.servers import ServerModelManager from dzgui.util.diag import write_diagnostic from dzgui.util.format import format_player_count -from dzgui.util.localize import number from dzgui.util.open_links import open_user_workshop, open_workshop_page from dzgui.views.dialogs.filepicker import FilePicker from dzgui.views.dialogs.generic import ExceptionDialog @@ -150,7 +149,6 @@ class Controller(GObject.GObject): def remove_statusbar(self, context: "NotebookPage | ServerTab") -> None: self.mediator.statusbar.pop(context) - # TODO: StatusBarManager, move out of here def set_statusbar_dist(self, haversine: "Haversine", enum: "ServerTab") -> None: """ NOTE: prevents race condition when server tab changed, @@ -165,19 +163,7 @@ class Controller(GObject.GObject): self.emitter.emit("distcalc_ended", None, context) return - dist: str - if haversine is None: - dist = "Unknown" - else: - if self.prefs.use_miles: - raw = round(haversine.as_miles()) - separated = number(raw) - dist = str(separated) + " mi" - else: - raw = round(haversine.as_kilometers()) - separated = number(raw) - dist = str(separated) + " km" - + dist = haversine.get_rounded(self.prefs.use_miles) self.emitter.emit("distcalc_ended", dist, context) def toggle_config(self, key: Preferences) -> None: @@ -301,13 +287,10 @@ class Controller(GObject.GObject): treeview.set_loaded(False) ServerModelManager(self, treeview).refresh() - # TODO: move to servermodelman def get_player_count(self) -> str: treeview = self.get_active_treeview() - model = treeview.get_model() - proxy_man = treeview.get_proxy_man() - control_model = proxy_man.get_control() - count = format_player_count(model, control_model) + model, control = treeview.get_model_and_control_model() + count = format_player_count(model, control) return count def get_statusbar(self) -> "Statusbar": @@ -318,8 +301,7 @@ class Controller(GObject.GObject): if tv.is_loaded(): self.emitter.emit("servers_loaded", tv.get_enum()) return - # TODO: placeholder logic, wipe statusbar when changing page - self.mediator.statusbar.set_text("", "") + self.mediator.statusbar.set_by_context(tv.get_enum(), "") ServerModelManager(self, tv).load() def get_dist_cache(self) -> dict[str, "Haversine"]: diff --git a/dzgui/util/dist.py b/dzgui/util/dist.py index b1c7721..984dedb 100644 --- a/dzgui/util/dist.py +++ b/dzgui/util/dist.py @@ -5,6 +5,7 @@ from typing import TYPE_CHECKING from dzgui.const.constants import APP_NAME from dzgui.util.ip import get_coords +from dzgui.util.localize import number import gi @@ -40,6 +41,18 @@ class Haversine: def as_miles(self) -> float: return self.dist / 1609.344 + def get_rounded(self, use_miles: bool) -> str: + if self is None: + return "Unknown" + if use_miles: + raw = round(self.as_miles()) + separated = number(raw) + return str(separated) + " mi" + else: + raw = round(self.as_kilometers()) + separated = number(raw) + return str(separated) + " km" + class CalcDist: def __init__( diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index 3ac8f87..cc416a8 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -30,6 +30,7 @@ logger = logging.getLogger(APP_NAME) if TYPE_CHECKING: from dzgui.controllers.mc import Controller from dzgui.controllers.emitter import Emitter + from dzgui.model.model_factory import FastInsertListStore QUEUE_CHECK_DELAY = 200 @@ -316,6 +317,11 @@ class ServerTreeView(ContextMixin, TreeView): def set_loaded(self, status: bool) -> None: self.loaded = status + def get_model_and_control_model(self) -> tuple["FastInsertListStore", list[Any]]: + model = self.get_model() + control = self.proxy_man.get_control() + return model, control + @staticmethod def ping_server( model, _iter: Gtk.TreeIter, ip: str, qport: int, ping_column: int From 29aeb550b30b7381bbcb1ab6afdc9383f7914e74 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Fri, 1 May 2026 21:46:13 +0900 Subject: [PATCH 205/309] chore: consolidate is_in_favs() methods --- dzgui/config/query.py | 10 +++++----- dzgui/controllers/mc.py | 4 ---- dzgui/views/trees/tree_servers.py | 3 +-- 3 files changed, 6 insertions(+), 11 deletions(-) diff --git a/dzgui/config/query.py b/dzgui/config/query.py index 00bb522..dc544d1 100644 --- a/dzgui/config/query.py +++ b/dzgui/config/query.py @@ -40,11 +40,11 @@ def get_favorites(path: Path) -> list[str]: 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 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: diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index ee05d32..30a132c 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -2,7 +2,6 @@ import inspect import logging from typing import Any, Callable, TYPE_CHECKING -from warnings import deprecated import dzgui.util._json as JSON # noqa @@ -373,9 +372,6 @@ class Controller(GObject.GObject): return False return True - # def is_in_favs(self) -> bool: - # return self.config_man.is_in_favs() - def get_config_man(self) -> ConfigManager: return self.config_man diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index cc416a8..98250c7 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -271,8 +271,7 @@ class ServerTreeView(ContextMixin, TreeView): def is_in_favs(self) -> bool: record = self.get_record_string() - ips = self.controller.get_config_man().lookup(Preferences.IP_LIST) - if record in ips: + if self.controller.get_config_man().is_in_favs(record): return True return False From 7bf0fde3fcefa8d5f9586216df2c144cd2df9b7e Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Fri, 1 May 2026 21:56:22 +0900 Subject: [PATCH 206/309] fix: block malformed records on config import --- dzgui/config/convert.py | 6 +++++- dzgui/model/servers.py | 4 ---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/dzgui/config/convert.py b/dzgui/config/convert.py index c4c886e..bd8c176 100644 --- a/dzgui/config/convert.py +++ b/dzgui/config/convert.py @@ -7,6 +7,7 @@ from pathlib import Path Convert legacy dztuirc to config.json """ + class UnsupportedKey(Exception): pass @@ -59,7 +60,10 @@ def rc2json(file: Path) -> str: ntok = lex.get_token().strip('""') if ntok == ")": break - ips.append(ntok) + # TODO: make test for this + # NOTE: strip malformed records from ancient config file versions + if len(ntok.split(":")) == 3 and ntok.split(":")[2] != "": + ips.append(ntok) continue if not tok: diff --git a/dzgui/model/servers.py b/dzgui/model/servers.py index f008c24..2df1068 100644 --- a/dzgui/model/servers.py +++ b/dzgui/model/servers.py @@ -152,10 +152,6 @@ class ServerModelManager: @call_on_thread(dialog.fetching) def _dump_ips(self, ips: list[str]) -> None: - # NOTE: block malformed records (TODO: add github issue no.) - # TODO: sanitize ip list at config time and drop this - # TODO: make test for this - ips = [ip for ip in ips if len(ip.split(":")) == 3 and ip.split(":")[2] != ""] job = Servers.query_direct servers = [] with ThreadPoolExecutor() as executor: From 90f818f462013f90942bb8d40dc2b97b254ea903 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Fri, 1 May 2026 22:35:24 +0900 Subject: [PATCH 207/309] chore: drop StoredFunc logic from main controller --- dzgui/controllers/mc.py | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 30a132c..5368b98 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -68,23 +68,12 @@ class AppNavigation: filters: "FilterPanel" -class StoredFunc: - def __init__(self, func: Callable, *args, **kwargs) -> None: - sig = inspect.signature(func) - self.func = func - self.bindings = sig.bind(*args, **kwargs) - - def call(self) -> None: - self.func(*self.bindings.args, *self.bindings.kwargs) - - class Controller(GObject.GObject): def __init__(self) -> None: self.dist_cache: dict[str, "Haversine"] = {} self.mediator = AppNavigation() self.prefs: UserPrefs - self.cleanup_func: StoredFunc = None self.emitter = Emitter() self.emitter.connect("map_selection_changed", self._on_map_selection_changed) From 0aafe44b4eb509369ebb172deb60eeee2bbc94f7 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Fri, 1 May 2026 22:54:20 +0900 Subject: [PATCH 208/309] feat: set exit event on SIGINT --- dzgui/controllers/mc.py | 12 ++++++++++-- dzgui/model/servers.py | 7 ++++++- dzgui/views/base.py | 2 +- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 5368b98..c18f083 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -1,7 +1,7 @@ -import inspect import logging +import threading -from typing import Any, Callable, TYPE_CHECKING +from typing import Any, TYPE_CHECKING import dzgui.util._json as JSON # noqa @@ -84,6 +84,8 @@ class Controller(GObject.GObject): self.loaded = False self.pending_jobs = 1 + self.exit_event = threading.Event() + def get_emitter(self) -> Emitter: return self.emitter @@ -442,3 +444,9 @@ class Controller(GObject.GObject): def get_modtreeview(self) -> "ModTreeView": return self.mediator.modtreeview + + def set_exit_event(self) -> None: + self.exit_event.set() + + def get_exit_event(self) -> None: + return self.exit_event diff --git a/dzgui/model/servers.py b/dzgui/model/servers.py index 2df1068..4c53043 100644 --- a/dzgui/model/servers.py +++ b/dzgui/model/servers.py @@ -91,6 +91,12 @@ class ServerModelManager: futures = [executor.submit(job, key, APPID_DAYZ, param) for param in params] for future in as_completed(futures): try: + + # NOTE: faciliate early aborting on boot via sigint + # this logic not available outside of initial boot + if self.controller.get_exit_event().is_set(): + return + self.thread_man.increment_dialog() res = future.result(timeout=API_TIMEOUT) if res.status != 200 or not res.parsed: @@ -101,7 +107,6 @@ class ServerModelManager: j = res.json servers += j["response"]["servers"] except Exception as e: - # TODO: could store exception in cleanup func logger.critical(e) self.thread_man.set_cleanup_func( StoredFunc(self._cleanup_on_failure) diff --git a/dzgui/views/base.py b/dzgui/views/base.py index bc722ec..d5ec8f7 100644 --- a/dzgui/views/base.py +++ b/dzgui/views/base.py @@ -94,7 +94,6 @@ class OuterWindow(Gtk.Window): self.halt_proc_and_quit() def halt_proc_and_quit(self) -> None: - # MainController.terminate_process() MainController.save_res_and_quit() @@ -319,6 +318,7 @@ class App(Gtk.Application): logger.critical(e) def _catch_sigint(self) -> Literal[True]: + MainController.set_exit_event() self.win.halt_proc_and_quit() return True From 7139686d9bc7c4211c96131e6dd561027302871e Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Fri, 1 May 2026 23:59:27 +0900 Subject: [PATCH 209/309] chore: store filter levels as constant --- dzgui/const/constants.py | 2 ++ dzgui/views/pages/log.py | 18 ++++++++---------- dzgui/views/trees/tree_log.py | 6 ++---- 3 files changed, 12 insertions(+), 14 deletions(-) diff --git a/dzgui/const/constants.py b/dzgui/const/constants.py index ed3f578..105fcc5 100644 --- a/dzgui/const/constants.py +++ b/dzgui/const/constants.py @@ -61,3 +61,5 @@ LEGACY_IPS_PATH = ".local/share/dzgui/helpers/ips.csv" DEBUG_LOG = f"{APP_NAME}_DEBUG.LOG" SYSTEM_LOG = f"{APP_NAME}_SYSTEM.LOG" CHANGELOG_PATH = "data/CHANGELOG.md" + +LOG_FILTERS = ("CRITICAL", "WARNING", "INFO", "DEBUG") diff --git a/dzgui/views/pages/log.py b/dzgui/views/pages/log.py index 50271ae..b9fbc3b 100644 --- a/dzgui/views/pages/log.py +++ b/dzgui/views/pages/log.py @@ -1,4 +1,5 @@ from typing import TYPE_CHECKING +from dzgui.const.constants import LOG_FILTERS from dzgui.views.mixins.cursor_mixin import CursorMixin from dzgui.views.mixins.help_menu_mixin import HelpMenuMixin from dzgui.views.trees.tree_log import LogTreeView @@ -16,23 +17,20 @@ if TYPE_CHECKING: class Log(CursorMixin, HelpMenuMixin, Gtk.Box): # type: ignore def __init__(self, controller: "Controller") -> None: super().__init__(orientation=Gtk.Orientation.VERTICAL) - # NOTE: LogTreeView is kept in a separate ScrolledWindow so that - # checkboxes will be flush on bottom + """ + NOTE: LogTreeView is kept in a separate ScrolledWindow so that + checkboxes will be flush on bottom + """ self.scrolled = Gtk.ScrolledWindow() self.treeview = LogTreeView(controller) self.scrolled.add(self.treeview) self.check_bar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) - # TODO: strings - active = ( - "WARN", - "CRITICAL", - ) - for check in ("WARN", "CRITICAL", "INFO", "DEBUG"): + + for check in LOG_FILTERS: c = Gtk.CheckButton(label=check) self.check_bar.pack_start(c, False, False, 0) - if check in active: - c.set_active(True) + c.set_active(True) c.connect("clicked", self._on_checkbox_clicked) self.add(self.scrolled) self.add(self.check_bar) diff --git a/dzgui/views/trees/tree_log.py b/dzgui/views/trees/tree_log.py index 30174c0..2340d59 100644 --- a/dzgui/views/trees/tree_log.py +++ b/dzgui/views/trees/tree_log.py @@ -2,7 +2,7 @@ import logging from typing import Any, TYPE_CHECKING -from dzgui.const.constants import APP_NAME +from dzgui.const.constants import APP_NAME, LOG_FILTERS from dzgui.const.enum import ContextMenuGroup from dzgui.model.model_factory import ModelFactory from dzgui.util import strings @@ -35,9 +35,7 @@ class LogTreeView(ContextMixin, TreeView): self.set_model(None) - # TODO: strings - # NOTE: default filters that should be shown on startup - self.filters = ["CRITICAL", "WARNING"] + self.filters = list(LOG_FILTERS) for i, column_title in enumerate(strings.log_cols): renderer = Gtk.CellRendererText() column = Gtk.TreeViewColumn(column_title, renderer, text=i) From 676f2ac12fc34913796811265da9615b1750e3eb Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Fri, 1 May 2026 23:59:57 +0900 Subject: [PATCH 210/309] chore: suppress branch switching behavior (WIP) --- dzgui/views/pages/options.py | 45 ++++++++++++++++++------------------ 1 file changed, 22 insertions(+), 23 deletions(-) diff --git a/dzgui/views/pages/options.py b/dzgui/views/pages/options.py index 1196d25..05c2088 100644 --- a/dzgui/views/pages/options.py +++ b/dzgui/views/pages/options.py @@ -130,17 +130,17 @@ class Options(Gtk.Box): 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("", controller) + #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("", controller) 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], + #[LeftLabel(strings.options.branch), self.branch_combo, self.branch_eb], ] api_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) @@ -428,8 +428,6 @@ class Options(Gtk.Box): dialog = ExceptionDialog(self.controller, strings.config_not_found) dialog.run() raise Exception - # FIXME: return to main menu - return config = query.get_config(prefs.paths.config) name = config["name"] @@ -489,23 +487,24 @@ class Options(Gtk.Box): # TODO: not happy with this active_combo = query.get_client_index(config["client"]) self.client_combo.set_active(active_combo) + active_combo = 0 - active_combo = 1 if config["branch"] == BETA_REPO else 0 + #active_combo = 1 if config["branch"] == BETA_REPO else 0 - self.controller.suppress_signal( - self, self.branch_combo, "_on_branch_changed", True - ) - self.branch_combo.set_active(active_combo) - self.branch_combo.set_sensitive(prefs.allow_updates) - self.controller.suppress_signal( - self, self.branch_combo, "_on_branch_changed", False - ) + #self.controller.suppress_signal( + # self, self.branch_combo, "_on_branch_changed", True + #) + #self.branch_combo.set_active(active_combo) + #self.branch_combo.set_sensitive(prefs.allow_updates) + #self.controller.suppress_signal( + # self, self.branch_combo, "_on_branch_changed", False + #) - if prefs.allow_updates is True: - msg = strings.options.self_update - else: - msg = strings.options.no_self_update - self.branch_eb.set_text(msg) + #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 [ From 5005f6923e0e5fd3c0c4ce1d839f17cbd1828d12 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 2 May 2026 00:00:43 +0900 Subject: [PATCH 211/309] chore: connection logic placeholders --- dzgui/api/servers.py | 1 + dzgui/controllers/mc.py | 1 + dzgui/managers/connection.py | 4 ++-- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/dzgui/api/servers.py b/dzgui/api/servers.py index c2e4322..ce07b89 100644 --- a/dzgui/api/servers.py +++ b/dzgui/api/servers.py @@ -429,6 +429,7 @@ def validate_ip(addr: str) -> Record: return record +# TODO: do not return empty list in event of failure def get_rules(record: Record) -> list["DayzMod"]: ip = record.ip qport = record.qport diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index c18f083..d1c2307 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -177,6 +177,7 @@ class Controller(GObject.GObject): self.open_page(NotebookPage.DEVELOPERS) def open_page(self, page: NotebookPage) -> None: + print(page) self.mediator.grid.notebook.set_page_by_enum(page) def open_page_by_button(self, button: "ContextualButton") -> None: diff --git a/dzgui/managers/connection.py b/dzgui/managers/connection.py index a8285bb..7d3b680 100644 --- a/dzgui/managers/connection.py +++ b/dzgui/managers/connection.py @@ -55,10 +55,11 @@ class ConnectionManager: self.thread_man.set_cleanup_func( StoredFunc(self._server_timeout), destroy_first=True ) + rules = Servers.get_rules(record) # TODO: add to history if successful - print("UNIMPLEMENTED: get rules") print(res) + print(rules) @call_on_thread(dialog.querying) def query_details(self, record: Servers.Record) -> None: @@ -74,7 +75,6 @@ class ConnectionManager: @call_on_thread(dialog.querying) def query_modlist(self, record: Servers.Record) -> None: - mods = Servers.get_rules(record) steam_path = self.controller.query_config(Preferences.DEFAULT) local = get_local_mod_ids(steam_path) From 11356547bfc1c94ca2025cf78831613032ca1663 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 2 May 2026 00:01:58 +0900 Subject: [PATCH 212/309] chore: clear typehinting errors --- dzgui/views/pages/options.py | 35 +++++++++++++++---------------- dzgui/views/trees/tree_servers.py | 2 +- 2 files changed, 18 insertions(+), 19 deletions(-) diff --git a/dzgui/views/pages/options.py b/dzgui/views/pages/options.py index 05c2088..beb5f86 100644 --- a/dzgui/views/pages/options.py +++ b/dzgui/views/pages/options.py @@ -19,7 +19,6 @@ from dzgui.const.constants import ( APPID_DAYZ_EXP, APPNAME_DAYZ, APPNAME_DAYZ_EXP, - BETA_REPO, FLATPAK_RUN_CMD, FLATPAK_SANDBOX, NO_EXPAND, @@ -130,17 +129,17 @@ class Options(Gtk.Box): 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("", controller) + # 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("", controller) 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], + # [LeftLabel(strings.options.branch), self.branch_combo, self.branch_eb], ] api_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) @@ -489,22 +488,22 @@ class Options(Gtk.Box): self.client_combo.set_active(active_combo) active_combo = 0 - #active_combo = 1 if config["branch"] == BETA_REPO else 0 + # active_combo = 1 if config["branch"] == BETA_REPO else 0 - #self.controller.suppress_signal( + # self.controller.suppress_signal( # self, self.branch_combo, "_on_branch_changed", True - #) - #self.branch_combo.set_active(active_combo) - #self.branch_combo.set_sensitive(prefs.allow_updates) - #self.controller.suppress_signal( + # ) + # self.branch_combo.set_active(active_combo) + # self.branch_combo.set_sensitive(prefs.allow_updates) + # self.controller.suppress_signal( # self, self.branch_combo, "_on_branch_changed", False - #) + # ) - #if prefs.allow_updates is True: + # if prefs.allow_updates is True: # msg = strings.options.self_update - #else: + # else: # msg = strings.options.no_self_update - #self.branch_eb.set_text(msg) + # self.branch_eb.set_text(msg) def _suppress_toggles(self, state: bool) -> None: for toggle in [ diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index 98250c7..7bf10db 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -7,7 +7,7 @@ from warnings import deprecated from dzgui.api.servers import ping, Record from dzgui.const.constants import APP_NAME -from dzgui.const.enum import ContextMenu, ContextMenuGroup, Preferences, ServerTab +from dzgui.const.enum import ContextMenu, ContextMenuGroup, ServerTab from dzgui.managers.filter import FilterManager from dzgui.model.proxy_model import ProxyModelManager from dzgui.util import strings From 806e05977d7a35575d4d77a36a594491093929a6 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 2 May 2026 00:14:37 +0900 Subject: [PATCH 213/309] chore: clear typehinting errors --- dzgui/controllers/mc.py | 3 +-- dzgui/views/components/filter_panel.py | 8 +++++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index d1c2307..caeb9af 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -177,7 +177,6 @@ class Controller(GObject.GObject): self.open_page(NotebookPage.DEVELOPERS) def open_page(self, page: NotebookPage) -> None: - print(page) self.mediator.grid.notebook.set_page_by_enum(page) def open_page_by_button(self, button: "ContextualButton") -> None: @@ -449,5 +448,5 @@ class Controller(GObject.GObject): def set_exit_event(self) -> None: self.exit_event.set() - def get_exit_event(self) -> None: + def get_exit_event(self) -> threading.Event: return self.exit_event diff --git a/dzgui/views/components/filter_panel.py b/dzgui/views/components/filter_panel.py index f24e3b8..9d14c3d 100644 --- a/dzgui/views/components/filter_panel.py +++ b/dzgui/views/components/filter_panel.py @@ -212,6 +212,9 @@ class FilterPanel(Gtk.Box): return if text == self.sel_map: return + + i: int + row: Gtk.TreeModelRow for i, row in enumerate(model): if text == row[0]: self.maps_combo.set_active(i) @@ -219,6 +222,9 @@ class FilterPanel(Gtk.Box): def _on_maps_loaded(self, emitter: "Emitter", store: Gtk.ListStore) -> None: self.maps_combo.set_model(store) tv = self.controller.get_active_treeview() + + ind: int + name: str ind, name = tv.filter_man.get_active_map() # NOTE: setting active index triggers a 'changed' signal on self.maps_combo self.block_map_change_propagation = True @@ -256,7 +262,7 @@ class FilterPanel(Gtk.Box): ) -> None: self.maps_combo.set_active_iter(it) - def _on_map_completion(self, entry, editable): + def _on_map_completion(self, entry: Gtk.Entry, editable: Literal[True]): text = entry.get_text() completion = entry.get_completion() store = self.controller.get_map_store() From 5757f7975589ef57e026e8f78278fa2e3f6d5cff Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 2 May 2026 20:01:57 +0900 Subject: [PATCH 214/309] chore: clear typehinting errors --- dzgui/api/steam.py | 2 +- dzgui/controllers/mc.py | 13 ++++++++----- dzgui/managers/config.py | 8 +++++--- dzgui/managers/contextmenu.py | 25 +++++++++++++++++++++---- dzgui/managers/filter.py | 2 +- dzgui/managers/threading.py | 16 ++++++++-------- dzgui/model/servers.py | 2 +- dzgui/views/base.py | 5 +++-- dzgui/views/components/connect_panel.py | 3 +++ dzgui/views/components/filter_panel.py | 11 ++++++----- dzgui/views/components/statusbar.py | 2 +- dzgui/views/dialogs/generic.py | 11 +++++++++-- dzgui/views/mixins/context_mixin.py | 3 ++- dzgui/views/pages/help.py | 3 --- dzgui/views/pages/options.py | 11 +++++++---- dzgui/views/pages/servers.py | 9 +++++++-- dzgui/views/trees/tree_servers.py | 11 ++++++++--- 17 files changed, 91 insertions(+), 46 deletions(-) diff --git a/dzgui/api/steam.py b/dzgui/api/steam.py index e0f872f..699e004 100644 --- a/dzgui/api/steam.py +++ b/dzgui/api/steam.py @@ -51,7 +51,7 @@ def query_defunct() -> None: def concat_mods(mods: list[str]) -> str: for mod in mods: - mods[mod] = "@" + mod + mods[mod] = f"@{mod}" return ";".join(mods) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index caeb9af..fb165a1 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -1,7 +1,7 @@ import logging import threading -from typing import Any, TYPE_CHECKING +from typing import Any, TYPE_CHECKING, Union import dzgui.util._json as JSON # noqa @@ -110,14 +110,14 @@ class Controller(GObject.GObject): return bool(self.query_config(Preferences.INSTALL)) def suppress_signal( - self, owner: Gtk.Widget, widget: Gtk.Widget, func_name: str, state: bool + self, owner: Any, child: Any, func_name: str, state: bool ) -> None: func = getattr(owner, func_name) if state: - widget.handler_block_by_func(func) + child.handler_block_by_func(func) else: - widget.handler_unblock_by_func(func) + child.handler_unblock_by_func(func) def get_active_context(self) -> "ServerTab": return self.get_active_treeview().get_enum() @@ -139,7 +139,7 @@ class Controller(GObject.GObject): def remove_statusbar(self, context: "NotebookPage | ServerTab") -> None: self.mediator.statusbar.pop(context) - def set_statusbar_dist(self, haversine: "Haversine", enum: "ServerTab") -> None: + def set_statusbar_dist(self, haversine: Union["Haversine", None], enum: "ServerTab") -> None: """ NOTE: prevents race condition when server tab changed, but allows caching the distance in the background @@ -153,6 +153,9 @@ class Controller(GObject.GObject): self.emitter.emit("distcalc_ended", None, context) return + if haversine is None: + self.emitter.emit("distcalc_ended", None, context) + return dist = haversine.get_rounded(self.prefs.use_miles) self.emitter.emit("distcalc_ended", dist, context) diff --git a/dzgui/managers/config.py b/dzgui/managers/config.py index 15c0b03..8099cd1 100644 --- a/dzgui/managers/config.py +++ b/dzgui/managers/config.py @@ -36,6 +36,7 @@ class ConfigManager: def __init__(self, prefs: "UserPrefs", controller: "Controller") -> None: self.prefs = prefs self.config = prefs.paths.config + self.controller = controller self.emitter = controller.get_emitter() self.thread_man = ThreadingManager(controller) @@ -95,7 +96,7 @@ class ConfigManager: return def get_favorites(self) -> list[str]: - return self.lookup(Preferences.IP_LIST) + return list(self.lookup(Preferences.IP_LIST)) def get_favorite(self) -> tuple[str, str] | tuple[None, None]: fav = str(self.lookup(Preferences.FAV_LBL)) @@ -112,7 +113,7 @@ class ConfigManager: return False def enum_to_key(self, enum: Preferences) -> str: - return enum.dict["key"] + return str(enum.dict["key"]) def get_client_index(self, client: str) -> int: if client == STEAM_CMD: @@ -121,6 +122,7 @@ class ConfigManager: return 1 if client == FLATPAK_SANDBOX: return 2 + return 0 def toggle_config(self, key: Preferences) -> None: try: @@ -149,7 +151,7 @@ class ConfigManager: except Exception as e: logger.critical(e) trace = traceback.format_exc() - dialog = ExceptionDialog(self, trace) + dialog = ExceptionDialog(self.controller, trace) dialog.run() raise e diff --git a/dzgui/managers/contextmenu.py b/dzgui/managers/contextmenu.py index 642d645..412a03c 100644 --- a/dzgui/managers/contextmenu.py +++ b/dzgui/managers/contextmenu.py @@ -8,6 +8,8 @@ from dzgui.util.clip import copy_clipboard from dzgui.util.open_links import open_workshop_page from dzgui.views.dialogs.note import NoteDialog +from dzgui.views.trees.tree_mods import ModTreeView +from dzgui.views.trees.tree_log import LogTreeView from dzgui.views.trees.tree_servers import ServerTreeView @@ -19,13 +21,12 @@ logger = logging.getLogger(APP_NAME) if TYPE_CHECKING: from dzgui.controllers.mc import Controller - from dzgui.views.trees.tree_servers import ServerTreeView class ContextMenuManager: def __init__( self, - treeview: "ServerTreeView", + treeview: LogTreeView | ModTreeView | ServerTreeView, controller: "Controller", ) -> None: self.controller = controller @@ -49,28 +50,40 @@ class ContextMenuManager: self.open_mod_page() case ContextMenu.SET_FAV: name = self.treeview.get_value_at_index(0) - record = self.treeview.get_record_string() + record_str = self.treeview.get_record_string() simple = self.treeview.get_simplified_ip() - self.controller.set_fav(name, record, simple) + self.controller.set_fav(name, record_str, simple) # THREADED case ContextMenu.ADD_SERVER: record = self.treeview.get_record() + if record is None: + return self.controller.add_by_record(record) case ContextMenu.CONNECT: record = self.treeview.get_record() + if record is None: + return self.controller.connect_by_record(record) case ContextMenu.REFRESH_PLAYERS: record = self.treeview.get_record() + if record is None: + return self.controller.refresh_players(record) case ContextMenu.REMOVE_HISTORY: record = self.treeview.get_record() + if record is None: + return self.controller.remove_from_history(record) case ContextMenu.REMOVE_SERVER: record = self.treeview.get_record() + if record is None: + return self.controller.remove_by_record(record) case ContextMenu.SHOW_DETAILS: record = self.treeview.get_record() + if record is None: + return self.controller.get_details(record) case ContextMenu.SHOW_MODS: record = self.treeview.get_record() @@ -89,11 +102,15 @@ class ContextMenuManager: copy_clipboard(name) def open_mod_page(self) -> None: + if not isinstance(self.treeview, ModTreeView): + return mod = self.treeview.get_selected_mod() cmd = self.controller.query_config(Preferences.CLIENT) open_workshop_page(mod, cmd) def copy_log(self) -> None: + if not isinstance(self.treeview, LogTreeView): + return log = self.treeview.concatenate_rows() if log is None: return diff --git a/dzgui/managers/filter.py b/dzgui/managers/filter.py index 4dfefcf..b6f7a6d 100644 --- a/dzgui/managers/filter.py +++ b/dzgui/managers/filter.py @@ -42,7 +42,7 @@ class FilterManager: def get_active_map_name(self) -> str: return self.active_map[1] - def get_active_map(self) -> int: + def get_active_map(self) -> tuple[int, str]: return self.active_map def set_active_map(self, ind: int, name: str) -> None: diff --git a/dzgui/managers/threading.py b/dzgui/managers/threading.py index 60406d5..a778124 100644 --- a/dzgui/managers/threading.py +++ b/dzgui/managers/threading.py @@ -1,7 +1,7 @@ import inspect import logging import threading -from typing import Any, TYPE_CHECKING +from typing import Any, Literal, TYPE_CHECKING from functools import wraps from typing import Callable @@ -23,7 +23,7 @@ logger = logging.getLogger(APP_NAME) def call_on_thread(dialog_str: str) -> Callable: def decorator(func: Callable) -> Callable: @wraps(func) - def wrapper(*args: Any, **kwargs: Any) -> Callable: + def wrapper(*args: Any, **kwargs: Any) -> None: self = args[0] stored = StoredFunc(func, *args, **kwargs) if not hasattr(self, "thread_man"): @@ -40,7 +40,7 @@ def call_on_thread(dialog_str: str) -> Callable: class StoredFunc: - def __init__(self, func: Callable, *args, **kwargs) -> None: + def __init__(self, func: Callable, *args: Any, **kwargs: Any) -> None: sig = inspect.signature(func) self.func = func self.bindings = sig.bind(*args, **kwargs) @@ -53,7 +53,7 @@ class ThreadingManager: def __init__(self, controller: "Controller") -> None: self.controller = controller self.jobs = 1 - self.cleanup_func = None + self.cleanup_func: StoredFunc | None self.destroy_first = False def call_on_thread(self, dialog_str: str, func: StoredFunc) -> None: @@ -75,7 +75,7 @@ class ThreadingManager: def increment_dialog_with_str(self, text: str) -> None: GLib.idle_add(lambda: self.wait_dialog.increment(text)) - def set_cleanup_func(self, func: StoredFunc, destroy_first: bool = False) -> None: + def set_cleanup_func(self, func: StoredFunc | None, destroy_first: bool = False) -> None: if type(func) not in (StoredFunc, type(None)): msg = f"Callback function '{func}' is not of type StoredFunc or None" logger.critical(msg) @@ -83,10 +83,10 @@ class ThreadingManager: self.destroy_first = destroy_first self.cleanup_func = func - def get_cleanup_func(self) -> StoredFunc: + def get_cleanup_func(self) -> StoredFunc | None: return self.cleanup_func - def _destroy_on_idle(self) -> GLib.SOURCE_REMOVE: + def _destroy_on_idle(self) -> Literal[False]: if self.destroy_first: self.wait_dialog.destroy() @@ -97,7 +97,7 @@ class ThreadingManager: if not self.destroy_first: self.wait_dialog.destroy() - return GLib.SOURCE_REMOVE + return False def get_wait_dialog(self) -> WaitDialog: return self.wait_dialog diff --git a/dzgui/model/servers.py b/dzgui/model/servers.py index 4c53043..351e5c4 100644 --- a/dzgui/model/servers.py +++ b/dzgui/model/servers.py @@ -86,7 +86,7 @@ class ServerModelManager: key = config_man.lookup(Preferences.STEAM) job = Servers.query_api params = Servers.params - servers = [] + servers: list[dict[Any, Any]] = [] with ThreadPoolExecutor() as executor: futures = [executor.submit(job, key, APPID_DAYZ, param) for param in params] for future in as_completed(futures): diff --git a/dzgui/views/base.py b/dzgui/views/base.py index d5ec8f7..bcbcffa 100644 --- a/dzgui/views/base.py +++ b/dzgui/views/base.py @@ -33,7 +33,7 @@ import gi gi.require_version("Gtk", "3.0") gi.require_version("GLibUnix", "2.0") -from gi.repository import Gtk, GLib, GLibUnix, Gdk, GObject, Pango # noqa E402 +from gi.repository import Gtk, GLib, GLibUnix, Gdk # type: ignore # noqa E402 logger = logging.getLogger(APP_NAME) # https://bugzilla.gnome.org/show_bug.cgi?id=708676 @@ -201,7 +201,8 @@ class Notebook(ScrollableMixin, Gtk.Notebook): # type: ignore if widget is None: return - widget.grab_content_area() + if hasattr(widget, "grab_content_area"): + widget.grab_content_area() def get_page(self) -> Gtk.Widget | None: ind = self.get_current_page() diff --git a/dzgui/views/components/connect_panel.py b/dzgui/views/components/connect_panel.py index 1e9dd56..63324bd 100644 --- a/dzgui/views/components/connect_panel.py +++ b/dzgui/views/components/connect_panel.py @@ -133,6 +133,9 @@ class FavPanel(Gtk.Frame): emitter.connect("fav_server_changed", self._on_fav_server_changed) # TODO: do not return a None, None tuple + self.server_name: str + self.server_ip: str + self.server_name, self.server_ip = ( self.controller.get_config_man().get_favorite() ) diff --git a/dzgui/views/components/filter_panel.py b/dzgui/views/components/filter_panel.py index 9d14c3d..222c61d 100644 --- a/dzgui/views/components/filter_panel.py +++ b/dzgui/views/components/filter_panel.py @@ -45,7 +45,7 @@ class ButtonGrid(Gtk.Grid): checkbox = Gtk.CheckButton(label=check) label = checkbox.get_child() if label is not None: - label.set_ellipsize(Pango.EllipsizeMode.END) + label.set_ellipsize(Pango.EllipsizeMode.END) # type: ignore if defaults[check]: checkbox.set_active(True) @@ -186,7 +186,7 @@ class FilterPanel(Gtk.Box): self.maps_combo = Gtk.ComboBox.new_with_model_and_entry(self.map_store) self.maps_combo.set_entry_text_column(0) - self.maps_entry = self.maps_combo.get_child() + self.maps_entry: Gtk.Entry = self.maps_combo.get_child() # type: ignore 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) @@ -215,9 +215,10 @@ class FilterPanel(Gtk.Box): i: int row: Gtk.TreeModelRow - for i, row in enumerate(model): - if text == row[0]: + for i, row in enumerate(model): # type: ignore + if text == row[0]: # type: ignore self.maps_combo.set_active(i) + return def _on_maps_loaded(self, emitter: "Emitter", store: Gtk.ListStore) -> None: self.maps_combo.set_model(store) @@ -262,7 +263,7 @@ class FilterPanel(Gtk.Box): ) -> None: self.maps_combo.set_active_iter(it) - def _on_map_completion(self, entry: Gtk.Entry, editable: Literal[True]): + def _on_map_completion(self, entry: Gtk.Entry, editable: Literal[True]) -> None: text = entry.get_text() completion = entry.get_completion() store = self.controller.get_map_store() diff --git a/dzgui/views/components/statusbar.py b/dzgui/views/components/statusbar.py index b873bb7..b334f0e 100644 --- a/dzgui/views/components/statusbar.py +++ b/dzgui/views/components/statusbar.py @@ -98,7 +98,7 @@ class Statusbar(Gtk.Grid): def _on_server_row_changed(self, statusbar: Self) -> None: self.spinner.start() - def _on_distcalc_started(self, e) -> None: + def _on_distcalc_started(self, emitter: "Emitter") -> None: self.spinner.start() def _on_distcalc_ended( diff --git a/dzgui/views/dialogs/generic.py b/dzgui/views/dialogs/generic.py index 606eb7b..79f2363 100644 --- a/dzgui/views/dialogs/generic.py +++ b/dzgui/views/dialogs/generic.py @@ -44,7 +44,7 @@ class GenericDialog(Gtk.MessageDialog): self.set_default_response(Gtk.ResponseType.OK) - self.get_action_area().set_layout(Gtk.ButtonBoxStyle.CENTER) + self.get_action_area().set_layout(Gtk.ButtonBoxStyle.CENTER) # type: ignore self.get_action_area().set_margin_bottom(20) self.outer = self.get_content_area() self.outer.set_margin_start(30) @@ -76,7 +76,10 @@ class IgnoreDialog(GenericDialog): secondary=secondary, ) cancel = self.get_widget_for_response(Gtk.ResponseType.CANCEL) - cancel.set_label("Ignore") + if cancel is None: + return + if hasattr(cancel, "set_label"): + cancel.set_label("Ignore") class NotifyDialog(GenericDialog): @@ -199,7 +202,11 @@ class ExceptionDialog(GenericDialog): match response: case Gtk.ResponseType.OK: self.destroy() + return None case Gtk.ResponseType.NONE: return True case Gtk.ResponseType.DELETE_EVENT: self.destroy() + return None + case _: + return None diff --git a/dzgui/views/mixins/context_mixin.py b/dzgui/views/mixins/context_mixin.py index 1b1d952..de07fc4 100644 --- a/dzgui/views/mixins/context_mixin.py +++ b/dzgui/views/mixins/context_mixin.py @@ -19,7 +19,7 @@ class ContextMixin(TreeView): self, widget: Gtk.Widget, event: Gdk.EventButton | Gdk.EventKey, - ) -> None: + ) -> bool: if self.is_selection_empty(): return False @@ -56,6 +56,7 @@ class ContextMixin(TreeView): self.context_menu.popup_at_pointer(event) self.context_menu.select_first(False) + return True def _process_dynamic_row(self, row: ContextMenu) -> Gtk.MenuItem: if row == ContextMenu.ADD_SERVER and self.is_in_favs(): # type: ignore diff --git a/dzgui/views/pages/help.py b/dzgui/views/pages/help.py index 531dcc9..bcfbfd8 100644 --- a/dzgui/views/pages/help.py +++ b/dzgui/views/pages/help.py @@ -18,9 +18,6 @@ class Help(Gtk.ScrolledWindow): self.controller = controller - # FIXME: remember focused row - #self.treeview.focus_first_row() - def get_treeview(self) -> MenuTreeView: return self.treeview diff --git a/dzgui/views/pages/options.py b/dzgui/views/pages/options.py index beb5f86..1bb4d53 100644 --- a/dzgui/views/pages/options.py +++ b/dzgui/views/pages/options.py @@ -59,8 +59,8 @@ class Options(Gtk.Box): css.add_class(label, "page-heading") self.add(label) - self.steam_entry: Gtk.Entry = None - self.bm_entry: Gtk.Entry = None + self.steam_entry: Gtk.Entry + self.bm_entry: Gtk.Entry self.steam = WebButton(label=strings.options.steam_web) self.steam.connect("clicked", self._on_link_button_clicked, STEAM_API_SETUP) @@ -436,7 +436,8 @@ class Options(Gtk.Box): install = config["auto_install"] steam_path = Path(default_steam_path) - self.uid = find_user_id(steam_path) + uid = find_user_id(steam_path) + self.uid = "" if uid is None else uid self.old_steam = steam self.old_bm = bm @@ -444,7 +445,9 @@ class Options(Gtk.Box): self.steam_entry.set_text(steam) self.bm_entry.set_text(bm) - self.player_box.get_children()[0].set_text(name) + p = self.player_box.get_children()[0] + if hasattr(p, "set_text"): + p.set_text(name) # NOTE: suppress toggle signal until radios are built self._suppress_toggles(True) diff --git a/dzgui/views/pages/servers.py b/dzgui/views/pages/servers.py index 0be914f..f3fb435 100644 --- a/dzgui/views/pages/servers.py +++ b/dzgui/views/pages/servers.py @@ -1,6 +1,6 @@ import logging -from typing import Optional, Self, TYPE_CHECKING +from typing import Self, TYPE_CHECKING from dzgui.const.constants import APP_NAME from dzgui.const.enum import ContextMenuGroup, ServerTab @@ -77,6 +77,8 @@ class ServerNotebook(Gtk.ScrolledWindow): return page = self.notebook.get_nth_page(saved) label = self.notebook.get_tab_label(page) + if label is None: + return label.set_text(f"{server_labels.saved}*") def _on_servers_loaded(self, emitter: "Emitter", tab: "ServerTab") -> None: @@ -86,6 +88,7 @@ class ServerNotebook(Gtk.ScrolledWindow): state = False if tv.get_model() is None else True tv.set_headers_visible(state) tv.set_loaded(True) + tv.focus_first_row() tv.grab_focus() def _on_map(self, widget: Self) -> None: @@ -94,7 +97,7 @@ class ServerNotebook(Gtk.ScrolledWindow): def _on_unmap(self, widget: Self) -> None: self.emitter.emit("server_page_toggled", False) - def _on_keypress(self, widget: Self, event: Gdk.EventKey) -> Optional[False]: + def _on_keypress(self, widget: Self, event: Gdk.EventKey) -> bool: # NOTE: abort if modifier mask is active if event.state != 0: return False @@ -116,6 +119,8 @@ class ServerNotebook(Gtk.ScrolledWindow): def get_current_tab_text(self) -> str: ind = self.notebook.get_current_page() child = self.notebook.get_nth_page(ind) + if child is None: + return "" return self.notebook.get_tab_label_text(child) def _on_page_changed( diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index 7bf10db..19492a2 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -172,7 +172,7 @@ class ServerTreeView(ContextMixin, TreeView): self.start_queue_checker() self.start_distcalc() - def _on_unmap(self, a) -> None: + def _on_unmap(self, tree: Self) -> None: # NOTE: removes queue checker for this tab GLib.Source.remove(self.queue_id) self.emitter.disconnect(self.handler_id) @@ -229,7 +229,7 @@ class ServerTreeView(ContextMixin, TreeView): def _on_server_keypress( self, treeview: Gtk.TreeView, event: Gdk.EventKey - ) -> bool | None: + ) -> None: if event.state is Gdk.ModifierType.CONTROL_MASK: match event.keyval: case Gdk.KEY_f: @@ -248,10 +248,14 @@ class ServerTreeView(ContextMixin, TreeView): case Gdk.KEY_r: # TODO: unimplemented, needs threading self.controller.menu_action(ContextMenu.REFRESH_PLAYERS, self) + case _: + return False + return True else: match event.keyval: case Gdk.KEY_l | Gdk.KEY_Right: self.emitter.emit("request_button_box_focus") + return True case _: self.emitter.emit("check_button_pressed", event.keyval) @@ -307,7 +311,8 @@ class ServerTreeView(ContextMixin, TreeView): try: ip, gameport, qport = r.split(":") return Record(ip, int(gameport), int(qport)) - except ValueError: + except ValueError as e: + logger.critical(e) return None def is_loaded(self) -> bool: From 8bcb64810dbe014eec4d029b2885cc05f896cdc9 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 2 May 2026 20:29:52 +0900 Subject: [PATCH 215/309] feat: casefold mod names before sorting --- dzgui/api/mods.py | 2 +- dzgui/const/enum.py | 4 ++-- dzgui/managers/mods.py | 25 +++++++++++++++---------- dzgui/util/_json.py | 5 +++-- dzgui/views/trees/tree_servers.py | 4 ++-- 5 files changed, 23 insertions(+), 17 deletions(-) diff --git a/dzgui/api/mods.py b/dzgui/api/mods.py index c04f1fc..bcbd1c6 100644 --- a/dzgui/api/mods.py +++ b/dzgui/api/mods.py @@ -102,7 +102,7 @@ def get_delimited_mods(steam_path: Path) -> list[Any]: size = get_mod_size(mod) # NOTE: final col is cell renderer highlight toggle clean.append([meta.name, symlink, mod_dir, size, False]) - clean.sort(key=lambda row: row[0]) + clean.sort(key=lambda row: row[0].casefold()) return clean diff --git a/dzgui/const/enum.py b/dzgui/const/enum.py index 254e3fd..712d4e2 100644 --- a/dzgui/const/enum.py +++ b/dzgui/const/enum.py @@ -1,5 +1,5 @@ from enum import Enum -from typing import Self +from typing import Any, Self from dzgui.util import strings @@ -47,7 +47,7 @@ class FilterMode(Enum): class EnumWithAttrs(Enum): - def __new__(cls, *args, **kwargs) -> Self: + def __new__(cls, *args: Any, **kwargs: Any) -> Self: value = len(cls.__members__) + 1 obj = object.__new__(cls) obj._value_ = value diff --git a/dzgui/managers/mods.py b/dzgui/managers/mods.py index 46dff80..9e09abf 100644 --- a/dzgui/managers/mods.py +++ b/dzgui/managers/mods.py @@ -14,7 +14,7 @@ from dzgui.api.mods import ( from dzgui.const.constants import APP_NAME, APPID_DAYZ, APPID_DAYZ_EXP from dzgui.const.enum import Preferences from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager -from dzgui.model.model_factory import ModelFactory +from dzgui.model.model_factory import FastInsertListStore, ModelFactory from dzgui.util.format import format_mods import dzgui.api.pefile as PeFile @@ -44,11 +44,13 @@ class ModManager: self.path = controller.query_config(Preferences.DEFAULT) self.treeview = self.controller.get_modtreeview() + self.store: FastInsertListStore + self.thread_man = ThreadingManager(controller) self._get_mods() @call_on_thread("getting mods") - def _get_mods(self) -> list: + def _get_mods(self) -> None: mods = get_delimited_mods(self.path) if len(mods) < 1: msg = self.format_mod_statusbar() @@ -58,13 +60,13 @@ class ModManager: func = StoredFunc(self._on_mods_loaded, mods) self.thread_man.set_cleanup_func(func) - def _on_mods_loaded(self, mods: list[int]) -> None: - model = ModelFactory().make_mod_store() - model.extend(mods) - self.treeview.set_model(model) + def _on_mods_loaded(self, mods: list[list[str, str, str, float, bool]]) -> None: + self.store = ModelFactory().make_mod_store() + self.store.extend(mods) + self.treeview.set_model(self.store) msg = self.format_mod_statusbar() - mods = len(model) - self.emitter.emit("mods_updated", msg, mods) + total_mods = len(self.store) + self.emitter.emit("mods_updated", msg, total_mods) @call_on_thread("deleting mods") def delete_mods(self) -> None: @@ -87,7 +89,7 @@ class ModManager: mod = model.get(tree_iter, 2)[0] return mod, tree_iter - def delete_single_mod(self, tree_path: Gtk.TreePath) -> None: + def delete_single_mod(self, tree_path: Gtk.TreePath) -> Gtk.TreeIter: mod, _iter = self.get_mod_from_tree_path(tree_path) steam_path = Path(self.path) @@ -111,7 +113,10 @@ class ModManager: def _on_mods_deleted(self, iters: list[Gtk.TreeIter]) -> None: for _iter in iters: - self.treeview.get_model().remove(_iter) + self.store.remove(_iter) + #model = self.treeview.get_model() + #if model: + # model.remove(_iter) # FIXME: do not remove from model interface directly remove_stale_signatures(self.prefs.paths.config, self.prefs.paths.version) mods = len(self.treeview.get_model()) msg = self.format_mod_statusbar() diff --git a/dzgui/util/_json.py b/dzgui/util/_json.py index 4a46bdf..bc8f70e 100644 --- a/dzgui/util/_json.py +++ b/dzgui/util/_json.py @@ -1,16 +1,17 @@ import json from pathlib import Path +from typing import Any -def read_json(path: Path) -> dict: +def read_json(path: Path) -> Any: try: with open(path, "r") as infile: try: data = json.load(infile) + return data except json.decoder.JSONDecodeError as e: raise e except OSError as e: raise e - return data def write_json(data: dict, path: Path) -> None: diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index 19492a2..3866126 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -2,7 +2,7 @@ import logging import threading from queue import Queue -from typing import Any, Optional, Self +from typing import Any, Optional, Self, Union from warnings import deprecated from dzgui.api.servers import ping, Record @@ -321,7 +321,7 @@ class ServerTreeView(ContextMixin, TreeView): def set_loaded(self, status: bool) -> None: self.loaded = status - def get_model_and_control_model(self) -> tuple["FastInsertListStore", list[Any]]: + def get_model_and_control_model(self) -> tuple[Union["FastInsertListStore", None], list[Any]]: model = self.get_model() control = self.proxy_man.get_control() return model, control From 9c821e62eea92b958e267da7296e465bf7d73407 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 2 May 2026 21:11:45 +0900 Subject: [PATCH 216/309] fix: handle no favorite server --- dzgui/managers/config.py | 4 +- dzgui/util/strings.py | 4 ++ dzgui/views/components/connect_panel.py | 69 +++++++++++++------------ 3 files changed, 41 insertions(+), 36 deletions(-) diff --git a/dzgui/managers/config.py b/dzgui/managers/config.py index 8099cd1..561fa4d 100644 --- a/dzgui/managers/config.py +++ b/dzgui/managers/config.py @@ -98,10 +98,10 @@ class ConfigManager: def get_favorites(self) -> list[str]: return list(self.lookup(Preferences.IP_LIST)) - def get_favorite(self) -> tuple[str, str] | tuple[None, None]: + def get_favorite(self) -> tuple[str, str] | None: fav = str(self.lookup(Preferences.FAV_LBL)) if len(fav) < 1: - return None, None + return None ip = str(self.lookup(Preferences.FAV_SRV)) addr = ip.split(":") return fav, f"{addr[0]}:{addr[2]}" diff --git a/dzgui/util/strings.py b/dzgui/util/strings.py index afe6c9f..4d3d8f6 100644 --- a/dzgui/util/strings.py +++ b/dzgui/util/strings.py @@ -615,3 +615,7 @@ esc_to_return = "Press ESC to return" question_to_return = "Press ? to return" alert_button_tooltip = "Click to open debug log" + +address_already_saved = "This address is already in your Saved Servers" +stop_scanning = "Stop scanning on first_hit" +abort_tooltip = "Unless you have multiple DayZ servers on your LAN,\nleave this checked to get results faster" diff --git a/dzgui/views/components/connect_panel.py b/dzgui/views/components/connect_panel.py index 63324bd..5208abc 100644 --- a/dzgui/views/components/connect_panel.py +++ b/dzgui/views/components/connect_panel.py @@ -1,7 +1,8 @@ from typing import TYPE_CHECKING +from dzgui.const.constants import UDP_PORT from dzgui.model.servers import ServerModelManager -from dzgui.util.strings import connect_panel, fav_panel, lan_panel +from dzgui.strings import connect_panel from dzgui.views.components.buttons import ( AddButton, ClipboardButton, @@ -29,26 +30,24 @@ class LanPanel(Gtk.Frame): self.controller = controller self.emitter = controller.get_emitter() - label = BoldLabel(lan_panel.heading) + label = BoldLabel(connect_panel.lan_heading) self.set_label_widget(label) self.default_radio = Gtk.RadioButton.new_with_label( - None, lan_panel.default_button + None, connect_panel.lan_default_button ) self.custom_radio = Gtk.RadioButton.new_with_label_from_widget( - self.default_radio, lan_panel.custom_button + self.default_radio, connect_panel.lan_custom_button ) self.entry = PortEntry(controller) - self.scan = Gtk.Button(label=lan_panel.scan_button) + self.scan = Gtk.Button(label=connect_panel.lan_scan_button) self.scan.connect("clicked", self._on_scan_clicked) # TODO: strings - self.early_abort = Gtk.CheckButton(label="Stop scanning on first hit") + self.early_abort = Gtk.CheckButton(label=connect_panel.lan_checkbox) self.early_abort.set_active(True) - self.early_abort.set_tooltip_text( - "Unless you have multiple DayZ servers on your LAN,\nleave this checked to get results faster" - ) + self.early_abort.set_tooltip_text(connect_panel.lan_abort_tooltip) self.entry.connect("activate", self._on_entry_activated) self.entry.connect("string_validated", self._on_port_validated) @@ -100,7 +99,7 @@ class LanPanel(Gtk.Frame): def scan_ports(self) -> None: if self.default_radio.get_active(): - port = 27016 + port = UDP_PORT else: port = int(self.entry.get_text()) abort = self.early_abort.get_active() @@ -125,62 +124,65 @@ class FavPanel(Gtk.Frame): def __init__(self, controller: "Controller") -> None: super().__init__(margin_top=10, margin_bottom=5) - label = BoldLabel(fav_panel.heading) + label = BoldLabel(connect_panel.fav_heading) self.set_label_widget(label) self.controller = controller emitter = self.controller.get_emitter() emitter.connect("fav_server_changed", self._on_fav_server_changed) - # TODO: do not return a None, None tuple - self.server_name: str - self.server_ip: str + self.server_name = "" + self.server_ip = "" + + favorite = self.controller.get_config_man().get_favorite() + if favorite is None: + server_name = connect_panel.favs_empty + else: + self.server_name, self.server_ip = favorite + server_name = f"{self.server_name} ({self.server_ip})" - self.server_name, self.server_ip = ( - self.controller.get_config_man().get_favorite() - ) - server_name = ( - f"{self.server_name} ({self.server_ip})" - if self.server_name is not None - else connect_panel.no_fav - ) self.fav_label = Gtk.Label( label=server_name, track_visited_links=False, halign=Gtk.Align.START, hexpand=True, ) + + self.fav_button = SteamConnectButton() + self.fav_button.connect("clicked", self._on_connect_clicked) + self.copy_button = ClipboardButton(self.controller, self.get_fav_ip) + if favorite is None: + self.toggle_buttons(False) + # NOTE: disable vscrollbar to prevent layout jumping behavior scrollable_label = Gtk.ScrolledWindow(vscrollbar_policy=Gtk.PolicyType.NEVER) scrollable_label.add(self.fav_label) - self.fav_button = SteamConnectButton() - self.fav_button.connect("clicked", self._on_connect_clicked) - if server_name is None: - self.fav_button.set_sensitive(False) - copy = ClipboardButton(self.controller, self.get_fav_ip) - grid = Gtk.Grid(margin=10, vexpand=False, column_spacing=15, row_spacing=5) grid.attach(scrollable_label, 0, 0, 3, ROWS) - grid.attach_next_to(copy, scrollable_label, Gtk.PositionType.RIGHT, COLS, ROWS) - grid.attach_next_to(self.fav_button, copy, Gtk.PositionType.RIGHT, COLS, ROWS) + grid.attach_next_to(self.copy_button, scrollable_label, Gtk.PositionType.RIGHT, COLS, ROWS) + grid.attach_next_to(self.fav_button, self.copy_button, Gtk.PositionType.RIGHT, COLS, ROWS) self.add(grid) + def toggle_buttons(self, state: bool) -> None: + for button in self.fav_button, self.copy_button: + button.set_sensitive(state) + def _on_connect_clicked(self, button: Gtk.Button) -> None: self.controller.connect_by_str(self.server_ip) def get_fav_ip(self) -> str: return self.server_ip - from dzgui.api.servers import Record - def _on_fav_server_changed( self, emitter: "Emitter", name: str, record: str ) -> None: self.server_name = name self.server_ip = record self.fav_label.set_text(f"{name} ({record})") + if self.server_name != "": + self.toggle_buttons(True) class AddPanel(Gtk.Frame): @@ -221,10 +223,9 @@ class AddPanel(Gtk.Frame): self.add(self.grid) - # TODO: dynamic popover text self.pop = Gtk.Popover() self.pop_label = Gtk.Label( - label="This address is already in your Saved Servers", + label=connect_panel.add_popover, margin_start=10, margin_end=10, ) From 99083d207a597a118e3d2d0ba74d05b3b6f9d974 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 2 May 2026 21:16:06 +0900 Subject: [PATCH 217/309] chore: add standalone strings file --- dzgui/strings/connect_panel.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 dzgui/strings/connect_panel.py diff --git a/dzgui/strings/connect_panel.py b/dzgui/strings/connect_panel.py new file mode 100644 index 0000000..4fc831a --- /dev/null +++ b/dzgui/strings/connect_panel.py @@ -0,0 +1,24 @@ +add_button="Add" +add_con_heading="Add/connect" +add_popover = "This address is already in your Saved Servers" +add_tooltip="Add to Saved Servers" +connect_button="Connect" +connect_tooltip="Connect to this server" +connect_entry_tooltip=( + "- IP: format as IP:Query port\ne.g. 192.168.1.1:27016\n" + "- Battlemetrics: numeric server ID\ne.g. 123456" +) +connect_entry_placeholder="Enter IP (IP:Query port) or Battlemetrics ID (numerical id)" + +fav_heading="Favorite server" +favs_empty="None set. Right click a server and select 'Set favorite' to set." + +lan_heading="LAN query port", +lan_default_button="Default port (27016)" +lan_custom_button="Custom port" +lan_scan_button="Scan" +lan_abort_tooltip = "Unless you have multiple DayZ servers on your LAN,\nleave this checked to get results faster" +lan_checkbox = "Stop scanning on first_hit" +lan_placeholder="Enter the query port (1-65535)" +lan_entry_tooltip="Specify the port to search for DayZ servers on the local network" +lan_scan_tooltip="Scan for servers" From 9bfa39c17e2676259aea23967e51ec0231e15dbd Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 3 May 2026 02:14:08 +0900 Subject: [PATCH 218/309] chore: clear typehinting errors --- dzgui/api/mods.py | 4 +- dzgui/api/servers.py | 14 ++-- dzgui/managers/config.py | 2 +- dzgui/managers/contextmenu.py | 102 ++++++++++++++---------------- dzgui/managers/filter.py | 2 +- dzgui/managers/mods.py | 27 ++++++-- dzgui/model/proxy_model.py | 11 ++-- dzgui/model/servers.py | 15 +++-- dzgui/strings/connect_panel.py | 2 +- dzgui/views/dialogs/servers.py | 7 +- dzgui/views/pages/servers.py | 36 ++++++++--- dzgui/views/trees/tree_log.py | 16 +++-- dzgui/views/trees/tree_mods.py | 24 ++----- dzgui/views/trees/tree_servers.py | 40 ++++++------ 14 files changed, 160 insertions(+), 142 deletions(-) diff --git a/dzgui/api/mods.py b/dzgui/api/mods.py index bcbd1c6..4953ceb 100644 --- a/dzgui/api/mods.py +++ b/dzgui/api/mods.py @@ -101,8 +101,8 @@ def get_delimited_mods(steam_path: Path) -> list[Any]: continue size = get_mod_size(mod) # NOTE: final col is cell renderer highlight toggle - clean.append([meta.name, symlink, mod_dir, size, False]) - clean.sort(key=lambda row: row[0].casefold()) + clean.append([meta.name.casefold(), symlink, mod_dir, size, False]) + clean.sort(key=lambda row: row[0]) return clean diff --git a/dzgui/api/servers.py b/dzgui/api/servers.py index ce07b89..29b23b4 100644 --- a/dzgui/api/servers.py +++ b/dzgui/api/servers.py @@ -9,7 +9,7 @@ import subprocess import threading from dataclasses import dataclass -from typing import Optional, TYPE_CHECKING, Union +from typing import Any, Optional, TYPE_CHECKING, Union from dzgui.api.bm import map_id_to_record from dzgui.const.constants import APP_NAME, REQUEST_TIMEOUT @@ -228,7 +228,7 @@ def query_direct(ip: str, qport: int, timeout: float = 3.0) -> dict | None: class Res: status: int parsed: bool - json: Union[str, None] + json: Union[dict[str, Any], None] @dataclass(slots=True, frozen=True) @@ -393,13 +393,13 @@ def query_api(key: str, appid: int, param: str) -> Res: res.raise_for_status() parsed = True status = 200 - data = res.json() + json = res.json() except Exception: parsed = False - data = None + json = None status = 403 finally: - return Res(status, parsed, data) + return Res(status, parsed, json) def validate_ip(addr: str) -> Record: @@ -453,7 +453,7 @@ def query_by_id(addr: str, key: str) -> Optional[dict]: return None -def query_playercount(record: Record) -> Optional[tuple[int]]: +def query_playercount(record: Record) -> tuple[int, int] | None: try: res = query_direct(record.ip, record.qport) players = int(res["players"]) @@ -462,7 +462,7 @@ def query_playercount(record: Record) -> Optional[tuple[int]]: queue = int(r[1].split(",")[0]) except IndexError: queue = 0 - return (players, queue) + return players, queue except Exception as e: logger.critical(e) return None diff --git a/dzgui/managers/config.py b/dzgui/managers/config.py index 561fa4d..93cd012 100644 --- a/dzgui/managers/config.py +++ b/dzgui/managers/config.py @@ -72,7 +72,7 @@ class ConfigManager: # TODO: do this on demand if/when in options page # self.mediator.grid.notebook.settings.populate_settings() - def get_config(self) -> dict: + def get_config(self) -> Any: # TODO: is this being called twice? try: return read_json(self.config) diff --git a/dzgui/managers/contextmenu.py b/dzgui/managers/contextmenu.py index 412a03c..51a158a 100644 --- a/dzgui/managers/contextmenu.py +++ b/dzgui/managers/contextmenu.py @@ -33,61 +33,55 @@ class ContextMenuManager: self.treeview = treeview def process(self, action: ContextMenu) -> None: - match action: - # UNTHREADED - case ContextMenu.ADD_NOTE: - dialog = NoteDialog(self.controller) - dialog.run() - case ContextMenu.COPY_LOG_CLIPBOARD: - self.copy_log() - case ContextMenu.COPY_SERVER_IP: - self.copy_server_ip() - case ContextMenu.COPY_SERVER_NAME: - self.copy_server_name() - case ContextMenu.DELETE_MOD: - self.controller.delete_mods() - case ContextMenu.OPEN_WORKSHOP: - self.open_mod_page() - case ContextMenu.SET_FAV: - name = self.treeview.get_value_at_index(0) - record_str = self.treeview.get_record_string() - simple = self.treeview.get_simplified_ip() - self.controller.set_fav(name, record_str, simple) + if isinstance(self.treeview, ServerTreeView): + record = self.treeview.get_record() + if record is None: + return - # THREADED - case ContextMenu.ADD_SERVER: - record = self.treeview.get_record() - if record is None: - return - self.controller.add_by_record(record) - case ContextMenu.CONNECT: - record = self.treeview.get_record() - if record is None: - return - self.controller.connect_by_record(record) - case ContextMenu.REFRESH_PLAYERS: - record = self.treeview.get_record() - if record is None: - return - self.controller.refresh_players(record) - case ContextMenu.REMOVE_HISTORY: - record = self.treeview.get_record() - if record is None: - return - self.controller.remove_from_history(record) - case ContextMenu.REMOVE_SERVER: - record = self.treeview.get_record() - if record is None: - return - self.controller.remove_by_record(record) - case ContextMenu.SHOW_DETAILS: - record = self.treeview.get_record() - if record is None: - return - self.controller.get_details(record) - case ContextMenu.SHOW_MODS: - record = self.treeview.get_record() - self.controller.get_modlist(record) + match action: + # UNTHREADED + case ContextMenu.ADD_NOTE: + dialog = NoteDialog(self.controller) + dialog.run() + case ContextMenu.COPY_SERVER_IP: + self.copy_server_ip() + case ContextMenu.COPY_SERVER_NAME: + self.copy_server_name() + case ContextMenu.SET_FAV: + name = self.treeview.get_value_at_index(0) + # NOTE: fully qualified ip + fqip = self.treeview.get_record_string() + # NOTE: short ip for display purposes + simple = self.treeview.get_simplified_ip() + self.controller.set_fav(name, fqip, simple) + + # THREADED + case ContextMenu.ADD_SERVER: + self.controller.add_by_record(record) + case ContextMenu.CONNECT: + self.controller.connect_by_record(record) + case ContextMenu.REFRESH_PLAYERS: + self.controller.refresh_players(record) + case ContextMenu.REMOVE_HISTORY: + self.controller.remove_from_history(record) + case ContextMenu.REMOVE_SERVER: + self.controller.remove_by_record(record) + case ContextMenu.SHOW_DETAILS: + self.controller.get_details(record) + case ContextMenu.SHOW_MODS: + self.controller.get_modlist(record) + + if isinstance(self.treeview, ModTreeView): + match action: + case ContextMenu.DELETE_MOD: + self.controller.delete_mods() + case ContextMenu.OPEN_WORKSHOP: + self.open_mod_page() + + if isinstance(self.treeview, LogTreeView): + match action: + case ContextMenu.COPY_LOG_CLIPBOARD: + self.copy_log() def copy_server_ip(self) -> None: if not isinstance(self.treeview, ServerTreeView): diff --git a/dzgui/managers/filter.py b/dzgui/managers/filter.py index b6f7a6d..52ef6a0 100644 --- a/dzgui/managers/filter.py +++ b/dzgui/managers/filter.py @@ -76,7 +76,7 @@ class FilterManager: def append_map(self, row: list[str]) -> None: self.map_store.append(row) - def set_unique_maps(self, maps: list) -> None: + def set_unique_maps(self, maps: list[str]) -> None: if maps is None: return if len(maps) < 1: diff --git a/dzgui/managers/mods.py b/dzgui/managers/mods.py index 9e09abf..f0ed79f 100644 --- a/dzgui/managers/mods.py +++ b/dzgui/managers/mods.py @@ -2,7 +2,7 @@ import logging import shutil from pathlib import Path -from typing import TYPE_CHECKING +from typing import Any, TYPE_CHECKING from dzgui.api.mods import ( get_delimited_mods, @@ -60,7 +60,7 @@ class ModManager: func = StoredFunc(self._on_mods_loaded, mods) self.thread_man.set_cleanup_func(func) - def _on_mods_loaded(self, mods: list[list[str, str, str, float, bool]]) -> None: + def _on_mods_loaded(self, mods: list[list[Any]]) -> None: self.store = ModelFactory().make_mod_store() self.store.extend(mods) self.treeview.set_model(self.store) @@ -83,14 +83,20 @@ class ModManager: def get_mod_from_tree_path( self, tree_path: Gtk.TreePath - ) -> tuple[str, Gtk.TreeIter]: + ) -> tuple[str, Gtk.TreeIter] | None: model = self.treeview.get_model() + if model is None: + return None tree_iter = model.get_iter(tree_path) - mod = model.get(tree_iter, 2)[0] + mod = model.get_value(tree_iter, 2)[0] + print(mod) return mod, tree_iter - def delete_single_mod(self, tree_path: Gtk.TreePath) -> Gtk.TreeIter: - mod, _iter = self.get_mod_from_tree_path(tree_path) + def delete_single_mod(self, tree_path: Gtk.TreePath) -> Gtk.TreeIter | None: + res = self.get_mod_from_tree_path(tree_path) + if res is None: + return None + mod, _iter = res steam_path = Path(self.path) mods_path = get_local_mod_path(steam_path) @@ -118,12 +124,17 @@ class ModManager: #if model: # model.remove(_iter) # FIXME: do not remove from model interface directly remove_stale_signatures(self.prefs.paths.config, self.prefs.paths.version) - mods = len(self.treeview.get_model()) + model = self.treeview.get_model() + if model is None: + return + mods = len(model) msg = self.format_mod_statusbar() self.emitter.emit("mods_updated", msg, mods) def uncolorize_mods(self) -> None: model = self.treeview.get_model() + if model is None: + return for mod in model: _iter = mod.iter path = model.get_path(_iter) @@ -175,6 +186,8 @@ class ModManager: def select_colorized(self) -> None: model = self.treeview.get_model() + if model is None: + return sel = self.treeview.get_selection() for mod in model: it = mod.iter diff --git a/dzgui/model/proxy_model.py b/dzgui/model/proxy_model.py index 9cbc7da..472bf1a 100644 --- a/dzgui/model/proxy_model.py +++ b/dzgui/model/proxy_model.py @@ -32,8 +32,9 @@ class ProxyModelManager: self.proxy_model: "FastInsertListStore" = None self.filter_man = filter_man - self.control_model: list = None - self.filtered: list = None + # TODO: list typehints + self.control_model: list | None = None + self.filtered: list | None = None self.success = True def has_control_model(self) -> bool: @@ -61,9 +62,9 @@ class ProxyModelManager: def remove_row_from_control(self, record: "Record") -> None: addr = f"{record.ip}:{record.gameport}" qport = record.qport - for record in self.control_model: - if addr == record[7] and qport == record[8]: - self.control_model.remove(record) + for row in self.control_model: + if addr == row[7] and qport == row[8]: + self.control_model.remove(row) break self.wipe_cache() self.filter(FilterMode.INITIAL, skip_cache=True) diff --git a/dzgui/model/servers.py b/dzgui/model/servers.py index 351e5c4..ec93aa5 100644 --- a/dzgui/model/servers.py +++ b/dzgui/model/servers.py @@ -86,7 +86,7 @@ class ServerModelManager: key = config_man.lookup(Preferences.STEAM) job = Servers.query_api params = Servers.params - servers: list[dict[Any, Any]] = [] + servers = [] with ThreadPoolExecutor() as executor: futures = [executor.submit(job, key, APPID_DAYZ, param) for param in params] for future in as_completed(futures): @@ -105,7 +105,8 @@ class ServerModelManager: ) return j = res.json - servers += j["response"]["servers"] + if j is not None: + servers.extend(j["response"]["servers"]) except Exception as e: logger.critical(e) self.thread_man.set_cleanup_func( @@ -117,7 +118,8 @@ class ServerModelManager: res = Servers.query_api(key, APPID_DAYZ_EXP, "") if res.status == 200 and res.parsed is True: j = res.json - servers += j["response"]["servers"] + if j is not None: + servers.extend(j["response"]["servers"]) parsed = Servers.parse_json(servers) self._push_data(parsed) @@ -364,14 +366,15 @@ class ServerModelManager: if self.first_iteration: self._update_maps() - def _cleanup_on_failure(self, show_dialog=True) -> None: + def _cleanup_on_failure(self, show_dialog: bool = True) -> None: # TODO: disable map, keyword, and filter widgets if model is None # -> signal driven (servers_empty, servers_failed_to_load) if self.preserve_on_fail is False: + # CHORE: test if maps are cleared on failure self.tv.set_model(None) - filter_man = self.tv.get_filter_man() - filter_man.set_unique_maps(None) + #filter_man = self.tv.get_filter_man() + #filter_man.set_unique_maps([]) # TODO: emit signal to not disable widget sensitivity # TODO: distinguish signals, e.g. "servers_failed_to_load", "servers_loaded_empty" diff --git a/dzgui/strings/connect_panel.py b/dzgui/strings/connect_panel.py index 4fc831a..abfcfc8 100644 --- a/dzgui/strings/connect_panel.py +++ b/dzgui/strings/connect_panel.py @@ -13,7 +13,7 @@ connect_entry_placeholder="Enter IP (IP:Query port) or Battlemetrics ID (numeric fav_heading="Favorite server" favs_empty="None set. Right click a server and select 'Set favorite' to set." -lan_heading="LAN query port", +lan_heading="LAN query port" lan_default_button="Default port (27016)" lan_custom_button="Custom port" lan_scan_button="Scan" diff --git a/dzgui/views/dialogs/servers.py b/dzgui/views/dialogs/servers.py index 70dd7f5..3ed4178 100644 --- a/dzgui/views/dialogs/servers.py +++ b/dzgui/views/dialogs/servers.py @@ -59,7 +59,7 @@ class ServerDialog(GenericDialog): class ServerDetailsDialog(ServerDialog): - def __init__(self, controller, details: "Details"): + def __init__(self, controller: "Controller", details: "Details"): name = controller.get_server_name() super().__init__(controller, strings.server_details, name) @@ -96,6 +96,9 @@ class ServerDetailsDialog(ServerDialog): self.pack(scrollable_message) + if details.data is None: + return + for row in details.data: self.store.append(row + [Pango.Weight.BOLD]) self.view.set_model(self.store) @@ -115,7 +118,7 @@ class ServerDetailsDialog(ServerDialog): class ServerModDialog(ServerDialog): - def __init__(self, controller, mods: list[str]): + def __init__(self, controller: "Controller", mods: list[list[str]]): name = controller.get_server_name() super().__init__(controller, strings.modlist, name) diff --git a/dzgui/views/pages/servers.py b/dzgui/views/pages/servers.py index f3fb435..63fb091 100644 --- a/dzgui/views/pages/servers.py +++ b/dzgui/views/pages/servers.py @@ -76,15 +76,20 @@ class ServerNotebook(Gtk.ScrolledWindow): if cur_page == saved: return page = self.notebook.get_nth_page(saved) + if not page: + return label = self.notebook.get_tab_label(page) if label is None: return - label.set_text(f"{server_labels.saved}*") + if hasattr(label, "set_text"): + label.set_text(f"{server_labels.saved}*") def _on_servers_loaded(self, emitter: "Emitter", tab: "ServerTab") -> None: # NOTE: workaround for GTK bug where fullscreen causes headers to vanish when model is None # TODO: this should be internal to servers page tv = self.get_active_treeview() + if tv is None: + return state = False if tv.get_model() is None else True tv.set_headers_visible(state) tv.set_loaded(True) @@ -114,14 +119,19 @@ class ServerNotebook(Gtk.ScrolledWindow): return False def grab_content_area(self) -> None: - self.get_active_treeview().grab_focus() + tv = self.get_active_treeview() + if tv is not None: + tv.grab_focus() def get_current_tab_text(self) -> str: ind = self.notebook.get_current_page() child = self.notebook.get_nth_page(ind) if child is None: return "" - return self.notebook.get_tab_label_text(child) + text = self.notebook.get_tab_label_text(child) + if text is None: + return "" + return text def _on_page_changed( self, notebook: Gtk.Notebook, child: ScrollableTree, index: int @@ -130,21 +140,27 @@ class ServerNotebook(Gtk.ScrolledWindow): return label = self.notebook.get_tab_label_text(child) - # TODO: strings - text = label.strip("*") - self.notebook.set_tab_label_text(child, text) + if label is not None: + # TODO: strings + text = label.strip("*") + self.notebook.set_tab_label_text(child, text) tree = child.get_tree() self.emitter.emit("server_page_changed", tree) # NOTE: spawns a thread - self.controller.populate_model(self.get_active_treeview()) + tv = self.get_active_treeview() + if tv is not None: + self.controller.populate_model(tv) - def get_active_treeview(self) -> ServerTreeView: + def get_active_treeview(self) -> ServerTreeView | None: index = self.notebook.get_current_page() scrollable = self.notebook.get_nth_page(index) - treeview = scrollable.get_children()[0] - return treeview + if scrollable is not None and hasattr(scrollable, "get_children"): + tv = scrollable.get_children()[0] + if isinstance(tv, ServerTreeView): + return tv + return None def add_notification(self) -> None: saved = self.notebook.get_nth_page(1) diff --git a/dzgui/views/trees/tree_log.py b/dzgui/views/trees/tree_log.py index 2340d59..1d7dae4 100644 --- a/dzgui/views/trees/tree_log.py +++ b/dzgui/views/trees/tree_log.py @@ -21,7 +21,7 @@ if TYPE_CHECKING: logger = logging.getLogger(APP_NAME) -class LogTreeView(ContextMixin, TreeView): +class LogTreeView(ContextMixin, TreeView): # type: ignore def __init__(self, controller: "Controller") -> None: super().__init__(controller, menu=ContextMenuGroup.LOG) @@ -56,7 +56,8 @@ class LogTreeView(ContextMixin, TreeView): sortable = Gtk.TreeModelSort(_filter) self.set_model(sortable) _filter.refilter() - self.set_cursor(0) + path = Gtk.TreePath.new_from_indices([0]) + self.set_cursor(path) def toggle_filter(self, _filter: str) -> None: if _filter in self.filters: @@ -64,19 +65,20 @@ class LogTreeView(ContextMixin, TreeView): else: self.filters.append(_filter) # NOTE: unwrap TreeModelSort and TreeModelFilter - self.get_model().get_model().refilter() + self.get_model().get_model().refilter() # type: ignore def _filter_rows( - self, model: Gtk.ListStore, _iter: Gtk.TreeIter, data: Any - ) -> None: + self, filter_model: Gtk.TreeModelFilter, _iter: Gtk.TreeIter, data: Any + ) -> bool: if len(self.filters) < 1: return False - return model[_iter][1] in self.filters + return filter_model[_iter][1] in self.filters - def _on_log_buttonpress(self, widget: Gtk.Widget, event: Gdk.EventButton) -> None: + def _on_log_buttonpress(self, widget: Gtk.Widget, event: Gdk.EventButton) -> bool: if event.button == Gdk.BUTTON_SECONDARY: self.present_menu(widget, event) return True + return False def _on_log_keypress(self, widget: Gtk.Widget, event: Gdk.EventKey) -> None: self.present_menu(widget, event) diff --git a/dzgui/views/trees/tree_mods.py b/dzgui/views/trees/tree_mods.py index 8067343..8e9d165 100644 --- a/dzgui/views/trees/tree_mods.py +++ b/dzgui/views/trees/tree_mods.py @@ -21,7 +21,7 @@ if TYPE_CHECKING: logger = logging.getLogger(APP_NAME) -class ModTreeView(ModsMixin, ContextMixin, TreeView): +class ModTreeView(ModsMixin, ContextMixin, TreeView): # type: ignore def __init__(self, controller: "Controller") -> None: super().__init__(controller, menu=ContextMenuGroup.MOD) self.controller = controller @@ -64,31 +64,17 @@ class ModTreeView(ModsMixin, ContextMixin, TreeView): def _on_mods_updated(self, emitter: "Emitter", msg: str, mods: int) -> None: if mods < 1: return - self.set_cursor(0) + path = Gtk.TreePath.new_from_indices([0]) + self.set_cursor(path) def get_selected_mod(self) -> str: path = self.get_focused_row_path() model = self.get_model() tree_iter = model.get_iter(path) mod = model.get(tree_iter, 2)[0] - return mod - # return mod, tree_iter + return str(mod) - # def _on_mods_keypress(self, widget: Gtk.Widget, event: Gdk.EventKey) -> None: - # # TODO: multiselect - # # if event.keyval is Gdk.KEY_space: - # # it = self.get_focused_row_iter() - # # self.get_selection().select_iter(it) - # # path = self.get_focused_row_path() - # # self.set_cursor(path) - # # return False - # 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): + def _parent_selection_changed(self, base_class: TreeView, sel: Gtk.TreeSelection) -> None: pass def _format_color( diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index 3866126..9980671 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -35,7 +35,7 @@ if TYPE_CHECKING: QUEUE_CHECK_DELAY = 200 -class ServerTreeView(ContextMixin, TreeView): +class ServerTreeView(ContextMixin, TreeView): # type: ignore def __init__( self, controller: "Controller", enum: ServerTab, menu: ContextMenuGroup ) -> None: @@ -58,9 +58,9 @@ class ServerTreeView(ContextMixin, TreeView): self.queue_id: int self.handler_id: int - self.queue = Queue() + self.queue: Queue = Queue() - self.seen_cache = [] + self.seen_cache: list[str] = [] prefs = self.controller.get_prefs() columns = prefs.paths.columns @@ -119,7 +119,7 @@ class ServerTreeView(ContextMixin, TreeView): def _on_tooltip( self, - widget: Gtk.Widget, + treeview: Self, x: int, y: int, keyboard_mode: bool, @@ -128,12 +128,13 @@ class ServerTreeView(ContextMixin, TreeView): """ Present record data for the hovered row even if it is unfocused """ - coords = widget.convert_widget_to_bin_window_coords(x, y) - path = self.get_path_at_pos(coords.bx, coords.by) - if path is None: + coords_x, coords_y = treeview.convert_widget_to_bin_window_coords(x, y) + path = self.get_path_at_pos(coords_x, coords_y) + if path is None or path[0] is None: return False - model = self.get_model() + if model is None: + return False tree_iter = model.get_iter(path[0]) ip = model.get_value(tree_iter, 7) qport = model.get_value(tree_iter, 8) @@ -188,7 +189,7 @@ class ServerTreeView(ContextMixin, TreeView): # NOTE: get final width after drag action completes GLib.idle_add(self.controller.propagate_column_width, col) - def start_distcalc(self, emitter: Optional["Emitter"] = None): + def start_distcalc(self, emitter: Union["Emitter", None] = None) -> None: self.emitter.emit("distcalc_started") record = self.get_record() if record is None: @@ -248,14 +249,10 @@ class ServerTreeView(ContextMixin, TreeView): case Gdk.KEY_r: # TODO: unimplemented, needs threading self.controller.menu_action(ContextMenu.REFRESH_PLAYERS, self) - case _: - return False - return True else: match event.keyval: case Gdk.KEY_l | Gdk.KEY_Right: self.emitter.emit("request_button_box_focus") - return True case _: self.emitter.emit("check_button_pressed", event.keyval) @@ -266,12 +263,13 @@ class ServerTreeView(ContextMixin, TreeView): path = pathlist[0] tree_iter = model.get_iter(path) has_mods = model.get_value(tree_iter, 11) - return has_mods + return bool(has_mods) - def get_selected_row(self) -> Gtk.TreeModelRow: - sel = self.get_selection() - sels = sel.get_selected_rows() - return sels[0] + #def get_selected_row(self) -> Gtk.TreeModelRow: + # sel = self.get_selection() + # sels = sel.get_selected_rows() + # print(type(sels[0])) + # return sels[0] def is_in_favs(self) -> bool: record = self.get_record_string() @@ -283,6 +281,8 @@ class ServerTreeView(ContextMixin, TreeView): self, tree: TreeView, path: Gtk.TreePath, column: Gtk.TreeViewColumn ) -> None: record = self.get_record() + if record is None: + return self.controller.connect_by_record(record) def _parent_selection_changed( @@ -321,14 +321,14 @@ class ServerTreeView(ContextMixin, TreeView): def set_loaded(self, status: bool) -> None: self.loaded = status - def get_model_and_control_model(self) -> tuple[Union["FastInsertListStore", None], list[Any]]: + def get_model_and_control_model(self) -> tuple[Union[Gtk.TreeModel, None], list[Any]]: model = self.get_model() control = self.proxy_man.get_control() return model, control @staticmethod def ping_server( - model, _iter: Gtk.TreeIter, ip: str, qport: int, ping_column: int + model: "FastInsertListStore", _iter: Gtk.TreeIter, ip: str, qport: int, ping_column: int ) -> None: _ping = ping(ip, qport) GLib.idle_add(lambda: model.set(_iter, ping_column, _ping)) From 139b2269593ec06085da6dec353e519c31556eb0 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 3 May 2026 02:42:43 +0900 Subject: [PATCH 219/309] chore: drop unused code --- dzgui/config/freedesktop.py | 25 +------------------------ dzgui/managers/mods.py | 5 +---- dzgui/views/base.py | 3 +-- dzgui/views/pages/options.py | 1 - 4 files changed, 3 insertions(+), 31 deletions(-) diff --git a/dzgui/config/freedesktop.py b/dzgui/config/freedesktop.py index a9aa87c..147d3b9 100644 --- a/dzgui/config/freedesktop.py +++ b/dzgui/config/freedesktop.py @@ -1,7 +1,7 @@ -import shutil import textwrap from pathlib import Path + def write_desktop_file(share_path: Path) -> None: template = f"""\ [Desktop Entry] @@ -16,26 +16,3 @@ def write_desktop_file(share_path: Path) -> None: 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)) - file.chmod(file.stat().st_mode | stat.S_IEXEC) - except OSError as e: - 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) diff --git a/dzgui/managers/mods.py b/dzgui/managers/mods.py index f0ed79f..39125b5 100644 --- a/dzgui/managers/mods.py +++ b/dzgui/managers/mods.py @@ -36,6 +36,7 @@ class ModManager: this manager is instantiated each time the Mods page is opened and persists until the next time the page is opened """ + def __init__(self, controller: "Controller") -> None: self.controller = controller @@ -89,7 +90,6 @@ class ModManager: return None tree_iter = model.get_iter(tree_path) mod = model.get_value(tree_iter, 2)[0] - print(mod) return mod, tree_iter def delete_single_mod(self, tree_path: Gtk.TreePath) -> Gtk.TreeIter | None: @@ -120,9 +120,6 @@ class ModManager: def _on_mods_deleted(self, iters: list[Gtk.TreeIter]) -> None: for _iter in iters: self.store.remove(_iter) - #model = self.treeview.get_model() - #if model: - # model.remove(_iter) # FIXME: do not remove from model interface directly remove_stale_signatures(self.prefs.paths.config, self.prefs.paths.version) model = self.treeview.get_model() if model is None: diff --git a/dzgui/views/base.py b/dzgui/views/base.py index bcbcffa..bc9a816 100644 --- a/dzgui/views/base.py +++ b/dzgui/views/base.py @@ -33,7 +33,7 @@ import gi gi.require_version("Gtk", "3.0") gi.require_version("GLibUnix", "2.0") -from gi.repository import Gtk, GLib, GLibUnix, Gdk # type: ignore # noqa E402 +from gi.repository import Gtk, GLib, GLibUnix, Gdk # type: ignore # noqa E402 logger = logging.getLogger(APP_NAME) # https://bugzilla.gnome.org/show_bug.cgi?id=708676 @@ -87,7 +87,6 @@ class OuterWindow(Gtk.Window): # TODO: POC, trigger page change here MainController.loaded = True - MainController.populate_model(MainController.get_active_treeview()) def _on_delete_event(self, window: "OuterWindow", event: Gdk.EventKey) -> None: diff --git a/dzgui/views/pages/options.py b/dzgui/views/pages/options.py index 1bb4d53..62ba69b 100644 --- a/dzgui/views/pages/options.py +++ b/dzgui/views/pages/options.py @@ -301,7 +301,6 @@ class Options(Gtk.Box): def _on_force_update_clicked(self, button: Gtk.Button) -> None: # TODO: unimplemented print("UNIMPLEMENTED") - # wait_msg = strings.dialog.updating_mods def _on_client_changed(self, combo: Gtk.ComboBoxText) -> None: # TODO: use two columns or constants here, not strings From 33fbf765edf930871a42a24106f4196b6812cc1c Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Mon, 4 May 2026 01:18:09 +0900 Subject: [PATCH 220/309] feat: preconnection assistant (WIP) --- dzgui/api/mods.py | 7 ++++++- dzgui/api/servers.py | 1 + dzgui/const/enum.py | 1 + dzgui/controllers/mc.py | 18 ++++++++++++---- dzgui/managers/connection.py | 40 +++++++++++++++++++++++++----------- dzgui/managers/mods.py | 22 ++++++++++++++++---- dzgui/managers/threading.py | 6 ++++-- dzgui/views/base.py | 6 ++++++ 8 files changed, 78 insertions(+), 23 deletions(-) diff --git a/dzgui/api/mods.py b/dzgui/api/mods.py index 4953ceb..9bda5be 100644 --- a/dzgui/api/mods.py +++ b/dzgui/api/mods.py @@ -154,7 +154,12 @@ def remove_stale_signatures(config: Path, versions: Path) -> None: def find_stale_mods(config: Path) -> list[int]: def push_record(rec: str) -> list: record = fqip_to_record(rec) - return [mod.workshop_id for mod in get_rules(record)] + try: + mods = get_rules(record) + print(mods) + except Exception: + return [] + return [mod.workshop_id for mod in mods] steam = lookup(config, Preferences.DEFAULT) steam_path = Path(steam) diff --git a/dzgui/api/servers.py b/dzgui/api/servers.py index 29b23b4..aa54712 100644 --- a/dzgui/api/servers.py +++ b/dzgui/api/servers.py @@ -438,6 +438,7 @@ def get_rules(record: Record) -> list["DayzMod"]: return [mod for mod in mods] except Exception as e: logger.critical(e) + # TODO: supposed to skip failing servers for highlight action return [] diff --git a/dzgui/const/enum.py b/dzgui/const/enum.py index 712d4e2..a1d8eac 100644 --- a/dzgui/const/enum.py +++ b/dzgui/const/enum.py @@ -109,6 +109,7 @@ class NotebookPage(EnumWithAttrs): OPTIONS = {"crumbs": strings.crumbs.options, "statusbar": False} SERVERS = {"crumbs": strings.crumbs.servers, "statusbar": True} THANKS = {"crumbs": strings.crumbs.thanks, "statusbar": True} + CONNECTION = {"crumbs": "Connect", "statusbar": False} class RowType(EnumWithAttrs): diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index fb165a1..53af881 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -27,6 +27,7 @@ from dzgui.util.format import format_player_count from dzgui.util.open_links import open_user_workshop, open_workshop_page from dzgui.views.dialogs.filepicker import FilePicker from dzgui.views.dialogs.generic import ExceptionDialog +from dzgui.views.dialogs.servers import ServerModDialog import gi @@ -38,6 +39,7 @@ logger = logging.getLogger(APP_NAME) if TYPE_CHECKING: from dzgui.api.servers import Record from dzgui.const.enum import ServerTab + from dzgui.lib.dayzquery import DayzMod from dzgui.managers.filter import FilterManager from dzgui.util.dist import Haversine from dzgui.views.base import Notebook, Grid, OuterWindow @@ -139,7 +141,9 @@ class Controller(GObject.GObject): def remove_statusbar(self, context: "NotebookPage | ServerTab") -> None: self.mediator.statusbar.pop(context) - def set_statusbar_dist(self, haversine: Union["Haversine", None], enum: "ServerTab") -> None: + def set_statusbar_dist( + self, haversine: Union["Haversine", None], enum: "ServerTab" + ) -> None: """ NOTE: prevents race condition when server tab changed, but allows caching the distance in the background @@ -400,7 +404,7 @@ class Controller(GObject.GObject): ConnectionManager(self).query_details(record) def get_modlist(self, record: "Record") -> None: - ConnectionManager(self).query_modlist(record) + ConnectionManager(self).query_modlist_and_present(record) def get_server_name(self) -> str: tv = self.get_active_treeview() @@ -448,8 +452,14 @@ class Controller(GObject.GObject): def get_modtreeview(self) -> "ModTreeView": return self.mediator.modtreeview + def get_exit_event(self) -> None: + return self.exit_event + def set_exit_event(self) -> None: self.exit_event.set() - def get_exit_event(self) -> threading.Event: - return self.exit_event + def open_connection_assistant(self, res: dict[Any], mods: list["DayzMod"]) -> None: + self.open_page(NotebookPage.CONNECTION) + # TODO: populate assistant + # TODO: embed dialogs + # dialog = ServerModDialog(self, mods) diff --git a/dzgui/managers/connection.py b/dzgui/managers/connection.py index 7d3b680..eea70ce 100644 --- a/dzgui/managers/connection.py +++ b/dzgui/managers/connection.py @@ -55,11 +55,18 @@ class ConnectionManager: self.thread_man.set_cleanup_func( StoredFunc(self._server_timeout), destroy_first=True ) - rules = Servers.get_rules(record) + # TODO: proper error handling (currently returns empty list) + try: + mods = self.query_modlist(record) + except Exception: + self.thread_man.set_cleanup_func( + StoredFunc(self._server_timeout), destroy_first=True + ) + self.controller.open_connection_assistant(res, mods) # TODO: add to history if successful - print(res) - print(rules) + # print(res) + # print(rules) @call_on_thread(dialog.querying) def query_details(self, record: Servers.Record) -> None: @@ -73,18 +80,17 @@ class ConnectionManager: StoredFunc(self._present_details_dialog, details), destroy_first=True ) - @call_on_thread(dialog.querying) def query_modlist(self, record: Servers.Record) -> None: mods = Servers.get_rules(record) steam_path = self.controller.query_config(Preferences.DEFAULT) local = get_local_mod_ids(steam_path) - if len(mods) == 0: - # TODO: separate message for no mods - # TODO: separate message for actual timeout - self.thread_man.set_cleanup_func( - StoredFunc(self._server_timeout), destroy_first=True - ) - return + # if len(mods) == 0: + # # TODO: separate message for no mods + # # TODO: separate message for actual timeout + # self.thread_man.set_cleanup_func( + # StoredFunc(self._server_timeout), destroy_first=True + # ) + # return alpha_mods = [ [ mod.name, @@ -94,9 +100,19 @@ class ConnectionManager: for mod in mods ] alpha_mods.sort(key=lambda x: x[0]) + return alpha_mods + @call_on_thread(dialog.querying) + def query_modlist_and_present(self, record: Servers.Record) -> None: + try: + mods = self.query_modlist(record) + except Exception: + self.thread_man.set_cleanup_func( + StoredFunc(self._server_timeout), destroy_first=True + ) + return self.thread_man.set_cleanup_func( - StoredFunc(self._present_modlist_dialog, alpha_mods), + StoredFunc(self._present_modlist_dialog, mods), destroy_first=True, ) diff --git a/dzgui/managers/mods.py b/dzgui/managers/mods.py index 39125b5..a44c3bc 100644 --- a/dzgui/managers/mods.py +++ b/dzgui/managers/mods.py @@ -16,6 +16,9 @@ from dzgui.const.enum import Preferences from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager from dzgui.model.model_factory import FastInsertListStore, ModelFactory from dzgui.util.format import format_mods +from dzgui.util.strings import server_timeout +from dzgui.views.dialogs.generic import ExceptionDialog + import dzgui.api.pefile as PeFile @@ -34,11 +37,9 @@ class ModManager: """ Because mods may be dynamically updated on the system, this manager is instantiated each time the Mods page is opened - and persists until the next time the page is opened """ def __init__(self, controller: "Controller") -> None: - self.controller = controller self.emitter = controller.get_emitter() self.prefs = controller.get_prefs() @@ -50,6 +51,7 @@ class ModManager: self.thread_man = ThreadingManager(controller) self._get_mods() + # TODO: strings @call_on_thread("getting mods") def _get_mods(self) -> None: mods = get_delimited_mods(self.path) @@ -69,11 +71,12 @@ class ModManager: total_mods = len(self.store) self.emitter.emit("mods_updated", msg, total_mods) + # TODO: strings @call_on_thread("deleting mods") def delete_mods(self) -> None: sel = self.treeview.get_selection() model, pathlist = sel.get_selected_rows() - # NOTE reverse when multiple selection + # NOTE: reverse when multiple selection iters = [] for path in reversed(pathlist): _iter = self.delete_single_mod(path) @@ -174,13 +177,24 @@ class ModManager: model[path][4] = True self.emitter.emit("mods_highlighted") + # TODO: strings @call_on_thread("working") def highlight_stale(self) -> None: - # TODO: set progress bar for number of mods + # try: stale = find_stale_mods(self.prefs.paths.config) + # except Exception: + # # TODO: clearer error message + # func = StoredFunc(self._server_timeout) + # self.thread_man.set_cleanup_func(func) + # return + func = StoredFunc(self._on_stale_mods_found, stale) self.thread_man.set_cleanup_func(func) + def _server_timeout(self) -> None: + dialog = ExceptionDialog(self.controller, server_timeout) + dialog.run() + def select_colorized(self) -> None: model = self.treeview.get_model() if model is None: diff --git a/dzgui/managers/threading.py b/dzgui/managers/threading.py index a778124..8f85302 100644 --- a/dzgui/managers/threading.py +++ b/dzgui/managers/threading.py @@ -53,7 +53,7 @@ class ThreadingManager: def __init__(self, controller: "Controller") -> None: self.controller = controller self.jobs = 1 - self.cleanup_func: StoredFunc | None + self.cleanup_func: StoredFunc | None = None self.destroy_first = False def call_on_thread(self, dialog_str: str, func: StoredFunc) -> None: @@ -75,7 +75,9 @@ class ThreadingManager: def increment_dialog_with_str(self, text: str) -> None: GLib.idle_add(lambda: self.wait_dialog.increment(text)) - def set_cleanup_func(self, func: StoredFunc | None, destroy_first: bool = False) -> None: + def set_cleanup_func( + self, func: StoredFunc | None, destroy_first: bool = False + ) -> None: if type(func) not in (StoredFunc, type(None)): msg = f"Callback function '{func}' is not of type StoredFunc or None" logger.critical(msg) diff --git a/dzgui/views/base.py b/dzgui/views/base.py index bc9a816..681dc7b 100644 --- a/dzgui/views/base.py +++ b/dzgui/views/base.py @@ -23,6 +23,7 @@ from dzgui.views.pages.keys import Keybindings from dzgui.views.pages.log import Log from dzgui.views.pages.mods import Mods from dzgui.views.pages.options import Options +from dzgui.views.pages.preconnect import PreConnectionAssistant from dzgui.views.pages.servers import ServerNotebook from dzgui.views.pages.thanks import Thanks @@ -62,6 +63,8 @@ warnings.filterwarnings("ignore", ".*g_value_get_int", Warning) # return f"{record.ip}:{record.gameport}:{record.qport}" # # + + class OuterWindow(Gtk.Window): def __init__(self) -> None: super().__init__(title=APP_NAME, border_width=10, icon_name=APP_NAME_LOWER) @@ -125,6 +128,8 @@ class Notebook(ScrollableMixin, Gtk.Notebook): # type: ignore self.log = Log(MainController) self.developers = Developers(MainController) + self.connection = PreConnectionAssistant(MainController) + self.pages = { self.help: NotebookPage.HELP, self.clog: NotebookPage.CHANGELOG, @@ -135,6 +140,7 @@ class Notebook(ScrollableMixin, Gtk.Notebook): # type: ignore self.log: NotebookPage.LOG, self.thanks: NotebookPage.THANKS, self.developers: NotebookPage.DEVELOPERS, + self.connection: NotebookPage.CONNECTION, } self.indexes = {} From 2c07b8f3f2473627f34278c7e6934e95aee41ec1 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Mon, 4 May 2026 01:19:04 +0900 Subject: [PATCH 221/309] fix: do not literally casefold row element --- dzgui/api/mods.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dzgui/api/mods.py b/dzgui/api/mods.py index 9bda5be..46498ee 100644 --- a/dzgui/api/mods.py +++ b/dzgui/api/mods.py @@ -101,8 +101,8 @@ def get_delimited_mods(steam_path: Path) -> list[Any]: continue size = get_mod_size(mod) # NOTE: final col is cell renderer highlight toggle - clean.append([meta.name.casefold(), symlink, mod_dir, size, False]) - clean.sort(key=lambda row: row[0]) + clean.append([meta.name, symlink, mod_dir, size, False]) + clean.sort(key=lambda row: row[0].casefold()) return clean From 2dbdc3f92826262ffc61f6a16d803600f1df0549 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Mon, 4 May 2026 01:43:41 +0900 Subject: [PATCH 222/309] feat: HeadingFrame class --- dzgui/controllers/mc.py | 2 +- dzgui/views/components/frame.py | 21 +++++++++ dzgui/views/pages/options.py | 28 ++++------- dzgui/views/pages/preconnect.py | 84 +++++++++++++++++++++++++++++++++ 4 files changed, 115 insertions(+), 20 deletions(-) create mode 100644 dzgui/views/components/frame.py create mode 100644 dzgui/views/pages/preconnect.py diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 53af881..785c790 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -37,9 +37,9 @@ from gi.repository import Gtk, Gdk, GLib, GObject # noqa E402 logger = logging.getLogger(APP_NAME) if TYPE_CHECKING: + from dayzquery import DayzMod from dzgui.api.servers import Record from dzgui.const.enum import ServerTab - from dzgui.lib.dayzquery import DayzMod from dzgui.managers.filter import FilterManager from dzgui.util.dist import Haversine from dzgui.views.base import Notebook, Grid, OuterWindow diff --git a/dzgui/views/components/frame.py b/dzgui/views/components/frame.py new file mode 100644 index 0000000..d08c16d --- /dev/null +++ b/dzgui/views/components/frame.py @@ -0,0 +1,21 @@ +from dzgui.util import strings, css, open_links + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk # noqa + +class HeadingFrame(Gtk.Box): + def __init__(self, widget: Gtk.Widget, heading: str) -> None: + super().__init__(orientation=Gtk.Orientation.VERTICAL) + + label = Gtk.Label(label=heading) + label.set_halign(Gtk.Align.START) + # TODO: rename selector + css.add_class(label, "settings-subheading") + + frame = Gtk.Frame(hexpand=True) + frame.add(widget) + frame.set_label_widget(label) + + self.add(frame) diff --git a/dzgui/views/pages/options.py b/dzgui/views/pages/options.py index 62ba69b..0a82df2 100644 --- a/dzgui/views/pages/options.py +++ b/dzgui/views/pages/options.py @@ -9,6 +9,7 @@ from dzgui.util import strings, css, open_links from dzgui.views.components.labels import LeftLabel from dzgui.views.components.eventbox import InfoEventBox from dzgui.views.components.buttons import WebButton +from dzgui.views.components.frame import HeadingFrame from dzgui.views.dialogs.generic import ExceptionDialog from dzgui.views.dialogs.link_dialog import WorkshopLinkDialog @@ -178,10 +179,10 @@ class Options(Gtk.Box): grid.attach(developers, 1, 0, self.DEFAULT_WIDTH, self.DEFAULT_HEIGHT) 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), + HeadingFrame(api_box, strings.options.api_keys), + HeadingFrame(prefs_grid, strings.options.prefs), + HeadingFrame(mods_grid, strings.options.mods), + HeadingFrame(version_grid, strings.options.version), ]: grid.attach(frame, col, row, self.DEFAULT_WIDTH, self.DEFAULT_HEIGHT) row += 1 @@ -405,29 +406,16 @@ class Options(Gtk.Box): 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) - frame.set_label_widget(label) - - box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) - box.add(frame) - - return box - def populate_settings(self) -> None: prefs = self.controller.get_prefs() + # NOTE: re-check in case file was removed by user between runs if prefs.paths.config.is_file() is False: - # NOTE: in case file got deleted locally dialog = ExceptionDialog(self.controller, strings.config_not_found) dialog.run() raise Exception config = query.get_config(prefs.paths.config) + # TODO: use newer config enums name = config["name"] default_steam_path = config["default_steam_path"] steam = config["steam_api"] @@ -435,6 +423,7 @@ class Options(Gtk.Box): install = config["auto_install"] steam_path = Path(default_steam_path) + # NOTE: this is a best effort guess at the most recent user uid = find_user_id(steam_path) self.uid = "" if uid is None else uid @@ -468,6 +457,7 @@ class Options(Gtk.Box): if field[0] == "": field[1].get_children()[1].set_sensitive(False) + # TODO: abstract this logic try: pe_file_path = PeFile.get_pefile_path(steam_path, APPID_DAYZ) vers = PeFile.get_dayz_version(pe_file_path) diff --git a/dzgui/views/pages/preconnect.py b/dzgui/views/pages/preconnect.py new file mode 100644 index 0000000..8eb0a4a --- /dev/null +++ b/dzgui/views/pages/preconnect.py @@ -0,0 +1,84 @@ +from typing import Any, TYPE_CHECKING + +from dzgui.api import pefile as PeFile +from dzgui.const.constants import ( + APPID_DAYZ, + APPID_DAYZ_EXP, + APPNAME_DAYZ, + APPNAME_DAYZ_EXP, +) +from dayzquery import DayzMod +from dzgui.views.components.frame import HeadingFrame + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk # type: ignore # noqa E402 + + +if TYPE_CHECKING: + from dzgui.controllers.mc import Controller + + +class PreConnectionAssistant(Gtk.Box): + def __init__(self, controller: "Controller") -> None: + super().__init__() + + self.controller = controller + + self.button_box = Gtk.Box( + orientation=Gtk.Orientation.HORIZONTAL, + halign=Gtk.Align.END, + valign=Gtk.Align.END, + hexpand=True, + spacing=10, + ) + + self.rules: dict[Any] + self.mods: list["DayzMod"] + + # TODO: pack in scrolled window + + # TODO: strings + self.back = Gtk.Button(label="Back") + # TODO: dynamic button text if no mods needed + self.ok = Gtk.Button(label="Download mods and connect") + self.button_box.add(self.back) + self.button_box.add(self.ok) + + self.ok.connect("clicked", self._on_ok_clicked) + self.back.connect("clicked", self._on_back_clicked) + + self.warnings = [] + # TODO: populate with strings and icons + # set visibility if warnings > 1 + frame = HeadingFrame(Gtk.Label(label="ITEM ONE"), "Warnings") + self.add(frame) + + self.add(self.button_box) + + def _on_ok_clicked(self, button: Gtk.Button) -> None: + # TODO: update mod store in place with spinner/toast + # TODO: cancel mod downloads + pass + + def _on_back_clicked(self, button: Gtk.Button) -> None: + page = self.controller.get_prior_page() + self.controller.open_page(page) + + def populate(self, res: dict[Any], mods: list["DayzMod"]) -> None: + # TODO: compare remote and local build versions + # 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 + pass From a5c562f0227e3622955cd5c0ded3ff1fe782de7c Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Mon, 4 May 2026 02:04:34 +0900 Subject: [PATCH 223/309] chore: placeholder logic --- dzgui/api/pefile.py | 10 +++++++ dzgui/managers/connection.py | 3 -- dzgui/views/pages/options.py | 52 ++++----------------------------- dzgui/views/pages/preconnect.py | 24 +++++++-------- 4 files changed, 26 insertions(+), 63 deletions(-) diff --git a/dzgui/api/pefile.py b/dzgui/api/pefile.py index 479783b..84edc7d 100644 --- a/dzgui/api/pefile.py +++ b/dzgui/api/pefile.py @@ -461,3 +461,13 @@ def compare_versions(local: DayZVersion, remote: DayZVersion) -> VersionMatch: if local_str > remote_str: return VersionMatch.LOCAL_NEWER + + +def get_pretty_version(steam_path: Path, appid: int) -> str | None: + try: + pe_file_path = get_pefile_path(steam_path, appid) + vers = get_dayz_version(pe_file_path) + dayz_version = dayz_version_to_str(vers) + return dayz_version + except Exception: + return None diff --git a/dzgui/managers/connection.py b/dzgui/managers/connection.py index eea70ce..f00a845 100644 --- a/dzgui/managers/connection.py +++ b/dzgui/managers/connection.py @@ -64,9 +64,6 @@ class ConnectionManager: StoredFunc(self._server_timeout), destroy_first=True ) self.controller.open_connection_assistant(res, mods) - # TODO: add to history if successful - # print(res) - # print(rules) @call_on_thread(dialog.querying) def query_details(self, record: Servers.Record) -> None: diff --git a/dzgui/views/pages/options.py b/dzgui/views/pages/options.py index 0a82df2..205010a 100644 --- a/dzgui/views/pages/options.py +++ b/dzgui/views/pages/options.py @@ -130,17 +130,9 @@ class Options(Gtk.Box): 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("", controller) - 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) @@ -315,15 +307,6 @@ class Options(Gtk.Box): 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: ", 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: try: self.controller.toggle_config(context) @@ -415,6 +398,7 @@ class Options(Gtk.Box): raise Exception config = query.get_config(prefs.paths.config) + # TODO: use newer config enums name = config["name"] default_steam_path = config["default_steam_path"] @@ -457,19 +441,12 @@ class Options(Gtk.Box): if field[0] == "": field[1].get_children()[1].set_sensitive(False) - # TODO: abstract this logic - 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 = PeFile.get_pretty_version(steam_path, APPID_DAYZ) + if dayz_version is None: 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 = PeFile.get_pretty_version(steam_path, APPID_DAYZ_EXP) + if dayz_exp_version is None: dayz_exp_version = strings.null self.dayz_version_label.set_text(dayz_version) @@ -478,24 +455,7 @@ class Options(Gtk.Box): # TODO: not happy with this active_combo = query.get_client_index(config["client"]) self.client_combo.set_active(active_combo) - active_combo = 0 - - # active_combo = 1 if config["branch"] == BETA_REPO else 0 - - # self.controller.suppress_signal( - # self, self.branch_combo, "_on_branch_changed", True - # ) - # self.branch_combo.set_active(active_combo) - # self.branch_combo.set_sensitive(prefs.allow_updates) - # self.controller.suppress_signal( - # self, self.branch_combo, "_on_branch_changed", False - # ) - - # if prefs.allow_updates is True: - # msg = strings.options.self_update - # else: - # msg = strings.options.no_self_update - # self.branch_eb.set_text(msg) + # active_combo = 0 def _suppress_toggles(self, state: bool) -> None: for toggle in [ diff --git a/dzgui/views/pages/preconnect.py b/dzgui/views/pages/preconnect.py index 8eb0a4a..cf17439 100644 --- a/dzgui/views/pages/preconnect.py +++ b/dzgui/views/pages/preconnect.py @@ -46,8 +46,8 @@ class PreConnectionAssistant(Gtk.Box): self.button_box.add(self.back) self.button_box.add(self.ok) - self.ok.connect("clicked", self._on_ok_clicked) self.back.connect("clicked", self._on_back_clicked) + self.ok.connect("clicked", self._on_ok_clicked) self.warnings = [] # TODO: populate with strings and icons @@ -67,18 +67,14 @@ class PreConnectionAssistant(Gtk.Box): self.controller.open_page(page) def populate(self, res: dict[Any], mods: list["DayzMod"]) -> None: - # TODO: compare remote and local build versions - # 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 + # steam_path = self.controller.get_config_man().lookup(Preferences.DEFAULT) + # dayz_version = PeFile.get_pretty_version(steam_path, APPID_DAYZ) + # dayz_exp_version = PeFile.get_pretty_version(steam_path, APPID_DAYZ_EXP) + pass - # 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 + def download_mods(self) -> None: + pass + + def connect(self) -> None: + # TODO: add to history file and list store pass From e3e507598bcce58de2bbe802101ef54b77b155ff Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Mon, 4 May 2026 02:44:16 +0900 Subject: [PATCH 224/309] feat: connection dialog (WIP) --- dzgui/controllers/mc.py | 3 +- dzgui/managers/connection.py | 3 +- dzgui/views/dialogs/servers.py | 2 +- dzgui/views/pages/preconnect.py | 22 +++++- dzgui/views/trees/tree_server_mods.py | 110 +++++++------------------- 5 files changed, 54 insertions(+), 86 deletions(-) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 785c790..a9dd8d1 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -27,7 +27,6 @@ from dzgui.util.format import format_player_count from dzgui.util.open_links import open_user_workshop, open_workshop_page from dzgui.views.dialogs.filepicker import FilePicker from dzgui.views.dialogs.generic import ExceptionDialog -from dzgui.views.dialogs.servers import ServerModDialog import gi @@ -68,6 +67,7 @@ class AppNavigation: lan: "ServerTreeView" logtreeview: "LogTreeView" filters: "FilterPanel" + preconnect: "PreConnectionAssistant" class Controller(GObject.GObject): @@ -460,6 +460,7 @@ class Controller(GObject.GObject): def open_connection_assistant(self, res: dict[Any], mods: list["DayzMod"]) -> None: self.open_page(NotebookPage.CONNECTION) + self.mediator.preconnect.populate(res, mods) # TODO: populate assistant # TODO: embed dialogs # dialog = ServerModDialog(self, mods) diff --git a/dzgui/managers/connection.py b/dzgui/managers/connection.py index f00a845..29a55a7 100644 --- a/dzgui/managers/connection.py +++ b/dzgui/managers/connection.py @@ -63,7 +63,8 @@ class ConnectionManager: self.thread_man.set_cleanup_func( StoredFunc(self._server_timeout), destroy_first=True ) - self.controller.open_connection_assistant(res, mods) + func = StoredFunc(self.controller.open_connection_assistant, res, mods) + self.thread_man.set_cleanup_func(func, destroy_first=True) @call_on_thread(dialog.querying) def query_details(self, record: Servers.Record) -> None: diff --git a/dzgui/views/dialogs/servers.py b/dzgui/views/dialogs/servers.py index 3ed4178..903cebb 100644 --- a/dzgui/views/dialogs/servers.py +++ b/dzgui/views/dialogs/servers.py @@ -19,7 +19,7 @@ if TYPE_CHECKING: class ServerDialog(GenericDialog): - def __init__(self, controller: "Controller", title: str, secondary: str): + def __init__(self, controller: "Controller", title: str, secondary: str) -> None: super().__init__( controller=controller, text=title, diff --git a/dzgui/views/pages/preconnect.py b/dzgui/views/pages/preconnect.py index cf17439..75665b6 100644 --- a/dzgui/views/pages/preconnect.py +++ b/dzgui/views/pages/preconnect.py @@ -9,6 +9,7 @@ from dzgui.const.constants import ( ) from dayzquery import DayzMod from dzgui.views.components.frame import HeadingFrame +from dzgui.views.trees.tree_server_mods import ServerModTree import gi @@ -20,9 +21,10 @@ if TYPE_CHECKING: from dzgui.controllers.mc import Controller +# TODO: pack entire box in scrolled window class PreConnectionAssistant(Gtk.Box): def __init__(self, controller: "Controller") -> None: - super().__init__() + super().__init__(orientation=Gtk.Orientation.VERTICAL) self.controller = controller @@ -31,13 +33,15 @@ class PreConnectionAssistant(Gtk.Box): halign=Gtk.Align.END, valign=Gtk.Align.END, hexpand=True, + vexpand=True, spacing=10, ) self.rules: dict[Any] self.mods: list["DayzMod"] - # TODO: pack in scrolled window + self.controller.register_widget("preconnect", self) + # TODO: strings self.back = Gtk.Button(label="Back") @@ -53,8 +57,15 @@ class PreConnectionAssistant(Gtk.Box): # TODO: populate with strings and icons # set visibility if warnings > 1 frame = HeadingFrame(Gtk.Label(label="ITEM ONE"), "Warnings") - self.add(frame) + self.tree = ServerModTree(self.controller) + + self.scrolled = Gtk.ScrolledWindow() + self.scrolled.add(self.tree) + self.scrolled.set_size_request(600, 400) + + self.add(self.scrolled) + self.add(frame) self.add(self.button_box) def _on_ok_clicked(self, button: Gtk.Button) -> None: @@ -67,6 +78,8 @@ class PreConnectionAssistant(Gtk.Box): self.controller.open_page(page) def populate(self, res: dict[Any], mods: list["DayzMod"]) -> None: + # TODO: embed tree from dialog, but not dialog itself + self.tree.populate(mods) # steam_path = self.controller.get_config_man().lookup(Preferences.DEFAULT) # dayz_version = PeFile.get_pretty_version(steam_path, APPID_DAYZ) # dayz_exp_version = PeFile.get_pretty_version(steam_path, APPID_DAYZ_EXP) @@ -78,3 +91,6 @@ class PreConnectionAssistant(Gtk.Box): def connect(self) -> None: # TODO: add to history file and list store pass + + # TODO: icon for mod signature issue + # or "Update mods and connect" diff --git a/dzgui/views/trees/tree_server_mods.py b/dzgui/views/trees/tree_server_mods.py index df44134..d239d1a 100644 --- a/dzgui/views/trees/tree_server_mods.py +++ b/dzgui/views/trees/tree_server_mods.py @@ -1,47 +1,43 @@ -import logging +from dzgui.model.model_factory import ModelFactory +from dzgui.views.mixins.cursor_mixin import CursorMixin +from dzgui.views.trees.tree_base import TreeView +from dzgui.util import strings + +from typing import TYPE_CHECKING import gi + gi.require_version("Gtk", "3.0") -from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa +from gi.repository import Gtk, GLib, Gdk, Pango # noqa -from dzgui.const.enum import ( - ContextMenuGroup, - Popup, - RowType, - WindowContext, - ) -from dzgui.util.open_links import open_workshop_page -from dzgui.util import strings, localize -from typing import Any +if TYPE_CHECKING: + from dzgui.controllers.mc import Controller -from dzgui.views.trees.tree_base import TreeView -class ModDialog(GenericDialog): - def __init__(self, record: str): - # TODO: center secondary text - msg = strings.workshop - super().__init__(textwrap.dedent(msg), Popup.MODLIST) +class ServerModTree(TreeView): # type: ignore + def __init__(self, controller: "Controller") -> None: + super().__init__(controller) - dialogBox = self.get_content_area() - self.set_default_response(Gtk.ResponseType.OK) - self.set_size_request(800, 500) + # self.view = TreeView(controller) + self.mod_store = ModelFactory().make_server_mod_store() - 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.set_fixed_height_mode(True) + self.set_headers_visible(True) + self.set_model(self.mod_store) - self.connect("generic_row_activated", self._on_mod_row_activated) - self.connect("generic_treesel_changed", self._parent_selection_changed) + # self.scrollable_tree = Gtk.ScrolledWindow() + # self.scrollable_tree.add(self.view) + # self.scrollable_tree.set_size_request(700, 400) + + # 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) + self.append_column(column) column.set_sort_column_id(i) + # FIXME: do not recycle generic string vars match column_title: case strings.mod: column.set_fixed_width(350) @@ -50,55 +46,9 @@ class ModDialog(GenericDialog): case _: pass - dialogBox.pack_end(self.scrollable, EXPAND, FILL, 0) + # mod_count = len(mods) + # self._set_footer(mod_count) - wait_dialog = GenericDialog(strings.modlist, Popup.WAIT) - wait_dialog.show_all() - thread = threading.Thread( - target=self._background, args=(wait_dialog, record) - ) - thread.start() - - def _on_mod_row_activated(self, - treeview: Gtk.TreeView, - path: Gtk.TreePath, - column: Gtk.TreeViewColumn - ) -> None: - uid = self.get_value_at_index(1) - self.controller.open_mod_page(uid) - - def _parent_selection_changed(self, base_class: TreeView, sel: Gtk.TreeSelection): - pass - - # TODO: threading shouldn't take place inside this dialog, should be external to controller - #def _background(self, dialog: "GenericDialog", record: str) -> None: - # def _load(): - # dialog.destroy() - # # TODO: natively implemented - # #if data.returncode == 1: - # # spawn_dialog(strings.server_error, Popup.NOTIFY) - # # return - # self.show_all() - # self.set_markup(f"Modlist ({mod_count} mods)") - # self.run() - # self.destroy() - - # record = treeview.get_record() - # if not record: - # return - - # # TODO: thread - # try: - # 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 populate(self, mods: list[list[str]]) -> None: + for mod in mods: + self.mod_store.append(mod) From 29082ea1c1e2fded946593136ad2893008f22a07 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Mon, 4 May 2026 04:52:07 +0900 Subject: [PATCH 225/309] change: consolidate query methods --- dzgui/api/servers.py | 6 ++--- dzgui/const/enum.py | 1 + dzgui/managers/connection.py | 27 +++++++------------ dzgui/managers/contextmenu.py | 10 ++++++-- dzgui/model/servers.py | 7 +++-- dzgui/views/pages/preconnect.py | 28 ++++++++++++++++---- dzgui/views/trees/tree_server_mods.py | 37 +++++++++++++++++++-------- 7 files changed, 75 insertions(+), 41 deletions(-) diff --git a/dzgui/api/servers.py b/dzgui/api/servers.py index aa54712..d054225 100644 --- a/dzgui/api/servers.py +++ b/dzgui/api/servers.py @@ -442,7 +442,7 @@ def get_rules(record: Record) -> list["DayzMod"]: return [] -def query_by_id(addr: str, key: str) -> Optional[dict]: +def query_by_id(addr: str, key: str) -> dict[Any] | None: """ Used with numeric Battlemetrics IDs """ @@ -469,12 +469,12 @@ def query_playercount(record: Record) -> tuple[int, int] | None: return None -def query_by_ip(addr: str) -> Optional[dict]: +def query_by_ip(addr: str) -> dict[Any] | None: record = short_ip_to_record(addr) return query_by_record(record) -def query_by_record(record: Record) -> Optional[dict]: +def query_by_record(record: Record) -> dict[Any] | None: try: return query_direct(record.ip, record.qport) except Exception as e: diff --git a/dzgui/const/enum.py b/dzgui/const/enum.py index a1d8eac..13ea054 100644 --- a/dzgui/const/enum.py +++ b/dzgui/const/enum.py @@ -179,6 +179,7 @@ class ContextMenuGroup(Enum): Groupings of context menu items """ + SERVER_MOD = (ContextMenu.OPEN_WORKSHOP,) MOD = (ContextMenu.OPEN_WORKSHOP, ContextMenu.DELETE_MOD) LOG = (ContextMenu.COPY_LOG_CLIPBOARD,) SERVER_BROWSER = ( diff --git a/dzgui/managers/connection.py b/dzgui/managers/connection.py index 29a55a7..b13d06a 100644 --- a/dzgui/managers/connection.py +++ b/dzgui/managers/connection.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING +from typing import Any, TYPE_CHECKING import dzgui.api.servers as Servers @@ -27,37 +27,28 @@ class ConnectionManager: @call_on_thread(dialog.querying) def connect_by_id(self, addr: str, key: str) -> None: res = Servers.query_by_id(addr, key) - if res is None: - self.thread_man.set_cleanup_func( - StoredFunc(self._server_timeout), destroy_first=True - ) - - # TODO: resolve real ip - print("UNIMPLEMENTED: get rules") - print(res) + self.prepare_connection(res) @call_on_thread(dialog.querying) def connect_by_ip(self, addr: str) -> None: res = Servers.query_by_ip(addr) - if res is None: - self.thread_man.set_cleanup_func( - StoredFunc(self._server_timeout), destroy_first=True - ) - - # TODO: resolve real ip - print("UNIMPLEMENTED: get rules") - print(res) + self.prepare_connection(res) @call_on_thread(dialog.querying) def connect_by_record(self, record: Servers.Record) -> None: res = Servers.query_by_record(record) + self.prepare_connection(res) + + def prepare_connection(self, res: dict[Any] | None) -> None: if res is None: self.thread_man.set_cleanup_func( StoredFunc(self._server_timeout), destroy_first=True ) + return - # TODO: proper error handling (currently returns empty list) + record = Servers.response_to_record(res) try: + # TODO: proper error handling (currently returns empty list) mods = self.query_modlist(record) except Exception: self.thread_man.set_cleanup_func( diff --git a/dzgui/managers/contextmenu.py b/dzgui/managers/contextmenu.py index 51a158a..33aea9a 100644 --- a/dzgui/managers/contextmenu.py +++ b/dzgui/managers/contextmenu.py @@ -11,6 +11,7 @@ from dzgui.views.dialogs.note import NoteDialog from dzgui.views.trees.tree_mods import ModTreeView from dzgui.views.trees.tree_log import LogTreeView from dzgui.views.trees.tree_servers import ServerTreeView +from dzgui.views.trees.tree_server_mods import ServerModTreeView gi.require_version("Gtk", "3.0") @@ -26,7 +27,7 @@ if TYPE_CHECKING: class ContextMenuManager: def __init__( self, - treeview: LogTreeView | ModTreeView | ServerTreeView, + treeview: LogTreeView | ModTreeView | ServerTreeView | ServerModTreeView, controller: "Controller", ) -> None: self.controller = controller @@ -83,6 +84,11 @@ class ContextMenuManager: case ContextMenu.COPY_LOG_CLIPBOARD: self.copy_log() + if isinstance(self.treeview, ServerModTreeView): + match action: + case ContextMenu.OPEN_WORKSHOP: + self.open_mod_page() + def copy_server_ip(self) -> None: if not isinstance(self.treeview, ServerTreeView): return @@ -96,7 +102,7 @@ class ContextMenuManager: copy_clipboard(name) def open_mod_page(self) -> None: - if not isinstance(self.treeview, ModTreeView): + if not hasattr(self.treeview, "get_selected_mod"): return mod = self.treeview.get_selected_mod() cmd = self.controller.query_config(Preferences.CLIENT) diff --git a/dzgui/model/servers.py b/dzgui/model/servers.py index ec93aa5..56acc93 100644 --- a/dzgui/model/servers.py +++ b/dzgui/model/servers.py @@ -288,6 +288,9 @@ class ServerModelManager: self.emitter.emit("already_saved_server") return config_man.add_saved_server(fqip) + # 2026-05-04 + # TODO: this is valid if saved servers tab is already open, + # but not if app was just booted if proxy_man.has_control_model() is False: self._get_proxy_man().push(records) else: @@ -373,8 +376,8 @@ class ServerModelManager: if self.preserve_on_fail is False: # CHORE: test if maps are cleared on failure self.tv.set_model(None) - #filter_man = self.tv.get_filter_man() - #filter_man.set_unique_maps([]) + # filter_man = self.tv.get_filter_man() + # filter_man.set_unique_maps([]) # TODO: emit signal to not disable widget sensitivity # TODO: distinguish signals, e.g. "servers_failed_to_load", "servers_loaded_empty" diff --git a/dzgui/views/pages/preconnect.py b/dzgui/views/pages/preconnect.py index 75665b6..935ae1f 100644 --- a/dzgui/views/pages/preconnect.py +++ b/dzgui/views/pages/preconnect.py @@ -9,7 +9,7 @@ from dzgui.const.constants import ( ) from dayzquery import DayzMod from dzgui.views.components.frame import HeadingFrame -from dzgui.views.trees.tree_server_mods import ServerModTree +from dzgui.views.trees.tree_server_mods import ServerModTreeView import gi @@ -42,7 +42,6 @@ class PreConnectionAssistant(Gtk.Box): self.controller.register_widget("preconnect", self) - # TODO: strings self.back = Gtk.Button(label="Back") # TODO: dynamic button text if no mods needed @@ -58,13 +57,22 @@ class PreConnectionAssistant(Gtk.Box): # set visibility if warnings > 1 frame = HeadingFrame(Gtk.Label(label="ITEM ONE"), "Warnings") - self.tree = ServerModTree(self.controller) + self.tree = ServerModTreeView(self.controller) self.scrolled = Gtk.ScrolledWindow() self.scrolled.add(self.tree) self.scrolled.set_size_request(600, 400) - self.add(self.scrolled) + self.tree_frame = HeadingFrame(self.scrolled, "Mods") + + # TODO: should form a part of tree_frame above + self.mod_count = Gtk.Label(label="") + + self.title = Gtk.Label(label="") + + self.add(self.title) + self.add(self.tree_frame) + self.add(self.mod_count) self.add(frame) self.add(self.button_box) @@ -78,8 +86,17 @@ class PreConnectionAssistant(Gtk.Box): self.controller.open_page(page) def populate(self, res: dict[Any], mods: list["DayzMod"]) -> None: - # TODO: embed tree from dialog, but not dialog itself self.tree.populate(mods) + total = len(mods) + self.title.set_text(f"Connecting to {res["name"]}") + if total < 1: + self.tree.set_visible(False) + self.mod_count.set_visible(False) + return + else: + self.tree.set_visible(True) + self.mod_count.set_visible(True) + self.mod_count.set_text(f"Total mods: {str(total)}") # steam_path = self.controller.get_config_man().lookup(Preferences.DEFAULT) # dayz_version = PeFile.get_pretty_version(steam_path, APPID_DAYZ) # dayz_exp_version = PeFile.get_pretty_version(steam_path, APPID_DAYZ_EXP) @@ -94,3 +111,4 @@ class PreConnectionAssistant(Gtk.Box): # TODO: icon for mod signature issue # or "Update mods and connect" + # also handle servers with no mods; do not show tree diff --git a/dzgui/views/trees/tree_server_mods.py b/dzgui/views/trees/tree_server_mods.py index d239d1a..afec950 100644 --- a/dzgui/views/trees/tree_server_mods.py +++ b/dzgui/views/trees/tree_server_mods.py @@ -1,5 +1,6 @@ +from dzgui.const.enum import ContextMenuGroup from dzgui.model.model_factory import ModelFactory -from dzgui.views.mixins.cursor_mixin import CursorMixin +from dzgui.views.mixins.context_mixin import ContextMixin from dzgui.views.trees.tree_base import TreeView from dzgui.util import strings @@ -14,22 +15,19 @@ if TYPE_CHECKING: from dzgui.controllers.mc import Controller -class ServerModTree(TreeView): # type: ignore +class ServerModTreeView(ContextMixin, TreeView): # type: ignore def __init__(self, controller: "Controller") -> None: - super().__init__(controller) + super().__init__(controller, menu=ContextMenuGroup.SERVER_MOD) - # self.view = TreeView(controller) self.mod_store = ModelFactory().make_server_mod_store() self.set_fixed_height_mode(True) self.set_headers_visible(True) self.set_model(self.mod_store) - # self.scrollable_tree = Gtk.ScrolledWindow() - # self.scrollable_tree.add(self.view) - # self.scrollable_tree.set_size_request(700, 400) - - # self.view.connect("row-activated", self._on_row_activated) + self.connect("button-press-event", self.present_menu) + self.connect("key-press-event", self.present_menu) + self.connect("row-activated", self._on_row_activated) for i, column_title in enumerate(strings.server_mod_cols): renderer = Gtk.CellRendererText(ellipsize=Pango.EllipsizeMode.END) @@ -46,9 +44,26 @@ class ServerModTree(TreeView): # type: ignore case _: pass - # mod_count = len(mods) - # self._set_footer(mod_count) + # TODO: could be problematic if user downloads mods out of band + def _on_row_activated( + self, + treeview: Gtk.TreeView, + path: Gtk.TreePath, + col: Gtk.TreeViewColumn, + ) -> None: + mod = self.get_value_at_index(1) + self.controller.open_workshop_page(mod) + + def get_selected_mod(self) -> str: + path = self.get_focused_row_path() + model = self.get_model() + tree_iter = model.get_iter(path) + mod = model.get(tree_iter, 1)[0] + return str(mod) def populate(self, mods: list[list[str]]) -> None: + self.mod_store.clear() for mod in mods: self.mod_store.append(mod) + path = Gtk.TreePath.new_from_indices([0]) + self.set_cursor(path) From e319c01d5889e7bf1b5e09f8ac9821eacac46903 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Mon, 4 May 2026 06:01:19 +0900 Subject: [PATCH 226/309] feat: connection dialog (WIP) --- dzgui/api/mods.py | 1 - dzgui/api/servers.py | 13 +++---- dzgui/managers/connection.py | 15 ++++---- dzgui/views/pages/preconnect.py | 62 +++++++++++++++++++++++++-------- 4 files changed, 63 insertions(+), 28 deletions(-) diff --git a/dzgui/api/mods.py b/dzgui/api/mods.py index 46498ee..49d5db8 100644 --- a/dzgui/api/mods.py +++ b/dzgui/api/mods.py @@ -156,7 +156,6 @@ def find_stale_mods(config: Path) -> list[int]: record = fqip_to_record(rec) try: mods = get_rules(record) - print(mods) except Exception: return [] return [mod.workshop_id for mod in mods] diff --git a/dzgui/api/servers.py b/dzgui/api/servers.py index d054225..dd4c7e4 100644 --- a/dzgui/api/servers.py +++ b/dzgui/api/servers.py @@ -433,13 +433,14 @@ def validate_ip(addr: str) -> Record: def get_rules(record: Record) -> list["DayzMod"]: ip = record.ip qport = record.qport - try: - mods = dayzquery.dayz_rules((ip, qport)).mods - return [mod for mod in mods] - except Exception as e: - logger.critical(e) + #try: + mods = dayzquery.dayz_rules((ip, qport)).mods + return [mod for mod in mods] + #except Exception as e: + # logger.critical(e) + # raise e # TODO: supposed to skip failing servers for highlight action - return [] + #return [] def query_by_id(addr: str, key: str) -> dict[Any] | None: diff --git a/dzgui/managers/connection.py b/dzgui/managers/connection.py index b13d06a..6e1cab3 100644 --- a/dzgui/managers/connection.py +++ b/dzgui/managers/connection.py @@ -27,19 +27,19 @@ class ConnectionManager: @call_on_thread(dialog.querying) def connect_by_id(self, addr: str, key: str) -> None: res = Servers.query_by_id(addr, key) - self.prepare_connection(res) + self._prepare_connection(res) @call_on_thread(dialog.querying) def connect_by_ip(self, addr: str) -> None: res = Servers.query_by_ip(addr) - self.prepare_connection(res) + self._prepare_connection(res) @call_on_thread(dialog.querying) def connect_by_record(self, record: Servers.Record) -> None: res = Servers.query_by_record(record) - self.prepare_connection(res) + self._prepare_connection(res) - def prepare_connection(self, res: dict[Any] | None) -> None: + def _prepare_connection(self, res: dict[Any] | None) -> None: if res is None: self.thread_man.set_cleanup_func( StoredFunc(self._server_timeout), destroy_first=True @@ -49,11 +49,12 @@ class ConnectionManager: record = Servers.response_to_record(res) try: # TODO: proper error handling (currently returns empty list) - mods = self.query_modlist(record) + mods = self._query_modlist(record) except Exception: self.thread_man.set_cleanup_func( StoredFunc(self._server_timeout), destroy_first=True ) + return func = StoredFunc(self.controller.open_connection_assistant, res, mods) self.thread_man.set_cleanup_func(func, destroy_first=True) @@ -69,7 +70,7 @@ class ConnectionManager: StoredFunc(self._present_details_dialog, details), destroy_first=True ) - def query_modlist(self, record: Servers.Record) -> None: + def _query_modlist(self, record: Servers.Record) -> None: mods = Servers.get_rules(record) steam_path = self.controller.query_config(Preferences.DEFAULT) local = get_local_mod_ids(steam_path) @@ -94,7 +95,7 @@ class ConnectionManager: @call_on_thread(dialog.querying) def query_modlist_and_present(self, record: Servers.Record) -> None: try: - mods = self.query_modlist(record) + mods = self._query_modlist(record) except Exception: self.thread_man.set_cleanup_func( StoredFunc(self._server_timeout), destroy_first=True diff --git a/dzgui/views/pages/preconnect.py b/dzgui/views/pages/preconnect.py index 935ae1f..4134743 100644 --- a/dzgui/views/pages/preconnect.py +++ b/dzgui/views/pages/preconnect.py @@ -43,36 +43,61 @@ class PreConnectionAssistant(Gtk.Box): self.controller.register_widget("preconnect", self) # TODO: strings - self.back = Gtk.Button(label="Back") # TODO: dynamic button text if no mods needed - self.ok = Gtk.Button(label="Download mods and connect") - self.button_box.add(self.back) - self.button_box.add(self.ok) + self.back = Gtk.Button(label="Back", halign=Gtk.Align.START) + self.cancel = Gtk.Button( + label="Cancel", halign=Gtk.Align.END, sensitive=False, hexpand=True + ) + self.ok = Gtk.Button(label="Update mods and connect", halign=Gtk.Align.END) + + self.button_box = Gtk.Box( + orientation=Gtk.Orientation.HORIZONTAL, + valign=Gtk.Align.END, + vexpand=True, + spacing=5, + ) + for button in self.back, self.cancel, self.ok: + self.button_box.add(button) self.back.connect("clicked", self._on_back_clicked) self.ok.connect("clicked", self._on_ok_clicked) - self.warnings = [] - # TODO: populate with strings and icons - # set visibility if warnings > 1 - frame = HeadingFrame(Gtk.Label(label="ITEM ONE"), "Warnings") + self.title = Gtk.Label(label="") self.tree = ServerModTreeView(self.controller) + self.mod_count = Gtk.Label(label="", halign=Gtk.Align.START, margin_start=5) + # TODO: live count of remaining downloads + # "Steam is downloading: {mod_name}" + # mention whether manual or auto mod is active self.scrolled = Gtk.ScrolledWindow() self.scrolled.add(self.tree) self.scrolled.set_size_request(600, 400) - self.tree_frame = HeadingFrame(self.scrolled, "Mods") + self.tree_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + self.tree_box.add(self.scrolled) + self.tree_box.add(self.mod_count) - # TODO: should form a part of tree_frame above - self.mod_count = Gtk.Label(label="") + self.tree_frame = HeadingFrame(self.tree_box, "Mods") - self.title = Gtk.Label(label="") + self.warnings = [] + # TODO: populate with strings and icons + # set visibility if warnings > 1 + # warning category enums with matching strings + """ + blocking warning types: + - build mismatch + - version mismatch + - not enough drive space + passing warning types: + - dayz is running + - steam is not running + - server has password + """ + frame = HeadingFrame(Gtk.Label(label="ITEM ONE"), "Warnings") self.add(self.title) self.add(self.tree_frame) - self.add(self.mod_count) self.add(frame) self.add(self.button_box) @@ -90,13 +115,16 @@ class PreConnectionAssistant(Gtk.Box): total = len(mods) self.title.set_text(f"Connecting to {res["name"]}") if total < 1: - self.tree.set_visible(False) + self.tree_frame.set_visible(False) self.mod_count.set_visible(False) return else: self.tree.set_visible(True) self.mod_count.set_visible(True) self.mod_count.set_text(f"Total mods: {str(total)}") + + # TODO: check which mods need updating + # TODO: fourth column: needs update # steam_path = self.controller.get_config_man().lookup(Preferences.DEFAULT) # dayz_version = PeFile.get_pretty_version(steam_path, APPID_DAYZ) # dayz_exp_version = PeFile.get_pretty_version(steam_path, APPID_DAYZ_EXP) @@ -107,6 +135,12 @@ class PreConnectionAssistant(Gtk.Box): def connect(self) -> None: # TODO: add to history file and list store + """ + spawn dialog in thread + watch for subprocess + return to prior page when finished + """ + self.back.emit("clicked") pass # TODO: icon for mod signature issue From eecbde24d8d1b63e5d3c73ab53c9dfb8ac63cc20 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Mon, 4 May 2026 06:11:11 +0900 Subject: [PATCH 227/309] fix: abort distcalc if tab is not loaded --- dzgui/util/format.py | 2 +- dzgui/views/trees/tree_servers.py | 18 ++++++++++++------ 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/dzgui/util/format.py b/dzgui/util/format.py index 78c02bf..937c6d4 100644 --- a/dzgui/util/format.py +++ b/dzgui/util/format.py @@ -47,7 +47,7 @@ def format_mods(size: int, mods: int) -> str: l_size = number(size) plural = pluralize("mods", mods) # TODO: strings - suffix = "Ctrl-click to select multiple." + suffix = "Ctrl-click to select multiple; Shift-click to select a range." return f"Found {mods:n} {plural} taking up {l_size} MiB. {suffix}" diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index 9980671..02cc14e 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -228,9 +228,7 @@ class ServerTreeView(ContextMixin, TreeView): # type: ignore self.controller.set_statusbar_dist(haversine, self.get_enum()) return True - def _on_server_keypress( - self, treeview: Gtk.TreeView, event: Gdk.EventKey - ) -> None: + def _on_server_keypress(self, treeview: Gtk.TreeView, event: Gdk.EventKey) -> None: if event.state is Gdk.ModifierType.CONTROL_MASK: match event.keyval: case Gdk.KEY_f: @@ -265,7 +263,7 @@ class ServerTreeView(ContextMixin, TreeView): # type: ignore has_mods = model.get_value(tree_iter, 11) return bool(has_mods) - #def get_selected_row(self) -> Gtk.TreeModelRow: + # def get_selected_row(self) -> Gtk.TreeModelRow: # sel = self.get_selection() # sels = sel.get_selected_rows() # print(type(sels[0])) @@ -307,6 +305,8 @@ class ServerTreeView(ContextMixin, TreeView): # type: ignore return f"{addr}:{qport}" def get_record(self) -> Record | None: + if self.loaded is False: + return r = self.get_record_string() try: ip, gameport, qport = r.split(":") @@ -321,14 +321,20 @@ class ServerTreeView(ContextMixin, TreeView): # type: ignore def set_loaded(self, status: bool) -> None: self.loaded = status - def get_model_and_control_model(self) -> tuple[Union[Gtk.TreeModel, None], list[Any]]: + def get_model_and_control_model( + self, + ) -> tuple[Union[Gtk.TreeModel, None], list[Any]]: model = self.get_model() control = self.proxy_man.get_control() return model, control @staticmethod def ping_server( - model: "FastInsertListStore", _iter: Gtk.TreeIter, ip: str, qport: int, ping_column: int + model: "FastInsertListStore", + _iter: Gtk.TreeIter, + ip: str, + qport: int, + ping_column: int, ) -> None: _ping = ping(ip, qport) GLib.idle_add(lambda: model.set(_iter, ping_column, _ping)) From 47f8ae2db7496bfd3e20d073ed8ae21ecb63f944 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Mon, 4 May 2026 07:33:07 +0900 Subject: [PATCH 228/309] fix: emit signal outside of thread --- dzgui/model/servers.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/dzgui/model/servers.py b/dzgui/model/servers.py index 56acc93..c666515 100644 --- a/dzgui/model/servers.py +++ b/dzgui/model/servers.py @@ -92,8 +92,8 @@ class ServerModelManager: for future in as_completed(futures): try: - # NOTE: faciliate early aborting on boot via sigint - # this logic not available outside of initial boot + # NOTE: faciliates early aborting via sigint + # TODO: make this logic available to all dump contexts if self.controller.get_exit_event().is_set(): return @@ -268,11 +268,10 @@ class ServerModelManager: server = records[0] proxy_man = self._get_proxy_man() + config_man = self.controller.get_config_man() fqip = Servers.response_to_fqip(response) record = Servers.response_to_record(response) - config_man = self.controller.get_config_man() - # TODO: less convoluted if delete: # NOTE: abort early if Saved Servers tab was not loaded yet @@ -285,7 +284,8 @@ class ServerModelManager: proxy_man.remove_row_from_control(record) else: if config_man.is_in_favs(fqip): - self.emitter.emit("already_saved_server") + func = StoredFunc(lambda: self.emitter.emit("already_saved_server")) + self.thread_man.set_cleanup_func(func) return config_man.add_saved_server(fqip) # 2026-05-04 From 2e5630034aff0bf1eb7f5001e498b988da39ac2a Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Mon, 4 May 2026 07:33:45 +0900 Subject: [PATCH 229/309] chore: update changelog --- CHANGELOG.md | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c07b2d4..0b03c65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ ## Added -- Add pyproject.toml file - Changelog text wrapping and formatting +- Changelog ships with source +- Documentation ships with source - Decouple UI components into modules - Choose from kilometer or miles distance display - More granular haversine calculation @@ -8,12 +9,12 @@ - Dedicated mods page - Dedicated thanks page - Rearrange menus/breadcrumbs -- Documentation ships with source - Open filepicker when generating system log - Developers page (and -d flag) - Redact API key in log table - Integrate add/connect widgets into main menu - Favorite/connect/LAN panels integrated with server views +- Abort on first LAN server found - Colorized IP/ID validation fields - Copy favorite server IP to clipboard - Set favorite server from tables @@ -30,6 +31,7 @@ - Lazy load ping cell data func - Refresh servers button - Early load alerts button +- Preconnect dialog ## Changed - Reduce padding on keys button @@ -39,6 +41,11 @@ - Copy IP copies IP:queryport only instead of IP:gameport:queryport, mimics syntax needed by add by ip method - Load new model into view without flushing - Cull servers with abnormal queue values (integer overflow: 2147483647) +- Packaging structure + +## Dropped +- Debug mode +- Branch switching ## Fixed - Longstanding issue with left clicks not registering as tree selection changes after spamming keyboard input @@ -53,7 +60,8 @@ - Move debug mode to developers only - Local documentation -## Devs +## Developer-facing +- Add pyproject.toml file - Show deprecation warnings - Options -> Dev page - Raw debug command From 9bc94f75878539fb27e4de77fdb4c9be689e954e Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Mon, 4 May 2026 07:34:23 +0900 Subject: [PATCH 230/309] fix: typo in string --- dzgui/strings/connect_panel.py | 2 +- dzgui/util/strings.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dzgui/strings/connect_panel.py b/dzgui/strings/connect_panel.py index abfcfc8..d2db974 100644 --- a/dzgui/strings/connect_panel.py +++ b/dzgui/strings/connect_panel.py @@ -18,7 +18,7 @@ lan_default_button="Default port (27016)" lan_custom_button="Custom port" lan_scan_button="Scan" lan_abort_tooltip = "Unless you have multiple DayZ servers on your LAN,\nleave this checked to get results faster" -lan_checkbox = "Stop scanning on first_hit" +lan_checkbox = "Stop scanning on first hit" lan_placeholder="Enter the query port (1-65535)" lan_entry_tooltip="Specify the port to search for DayZ servers on the local network" lan_scan_tooltip="Scan for servers" diff --git a/dzgui/util/strings.py b/dzgui/util/strings.py index 4d3d8f6..82853eb 100644 --- a/dzgui/util/strings.py +++ b/dzgui/util/strings.py @@ -617,5 +617,5 @@ question_to_return = "Press ? to return" alert_button_tooltip = "Click to open debug log" address_already_saved = "This address is already in your Saved Servers" -stop_scanning = "Stop scanning on first_hit" +stop_scanning = "Stop scanning on first hit" abort_tooltip = "Unless you have multiple DayZ servers on your LAN,\nleave this checked to get results faster" From 04e0b7ae5510d2eb28f42e37b541c9d896286e2c Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Mon, 4 May 2026 07:34:57 +0900 Subject: [PATCH 231/309] fix: hide popover after initial render --- dzgui/views/components/connect_panel.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/dzgui/views/components/connect_panel.py b/dzgui/views/components/connect_panel.py index 5208abc..1ebf1b2 100644 --- a/dzgui/views/components/connect_panel.py +++ b/dzgui/views/components/connect_panel.py @@ -160,8 +160,12 @@ class FavPanel(Gtk.Frame): grid = Gtk.Grid(margin=10, vexpand=False, column_spacing=15, row_spacing=5) grid.attach(scrollable_label, 0, 0, 3, ROWS) - grid.attach_next_to(self.copy_button, scrollable_label, Gtk.PositionType.RIGHT, COLS, ROWS) - grid.attach_next_to(self.fav_button, self.copy_button, Gtk.PositionType.RIGHT, COLS, ROWS) + grid.attach_next_to( + self.copy_button, scrollable_label, Gtk.PositionType.RIGHT, COLS, ROWS + ) + grid.attach_next_to( + self.fav_button, self.copy_button, Gtk.PositionType.RIGHT, COLS, ROWS + ) self.add(grid) @@ -230,9 +234,11 @@ class AddPanel(Gtk.Frame): margin_end=10, ) self.pop.add(self.pop_label) + # NOTE: render once to draw text in bubble self.pop.show_all() self.pop.set_margin_start(10) self.pop.set_relative_to(self.entry) + self.pop.popdown() def _on_duplicate_server(self, emitter: "Emitter") -> None: self.pop.popup() From ce44d67bf6a89500a8794ac9c88c82d46ea32b8e Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Mon, 4 May 2026 07:35:13 +0900 Subject: [PATCH 232/309] chore: add comments --- dzgui/api/servers.py | 12 ++++++------ dzgui/views/base.py | 6 ++++++ 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/dzgui/api/servers.py b/dzgui/api/servers.py index dd4c7e4..fbc09e2 100644 --- a/dzgui/api/servers.py +++ b/dzgui/api/servers.py @@ -40,12 +40,12 @@ params = [ ] -class BmAPIError(Exception): - pass - - -class BmIdError(Exception): - pass +#class BmAPIError(Exception): +# pass +# +# +#class BmIdError(Exception): +# pass class InvalidIpError(Exception): diff --git a/dzgui/views/base.py b/dzgui/views/base.py index 681dc7b..f108364 100644 --- a/dzgui/views/base.py +++ b/dzgui/views/base.py @@ -37,6 +37,8 @@ gi.require_version("GLibUnix", "2.0") from gi.repository import Gtk, GLib, GLibUnix, Gdk # type: ignore # noqa E402 logger = logging.getLogger(APP_NAME) + +# TODO: drop # https://bugzilla.gnome.org/show_bug.cgi?id=708676 warnings.filterwarnings("ignore", ".*g_value_get_int", Warning) @@ -86,6 +88,10 @@ class OuterWindow(Gtk.Window): css.load_css() MainController.open_page(NotebookPage.SERVERS) + # TODO: + # combo box in options could link to same index as pages + # or use enum in case page order is changed in future + # MainController.get_servers().notebook.set_current_page(3) self.grid.hide_widgets_on_init() # TODO: POC, trigger page change here From 73d131a64b05a00d6273412b693e234178945fd4 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Tue, 5 May 2026 18:31:50 +0900 Subject: [PATCH 233/309] feat: set start tab --- dzgui/config/convert.py | 11 ++++--- dzgui/const/enum.py | 11 ++++--- dzgui/controllers/mc.py | 4 +++ dzgui/data/CHANGELOG.md | 14 ++++++-- dzgui/managers/config.py | 3 ++ dzgui/views/base.py | 5 +-- dzgui/views/pages/options.py | 55 +++++++++++++++++++++++-------- dzgui/views/trees/tree_servers.py | 2 +- tests/test_config.py | 1 + 9 files changed, 75 insertions(+), 31 deletions(-) diff --git a/dzgui/config/convert.py b/dzgui/config/convert.py index bd8c176..5231bf7 100644 --- a/dzgui/config/convert.py +++ b/dzgui/config/convert.py @@ -34,7 +34,7 @@ def rc2json(file: Path) -> str: lex = shlex.shlex(f.read()) lex.whitespace += "=" - d = {} + keys = {} ips: list[str] = [] toggles = ["auto_install", "fullscreen"] @@ -69,8 +69,9 @@ def rc2json(file: Path) -> str: if not tok: break - d[tok] = ntok + keys[tok] = ntok - d["ip_list"] = ips - d["use_miles"] = False - return json.dumps(d, indent=2) + keys["ip_list"] = ips + keys["use_miles"] = False + keys["start_tab"] = 0 + return json.dumps(keys, indent=2) diff --git a/dzgui/const/enum.py b/dzgui/const/enum.py index 13ea054..be62cb2 100644 --- a/dzgui/const/enum.py +++ b/dzgui/const/enum.py @@ -10,10 +10,10 @@ class Port(Enum): class ServerTab(Enum): - BROWSER = 1 - SAVED = 2 - RECENT = 3 - LAN = 4 + BROWSER = 0 + SAVED = 1 + RECENT = 2 + LAN = 3 class Popup(Enum): @@ -97,6 +97,9 @@ class Preferences(EnumWithAttrs): BRANCH = { "key": "branch", } + START_TAB = { + "key": "start_tab", + } class NotebookPage(EnumWithAttrs): diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index a9dd8d1..baac8fe 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -464,3 +464,7 @@ class Controller(GObject.GObject): # TODO: populate assistant # TODO: embed dialogs # dialog = ServerModDialog(self, mods) + + def set_start_tab(self) -> int: + ind = self.config_man.get_start_tab() + self.get_servers().notebook.set_current_page(ind) diff --git a/dzgui/data/CHANGELOG.md b/dzgui/data/CHANGELOG.md index c07b2d4..0b03c65 100644 --- a/dzgui/data/CHANGELOG.md +++ b/dzgui/data/CHANGELOG.md @@ -1,6 +1,7 @@ ## Added -- Add pyproject.toml file - Changelog text wrapping and formatting +- Changelog ships with source +- Documentation ships with source - Decouple UI components into modules - Choose from kilometer or miles distance display - More granular haversine calculation @@ -8,12 +9,12 @@ - Dedicated mods page - Dedicated thanks page - Rearrange menus/breadcrumbs -- Documentation ships with source - Open filepicker when generating system log - Developers page (and -d flag) - Redact API key in log table - Integrate add/connect widgets into main menu - Favorite/connect/LAN panels integrated with server views +- Abort on first LAN server found - Colorized IP/ID validation fields - Copy favorite server IP to clipboard - Set favorite server from tables @@ -30,6 +31,7 @@ - Lazy load ping cell data func - Refresh servers button - Early load alerts button +- Preconnect dialog ## Changed - Reduce padding on keys button @@ -39,6 +41,11 @@ - Copy IP copies IP:queryport only instead of IP:gameport:queryport, mimics syntax needed by add by ip method - Load new model into view without flushing - Cull servers with abnormal queue values (integer overflow: 2147483647) +- Packaging structure + +## Dropped +- Debug mode +- Branch switching ## Fixed - Longstanding issue with left clicks not registering as tree selection changes after spamming keyboard input @@ -53,7 +60,8 @@ - Move debug mode to developers only - Local documentation -## Devs +## Developer-facing +- Add pyproject.toml file - Show deprecation warnings - Options -> Dev page - Raw debug command diff --git a/dzgui/managers/config.py b/dzgui/managers/config.py index 93cd012..4169496 100644 --- a/dzgui/managers/config.py +++ b/dzgui/managers/config.py @@ -95,6 +95,9 @@ class ConfigManager: ) return + def get_start_tab(self) -> int: + return self.lookup(Preferences.START_TAB) + def get_favorites(self) -> list[str]: return list(self.lookup(Preferences.IP_LIST)) diff --git a/dzgui/views/base.py b/dzgui/views/base.py index f108364..00fc40c 100644 --- a/dzgui/views/base.py +++ b/dzgui/views/base.py @@ -88,10 +88,7 @@ class OuterWindow(Gtk.Window): css.load_css() MainController.open_page(NotebookPage.SERVERS) - # TODO: - # combo box in options could link to same index as pages - # or use enum in case page order is changed in future - # MainController.get_servers().notebook.set_current_page(3) + MainController.set_start_tab() self.grid.hide_widgets_on_init() # TODO: POC, trigger page change here diff --git a/dzgui/views/pages/options.py b/dzgui/views/pages/options.py index 205010a..7aeea6b 100644 --- a/dzgui/views/pages/options.py +++ b/dzgui/views/pages/options.py @@ -4,17 +4,6 @@ from typing import TYPE_CHECKING from dzgui.api import pefile as PeFile from dzgui.api.steam import find_user_id from dzgui.config import query -from dzgui.util import strings, css, open_links - -from dzgui.views.components.labels import LeftLabel -from dzgui.views.components.eventbox import InfoEventBox -from dzgui.views.components.buttons import WebButton -from dzgui.views.components.frame import HeadingFrame -from dzgui.views.dialogs.generic import ExceptionDialog -from dzgui.views.dialogs.link_dialog import WorkshopLinkDialog - -from dzgui.const.enum import Preferences -from dzgui.const.endpoints import STEAM_API_SETUP, BM_API_SETUP from dzgui.const.constants import ( APPID_DAYZ, APPID_DAYZ_EXP, @@ -29,6 +18,16 @@ from dzgui.const.constants import ( VIEW_CONCEAL, VIEW_REVEAL, ) +from dzgui.const.endpoints import STEAM_API_SETUP, BM_API_SETUP +from dzgui.const.enum import Preferences, ServerTab +from dzgui.util import strings, css, open_links +from dzgui.views.components.labels import LeftLabel +from dzgui.views.components.eventbox import InfoEventBox +from dzgui.views.components.buttons import WebButton +from dzgui.views.components.frame import HeadingFrame +from dzgui.views.dialogs.generic import ExceptionDialog +from dzgui.views.dialogs.link_dialog import WorkshopLinkDialog + import gi @@ -96,17 +95,36 @@ class Options(Gtk.Box): 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, NO_PADDING) + client_hbox = Gtk.Box(spacing=5, halign=Gtk.Align.START) + client_hbox.pack_start(self.client_combo, NO_EXPAND, NO_FILL, NO_PADDING) self.distance_toggle = self.make_binary_radio( strings.options.km, strings.options.mi, Preferences.DIST ) + combo_store = Gtk.ListStore(str, object) + tabs = ( + ("Server browser", ServerTab.BROWSER), + ("Saved servers", ServerTab.SAVED), + ("Recent", ServerTab.RECENT), + ("LAN", ServerTab.LAN), + ) + for tab in tabs: + combo_store.append(tab) + self.start_tab_combo = Gtk.ComboBox.new_with_model(combo_store) + renderer_text = Gtk.CellRendererText() + self.start_tab_combo.pack_start(renderer_text, True) + self.start_tab_combo.add_attribute(renderer_text, "text", 0) + self.start_tab_combo.set_active(0) + start_tab_hbox = Gtk.Box(spacing=5, halign=Gtk.Align.START) + start_tab_hbox.pack_start(self.start_tab_combo, NO_EXPAND, NO_FILL, NO_PADDING) + self.start_tab_combo.connect("changed", self._on_start_tab_changed) + pref_rows = [ - [LeftLabel(strings.options.client), hbox], + [LeftLabel(strings.options.client), client_hbox], [LeftLabel(strings.options.window_size), self.fullscreen_toggle], [LeftLabel(strings.options.distance), self.distance_toggle], + [LeftLabel("Start tab"), start_tab_hbox], [LeftLabel(strings.options.name), self.player_box], ] @@ -295,6 +313,12 @@ class Options(Gtk.Box): # TODO: unimplemented print("UNIMPLEMENTED") + def _on_start_tab_changed(self, combo: Gtk.ComboBoxText) -> None: + _iter = combo.get_active_iter() + enum = combo.get_model()[_iter][1] + index = enum.value + self.controller.update_config(Preferences.START_TAB, index) + def _on_client_changed(self, combo: Gtk.ComboBoxText) -> None: # TODO: use two columns or constants here, not strings client = combo.get_active_text() @@ -457,6 +481,9 @@ class Options(Gtk.Box): self.client_combo.set_active(active_combo) # active_combo = 0 + start_tab = self.controller.query_config(Preferences.START_TAB) + self.start_tab_combo.set_active(start_tab) + def _suppress_toggles(self, state: bool) -> None: for toggle in [ self.mod_install_toggle, diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index 02cc14e..febe125 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -2,7 +2,7 @@ import logging import threading from queue import Queue -from typing import Any, Optional, Self, Union +from typing import Any, Self, Union from warnings import deprecated from dzgui.api.servers import ping, Record diff --git a/tests/test_config.py b/tests/test_config.py index 0cdcb80..a596945 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -29,6 +29,7 @@ def keys(): "client", "ip_list", "use_miles", + "start_tab" ] From 52917e7631012fe37731166d4f93848e21d483c9 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Tue, 5 May 2026 19:07:14 +0900 Subject: [PATCH 234/309] change: shorter name field --- dzgui/views/pages/options.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/dzgui/views/pages/options.py b/dzgui/views/pages/options.py index 7aeea6b..687e721 100644 --- a/dzgui/views/pages/options.py +++ b/dzgui/views/pages/options.py @@ -82,6 +82,10 @@ class Options(Gtk.Box): self.player_box = self._make_submit_field( strings.options.name_placeholder, Preferences.NAME ) + self.player_box.set_halign(Gtk.Align.START) + # TODO: make submit field a standalone class + self.player_box.get_children()[0].set_width_chars(30) + self.fullscreen_toggle = self.make_binary_radio( strings.options.last_used, strings.options.always_fs, @@ -95,6 +99,7 @@ class Options(Gtk.Box): self.client_combo.append_text("Flatpak (container)") self.client_combo.set_active(0) self.client_combo.connect("changed", self._on_client_changed) + # TODO: short hbox class client_hbox = Gtk.Box(spacing=5, halign=Gtk.Align.START) client_hbox.pack_start(self.client_combo, NO_EXPAND, NO_FILL, NO_PADDING) From dcbc1fa716ecd57473e685c359f761ec7b7781ef Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Tue, 5 May 2026 21:39:35 +0900 Subject: [PATCH 235/309] feat: add CSS file --- dzgui/data/app.css | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 dzgui/data/app.css diff --git a/dzgui/data/app.css b/dzgui/data/app.css new file mode 100644 index 0000000..a2346a8 --- /dev/null +++ b/dzgui/data/app.css @@ -0,0 +1,33 @@ +.invalid-entry { + border-color: red; +} +.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 +} +.preconnect-heading { + font-size: 1.2rem; + font-weight: 800; +} From 5f8795acf786e1872244a016af14bf76d31604ec Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Tue, 5 May 2026 21:40:04 +0900 Subject: [PATCH 236/309] feat: preconnect dialog fields (WIP) --- dzgui/api/servers.py | 61 ++++++++++++++++++++----------- dzgui/const/constants.py | 2 + dzgui/managers/connection.py | 44 ++++++++++++++++------ dzgui/strings/preconnect.py | 7 ++++ dzgui/util/css.py | 38 +++---------------- dzgui/views/components/labels.py | 11 ++++++ dzgui/views/pages/preconnect.py | 63 +++++++++++++++++++++++--------- 7 files changed, 144 insertions(+), 82 deletions(-) create mode 100644 dzgui/strings/preconnect.py diff --git a/dzgui/api/servers.py b/dzgui/api/servers.py index fbc09e2..82d5e10 100644 --- a/dzgui/api/servers.py +++ b/dzgui/api/servers.py @@ -191,6 +191,7 @@ def parse_json(json: list) -> list: return rows + def query_direct(ip: str, qport: int, timeout: float = 3.0) -> dict | None: try: info = a2s.info((ip, qport), timeout) @@ -238,12 +239,12 @@ class Details: success: bool -@dataclass(slots=True, frozen=True) -class Prereqs: - password: bool - gameport: int - appid: Union[int, None] - version: Union[str, None] +#@dataclass(slots=True, frozen=True) +#class Prereqs: +# password: bool +# gameport: int +# appid: Union[int, None] +# version: Union[str, None] @dataclass(slots=True) @@ -256,19 +257,25 @@ class Record: gameport: int qport: int +@dataclass(frozen=True, slots=True) +class PreReqs: + record: Record + source: a2s.SourceInfo -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 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 get_details(record: Record) -> Details: @@ -443,13 +450,13 @@ def get_rules(record: Record) -> list["DayzMod"]: #return [] -def query_by_id(addr: str, key: str) -> dict[Any] | None: +def query_by_id(addr: str, key: str, full: bool = False) -> dict[Any] | None: """ Used with numeric Battlemetrics IDs """ try: resolved = map_id_to_record(key, addr) - return query_direct(resolved.ip, resolved.qport) + return query_direct(resolved.ip, resolved.qport, full) except Exception as e: logger.critical(e) return None @@ -470,12 +477,19 @@ def query_playercount(record: Record) -> tuple[int, int] | None: return None -def query_by_ip(addr: str) -> dict[Any] | None: +def query_by_ip(addr: str, full: bool = False) -> dict[Any] | None: record = short_ip_to_record(addr) - return query_by_record(record) + return query_by_record(record, full) +def query_by_record(record: Record, full: bool = False) -> dict[Any] | None: + if full: + try: + info = a2s.info((record.ip, record.qport), 3.0) + return PreReqs(record, info) + except Exception as e: + logger.critical(e) + return None -def query_by_record(record: Record) -> dict[Any] | None: try: return query_direct(record.ip, record.qport) except Exception as e: @@ -504,6 +518,11 @@ def response_to_fqip(res: dict) -> str: return f"{ip}:{gameport}:{qport}" +def source_info_to_record(res: a2s.SourceInfo) -> Record: + print(res) + return + return Record(ip, int(gameport), int(qport)) + def response_to_record(res: dict) -> Record: ip = res["addr"].split(":")[0] gameport = res["gameport"] diff --git a/dzgui/const/constants.py b/dzgui/const/constants.py index 105fcc5..c50f0fe 100644 --- a/dzgui/const/constants.py +++ b/dzgui/const/constants.py @@ -39,6 +39,7 @@ SEARCH_ICON = "system-search-symbolic" STEAM_ICON = "steam_tray_mono" VIEW_CONCEAL = "view-conceal-symbolic" VIEW_REVEAL = "view-reveal-symbolic" +ERROR = "dialog-error-symbolic" WARNING = "dialog-warning-symbolic" WEB_BROWSER = "web-browser-symbolic" @@ -61,5 +62,6 @@ LEGACY_IPS_PATH = ".local/share/dzgui/helpers/ips.csv" DEBUG_LOG = f"{APP_NAME}_DEBUG.LOG" SYSTEM_LOG = f"{APP_NAME}_SYSTEM.LOG" CHANGELOG_PATH = "data/CHANGELOG.md" +CSS_PATH = "data/app.css" LOG_FILTERS = ("CRITICAL", "WARNING", "INFO", "DEBUG") diff --git a/dzgui/managers/connection.py b/dzgui/managers/connection.py index 6e1cab3..e49489c 100644 --- a/dzgui/managers/connection.py +++ b/dzgui/managers/connection.py @@ -1,5 +1,9 @@ -from typing import Any, TYPE_CHECKING +import shutil +from pathlib import Path +from typing import Any, Union, TYPE_CHECKING + +import dzgui.api.pefile as PeFile import dzgui.api.servers as Servers from dzgui.api.mods import get_local_mod_ids @@ -15,6 +19,7 @@ gi.require_version("Gtk", "3.0") from gi.repository import Gtk # noqa E402 if TYPE_CHECKING: + from dzgui.api.servers import PreReqs from dzgui.controllers.mc import Controller @@ -26,35 +31,50 @@ class ConnectionManager: @call_on_thread(dialog.querying) def connect_by_id(self, addr: str, key: str) -> None: - res = Servers.query_by_id(addr, key) + res = Servers.query_by_id(addr, key, full=True) self._prepare_connection(res) @call_on_thread(dialog.querying) def connect_by_ip(self, addr: str) -> None: - res = Servers.query_by_ip(addr) + res = Servers.query_by_ip(addr, full=True) self._prepare_connection(res) @call_on_thread(dialog.querying) def connect_by_record(self, record: Servers.Record) -> None: - res = Servers.query_by_record(record) + res = Servers.query_by_record(record, full=True) self._prepare_connection(res) - def _prepare_connection(self, res: dict[Any] | None) -> None: + def _prepare_connection(self, res: Union["PreReqs", None]) -> None: + failure_func = StoredFunc(self._server_timeout) + if res is None: - self.thread_man.set_cleanup_func( - StoredFunc(self._server_timeout), destroy_first=True - ) + self.thread_man.set_cleanup_func(failure_func, destroy_first=True) return - record = Servers.response_to_record(res) + record = res.record + info = res.source + try: # TODO: proper error handling (currently returns empty list) mods = self._query_modlist(record) except Exception: - self.thread_man.set_cleanup_func( - StoredFunc(self._server_timeout), destroy_first=True - ) + self.thread_man.set_cleanup_func(failure_func, destroy_first=True) return + + # TODO: get missing mod diff + # TODO: get missing mod sizes + + try: + path = Path(self.controller.query_config(Preferences.DEFAULT)) + dayz_path = PeFile.get_pefile_path(path, info.game_id) + total, used, free = shutil.disk_usage(dayz_path) + free_mib = free / (1024**2) + print(free_mib) + except Exception: + # TODO: if this fails, need to show missing build warning + # logger.warning(e) + self.thread_man.set_cleanup_func(failure_func, destroy_first=True) + func = StoredFunc(self.controller.open_connection_assistant, res, mods) self.thread_man.set_cleanup_func(func, destroy_first=True) diff --git a/dzgui/strings/preconnect.py b/dzgui/strings/preconnect.py new file mode 100644 index 0000000..b2a522d --- /dev/null +++ b/dzgui/strings/preconnect.py @@ -0,0 +1,7 @@ +update_mods = "Update mods and connect" +back = "Back" +cancel = "Cancel" +warnings = "Warnings" +errors = "Errors" +mods = "Mods" +total_mods = "Total mods: " diff --git a/dzgui/util/css.py b/dzgui/util/css.py index 3c18249..44bf7cd 100644 --- a/dzgui/util/css.py +++ b/dzgui/util/css.py @@ -1,4 +1,9 @@ +from importlib import resources + +from dzgui.const.constants import APP_NAME_LOWER, CSS_PATH + import gi + gi.require_version("Gtk", "3.0") from gi.repository import Gtk, Gdk # noqa E402 @@ -18,38 +23,7 @@ def remove_class(widget: Gtk.Widget, label: str) -> None: def load_css() -> None: - # TODO: consider storing this in a data file - css = """ - .invalid-entry { - border-color: red; - } - .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 - } - """ + css = resources.read_text(APP_NAME_LOWER, CSS_PATH) prov = Gtk.CssProvider() prov.load_from_data(css.encode("ascii")) screen = Gdk.Screen.get_default() diff --git a/dzgui/views/components/labels.py b/dzgui/views/components/labels.py index 3a5c243..9ecc52a 100644 --- a/dzgui/views/components/labels.py +++ b/dzgui/views/components/labels.py @@ -21,3 +21,14 @@ class LeftLabel(Gtk.Label): halign=Gtk.Align.START, ) self.set_tooltip_text(tooltip) + + +class IconLabel(Gtk.Box): + def __init__(self, text: str, label: str) -> None: + super().__init__( + orientation=Gtk.Orientation.HORIZONTAL, spacing=10, margin_start=10 + ) + self.label = Gtk.Label(label=text, halign=Gtk.Align.START, margin_bottom=2) + self.icon = Gtk.Image.new_from_icon_name(label, Gtk.IconSize.BUTTON) + for el in self.icon, self.label: + self.add(el) diff --git a/dzgui/views/pages/preconnect.py b/dzgui/views/pages/preconnect.py index 4134743..731daa7 100644 --- a/dzgui/views/pages/preconnect.py +++ b/dzgui/views/pages/preconnect.py @@ -6,9 +6,14 @@ from dzgui.const.constants import ( APPID_DAYZ_EXP, APPNAME_DAYZ, APPNAME_DAYZ_EXP, + ERROR, + WARNING, ) from dayzquery import DayzMod +from dzgui.util.css import add_class +from dzgui.strings import preconnect from dzgui.views.components.frame import HeadingFrame +from dzgui.views.components.labels import IconLabel from dzgui.views.trees.tree_server_mods import ServerModTreeView import gi @@ -18,13 +23,23 @@ from gi.repository import Gtk # type: ignore # noqa E402 if TYPE_CHECKING: + from dzgui.api.servers import PreReqs from dzgui.controllers.mc import Controller -# TODO: pack entire box in scrolled window -class PreConnectionAssistant(Gtk.Box): +class WarningLabel(IconLabel): + def __init__(self, text: str) -> None: + super().__init__(text, WARNING) + + +class ErrorLabel(IconLabel): + def __init__(self, text: str) -> None: + super().__init__(text, ERROR) + + +class PreConnectionAssistant(Gtk.ScrolledWindow): def __init__(self, controller: "Controller") -> None: - super().__init__(orientation=Gtk.Orientation.VERTICAL) + super().__init__() self.controller = controller @@ -44,11 +59,11 @@ class PreConnectionAssistant(Gtk.Box): # TODO: strings # TODO: dynamic button text if no mods needed - self.back = Gtk.Button(label="Back", halign=Gtk.Align.START) + self.back = Gtk.Button(label=preconnect.back, halign=Gtk.Align.START) self.cancel = Gtk.Button( - label="Cancel", halign=Gtk.Align.END, sensitive=False, hexpand=True + label=preconnect.cancel, halign=Gtk.Align.END, sensitive=False, hexpand=True ) - self.ok = Gtk.Button(label="Update mods and connect", halign=Gtk.Align.END) + self.ok = Gtk.Button(label=preconnect.update_mods, halign=Gtk.Align.END) self.button_box = Gtk.Box( orientation=Gtk.Orientation.HORIZONTAL, @@ -63,9 +78,11 @@ class PreConnectionAssistant(Gtk.Box): self.ok.connect("clicked", self._on_ok_clicked) self.title = Gtk.Label(label="") + add_class(self.title, "preconnect-heading") self.tree = ServerModTreeView(self.controller) self.mod_count = Gtk.Label(label="", halign=Gtk.Align.START, margin_start=5) + # TODO: live count of remaining downloads # "Steam is downloading: {mod_name}" # mention whether manual or auto mod is active @@ -78,7 +95,7 @@ class PreConnectionAssistant(Gtk.Box): self.tree_box.add(self.scrolled) self.tree_box.add(self.mod_count) - self.tree_frame = HeadingFrame(self.tree_box, "Mods") + self.tree_frame = HeadingFrame(self.tree_box, preconnect.mods) self.warnings = [] # TODO: populate with strings and icons @@ -94,12 +111,23 @@ class PreConnectionAssistant(Gtk.Box): - steam is not running - server has password """ - frame = HeadingFrame(Gtk.Label(label="ITEM ONE"), "Warnings") + # TODO: abstract into components + warning_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + warning_box.add(WarningLabel("Warning 1")) + self.warning_frame = HeadingFrame(warning_box, preconnect.warnings) - self.add(self.title) - self.add(self.tree_frame) - self.add(frame) - self.add(self.button_box) + error_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + error_box.add(ErrorLabel("Error 1")) + self.error_frame = HeadingFrame(error_box, preconnect.errors) + + self.box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + self.box.add(self.title) + self.box.add(self.tree_frame) + self.box.add(self.warning_frame) + self.box.add(self.error_frame) + self.box.add(self.button_box) + + self.add(self.box) def _on_ok_clicked(self, button: Gtk.Button) -> None: # TODO: update mod store in place with spinner/toast @@ -110,10 +138,12 @@ class PreConnectionAssistant(Gtk.Box): page = self.controller.get_prior_page() self.controller.open_page(page) - def populate(self, res: dict[Any], mods: list["DayzMod"]) -> None: + def populate(self, res: "PreReqs", mods: list["DayzMod"]) -> None: self.tree.populate(mods) total = len(mods) - self.title.set_text(f"Connecting to {res["name"]}") + + name = res.source.server_name + self.title.set_text(name) if total < 1: self.tree_frame.set_visible(False) self.mod_count.set_visible(False) @@ -121,14 +151,13 @@ class PreConnectionAssistant(Gtk.Box): else: self.tree.set_visible(True) self.mod_count.set_visible(True) - self.mod_count.set_text(f"Total mods: {str(total)}") + prefix = preconnect.total_mods + self.mod_count.set_text(f"{prefix}{str(total)}") # TODO: check which mods need updating - # TODO: fourth column: needs update # steam_path = self.controller.get_config_man().lookup(Preferences.DEFAULT) # dayz_version = PeFile.get_pretty_version(steam_path, APPID_DAYZ) # dayz_exp_version = PeFile.get_pretty_version(steam_path, APPID_DAYZ_EXP) - pass def download_mods(self) -> None: pass From f3ad104b1ba6b6afe91ef32f4db7f44e3e80b6e1 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Tue, 5 May 2026 22:24:31 +0900 Subject: [PATCH 237/309] chore: additional string files --- dzgui/strings/errors.py | 3 +++ dzgui/strings/server_mods.py | 4 ++++ 2 files changed, 7 insertions(+) create mode 100644 dzgui/strings/errors.py create mode 100644 dzgui/strings/server_mods.py diff --git a/dzgui/strings/errors.py b/dzgui/strings/errors.py new file mode 100644 index 0000000..bc5c5fd --- /dev/null +++ b/dzgui/strings/errors.py @@ -0,0 +1,3 @@ +api_validation_error = ( + "API key validation error. Key was typed incorrectly or is defunct." +) diff --git a/dzgui/strings/server_mods.py b/dzgui/strings/server_mods.py new file mode 100644 index 0000000..c1a3106 --- /dev/null +++ b/dzgui/strings/server_mods.py @@ -0,0 +1,4 @@ +mod = "Mod" +mod_id = "ID" +installed = "Installed" +modlist = "Modlist" From e64feac425678db5689214e34c8b7e8bdca409c3 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Tue, 5 May 2026 22:46:16 +0900 Subject: [PATCH 238/309] chore: clear errors --- dzgui/api/mods.py | 30 +++++++++++++-------------- dzgui/api/servers.py | 7 ------- dzgui/controllers/mc.py | 4 ++-- dzgui/init/migrate.py | 3 +-- dzgui/main.py | 1 + dzgui/managers/connection.py | 8 ------- dzgui/managers/mods.py | 7 ------- dzgui/model/servers.py | 4 +--- dzgui/util/symlink.py | 7 +++---- dzgui/views/base.py | 9 +++++--- dzgui/views/components/buttonbox.py | 1 - dzgui/views/dialogs/generic.py | 4 ++-- dzgui/views/dialogs/server_mods.py | 2 +- dzgui/views/dialogs/servers.py | 18 ++++++++++------ dzgui/views/mixins/context_mixin.py | 2 -- dzgui/views/pages/options.py | 4 ++-- dzgui/views/pages/preconnect.py | 2 +- dzgui/views/trees/tree_menu.py | 1 - dzgui/views/trees/tree_server_mods.py | 14 ++++++++----- 19 files changed, 56 insertions(+), 72 deletions(-) diff --git a/dzgui/api/mods.py b/dzgui/api/mods.py index 49d5db8..11d0683 100644 --- a/dzgui/api/mods.py +++ b/dzgui/api/mods.py @@ -110,21 +110,21 @@ def get_missing_mods(local: list, remote: list) -> list: return [mod for mod in remote if mod not in local] -# FIXME: steam path is missing when comparing to local mods -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 +# TODO: drop +# 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: diff --git a/dzgui/api/servers.py b/dzgui/api/servers.py index 82d5e10..9b63496 100644 --- a/dzgui/api/servers.py +++ b/dzgui/api/servers.py @@ -436,18 +436,11 @@ def validate_ip(addr: str) -> Record: return record -# TODO: do not return empty list in event of failure def get_rules(record: Record) -> list["DayzMod"]: ip = record.ip qport = record.qport - #try: mods = dayzquery.dayz_rules((ip, qport)).mods return [mod for mod in mods] - #except Exception as e: - # logger.critical(e) - # raise e - # TODO: supposed to skip failing servers for highlight action - #return [] def query_by_id(addr: str, key: str, full: bool = False) -> dict[Any] | None: diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index baac8fe..fb77c01 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -37,7 +37,7 @@ logger = logging.getLogger(APP_NAME) if TYPE_CHECKING: from dayzquery import DayzMod - from dzgui.api.servers import Record + from dzgui.api.servers import PreReqs, Record from dzgui.const.enum import ServerTab from dzgui.managers.filter import FilterManager from dzgui.util.dist import Haversine @@ -458,7 +458,7 @@ class Controller(GObject.GObject): def set_exit_event(self) -> None: self.exit_event.set() - def open_connection_assistant(self, res: dict[Any], mods: list["DayzMod"]) -> None: + def open_connection_assistant(self, res: "PreReqs", mods: list["DayzMod"]) -> None: self.open_page(NotebookPage.CONNECTION) self.mediator.preconnect.populate(res, mods) # TODO: populate assistant diff --git a/dzgui/init/migrate.py b/dzgui/init/migrate.py index 7ee5f0c..1e20c21 100644 --- a/dzgui/init/migrate.py +++ b/dzgui/init/migrate.py @@ -11,8 +11,7 @@ def migrate_legacy_conf(config: Path) -> None: old_conf = Path.home() / LEGACY_CONFIG_PATH if old_conf.is_file(): j = rc2json(old_conf) - # FIXME: superfluous use of Path() - Path(config).parent.mkdir(parents=True, exist_ok=True) + config.parent.mkdir(parents=True, exist_ok=True) config.write_text(j) else: print("Unimplemented. You must have a working dztuirc.") diff --git a/dzgui/main.py b/dzgui/main.py index d729e80..5d50098 100644 --- a/dzgui/main.py +++ b/dzgui/main.py @@ -133,6 +133,7 @@ def main() -> None: is_dayz_running() is_steam_running() + # NOTE: clear versions file of unlinked mods rebuild_symlinks(XDG.config) remove_stale_signatures(XDG.config, XDG.version) diff --git a/dzgui/managers/connection.py b/dzgui/managers/connection.py index e49489c..25c9f37 100644 --- a/dzgui/managers/connection.py +++ b/dzgui/managers/connection.py @@ -55,7 +55,6 @@ class ConnectionManager: info = res.source try: - # TODO: proper error handling (currently returns empty list) mods = self._query_modlist(record) except Exception: self.thread_man.set_cleanup_func(failure_func, destroy_first=True) @@ -94,13 +93,6 @@ class ConnectionManager: mods = Servers.get_rules(record) steam_path = self.controller.query_config(Preferences.DEFAULT) local = get_local_mod_ids(steam_path) - # if len(mods) == 0: - # # TODO: separate message for no mods - # # TODO: separate message for actual timeout - # self.thread_man.set_cleanup_func( - # StoredFunc(self._server_timeout), destroy_first=True - # ) - # return alpha_mods = [ [ mod.name, diff --git a/dzgui/managers/mods.py b/dzgui/managers/mods.py index a44c3bc..f43e669 100644 --- a/dzgui/managers/mods.py +++ b/dzgui/managers/mods.py @@ -180,14 +180,7 @@ class ModManager: # TODO: strings @call_on_thread("working") def highlight_stale(self) -> None: - # try: stale = find_stale_mods(self.prefs.paths.config) - # except Exception: - # # TODO: clearer error message - # func = StoredFunc(self._server_timeout) - # self.thread_man.set_cleanup_func(func) - # return - func = StoredFunc(self._on_stale_mods_found, stale) self.thread_man.set_cleanup_func(func) diff --git a/dzgui/model/servers.py b/dzgui/model/servers.py index c666515..2fb6c63 100644 --- a/dzgui/model/servers.py +++ b/dzgui/model/servers.py @@ -328,10 +328,8 @@ class ServerModelManager: ips = config_man.lookup(Preferences.IP_LIST) self.thread_man.set_job_count(len(ips)) - # TODO: customize statusbar to mention how records can be added via contextmenu if len(ips) == 0: - # FIXME: this is not a failure, just a quiet exit with custom statusbar - # TODO: add custom statusbar parameters + # TODO: customize statusbar to mention how records can be added via contextmenu self.thread_man.set_cleanup_func( StoredFunc(self._cleanup_on_failure, False) ) diff --git a/dzgui/util/symlink.py b/dzgui/util/symlink.py index 984a0d8..1665441 100644 --- a/dzgui/util/symlink.py +++ b/dzgui/util/symlink.py @@ -28,19 +28,18 @@ def rebuild_symlinks(config: Path) -> None: source = Path(dayz_path / md5sum) if source.exists() is False: source.symlink_to(workshop / uid) + clone_symlinks(steam_path) -def clone_symlinks(config: Path) -> None: +def clone_symlinks(steam_path: 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) + logger.warning(e) return # TODO: test these two operations diff --git a/dzgui/views/base.py b/dzgui/views/base.py index 00fc40c..7c4a2a6 100644 --- a/dzgui/views/base.py +++ b/dzgui/views/base.py @@ -161,15 +161,18 @@ class Notebook(ScrollableMixin, Gtk.Notebook): # type: ignore self.connect_after("switch-page", self._on_page_changed) self.connect("key-press-event", self._on_keypress) - def _on_keypress(self, widget: Gtk.Widget, event: Gdk.EventKey) -> None: + def _on_keypress(self, widget: Gtk.Widget, event: Gdk.EventKey) -> bool: match event.keyval: case Gdk.KEY_Right | Gdk.KEY_l: - # FIXME: propagation bool if event.state is Gdk.ModifierType.CONTROL_MASK: - return + return False MainController.get_emitter().emit("request_button_box_focus") + return True case Gdk.KEY_question: self.toggle_keybindings() + return True + case _: + return False def return_prior(self) -> None: """ diff --git a/dzgui/views/components/buttonbox.py b/dzgui/views/components/buttonbox.py index 2e81c5b..f14dfcd 100644 --- a/dzgui/views/components/buttonbox.py +++ b/dzgui/views/components/buttonbox.py @@ -53,7 +53,6 @@ class ButtonBox(Gtk.Box): 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) diff --git a/dzgui/views/dialogs/generic.py b/dzgui/views/dialogs/generic.py index 79f2363..0fd2816 100644 --- a/dzgui/views/dialogs/generic.py +++ b/dzgui/views/dialogs/generic.py @@ -126,7 +126,6 @@ class WaitDialog(GenericDialog): class QuitDialog(GenericDialog): def __init__(self, controller: "Controller", secondary: str): - # FIXME: do not pass controller to every dialog super().__init__( controller=controller, text=strings.wait, @@ -173,6 +172,7 @@ class ExceptionDialog(GenericDialog): propagate_natural_height=True, max_content_height=500 ) box = Gtk.Box(hexpand=True, vexpand=True, orientation=Gtk.Orientation.VERTICAL) + # TODO: wrap/truncate long messages textview = Gtk.TextView( wrap_mode=Gtk.WrapMode.WORD, editable=False, left_margin=10, right_margin=10 ) @@ -182,7 +182,7 @@ class ExceptionDialog(GenericDialog): content = self.get_content_area() content.set_spacing(0) - # FIXME: padding around top of content area when traceback is long + # TODO: padding around top of content area when traceback is long content.add(scrollable) copy_button = ClipboardButton(controller, self.get_trace) diff --git a/dzgui/views/dialogs/server_mods.py b/dzgui/views/dialogs/server_mods.py index c00c16b..63e967d 100644 --- a/dzgui/views/dialogs/server_mods.py +++ b/dzgui/views/dialogs/server_mods.py @@ -60,7 +60,7 @@ class ServerModDialog(GenericDialog): dialogBox.pack_end(self.scrollable, EXPAND, FILL, 0) mod_count = len(mods) - self.set_markup(f"Modlist ({mod_count} mods") + self.set_markup(f"Modlist ({mod_count} mods)") for mod in mods: self.mod_store.append(mod) diff --git a/dzgui/views/dialogs/servers.py b/dzgui/views/dialogs/servers.py index 903cebb..c2089ea 100644 --- a/dzgui/views/dialogs/servers.py +++ b/dzgui/views/dialogs/servers.py @@ -1,10 +1,11 @@ from typing import Self, TYPE_CHECKING -from dzgui.const.constants import EXPAND, FILL, NO_PADDING +from dzgui.const.constants import EXPAND, FILL from dzgui.model.model_factory import ModelFactory from dzgui.util import css from dzgui.util import strings from dzgui.util.format import format_hyperlinks, format_server_mods +from dzgui.strings import server_mods from dzgui.views.dialogs.generic import GenericDialog from dzgui.views.trees.tree_base import TreeView @@ -121,7 +122,7 @@ class ServerModDialog(ServerDialog): def __init__(self, controller: "Controller", mods: list[list[str]]): name = controller.get_server_name() - super().__init__(controller, strings.modlist, name) + super().__init__(controller, server_mods.modlist, name) self.controller = controller self.mod_store = ModelFactory().make_server_mod_store() @@ -130,17 +131,22 @@ class ServerModDialog(ServerDialog): self.view.set_model(self.mod_store) self.view.connect("row-activated", self._on_row_activated) - for i, column_title in enumerate(strings.server_mod_cols): + # TODO: inherit from ServerModTreeView + columns = [ + server_mods.mod, + server_mods.mod_id, + server_mods.installed, + ] + for i, column_title in enumerate(columns): 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) - # FIXME: do not recycle generic string vars match column_title: - case strings.mod: + case server_mods.mod: column.set_fixed_width(350) - case strings._id: + case server_mods.mod_id: column.set_fixed_width(200) case _: pass diff --git a/dzgui/views/mixins/context_mixin.py b/dzgui/views/mixins/context_mixin.py index de07fc4..be654d2 100644 --- a/dzgui/views/mixins/context_mixin.py +++ b/dzgui/views/mixins/context_mixin.py @@ -82,7 +82,6 @@ class ContextMixin(TreeView): (path, col, cellx, celly) = pathinfo if path is None: return True - # return True selection = self.get_selection() model, selected_paths = selection.get_selected_rows() if path not in selected_paths: @@ -90,7 +89,6 @@ class ContextMixin(TreeView): selection.unselect_path(p) self.set_cursor(path, col, False) return True - # FIXME: if selection is not multiple, change cursor except AttributeError: return False diff --git a/dzgui/views/pages/options.py b/dzgui/views/pages/options.py index 687e721..3bd54c0 100644 --- a/dzgui/views/pages/options.py +++ b/dzgui/views/pages/options.py @@ -20,6 +20,7 @@ from dzgui.const.constants import ( ) from dzgui.const.endpoints import STEAM_API_SETUP, BM_API_SETUP from dzgui.const.enum import Preferences, ServerTab +from dzgui.strings import errors from dzgui.util import strings, css, open_links from dzgui.views.components.labels import LeftLabel from dzgui.views.components.eventbox import InfoEventBox @@ -300,8 +301,7 @@ class Options(Gtk.Box): def _on_api_change_failed(self, emitter: "Emitter") -> None: self.old_entry.set_text(self.old_text) - # FIXME: ambiguous error message - dialog = ExceptionDialog(self.controller, strings.api_error) + dialog = ExceptionDialog(self.controller, errors.api_validation_error) dialog.run() def restore_api_text(self, text: str, entry: Gtk.Entry) -> None: diff --git a/dzgui/views/pages/preconnect.py b/dzgui/views/pages/preconnect.py index 731daa7..d989165 100644 --- a/dzgui/views/pages/preconnect.py +++ b/dzgui/views/pages/preconnect.py @@ -19,7 +19,7 @@ from dzgui.views.trees.tree_server_mods import ServerModTreeView import gi gi.require_version("Gtk", "3.0") -from gi.repository import Gtk # type: ignore # noqa E402 +from gi.repository import Gdk, Gtk # type: ignore # noqa E402 if TYPE_CHECKING: diff --git a/dzgui/views/trees/tree_menu.py b/dzgui/views/trees/tree_menu.py index d6a3e32..77af954 100644 --- a/dzgui/views/trees/tree_menu.py +++ b/dzgui/views/trees/tree_menu.py @@ -47,7 +47,6 @@ class MenuTreeView(TreeView): def _parent_row_activated( self, tree: TreeView, path: Gtk.TreePath, column: Gtk.TreeViewColumn ) -> None: - # FIXME: may be expected to return str, not enum row_type = self.get_value_at_index(1) match row_type: diff --git a/dzgui/views/trees/tree_server_mods.py b/dzgui/views/trees/tree_server_mods.py index afec950..edeba08 100644 --- a/dzgui/views/trees/tree_server_mods.py +++ b/dzgui/views/trees/tree_server_mods.py @@ -1,8 +1,8 @@ from dzgui.const.enum import ContextMenuGroup from dzgui.model.model_factory import ModelFactory +from dzgui.strings import server_mods from dzgui.views.mixins.context_mixin import ContextMixin from dzgui.views.trees.tree_base import TreeView -from dzgui.util import strings from typing import TYPE_CHECKING @@ -29,17 +29,21 @@ class ServerModTreeView(ContextMixin, TreeView): # type: ignore self.connect("key-press-event", self.present_menu) self.connect("row-activated", self._on_row_activated) - for i, column_title in enumerate(strings.server_mod_cols): + columns = [ + server_mods.mod, + server_mods.mod_id, + server_mods.installed, + ] + for i, column_title in enumerate(columns): renderer = Gtk.CellRendererText(ellipsize=Pango.EllipsizeMode.END) column = Gtk.TreeViewColumn(column_title, renderer, text=i) column.set_sizing(Gtk.TreeViewColumnSizing.FIXED) self.append_column(column) column.set_sort_column_id(i) - # FIXME: do not recycle generic string vars match column_title: - case strings.mod: + case server_mods.mod: column.set_fixed_width(350) - case strings._id: + case server_mods.mod_id: column.set_fixed_width(200) case _: pass From 25640751a588b43e0abda5ddc2e2edea269edf67 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Tue, 5 May 2026 23:37:31 +0900 Subject: [PATCH 239/309] chore: clear typehinting errors --- dzgui/api/mods.py | 21 +-------------------- dzgui/api/servers.py | 13 ++++--------- dzgui/api/steam.py | 4 ++-- dzgui/config/query.py | 2 +- dzgui/controllers/mc.py | 11 ++++------- dzgui/managers/connection.py | 8 ++++---- dzgui/model/proxy_model.py | 22 ++++++++++++++-------- dzgui/model/servers.py | 6 ++++-- dzgui/util/ip.py | 4 ++-- dzgui/views/base.py | 3 +-- dzgui/views/components/buttons.py | 4 ++-- dzgui/views/components/frame.py | 4 ++-- dzgui/views/components/mod_panel.py | 4 ++-- dzgui/views/components/statusbar.py | 2 +- dzgui/views/mixins/context_mixin.py | 2 +- dzgui/views/mixins/cursor_mixin.py | 8 ++++---- dzgui/views/mixins/help_menu_mixin.py | 7 +++---- dzgui/views/mixins/mods_mixin.py | 13 ++++++------- dzgui/views/mixins/scrollable_mixin.py | 13 ++++++++++--- dzgui/views/pages/keys.py | 25 ++++++++++--------------- dzgui/views/pages/preconnect.py | 8 ++++---- dzgui/views/trees/tree_base.py | 5 +++-- dzgui/views/trees/tree_servers.py | 2 +- 23 files changed, 86 insertions(+), 105 deletions(-) diff --git a/dzgui/api/mods.py b/dzgui/api/mods.py index 11d0683..29c154d 100644 --- a/dzgui/api/mods.py +++ b/dzgui/api/mods.py @@ -1,4 +1,3 @@ -import dayzquery import hashlib import logging import shlex @@ -9,7 +8,7 @@ from dataclasses import dataclass from pathlib import Path import dzgui.api.pefile as PeFile -from dzgui.api.servers import Record, get_rules, fqip_to_record +from dzgui.api.servers import get_rules, fqip_to_record from dzgui.const.constants import ( APP_NAME, APPID_DAYZ, @@ -17,7 +16,6 @@ from dzgui.const.constants import ( WORKSHOP_PATH, ) -from dzgui.util.strings import checkmark from dzgui.config.query import lookup from dzgui.const.enum import Preferences @@ -110,23 +108,6 @@ def get_missing_mods(local: list, remote: list) -> list: return [mod for mod in remote if mod not in local] -# TODO: drop -# 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")) diff --git a/dzgui/api/servers.py b/dzgui/api/servers.py index 9b63496..552a8ef 100644 --- a/dzgui/api/servers.py +++ b/dzgui/api/servers.py @@ -443,12 +443,12 @@ def get_rules(record: Record) -> list["DayzMod"]: return [mod for mod in mods] -def query_by_id(addr: str, key: str, full: bool = False) -> dict[Any] | None: +def query_by_id(server_id: int, key: str, full: bool = False) -> dict[Any] | None: """ Used with numeric Battlemetrics IDs """ try: - resolved = map_id_to_record(key, addr) + resolved = map_id_to_record(key, server_id) return query_direct(resolved.ip, resolved.qport, full) except Exception as e: logger.critical(e) @@ -470,11 +470,11 @@ def query_playercount(record: Record) -> tuple[int, int] | None: return None -def query_by_ip(addr: str, full: bool = False) -> dict[Any] | None: +def query_by_ip(addr: str, full: bool = False) -> dict[str, Any] | PreReqs | None: record = short_ip_to_record(addr) return query_by_record(record, full) -def query_by_record(record: Record, full: bool = False) -> dict[Any] | None: +def query_by_record(record: Record, full: bool = False) -> dict[str, Any] | PreReqs | None: if full: try: info = a2s.info((record.ip, record.qport), 3.0) @@ -511,11 +511,6 @@ def response_to_fqip(res: dict) -> str: return f"{ip}:{gameport}:{qport}" -def source_info_to_record(res: a2s.SourceInfo) -> Record: - print(res) - return - return Record(ip, int(gameport), int(qport)) - def response_to_record(res: dict) -> Record: ip = res["addr"].split(":")[0] gameport = res["gameport"] diff --git a/dzgui/api/steam.py b/dzgui/api/steam.py index 699e004..bbab06f 100644 --- a/dzgui/api/steam.py +++ b/dzgui/api/steam.py @@ -83,7 +83,7 @@ def find_user_id(path: Path) -> str | None: j = json.loads(vdf) for user in j["users"]: if j["users"][user]["MostRecent"] == "1": - return user + return str(user) return None except Exception as e: logger.warn(e) @@ -91,7 +91,7 @@ def find_user_id(path: Path) -> str | None: def vdf2json(path: Path) -> str: - def _istr(indent, string): + def _istr(indent: int, string: str): return (indent * " ") + string jbuf = "{\n" diff --git a/dzgui/config/query.py b/dzgui/config/query.py index dc544d1..9c836cd 100644 --- a/dzgui/config/query.py +++ b/dzgui/config/query.py @@ -48,7 +48,7 @@ def get_favorites(path: Path) -> list[str]: def enum_to_key(enum: Preferences) -> str: - return enum.dict["key"] + return str(enum.dict["key"]) def get_client_index(client: str) -> int: diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index fb77c01..eabdeff 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -46,6 +46,7 @@ if TYPE_CHECKING: from dzgui.views.components.filter_panel import FilterPanel from dzgui.views.components.right_panel import RightPanel from dzgui.views.components.statusbar import Statusbar + from dzgui.views.pages.preconnect import PreConnectionAssistant from dzgui.views.pages.servers import ServerNotebook from dzgui.views.trees.tree_log import LogTreeView from dzgui.views.trees.tree_menu import MenuTreeView @@ -199,7 +200,6 @@ class Controller(GObject.GObject): except Exception: return case ButtonType.MODS: - # TODO: reload using refresh button, rather than on demand? self.load_mods() self.open_page(button.opens) @@ -393,7 +393,7 @@ class Controller(GObject.GObject): if addr.isdigit(): config_man = self.get_config_man() key = config_man.lookup(Preferences.BM) - ConnectionManager(self).connect_by_id(addr, key) + ConnectionManager(self).connect_by_id(int(addr), key) else: ConnectionManager(self).connect_by_ip(addr) @@ -452,7 +452,7 @@ class Controller(GObject.GObject): def get_modtreeview(self) -> "ModTreeView": return self.mediator.modtreeview - def get_exit_event(self) -> None: + def get_exit_event(self) -> threading.Event: return self.exit_event def set_exit_event(self) -> None: @@ -461,10 +461,7 @@ class Controller(GObject.GObject): def open_connection_assistant(self, res: "PreReqs", mods: list["DayzMod"]) -> None: self.open_page(NotebookPage.CONNECTION) self.mediator.preconnect.populate(res, mods) - # TODO: populate assistant - # TODO: embed dialogs - # dialog = ServerModDialog(self, mods) - def set_start_tab(self) -> int: + def set_start_tab(self) -> None: ind = self.config_man.get_start_tab() self.get_servers().notebook.set_current_page(ind) diff --git a/dzgui/managers/connection.py b/dzgui/managers/connection.py index 25c9f37..0715568 100644 --- a/dzgui/managers/connection.py +++ b/dzgui/managers/connection.py @@ -1,7 +1,7 @@ import shutil from pathlib import Path -from typing import Any, Union, TYPE_CHECKING +from typing import Union, TYPE_CHECKING import dzgui.api.pefile as PeFile import dzgui.api.servers as Servers @@ -30,8 +30,8 @@ class ConnectionManager: self.thread_man = ThreadingManager(controller) @call_on_thread(dialog.querying) - def connect_by_id(self, addr: str, key: str) -> None: - res = Servers.query_by_id(addr, key, full=True) + def connect_by_id(self, _id: int, key: str) -> None: + res = Servers.query_by_id(_id, key, full=True) self._prepare_connection(res) @call_on_thread(dialog.querying) @@ -89,7 +89,7 @@ class ConnectionManager: StoredFunc(self._present_details_dialog, details), destroy_first=True ) - def _query_modlist(self, record: Servers.Record) -> None: + def _query_modlist(self, record: Servers.Record) -> list[list[str]]: mods = Servers.get_rules(record) steam_path = self.controller.query_config(Preferences.DEFAULT) local = get_local_mod_ids(steam_path) diff --git a/dzgui/model/proxy_model.py b/dzgui/model/proxy_model.py index 472bf1a..623590e 100644 --- a/dzgui/model/proxy_model.py +++ b/dzgui/model/proxy_model.py @@ -1,6 +1,6 @@ import re -from typing import Any, Optional, TYPE_CHECKING +from typing import Any, Optional, TYPE_CHECKING, Union from warnings import deprecated from dzgui.const.enum import FilterMode @@ -14,6 +14,7 @@ if TYPE_CHECKING: from dzgui.model.servers import NewPlayerCount +# TODO: annotate list contents (list[list[Any]]) class ProxyModelManager: """ Manages access to cached FastInsertListStore resources and @@ -34,7 +35,7 @@ class ProxyModelManager: # TODO: list typehints self.control_model: list | None = None - self.filtered: list | None = None + self.filtered: list = [] self.success = True def has_control_model(self) -> bool: @@ -60,6 +61,8 @@ class ProxyModelManager: # self.control_model.append(row) def remove_row_from_control(self, record: "Record") -> None: + if self.control_model is None: + return addr = f"{record.ip}:{record.gameport}" qport = record.qport for row in self.control_model: @@ -75,7 +78,9 @@ class ProxyModelManager: def get_proxy_model(self) -> "FastInsertListStore": return self.proxy_model - def filter(self, mode: FilterMode, skip_cache: bool = False) -> None: + def filter( + self, mode: FilterMode, skip_cache: bool = False + ) -> Union["FastInsertListStore", None]: """ Native Gtk.TreeView.refilter() method was not performant enough when running in the main loop with 40k+ records @@ -91,7 +96,7 @@ class ProxyModelManager: cache = self.filter_cache[filters] self.set_proxy_model(cache[0]) self.set_filtered(cache[1]) - return + return None match mode: case FilterMode.INITIAL: @@ -224,10 +229,11 @@ class ProxyModelManager: def convert_model_to_list(self, model: "FastInsertListStore") -> list: return [[el for el in row] for row in model] - def set_filtered(self, rows: Optional[list]) -> None: + def set_filtered(self, rows: list | None) -> None: if rows is None: - rows = [] - self.filtered = rows + self.filtered = [] + else: + self.filtered = rows def get_filtered(self) -> list: return self.filtered @@ -248,7 +254,7 @@ class ProxyModelManager: return self.control_model def wipe_cache(self, full: bool = False) -> None: - self.filtered = None + self.filtered = [] self.filter_cache = {} def push(self, data: list[Any]) -> None: diff --git a/dzgui/model/servers.py b/dzgui/model/servers.py index 2fb6c63..a88502a 100644 --- a/dzgui/model/servers.py +++ b/dzgui/model/servers.py @@ -187,10 +187,10 @@ class ServerModelManager: self._push_data(parsed) @call_on_thread(dialog.querying) - def add_by_id(self, addr: str) -> None: + def add_by_id(self, _id: str) -> None: config_man = self.controller.get_config_man() key = config_man.lookup(Preferences.BM) - res = Servers.query_by_id(addr, key) + res = Servers.query_by_id(int(_id), key) self._parse_single_record(res) @call_on_thread(dialog.querying) @@ -266,6 +266,8 @@ class ServerModelManager: # NOTE: expected to only contain one item records = Servers.parse_json([response]) server = records[0] + if server is None: + return proxy_man = self._get_proxy_man() config_man = self.controller.get_config_man() diff --git a/dzgui/util/ip.py b/dzgui/util/ip.py index ae3f9d8..d2edf89 100644 --- a/dzgui/util/ip.py +++ b/dzgui/util/ip.py @@ -62,9 +62,9 @@ def get_coords(ips: "Path", ip: str) -> Coords: if proc.returncode != 0: raise GeolocationError("Failed to split records") - ips = proc.stdout.splitlines() + ip_list = proc.stdout.splitlines() - for address in ips: + for address in ip_list: fields = address.split(",") upper = fields[1] diff --git a/dzgui/views/base.py b/dzgui/views/base.py index 7c4a2a6..899a715 100644 --- a/dzgui/views/base.py +++ b/dzgui/views/base.py @@ -194,11 +194,10 @@ class Notebook(ScrollableMixin, Gtk.Notebook): # type: ignore def get_prior_page(self) -> NotebookPage: return self.prior_page - def get_page_by_enum(self) -> NotebookPage | None: + def get_page_by_enum(self) -> NotebookPage: 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() diff --git a/dzgui/views/components/buttons.py b/dzgui/views/components/buttons.py index 390c0dc..399130a 100644 --- a/dzgui/views/components/buttons.py +++ b/dzgui/views/components/buttons.py @@ -117,13 +117,13 @@ class RefreshButton(IconTextButton): self.show_time(True) GLib.timeout_add_seconds(1, self.decrement) - def decrement(self) -> type[GLib.SOURCE_REMOVE] | Literal[True]: + def decrement(self) -> bool: self.time -= 1 if self.time == 0: self.time = 30 self.show_time(False) self.set_sensitive(True) - return GLib.SOURCE_REMOVE + return False self.show_time(True) return True diff --git a/dzgui/views/components/frame.py b/dzgui/views/components/frame.py index d08c16d..c782d5f 100644 --- a/dzgui/views/components/frame.py +++ b/dzgui/views/components/frame.py @@ -1,17 +1,17 @@ -from dzgui.util import strings, css, open_links +from dzgui.util import css import gi gi.require_version("Gtk", "3.0") from gi.repository import Gtk # noqa + class HeadingFrame(Gtk.Box): def __init__(self, widget: Gtk.Widget, heading: str) -> None: super().__init__(orientation=Gtk.Orientation.VERTICAL) label = Gtk.Label(label=heading) label.set_halign(Gtk.Align.START) - # TODO: rename selector css.add_class(label, "settings-subheading") frame = Gtk.Frame(hexpand=True) diff --git a/dzgui/views/components/mod_panel.py b/dzgui/views/components/mod_panel.py index 76298a6..e8b1372 100644 --- a/dzgui/views/components/mod_panel.py +++ b/dzgui/views/components/mod_panel.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING +from typing import Self, TYPE_CHECKING from dzgui.const.enum import ModButton from dzgui.const.constants import NO_EXPAND, FILL, NO_PADDING @@ -64,7 +64,7 @@ class ModSelectionPanel(Gtk.Box): self.connect("map", self._reinit_button) - def _reinit_button(self, s) -> None: + def _reinit_button(self, widget: Self) -> None: for child in self.stale_panel.get_children(): if child.enum == ModButton.UNHIGHLIGHT_STALE: child.set_sensitive(False) diff --git a/dzgui/views/components/statusbar.py b/dzgui/views/components/statusbar.py index b334f0e..4bf04d1 100644 --- a/dzgui/views/components/statusbar.py +++ b/dzgui/views/components/statusbar.py @@ -131,7 +131,7 @@ class Statusbar(Gtk.Grid): def get_text(self) -> str: area = self.statusbar.get_message_area() label = area.get_children()[0] - return label.get_text() + return str(label.get_text()) def set_by_context( self, context: Union[NotebookPage, "ServerTab"], string: str diff --git a/dzgui/views/mixins/context_mixin.py b/dzgui/views/mixins/context_mixin.py index be654d2..a384c1f 100644 --- a/dzgui/views/mixins/context_mixin.py +++ b/dzgui/views/mixins/context_mixin.py @@ -66,7 +66,7 @@ class ContextMixin(TreeView): item.connect("activate", self._on_menu_click, row) if row == ContextMenu.SHOW_MODS: - item.set_sensitive(self.is_modded()) + item.set_sensitive(self.is_modded()) # type: ignore if row == ContextMenu.ADD_NOTE: if self.controller.has_note(): diff --git a/dzgui/views/mixins/cursor_mixin.py b/dzgui/views/mixins/cursor_mixin.py index dd7d2ac..58087e9 100644 --- a/dzgui/views/mixins/cursor_mixin.py +++ b/dzgui/views/mixins/cursor_mixin.py @@ -21,13 +21,13 @@ class CursorMixin: case Gdk.KEY_l | Gdk.KEY_Right: if event.state is Gdk.ModifierType.CONTROL_MASK: return - self.emitter.emit("request_button_box_focus") + self.emitter.emit("request_button_box_focus") # type: ignore case _: return False def _move_cursor(self, position: CursorPosition) -> bool: - cur_row = self.get_focused_row_index() - model = self.get_model() + cur_row = self.get_focused_row_index() # type: ignore + model = self.get_model() # type: ignore if model: end = len(model) - 1 else: @@ -55,5 +55,5 @@ class CursorMixin: dest = end path = Gtk.TreePath.new_from_indices([dest]) - self.set_cursor(path) + self.set_cursor(path) # type: ignore return True diff --git a/dzgui/views/mixins/help_menu_mixin.py b/dzgui/views/mixins/help_menu_mixin.py index 1af135d..85a5361 100644 --- a/dzgui/views/mixins/help_menu_mixin.py +++ b/dzgui/views/mixins/help_menu_mixin.py @@ -7,10 +7,9 @@ from gi.repository import Gtk, Gdk # noqa class HelpMenuMixin: - def _on_esc_keypress( - self, widget: Gtk.Widget, event: Gdk.EventKey - ) -> Literal[True]: + def _on_esc_keypress(self, widget: Gtk.Widget, event: Gdk.EventKey) -> bool: if event.keyval == Gdk.KEY_Escape: - prior = self.controller.get_prior_page() + prior = self.controller.get_prior_page() # type: ignore self.controller.open_page(prior) # type: ignore return True + return False diff --git a/dzgui/views/mixins/mods_mixin.py b/dzgui/views/mixins/mods_mixin.py index 638b8db..8cce682 100644 --- a/dzgui/views/mixins/mods_mixin.py +++ b/dzgui/views/mixins/mods_mixin.py @@ -1,16 +1,15 @@ 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: + 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) + path = self.get_focused_row_path() # type: ignore + self.controller.open_mod_page(path) # type: ignore diff --git a/dzgui/views/mixins/scrollable_mixin.py b/dzgui/views/mixins/scrollable_mixin.py index 775a522..79d83b8 100644 --- a/dzgui/views/mixins/scrollable_mixin.py +++ b/dzgui/views/mixins/scrollable_mixin.py @@ -8,21 +8,28 @@ from gi.repository import Gtk, Gdk # noqa class ScrollableMixin: - def _on_keypress(self, widget: Gtk.Widget, event: Gdk.EventKey) -> None: + def _on_keypress(self, widget: Gtk.Widget, event: Gdk.EventKey) -> bool: match event.keyval: case Gdk.KEY_k | Gdk.KEY_Up: self._set_adjustment(VAdjustment.UP) + return True case Gdk.KEY_Down | Gdk.KEY_j: self._set_adjustment(VAdjustment.DOWN) + return True case Gdk.KEY_g: self._set_adjustment(VAdjustment.TOP) + return True case Gdk.KEY_G: self._set_adjustment(VAdjustment.BOTTOM) + return True case Gdk.KEY_l | Gdk.KEY_Right: - self.controller.get_emitter().emit("request_button_box_focus") + self.controller.get_emitter().emit("request_button_box_focus") # type: ignore + return True + case _: + return False def _set_adjustment(self, adjustment: VAdjustment) -> None: - vadj = self.get_vadjustment() + vadj = self.get_vadjustment() # type: ignore match adjustment: case VAdjustment.TOP: adj = vadj.get_lower() diff --git a/dzgui/views/pages/keys.py b/dzgui/views/pages/keys.py index 46ef945..0c75c32 100644 --- a/dzgui/views/pages/keys.py +++ b/dzgui/views/pages/keys.py @@ -5,17 +5,20 @@ from dzgui.util.strings import navigation, servers, vim, key_header, key_context from dzgui.util.css import add_class import gi + gi.require_version("Gtk", "3.0") -from gi.repository import Gtk, Gdk # noqa E402 +from gi.repository import Gtk # noqa E402 if TYPE_CHECKING: from dzgui.controllers.mc import Controller -class Keybindings(ScrollableMixin, Gtk.ScrolledWindow): + +class Keybindings(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore """ Notebook page holding a prearranged grid of keybindings and their descriptions """ + def __init__(self, controller: "Controller") -> None: super().__init__() @@ -57,9 +60,7 @@ class Keybindings(ScrollableMixin, Gtk.ScrolledWindow): if not sep: grid.attach(desc, col, row, w, h) else: - grid.attach_next_to( - desc, sep, Gtk.PositionType.BOTTOM, w, h - ) + 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) @@ -67,18 +68,14 @@ class Keybindings(ScrollableMixin, Gtk.ScrolledWindow): 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 - ) + 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 - ) + sidebar = Gtk.Grid(row_homogeneous=True, orientation=Gtk.Orientation.VERTICAL) for cat in categories: label = Gtk.Label(label=cat) add_class(label, "left-label") @@ -96,9 +93,7 @@ class Keybindings(ScrollableMixin, Gtk.ScrolledWindow): row, column = 1, 1 w, h = 1, 1 - sidebar = self.build_sidebar( - key_contexts - ) + sidebar = self.build_sidebar(key_contexts) separator = Gtk.Separator() keys_box = self.build_keys(items) @@ -106,6 +101,6 @@ class Keybindings(ScrollableMixin, Gtk.ScrolledWindow): 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 - + def grab_content_area(self) -> None: self.box.grab_focus() diff --git a/dzgui/views/pages/preconnect.py b/dzgui/views/pages/preconnect.py index d989165..a0ad525 100644 --- a/dzgui/views/pages/preconnect.py +++ b/dzgui/views/pages/preconnect.py @@ -52,8 +52,8 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): spacing=10, ) - self.rules: dict[Any] - self.mods: list["DayzMod"] + # self.rules: dict[Any] + # self.mods: list["DayzMod"] self.controller.register_widget("preconnect", self) @@ -97,7 +97,7 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): self.tree_frame = HeadingFrame(self.tree_box, preconnect.mods) - self.warnings = [] + self.warnings: list[WarningLabel] = [] # TODO: populate with strings and icons # set visibility if warnings > 1 # warning category enums with matching strings @@ -162,7 +162,7 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): def download_mods(self) -> None: pass - def connect(self) -> None: + def connect_server(self) -> None: # TODO: add to history file and list store """ spawn dialog in thread diff --git a/dzgui/views/trees/tree_base.py b/dzgui/views/trees/tree_base.py index be14d36..7f00d99 100644 --- a/dzgui/views/trees/tree_base.py +++ b/dzgui/views/trees/tree_base.py @@ -134,7 +134,8 @@ class TreeView(CursorMixin, Gtk.TreeView): # type: ignore self.get_selection().unselect_path(path) def focus_first_row(self) -> None: - self.set_cursor(0) + path = Gtk.TreePath.new_from_indices([0]) + self.set_cursor(path) def get_value_at_index(self, index: int) -> str: (model, pathlist) = self.get_model_and_pathlist() @@ -143,7 +144,7 @@ class TreeView(CursorMixin, Gtk.TreeView): # type: ignore path = pathlist[0] tree_iter = model.get_iter(path) value = model.get_value(tree_iter, index) - return value + return str(value) def get_name(self) -> str: name = self.get_value_at_index(0) diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index febe125..9ddc691 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -306,7 +306,7 @@ class ServerTreeView(ContextMixin, TreeView): # type: ignore def get_record(self) -> Record | None: if self.loaded is False: - return + return None r = self.get_record_string() try: ip, gameport, qport = r.split(":") From b42aea1e0211ec8df7fe241e2e8a3f496ac35cc2 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 6 May 2026 02:09:40 +0900 Subject: [PATCH 240/309] feat: preconnect dialog (WIP) --- dzgui/api/mods.py | 2 +- dzgui/api/steam.py | 56 ++++++++++++++++++++++++++- dzgui/const/endpoints.py | 1 + dzgui/managers/connection.py | 51 +++++++++++++++++------- dzgui/views/base.py | 6 +-- dzgui/views/components/entry.py | 1 - dzgui/views/dialogs/generic.py | 7 ---- dzgui/views/mixins/help_menu_mixin.py | 2 - dzgui/views/pages/preconnect.py | 14 +------ 9 files changed, 99 insertions(+), 41 deletions(-) diff --git a/dzgui/api/mods.py b/dzgui/api/mods.py index 29c154d..63015c0 100644 --- a/dzgui/api/mods.py +++ b/dzgui/api/mods.py @@ -81,7 +81,7 @@ 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) + size = round(s / (1024**2), 3) return size diff --git a/dzgui/api/steam.py b/dzgui/api/steam.py index bbab06f..7fb7c72 100644 --- a/dzgui/api/steam.py +++ b/dzgui/api/steam.py @@ -1,11 +1,13 @@ import json import logging +import requests import subprocess from shlex import shlex from pathlib import Path from dzgui.const.constants import APP_NAME +from dzgui.const.endpoints import STEAM_PUBLISHED_FILES from dzgui.util.bash import concat_bash_args @@ -49,15 +51,65 @@ def query_defunct() -> None: # query_defunct "3576065083" -def concat_mods(mods: list[str]) -> str: +def concat_mods(mods: list[int]) -> str: for mod in mods: mods[mod] = f"@{mod}" return ";".join(mods) +def get_local_signatures(version_file: Path) -> dict[str, int]: + hashes: dict[str, int] = {} + lines = version_file.read_text().splitlines() + for line in lines: + line = line.split(",") + _id = line[0] + _hash = line[1] + hashes[_id] = _hash + return hashes + + +def get_needs_update( + version_file: Path, remote_hashes: list[tuple[str, int, str]] +) -> list[tuple[str, int, str]]: + local_hashes = get_local_signatures(version_file) + needs_update: list[tuple[str, str]] = [] + for _id, _hash, size in remote_hashes: + if _id not in local_hashes: + needs_update.append((_id, _hash, size)) + elif _hash != local_hashes[_id]: + needs_update.append((_id, _hash, size)) + else: + continue + return needs_update + + +def get_remote_signatures(mods: list[str]) -> list[tuple[str, int]]: + payload: dict[str, str] = {} + payload["itemcount"] = len(mods) + for i, mod in enumerate(mods): + payload[f"publishedfileids[{i}]"] = mod + try: + r = requests.post(STEAM_PUBLISHED_FILES, payload) + except Exception as e: + logger.critical(e) + return [] + if r.status_code != 200: + return [] + + hashes: list[tuple[str, int, str]] = [] + j = r.json() + rows = j["response"]["publishedfiledetails"] + for row in rows: + _id = row["publishedfileid"] + time = row["time_updated"] + size = row["file_size"] + hashes.append((_id, time, size)) + return hashes + + # 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) -> None: +def connect(addr: str, appid: int, name: str, mods: list[int]) -> None: # TODO: get name from configs # TODO: concat_mods(mods): # @;@; diff --git a/dzgui/const/endpoints.py b/dzgui/const/endpoints.py index b27e921..cb1ca1c 100644 --- a/dzgui/const/endpoints.py +++ b/dzgui/const/endpoints.py @@ -1,4 +1,5 @@ # internal +STEAM_PUBLISHED_FILES = "https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/v1/?format=json" STEAM_SERVERS = "https://api.steampowered.com/IGameServersService/GetServerList/v1/?" BM_SERVERS = "https://api.battlemetrics.com/servers?" GITHUB = "https://github.com/aclist" diff --git a/dzgui/managers/connection.py b/dzgui/managers/connection.py index 0715568..f58d815 100644 --- a/dzgui/managers/connection.py +++ b/dzgui/managers/connection.py @@ -1,3 +1,4 @@ +import logging import shutil from pathlib import Path @@ -5,8 +6,10 @@ from typing import Union, TYPE_CHECKING import dzgui.api.pefile as PeFile import dzgui.api.servers as Servers +from dzgui.api.steam import get_remote_signatures, get_needs_update from dzgui.api.mods import get_local_mod_ids +from dzgui.const.constants import APP_NAME from dzgui.const.enum import Preferences from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager from dzgui.util.strings import dialog, server_timeout, checkmark @@ -22,6 +25,8 @@ if TYPE_CHECKING: from dzgui.api.servers import PreReqs from dzgui.controllers.mc import Controller +logger = logging.getLogger(APP_NAME) + class ConnectionManager: def __init__(self, controller: "Controller") -> None: @@ -46,35 +51,55 @@ class ConnectionManager: def _prepare_connection(self, res: Union["PreReqs", None]) -> None: failure_func = StoredFunc(self._server_timeout) - if res is None: self.thread_man.set_cleanup_func(failure_func, destroy_first=True) return record = res.record - info = res.source - try: - mods = self._query_modlist(record) - except Exception: + remote_mods = self._query_modlist(record) + remote_mod_ids = [mod[1] for mod in remote_mods] + except Exception as e: + print(e) self.thread_man.set_cleanup_func(failure_func, destroy_first=True) return - # TODO: get missing mod diff - # TODO: get missing mod sizes + steam_path = Path(self.controller.query_config(Preferences.DEFAULT)) + hashes = get_remote_signatures(remote_mod_ids) + version_file = self.controller.get_prefs().paths.version + needs_update = get_needs_update(version_file, hashes) + + # TODO: populate version file at boot if nonexistent (after symlinking) + # missing mods should be the totality of all mods with no signature + # missing = get_missing_mods(local_mod_ids, remote_mod_ids) + # print(missing) + + # TODO: when updating, create symlinks of everything + # contains id and signature + + # TODO: get missing mod sizes, warn if not enough space + info = res.source try: - path = Path(self.controller.query_config(Preferences.DEFAULT)) - dayz_path = PeFile.get_pefile_path(path, info.game_id) + dayz_path = PeFile.get_pefile_path(steam_path, info.game_id) total, used, free = shutil.disk_usage(dayz_path) - free_mib = free / (1024**2) - print(free_mib) + if len(needs_update) > 0: + # TODO: generic mib function + required_size = sum(int(row[2]) for row in needs_update) + required_mib = round(required_size / (1024**2), 3) + free_mib = round(free / (1024**2), 3) + print(required_mib) + print(free_mib) except Exception: - # TODO: if this fails, need to show missing build warning + # TODO: if this fails, need to show missing build warning, not failure func + # build up list of warnings/errors # logger.warning(e) self.thread_man.set_cleanup_func(failure_func, destroy_first=True) - func = StoredFunc(self.controller.open_connection_assistant, res, mods) + # TODO: number separator func + # TODO: pack a final PreReq struct with pre-process values + + func = StoredFunc(self.controller.open_connection_assistant, res, remote_mods) self.thread_man.set_cleanup_func(func, destroy_first=True) @call_on_thread(dialog.querying) diff --git a/dzgui/views/base.py b/dzgui/views/base.py index 899a715..35119c6 100644 --- a/dzgui/views/base.py +++ b/dzgui/views/base.py @@ -27,15 +27,15 @@ from dzgui.views.pages.preconnect import PreConnectionAssistant from dzgui.views.pages.servers import ServerNotebook from dzgui.views.pages.thanks import Thanks -if TYPE_CHECKING: - from dzgui.config.userprefs import UserPrefs - import gi gi.require_version("Gtk", "3.0") gi.require_version("GLibUnix", "2.0") from gi.repository import Gtk, GLib, GLibUnix, Gdk # type: ignore # noqa E402 +if TYPE_CHECKING: + from dzgui.config.userprefs import UserPrefs + logger = logging.getLogger(APP_NAME) # TODO: drop diff --git a/dzgui/views/components/entry.py b/dzgui/views/components/entry.py index 3f0e592..8dcd3f3 100644 --- a/dzgui/views/components/entry.py +++ b/dzgui/views/components/entry.py @@ -43,7 +43,6 @@ class ValidatedEntry(Gtk.Entry): placeholder_text: str = "", tooltip_text: str = "", ) -> None: - # TODO: tooltip text should not be hardcoded super().__init__( hexpand=True, placeholder_text=placeholder_text, tooltip_text=tooltip_text ) diff --git a/dzgui/views/dialogs/generic.py b/dzgui/views/dialogs/generic.py index 0fd2816..17029c1 100644 --- a/dzgui/views/dialogs/generic.py +++ b/dzgui/views/dialogs/generic.py @@ -12,13 +12,6 @@ from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa E402 if TYPE_CHECKING: from dzgui.controllers.mc import Controller -# TODO: reimplement as standalone dialogs -# NOTE: steam deck prints <2> if dialog title is same as window title -# case Popup.MODLIST: -# dialog_type = Gtk.MessageType.INFO -# button_type = Gtk.ButtonsType.OK -# header_text = strings.modlist - class GenericDialog(Gtk.MessageDialog): def __init__( diff --git a/dzgui/views/mixins/help_menu_mixin.py b/dzgui/views/mixins/help_menu_mixin.py index 85a5361..d287bb7 100644 --- a/dzgui/views/mixins/help_menu_mixin.py +++ b/dzgui/views/mixins/help_menu_mixin.py @@ -1,5 +1,3 @@ -from typing import Literal - import gi gi.require_version("Gtk", "3.0") diff --git a/dzgui/views/pages/preconnect.py b/dzgui/views/pages/preconnect.py index a0ad525..2ae6668 100644 --- a/dzgui/views/pages/preconnect.py +++ b/dzgui/views/pages/preconnect.py @@ -52,9 +52,6 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): spacing=10, ) - # self.rules: dict[Any] - # self.mods: list["DayzMod"] - self.controller.register_widget("preconnect", self) # TODO: strings @@ -154,24 +151,17 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): prefix = preconnect.total_mods self.mod_count.set_text(f"{prefix}{str(total)}") - # TODO: check which mods need updating - # steam_path = self.controller.get_config_man().lookup(Preferences.DEFAULT) - # dayz_version = PeFile.get_pretty_version(steam_path, APPID_DAYZ) - # dayz_exp_version = PeFile.get_pretty_version(steam_path, APPID_DAYZ_EXP) + # TODO: reset warning and error dialogs def download_mods(self) -> None: pass def connect_server(self) -> None: # TODO: add to history file and list store + # TODO: concat mods """ spawn dialog in thread watch for subprocess return to prior page when finished """ - self.back.emit("clicked") pass - - # TODO: icon for mod signature issue - # or "Update mods and connect" - # also handle servers with no mods; do not show tree From a9863ff86bdfc996419df469b01169e54dd3d2da Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 6 May 2026 05:38:29 +0900 Subject: [PATCH 241/309] feat: MaskedTree (WIP) --- dzgui/api/mods.py | 3 +- dzgui/data/app.css | 4 ++ dzgui/managers/connection.py | 20 ++++-- dzgui/managers/threading.py | 21 ++++--- dzgui/views/pages/preconnect.py | 107 +++++++++++++++++++++++--------- 5 files changed, 111 insertions(+), 44 deletions(-) diff --git a/dzgui/api/mods.py b/dzgui/api/mods.py index 63015c0..8d43e73 100644 --- a/dzgui/api/mods.py +++ b/dzgui/api/mods.py @@ -116,7 +116,8 @@ def _hash(uid: str) -> str: def remove_stale_signatures(config: Path, versions: Path) -> None: if versions.is_file() is False: - logger.warning("No mod signatures file found") + logger.warning("Creating new version signatures file") + versions.touch() return path = lookup(config, Preferences.DEFAULT) steam_path = Path(path) diff --git a/dzgui/data/app.css b/dzgui/data/app.css index a2346a8..40eb4e6 100644 --- a/dzgui/data/app.css +++ b/dzgui/data/app.css @@ -31,3 +31,7 @@ font-size: 1.2rem; font-weight: 800; } + +.masked-tree { + background-color: transparent; +} diff --git a/dzgui/managers/connection.py b/dzgui/managers/connection.py index f58d815..85df3e8 100644 --- a/dzgui/managers/connection.py +++ b/dzgui/managers/connection.py @@ -70,18 +70,19 @@ class ConnectionManager: version_file = self.controller.get_prefs().paths.version needs_update = get_needs_update(version_file, hashes) - # TODO: populate version file at boot if nonexistent (after symlinking) + # TODO: store mods that need update in class object for referencing later + # TODO: store remote destination to connect to + # missing mods should be the totality of all mods with no signature # missing = get_missing_mods(local_mod_ids, remote_mod_ids) # print(missing) - - # TODO: when updating, create symlinks of everything - # contains id and signature + # TODO: when downloading mods, create symlinks if missing # TODO: get missing mod sizes, warn if not enough space info = res.source try: dayz_path = PeFile.get_pefile_path(steam_path, info.game_id) + # TODO: handle missing path; do not calculate size if appid is missing total, used, free = shutil.disk_usage(dayz_path) if len(needs_update) > 0: # TODO: generic mib function @@ -97,8 +98,9 @@ class ConnectionManager: self.thread_man.set_cleanup_func(failure_func, destroy_first=True) # TODO: number separator func - # TODO: pack a final PreReq struct with pre-process values + # TODO: pack a final PreReq struct with pre-processed values + # TODO: connection assistant only receives user-facing warnings and list of mods func = StoredFunc(self.controller.open_connection_assistant, res, remote_mods) self.thread_man.set_cleanup_func(func, destroy_first=True) @@ -154,3 +156,11 @@ class ConnectionManager: def _server_timeout(self) -> None: dialog = ExceptionDialog(self.controller, server_timeout) dialog.run() + + def update_mods(self) -> None: + # self.needs_update + pass + + def connect(self) -> None: + # steam api, concat mods + pass diff --git a/dzgui/managers/threading.py b/dzgui/managers/threading.py index 8f85302..f451715 100644 --- a/dzgui/managers/threading.py +++ b/dzgui/managers/threading.py @@ -20,7 +20,7 @@ from gi.repository import Gtk, GLib # noqa E402 logger = logging.getLogger(APP_NAME) -def call_on_thread(dialog_str: str) -> Callable: +def call_on_thread(dialog_str: str, show_dialog: bool = True) -> Callable: def decorator(func: Callable) -> Callable: @wraps(func) def wrapper(*args: Any, **kwargs: Any) -> None: @@ -32,7 +32,7 @@ def call_on_thread(dialog_str: str) -> Callable: raise TypeError( "Attribute 'thread_man' must be of type 'ThreadingManager'" ) - self.thread_man.call_on_thread(dialog_str, stored) + self.thread_man.call_on_thread(dialog_str, stored, show_dialog) return wrapper @@ -56,13 +56,16 @@ class ThreadingManager: self.cleanup_func: StoredFunc | None = None self.destroy_first = False - def call_on_thread(self, dialog_str: str, func: StoredFunc) -> None: + def call_on_thread( + self, dialog_str: str, func: StoredFunc, show_dialog: bool = True + ) -> None: def callback() -> None: func.call() GLib.idle_add(self._destroy_on_idle) - self.wait_dialog = WaitDialog(self.controller, dialog_str, jobs=self.jobs) - self.wait_dialog.show_all() + if show_dialog: + self.wait_dialog = WaitDialog(self.controller, dialog_str, jobs=self.jobs) + self.wait_dialog.show_all() thread = threading.Thread(target=callback) thread.start() @@ -88,16 +91,20 @@ class ThreadingManager: def get_cleanup_func(self) -> StoredFunc | None: return self.cleanup_func + def destroy_dialog(self) -> None: + if hasattr(self, "wait_dialog"): + self.wait_dialog.destroy() + def _destroy_on_idle(self) -> Literal[False]: if self.destroy_first: - self.wait_dialog.destroy() + self.destroy_dialog() func = self.get_cleanup_func() if func is not None: func.call() self.set_cleanup_func(None) if not self.destroy_first: - self.wait_dialog.destroy() + self.destroy_dialog() return False diff --git a/dzgui/views/pages/preconnect.py b/dzgui/views/pages/preconnect.py index 2ae6668..9cae71d 100644 --- a/dzgui/views/pages/preconnect.py +++ b/dzgui/views/pages/preconnect.py @@ -1,6 +1,6 @@ -from typing import Any, TYPE_CHECKING +from dataclasses import dataclass +from typing import Self, TYPE_CHECKING -from dzgui.api import pefile as PeFile from dzgui.const.constants import ( APPID_DAYZ, APPID_DAYZ_EXP, @@ -13,7 +13,6 @@ from dayzquery import DayzMod from dzgui.util.css import add_class from dzgui.strings import preconnect from dzgui.views.components.frame import HeadingFrame -from dzgui.views.components.labels import IconLabel from dzgui.views.trees.tree_server_mods import ServerModTreeView import gi @@ -27,14 +26,48 @@ if TYPE_CHECKING: from dzgui.controllers.mc import Controller -class WarningLabel(IconLabel): - def __init__(self, text: str) -> None: - super().__init__(text, WARNING) +@dataclass +class Warnings: + passworded: bool + dayz_running: bool + no_steam: bool + wrong_version: bool -class ErrorLabel(IconLabel): - def __init__(self, text: str) -> None: - super().__init__(text, ERROR) +@dataclass +class Errors: + no_space: bool + no_dayz_exp: bool + no_dayz: bool + + +class MaskedTree(Gtk.TreeView): + def __init__(self, icon: str) -> None: + super().__init__(headers_visible=False, can_focus=False) + + self.icon = icon + self.store = Gtk.ListStore(str, str) + self.set_model(self.store) + self.get_selection().set_mode(Gtk.SelectionMode.NONE) + + icon_renderer = Gtk.CellRendererPixbuf() + # NOTE: adjust vertical offset between columns + icon_renderer.set_property("yalign", 0.6) + icon_column = Gtk.TreeViewColumn("", icon_renderer) + icon_column.add_attribute(icon_renderer, "icon-name", 0) + icon_column.set_fixed_width(50) + + text_renderer = Gtk.CellRendererText() + text_column = Gtk.TreeViewColumn("", text_renderer, text=1) + + self.append_column(icon_column) + self.append_column(text_column) + add_class(self, "masked-tree") + + def append(self, items: list[str]) -> None: + self.store.clear() + for item in items: + self.store.append([self.icon, item]) class PreConnectionAssistant(Gtk.ScrolledWindow): @@ -54,7 +87,6 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): self.controller.register_widget("preconnect", self) - # TODO: strings # TODO: dynamic button text if no mods needed self.back = Gtk.Button(label=preconnect.back, halign=Gtk.Align.START) self.cancel = Gtk.Button( @@ -94,28 +126,12 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): self.tree_frame = HeadingFrame(self.tree_box, preconnect.mods) - self.warnings: list[WarningLabel] = [] - # TODO: populate with strings and icons - # set visibility if warnings > 1 - # warning category enums with matching strings - """ - blocking warning types: - - build mismatch - - version mismatch - - not enough drive space - passing warning types: - - dayz is running - - steam is not running - - server has password - """ # TODO: abstract into components - warning_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) - warning_box.add(WarningLabel("Warning 1")) - self.warning_frame = HeadingFrame(warning_box, preconnect.warnings) + self.warning_tree = MaskedTree(WARNING) + self.warning_frame = HeadingFrame(self.warning_tree, preconnect.warnings) - error_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) - error_box.add(ErrorLabel("Error 1")) - self.error_frame = HeadingFrame(error_box, preconnect.errors) + self.error_tree = MaskedTree(ERROR) + self.error_frame = HeadingFrame(self.error_tree, preconnect.errors) self.box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) self.box.add(self.title) @@ -126,6 +142,17 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): self.add(self.box) + # self.connect("key-press-event", self._on_keypress) + self.connect("map", self._on_map) + + def _on_map(self, widget: Self) -> None: + self.tree_frame.set_visible(True) + self.mod_count.set_visible(True) + + def _on_keypress(self, widget: Self, event: Gdk.EventKey) -> None: + if event.keyval == Gdk.KEY_Escape: + self.back.emit("clicked") + def _on_ok_clicked(self, button: Gtk.Button) -> None: # TODO: update mod store in place with spinner/toast # TODO: cancel mod downloads @@ -139,6 +166,8 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): self.tree.populate(mods) total = len(mods) + self._set_warnings() + name = res.source.server_name self.title.set_text(name) if total < 1: @@ -148,10 +177,26 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): else: self.tree.set_visible(True) self.mod_count.set_visible(True) + # TODO: print no. of mods that need updating prefix = preconnect.total_mods self.mod_count.set_text(f"{prefix}{str(total)}") - # TODO: reset warning and error dialogs + """ + blocking warning types: + - build mismatch + - version mismatch + - not enough drive space + passing warning types: + - dayz is running + - steam is not running + - server has password + """ + # TODO: if errors > 1, disable buttons + + def _set_warnings(self) -> None: + self.warning_tree.append(["Password protected", "Some other error", "Error 3"]) + self.error_tree.append(["Password protected", "Some other error", "Error 3"]) + pass def download_mods(self) -> None: pass From 7523bffbd8ba7312d326f1c450d5be8c9e8ac05b Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 7 May 2026 00:19:01 +0900 Subject: [PATCH 242/309] change: simplify routes between query API --- dzgui/api/servers.py | 156 ++++++++++++++------------------ dzgui/controllers/mc.py | 4 +- dzgui/managers/connection.py | 18 ++-- dzgui/model/servers.py | 13 ++- dzgui/util/strings.py | 2 +- dzgui/views/pages/preconnect.py | 9 +- dzgui/views/trees/tree_base.py | 2 +- 7 files changed, 94 insertions(+), 110 deletions(-) diff --git a/dzgui/api/servers.py b/dzgui/api/servers.py index 552a8ef..8da3a24 100644 --- a/dzgui/api/servers.py +++ b/dzgui/api/servers.py @@ -20,6 +20,7 @@ import a2s import dayzquery if TYPE_CHECKING: + from a2s import SourceInfo from dayzquery import DayzMod logger = logging.getLogger(APP_NAME) @@ -40,18 +41,54 @@ params = [ ] -#class BmAPIError(Exception): -# pass -# -# -#class BmIdError(Exception): -# pass - - class InvalidIpError(Exception): pass +@dataclass(slots=True, frozen=True) +class Res: + status: int + parsed: bool + json: Union[dict[str, Any], None] + + +@dataclass(slots=True, frozen=True) +class Details: + data: Union[list, None] + description: str + success: bool + + +@dataclass(slots=True) +class Record: + """ + The gameport field is manipulated by the RowType.CONN_BY_IP method + """ + + ip: str + gameport: int + qport: int + + +class A2SInfo: + def __init__(self, record: Record, info: a2s.SourceInfo | None) -> None: + self.record = record + self.info = info + + def get_record(self) -> Record: + return self.record + + def get_info(self) -> a2s.SourceInfo | None: + return self.info + + def as_row(self) -> dict[str, Any] | None: + if self.info is None: + return None + ip = self.record.ip + qport = self.record.qport + return source_info_to_dict(ip, qport, self.info) + + def get_netmask() -> str: hostname = os.uname()[1] i = socket.gethostbyname(hostname) @@ -191,11 +228,8 @@ def parse_json(json: list) -> list: return rows - -def query_direct(ip: str, qport: int, timeout: float = 3.0) -> dict | None: +def source_info_to_dict(ip: str, qport: int, info: "SourceInfo") -> dict[str, Any]: try: - info = a2s.info((ip, qport), timeout) - name = info.server_name mapname = info.map_name address = ip + ":" + str(qport) @@ -221,61 +255,18 @@ def query_direct(ip: str, qport: int, timeout: float = 3.0) -> dict | None: res["ping"] = ping return res except Exception as e: + # TODO: generalized function logger.critical(f"{type(e).__name__}: {e} ({ip}:{qport})") return None -@dataclass(slots=True, frozen=True) -class Res: - status: int - parsed: bool - json: Union[dict[str, Any], None] - - -@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 - -@dataclass(frozen=True, slots=True) -class PreReqs: - record: Record - source: a2s.SourceInfo - - - -#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 query_direct(ip: str, qport: int, timeout: float = 3.0) -> dict[str, Any] | None: + try: + info = a2s.info((ip, qport), timeout) + return source_info_to_dict(ip, qport, info) + except Exception as e: + logger.critical(e) + return None def get_details(record: Record) -> Details: @@ -443,18 +434,6 @@ def get_rules(record: Record) -> list["DayzMod"]: return [mod for mod in mods] -def query_by_id(server_id: int, key: str, full: bool = False) -> dict[Any] | None: - """ - Used with numeric Battlemetrics IDs - """ - try: - resolved = map_id_to_record(key, server_id) - return query_direct(resolved.ip, resolved.qport, full) - except Exception as e: - logger.critical(e) - return None - - def query_playercount(record: Record) -> tuple[int, int] | None: try: res = query_direct(record.ip, record.qport) @@ -470,24 +449,25 @@ def query_playercount(record: Record) -> tuple[int, int] | None: return None -def query_by_ip(addr: str, full: bool = False) -> dict[str, Any] | PreReqs | None: +def query_by_ip(addr: str) -> A2SInfo: record = short_ip_to_record(addr) - return query_by_record(record, full) + return query_by_record(record) -def query_by_record(record: Record, full: bool = False) -> dict[str, Any] | PreReqs | None: - if full: - try: - info = a2s.info((record.ip, record.qport), 3.0) - return PreReqs(record, info) - except Exception as e: - logger.critical(e) - return None +def query_by_id(server_id: int, key: str) -> dict[Any] | None: + """ + Used with numeric Battlemetrics IDs + """ + record = map_id_to_record(key, server_id) + return query_by_record(record) + +def query_by_record(record: Record) -> A2SInfo: try: - return query_direct(record.ip, record.qport) + info = a2s.info((record.ip, record.qport), 3.0) + return A2SInfo(record, info) except Exception as e: - logger.critical(e) - return None + logger.warning(e) + return A2SInfo(record, None) def short_ip_to_record(addr: str) -> Optional[Record]: diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index eabdeff..45c5871 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -37,7 +37,7 @@ logger = logging.getLogger(APP_NAME) if TYPE_CHECKING: from dayzquery import DayzMod - from dzgui.api.servers import PreReqs, Record + from dzgui.api.servers import A2SInfo, Record from dzgui.const.enum import ServerTab from dzgui.managers.filter import FilterManager from dzgui.util.dist import Haversine @@ -458,7 +458,7 @@ class Controller(GObject.GObject): def set_exit_event(self) -> None: self.exit_event.set() - def open_connection_assistant(self, res: "PreReqs", mods: list["DayzMod"]) -> None: + def open_connection_assistant(self, res: "A2SInfo", mods: list["DayzMod"]) -> None: self.open_page(NotebookPage.CONNECTION) self.mediator.preconnect.populate(res, mods) diff --git a/dzgui/managers/connection.py b/dzgui/managers/connection.py index 85df3e8..fa00f8e 100644 --- a/dzgui/managers/connection.py +++ b/dzgui/managers/connection.py @@ -2,7 +2,7 @@ import logging import shutil from pathlib import Path -from typing import Union, TYPE_CHECKING +from typing import TYPE_CHECKING import dzgui.api.pefile as PeFile import dzgui.api.servers as Servers @@ -22,7 +22,7 @@ gi.require_version("Gtk", "3.0") from gi.repository import Gtk # noqa E402 if TYPE_CHECKING: - from dzgui.api.servers import PreReqs + from dzgui.api.servers import A2SInfo from dzgui.controllers.mc import Controller logger = logging.getLogger(APP_NAME) @@ -36,26 +36,26 @@ class ConnectionManager: @call_on_thread(dialog.querying) def connect_by_id(self, _id: int, key: str) -> None: - res = Servers.query_by_id(_id, key, full=True) + res = Servers.query_by_id(_id, key) self._prepare_connection(res) @call_on_thread(dialog.querying) def connect_by_ip(self, addr: str) -> None: - res = Servers.query_by_ip(addr, full=True) + res = Servers.query_by_ip(addr) self._prepare_connection(res) @call_on_thread(dialog.querying) def connect_by_record(self, record: Servers.Record) -> None: - res = Servers.query_by_record(record, full=True) + res = Servers.query_by_record(record) self._prepare_connection(res) - def _prepare_connection(self, res: Union["PreReqs", None]) -> None: + def _prepare_connection(self, res: "A2SInfo") -> None: failure_func = StoredFunc(self._server_timeout) - if res is None: + if res.get_info() is None: self.thread_man.set_cleanup_func(failure_func, destroy_first=True) return - record = res.record + record = res.get_record() try: remote_mods = self._query_modlist(record) remote_mod_ids = [mod[1] for mod in remote_mods] @@ -79,7 +79,7 @@ class ConnectionManager: # TODO: when downloading mods, create symlinks if missing # TODO: get missing mod sizes, warn if not enough space - info = res.source + info = res.get_info() try: dayz_path = PeFile.get_pefile_path(steam_path, info.game_id) # TODO: handle missing path; do not calculate size if appid is missing diff --git a/dzgui/model/servers.py b/dzgui/model/servers.py index a88502a..4341936 100644 --- a/dzgui/model/servers.py +++ b/dzgui/model/servers.py @@ -23,6 +23,7 @@ gi.require_version("Gtk", "3.0") from gi.repository import Gtk # noqa E402 if TYPE_CHECKING: + from dzgui.api.servers import A2SInfo from dzgui.controllers.mc import Controller from dzgui.model.proxy_model import ProxyModelManager from dzgui.views.trees.tree_servers import ServerTreeView @@ -39,6 +40,7 @@ class NewPlayerCount: queue: int + class ServerModelManager: def __init__(self, controller: "Controller", tv: "ServerTreeView") -> None: @@ -256,23 +258,24 @@ class ServerModelManager: proxy_man.update_playercount(self.playercount) def _parse_single_record( - self, response: dict[Any, Any] | None, delete: bool = False + self, response: "A2SInfo", delete: bool = False ) -> None: self.preserve_on_fail = True - if response is None: + row = response.as_row() + if row is None: self.thread_man.set_cleanup_func(StoredFunc(self._cleanup_on_failure)) return # NOTE: expected to only contain one item - records = Servers.parse_json([response]) + records = Servers.parse_json([row]) server = records[0] if server is None: return proxy_man = self._get_proxy_man() config_man = self.controller.get_config_man() - fqip = Servers.response_to_fqip(response) - record = Servers.response_to_record(response) + fqip = Servers.response_to_fqip(row) + record = response.get_record() #Servers.response_to_record(row) # TODO: less convoluted if delete: diff --git a/dzgui/util/strings.py b/dzgui/util/strings.py index 82853eb..95ee9c3 100644 --- a/dzgui/util/strings.py +++ b/dzgui/util/strings.py @@ -57,7 +57,7 @@ copy_ip = "Copy IP to clipboard" copy_log = "Copy record(s) to clipboard" add = "Add to Saved Servers" add_fav = "Set as favorite" -remove = "Remove from my servers" +remove = "Remove from Saved Servers" remove_history = "Remove from history" connect = "Connect" diff --git a/dzgui/views/pages/preconnect.py b/dzgui/views/pages/preconnect.py index 9cae71d..b9a0870 100644 --- a/dzgui/views/pages/preconnect.py +++ b/dzgui/views/pages/preconnect.py @@ -22,7 +22,7 @@ from gi.repository import Gdk, Gtk # type: ignore # noqa E402 if TYPE_CHECKING: - from dzgui.api.servers import PreReqs + from dzgui.api.servers import A2SInfo from dzgui.controllers.mc import Controller @@ -142,7 +142,7 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): self.add(self.box) - # self.connect("key-press-event", self._on_keypress) + self.connect("key-press-event", self._on_keypress) self.connect("map", self._on_map) def _on_map(self, widget: Self) -> None: @@ -162,13 +162,14 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): page = self.controller.get_prior_page() self.controller.open_page(page) - def populate(self, res: "PreReqs", mods: list["DayzMod"]) -> None: + def populate(self, res: "A2SInfo", mods: list["DayzMod"]) -> None: self.tree.populate(mods) total = len(mods) self._set_warnings() - name = res.source.server_name + info = res.get_info() + name = info.server_name self.title.set_text(name) if total < 1: self.tree_frame.set_visible(False) diff --git a/dzgui/views/trees/tree_base.py b/dzgui/views/trees/tree_base.py index 7f00d99..9603137 100644 --- a/dzgui/views/trees/tree_base.py +++ b/dzgui/views/trees/tree_base.py @@ -144,7 +144,7 @@ class TreeView(CursorMixin, Gtk.TreeView): # type: ignore path = pathlist[0] tree_iter = model.get_iter(path) value = model.get_value(tree_iter, index) - return str(value) + return value def get_name(self) -> str: name = self.get_value_at_index(0) From e2022fdee7c5890b0023fafe5e94715bc5c8a360 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 7 May 2026 02:47:42 +0900 Subject: [PATCH 243/309] feat: preconnect dialog warnings (WIP) --- dzgui/api/servers.py | 11 +++ dzgui/api/steam.py | 3 + dzgui/controllers/mc.py | 22 +++-- dzgui/init/proc.py | 9 +- dzgui/main.py | 4 +- dzgui/managers/connection.py | 146 +++++++++++++++++++--------- dzgui/strings/preconnect.py | 1 + dzgui/util/format.py | 4 + dzgui/views/pages/preconnect.py | 163 +++++++++++++++++++++----------- 9 files changed, 256 insertions(+), 107 deletions(-) diff --git a/dzgui/api/servers.py b/dzgui/api/servers.py index 8da3a24..6c5f6e0 100644 --- a/dzgui/api/servers.py +++ b/dzgui/api/servers.py @@ -88,6 +88,17 @@ class A2SInfo: qport = self.record.qport return source_info_to_dict(ip, qport, self.info) + def is_modded(self) -> bool: + if self.info is None: + raise ValueError("Cannot call this method on Nonetype") + try: + kw = self.info.keywords.split(",") + state = True if "mod" in kw else False + return state + except Exception as e: + logger.warning(e) + raise e + def get_netmask() -> str: hostname = os.uname()[1] diff --git a/dzgui/api/steam.py b/dzgui/api/steam.py index 7fb7c72..fc310a9 100644 --- a/dzgui/api/steam.py +++ b/dzgui/api/steam.py @@ -84,6 +84,9 @@ def get_needs_update( def get_remote_signatures(mods: list[str]) -> list[tuple[str, int]]: + """ + Attempts to continue connecting even if signatures are empty + """ payload: dict[str, str] = {} payload["itemcount"] = len(mods) for i, mod in enumerate(mods): diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 45c5871..36ea9c1 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -36,9 +36,9 @@ from gi.repository import Gtk, Gdk, GLib, GObject # noqa E402 logger = logging.getLogger(APP_NAME) if TYPE_CHECKING: - from dayzquery import DayzMod - from dzgui.api.servers import A2SInfo, Record + from dzgui.api.servers import Record from dzgui.const.enum import ServerTab + from dzgui.managers.connection import Prerequisites from dzgui.managers.filter import FilterManager from dzgui.util.dist import Haversine from dzgui.views.base import Notebook, Grid, OuterWindow @@ -88,6 +88,7 @@ class Controller(GObject.GObject): self.pending_jobs = 1 self.exit_event = threading.Event() + self.connection_man: ConnectionManager def get_emitter(self) -> Emitter: return self.emitter @@ -104,6 +105,7 @@ class Controller(GObject.GObject): def set_prefs(self, prefs: UserPrefs) -> None: self.config_man = ConfigManager(prefs, self) self.notes_man = NoteManager(self, prefs.paths.notes) + self.prefs = prefs def query_config(self, key: Preferences) -> Any: @@ -393,12 +395,15 @@ class Controller(GObject.GObject): if addr.isdigit(): config_man = self.get_config_man() key = config_man.lookup(Preferences.BM) - ConnectionManager(self).connect_by_id(int(addr), key) + self.connection_man = ConnectionManager(self) + self.connection_man.connect_by_id(int(addr), key) else: - ConnectionManager(self).connect_by_ip(addr) + self.connection_man = ConnectionManager(self) + self.connection_man.connect_by_ip(addr) def connect_by_record(self, record: "Record") -> None: - ConnectionManager(self).connect_by_record(record) + self.connection_man = ConnectionManager(self) + self.connection_man.connect_by_record(record) def get_details(self, record: "Record") -> None: ConnectionManager(self).query_details(record) @@ -458,10 +463,13 @@ class Controller(GObject.GObject): def set_exit_event(self) -> None: self.exit_event.set() - def open_connection_assistant(self, res: "A2SInfo", mods: list["DayzMod"]) -> None: + def open_connection_assistant(self, prereqs: "Prerequisites") -> None: self.open_page(NotebookPage.CONNECTION) - self.mediator.preconnect.populate(res, mods) + self.mediator.preconnect.populate(prereqs) def set_start_tab(self) -> None: ind = self.config_man.get_start_tab() self.get_servers().notebook.set_current_page(ind) + + def update_and_connect(self) -> None: + self.connection_man.update_and_connect() diff --git a/dzgui/init/proc.py b/dzgui/init/proc.py index 957205c..a3e6c1f 100644 --- a/dzgui/init/proc.py +++ b/dzgui/init/proc.py @@ -6,14 +6,19 @@ from dzgui.views.dialogs.early_alert import EarlyAlertDialog from dzgui.util.strings import init -def is_dayz_running() -> None: +# TODO: simplify +def is_dayz_running(dialog: bool = False) -> None: + if dialog is False: + return is_running(DAYZ_BINARY) if is_running(DAYZ_BINARY) is True: EarlyAlertDialog(init.is_dayz_running) sys.exit(1) -def is_steam_running() -> None: +def is_steam_running(dialog: bool = False) -> None: # TODO: check proc name of flatpak steam + if dialog is False: + return is_running(STEAM_CMD) if is_running(STEAM_CMD) is False: EarlyAlertDialog(init.is_steam_running) sys.exit(1) diff --git a/dzgui/main.py b/dzgui/main.py index 5d50098..da6ead1 100644 --- a/dzgui/main.py +++ b/dzgui/main.py @@ -130,8 +130,8 @@ def main() -> None: EarlyAlertDialog(init.requires_steam) is_dayz_installed(XDG.config) - is_dayz_running() - is_steam_running() + is_dayz_running(dialog=True) + is_steam_running(dialog=True) # NOTE: clear versions file of unlinked mods rebuild_symlinks(XDG.config) diff --git a/dzgui/managers/connection.py b/dzgui/managers/connection.py index fa00f8e..26f2a69 100644 --- a/dzgui/managers/connection.py +++ b/dzgui/managers/connection.py @@ -1,6 +1,8 @@ import logging import shutil +from dataclasses import dataclass +from packaging.version import Version from pathlib import Path from typing import TYPE_CHECKING @@ -9,9 +11,17 @@ import dzgui.api.servers as Servers from dzgui.api.steam import get_remote_signatures, get_needs_update from dzgui.api.mods import get_local_mod_ids -from dzgui.const.constants import APP_NAME +from dzgui.const.constants import ( + APP_NAME, + APPID_DAYZ, + APPID_DAYZ_EXP, + APPNAME_DAYZ, + APPNAME_DAYZ_EXP, +) from dzgui.const.enum import Preferences +from dzgui.init.proc import is_dayz_running, is_steam_running from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager +from dzgui.util.format import format_mib from dzgui.util.strings import dialog, server_timeout, checkmark from dzgui.views.dialogs.generic import ExceptionDialog from dzgui.views.dialogs.servers import ServerDetailsDialog, ServerModDialog @@ -22,18 +32,40 @@ gi.require_version("Gtk", "3.0") from gi.repository import Gtk # noqa E402 if TYPE_CHECKING: - from dzgui.api.servers import A2SInfo + from dzgui.api.servers import A2SInfo, Record from dzgui.controllers.mc import Controller logger = logging.getLogger(APP_NAME) +@dataclass(slots=True, frozen=True) +class Prerequisites: + name: str + appid: int + local_version: Version + remote_version: Version + build: str + binary_missing: bool + required_space: float + available_space: float + passworded: bool + dayz_running: bool + steam_running: bool + mods: list[str] + + class ConnectionManager: def __init__(self, controller: "Controller") -> None: self.controller = controller self.thread_man = ThreadingManager(controller) + self.appid: int + self.record: Record + + self.remote_mod_ids: list[str] = [] + self.missing_mods: list[str] = [] + @call_on_thread(dialog.querying) def connect_by_id(self, _id: int, key: str) -> None: res = Servers.query_by_id(_id, key) @@ -56,52 +88,66 @@ class ConnectionManager: return record = res.get_record() - try: - remote_mods = self._query_modlist(record) - remote_mod_ids = [mod[1] for mod in remote_mods] - except Exception as e: - print(e) - self.thread_man.set_cleanup_func(failure_func, destroy_first=True) - return + info = res.get_info() + + # NOTE: store metadata for later connection + self.appid = info.game_id + self.record = record + + builds = {APPID_DAYZ: APPNAME_DAYZ, APPID_DAYZ_EXP: APPNAME_DAYZ_EXP} + build = builds[self.appid] + binary_missing = False + required_mib = 0.0 + free_mib = 0.0 steam_path = Path(self.controller.query_config(Preferences.DEFAULT)) + local_version = PeFile.get_pretty_version(steam_path, info.game_id) + if local_version is None: + local_version = "0.0.0" + binary_missing = True - hashes = get_remote_signatures(remote_mod_ids) - version_file = self.controller.get_prefs().paths.version - needs_update = get_needs_update(version_file, hashes) + remote_mods: list[str, str, str] = [] + if res.is_modded(): + try: + remote_mods = self._query_modlist(record) + self.remote_mod_ids = [mod[1] for mod in remote_mods] + except Exception as e: + logger.warning(e) + self.thread_man.set_cleanup_func(failure_func, destroy_first=True) + return - # TODO: store mods that need update in class object for referencing later - # TODO: store remote destination to connect to + hashes = get_remote_signatures(self.remote_mod_ids) + version_file = self.controller.get_prefs().paths.version + self.missing_mods = get_needs_update(version_file, hashes) - # missing mods should be the totality of all mods with no signature - # missing = get_missing_mods(local_mod_ids, remote_mod_ids) - # print(missing) - # TODO: when downloading mods, create symlinks if missing + if local_version is not None: + pefile_path = PeFile.get_pefile_path(steam_path, info.game_id) + total, used, free = shutil.disk_usage(pefile_path) + if len(self.missing_mods) > 0: + required_size = sum(int(row[2]) for row in self.missing_mods) + required_mib = format_mib(required_size) + free_mib = format_mib(required_size) - # TODO: get missing mod sizes, warn if not enough space - info = res.get_info() - try: - dayz_path = PeFile.get_pefile_path(steam_path, info.game_id) - # TODO: handle missing path; do not calculate size if appid is missing - total, used, free = shutil.disk_usage(dayz_path) - if len(needs_update) > 0: - # TODO: generic mib function - required_size = sum(int(row[2]) for row in needs_update) - required_mib = round(required_size / (1024**2), 3) - free_mib = round(free / (1024**2), 3) - print(required_mib) - print(free_mib) - except Exception: - # TODO: if this fails, need to show missing build warning, not failure func - # build up list of warnings/errors - # logger.warning(e) - self.thread_man.set_cleanup_func(failure_func, destroy_first=True) + dayz_running = is_dayz_running() + steam_running = is_steam_running() + # TODO: is dayz downloading - # TODO: number separator func - # TODO: pack a final PreReq struct with pre-processed values + prereqs = Prerequisites( + name=info.server_name, + appid=info.game_id, + local_version=Version(local_version), + remote_version=Version(info.version), + build=build, + binary_missing=binary_missing, + required_space=required_mib, + available_space=free_mib, + passworded=info.password_protected, + dayz_running=dayz_running, + steam_running=steam_running, + mods=remote_mods, + ) - # TODO: connection assistant only receives user-facing warnings and list of mods - func = StoredFunc(self.controller.open_connection_assistant, res, remote_mods) + func = StoredFunc(self.controller.open_connection_assistant, prereqs) self.thread_man.set_cleanup_func(func, destroy_first=True) @call_on_thread(dialog.querying) @@ -157,10 +203,24 @@ class ConnectionManager: dialog = ExceptionDialog(self.controller, server_timeout) dialog.run() + def connect(self) -> None: + print(self.record.ip) + print(self.record.gameport) + print(self.appid) + # TODO: convert mod ids to symlink hashes + # steam api, concat mods + + # TODO: custom threading with glib idle callback def update_mods(self) -> None: + print(self.missing_mods) + # TODO: when downloading mods, create symlinks if missing + # TODO: pack a final PreReq struct with pre-processed values # self.needs_update + # then connect pass - def connect(self) -> None: - # steam api, concat mods - pass + def update_and_connect(self) -> None: + if len(self.missing_mods) > 0: + self.update_mods() + else: + self.connect() diff --git a/dzgui/strings/preconnect.py b/dzgui/strings/preconnect.py index b2a522d..ea87fec 100644 --- a/dzgui/strings/preconnect.py +++ b/dzgui/strings/preconnect.py @@ -1,4 +1,5 @@ update_mods = "Update mods and connect" +connect = "Connect" back = "Back" cancel = "Cancel" warnings = "Warnings" diff --git a/dzgui/util/format.py b/dzgui/util/format.py index 937c6d4..871e9a7 100644 --- a/dzgui/util/format.py +++ b/dzgui/util/format.py @@ -51,6 +51,10 @@ def format_mods(size: int, mods: int) -> str: return f"Found {mods:n} {plural} taking up {l_size} MiB. {suffix}" +def format_mib(bits: int) -> float: + return round(bits / (1024**2), 3) + + def format_server_mods(mods: int) -> str: plural = pluralize("mods", mods) return f"Found {mods:n} {plural}. {workshop}" diff --git a/dzgui/views/pages/preconnect.py b/dzgui/views/pages/preconnect.py index b9a0870..8dc5792 100644 --- a/dzgui/views/pages/preconnect.py +++ b/dzgui/views/pages/preconnect.py @@ -1,16 +1,12 @@ from dataclasses import dataclass -from typing import Self, TYPE_CHECKING +from typing import Self, Sequence, TYPE_CHECKING from dzgui.const.constants import ( - APPID_DAYZ, - APPID_DAYZ_EXP, - APPNAME_DAYZ, - APPNAME_DAYZ_EXP, ERROR, WARNING, ) -from dayzquery import DayzMod from dzgui.util.css import add_class +from dzgui.util.localize import number from dzgui.strings import preconnect from dzgui.views.components.frame import HeadingFrame from dzgui.views.trees.tree_server_mods import ServerModTreeView @@ -22,7 +18,7 @@ from gi.repository import Gdk, Gtk # type: ignore # noqa E402 if TYPE_CHECKING: - from dzgui.api.servers import A2SInfo + from dzgui.managers.connection import Prerequisites from dzgui.controllers.mc import Controller @@ -41,6 +37,17 @@ class Errors: no_dayz: bool +class Placeholder(Gtk.Label): + def __init__(self, text: str) -> None: + super().__init__( + label=text, + halign=Gtk.Align.START, + valign=Gtk.Align.START, + margin_start=10, + margin_bottom=5, + ) + + class MaskedTree(Gtk.TreeView): def __init__(self, icon: str) -> None: super().__init__(headers_visible=False, can_focus=False) @@ -64,7 +71,12 @@ class MaskedTree(Gtk.TreeView): self.append_column(text_column) add_class(self, "masked-tree") - def append(self, items: list[str]) -> None: + def append(self, item: Sequence[str]) -> None: + if len(item) > 1: + raise ValueError("This method only accepts one item") + self.store.append([self.icon, item]) + + def extend(self, items: list[str]) -> None: self.store.clear() for item in items: self.store.append([self.icon, item]) @@ -87,11 +99,11 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): self.controller.register_widget("preconnect", self) - # TODO: dynamic button text if no mods needed self.back = Gtk.Button(label=preconnect.back, halign=Gtk.Align.START) self.cancel = Gtk.Button( label=preconnect.cancel, halign=Gtk.Align.END, sensitive=False, hexpand=True ) + # TODO: dynamic button text if no mods needed self.ok = Gtk.Button(label=preconnect.update_mods, halign=Gtk.Align.END) self.button_box = Gtk.Box( @@ -111,7 +123,6 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): self.tree = ServerModTreeView(self.controller) self.mod_count = Gtk.Label(label="", halign=Gtk.Align.START, margin_start=5) - # TODO: live count of remaining downloads # "Steam is downloading: {mod_name}" # mention whether manual or auto mod is active @@ -124,16 +135,30 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): self.tree_box.add(self.scrolled) self.tree_box.add(self.mod_count) + # TODO: strings + self.mods_placeholder = Placeholder("This server has no mods.") + self.tree_box.add(self.mods_placeholder) + self.tree_frame = HeadingFrame(self.tree_box, preconnect.mods) # TODO: abstract into components + self.warning_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) self.warning_tree = MaskedTree(WARNING) - self.warning_frame = HeadingFrame(self.warning_tree, preconnect.warnings) + # TODO: strings + self.warning_placeholder = Placeholder("No warnings.") + self.warning_box.add(self.warning_tree) + self.warning_box.add(self.warning_placeholder) + self.warning_frame = HeadingFrame(self.warning_box, preconnect.warnings) + self.error_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) self.error_tree = MaskedTree(ERROR) - self.error_frame = HeadingFrame(self.error_tree, preconnect.errors) + # TODO: strings + self.error_placeholder = Placeholder("No errors.") + self.error_box.add(self.error_tree) + self.error_box.add(self.error_placeholder) + self.error_frame = HeadingFrame(self.error_box, preconnect.errors) - self.box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + self.box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) self.box.add(self.title) self.box.add(self.tree_frame) self.box.add(self.warning_frame) @@ -146,8 +171,15 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): self.connect("map", self._on_map) def _on_map(self, widget: Self) -> None: - self.tree_frame.set_visible(True) - self.mod_count.set_visible(True) + widgets = ( + self.tree_frame, + self.mod_count, + self.error_placeholder, + self.warning_placeholder, + ) + for widget in widgets: + widget.set_visible(True) + self.ok.set_sensitive(True) def _on_keypress(self, widget: Self, event: Gdk.EventKey) -> None: if event.keyval == Gdk.KEY_Escape: @@ -155,59 +187,84 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): def _on_ok_clicked(self, button: Gtk.Button) -> None: # TODO: update mod store in place with spinner/toast + # no dialog # TODO: cancel mod downloads + # TODO: add to history file and list store + # TODO: concat mods + self.controller.update_and_connect() pass def _on_back_clicked(self, button: Gtk.Button) -> None: page = self.controller.get_prior_page() self.controller.open_page(page) - def populate(self, res: "A2SInfo", mods: list["DayzMod"]) -> None: + def _process_warnings(self, prereqs: "Prerequisites") -> None: + warnings: list[str] = [] + errors: list[str] = [] + + if prereqs.binary_missing: + errors.append( + f"Remote server is running the build '{prereqs.build}', but it is not installed" + ) + elif prereqs.local_version != prereqs.remote_version: + print("versions do not match") + errors.append( + f"Local client version '{prereqs.local_version}' does not match remote version '{prereqs.remote_version}'" + ) + if prereqs.required_space > prereqs.available_space: + required_pretty = number(prereqs.required_space) + available_pretty = number(prereqs.available_space) + errors.append( + f"Need to update {required_pretty} MiB of mods, but installation path only has {available_pretty} MiB" + ) + if prereqs.passworded: + warnings.append( + "Protected: you will be prompted for a password when connecting to this server" + ) + if prereqs.dayz_running is True: + warnings.append( + "It looks like DayZ is already running in the background. Exit DayZ before connecting" + ) + if prereqs.steam_running is False: + warnings.append( + "It looks like Steam is not running. Launch Steam before connecting" + ) + + self.add_warnings(warnings) + self.add_errors(errors) + + if len(warnings) > 0: + self.warning_placeholder.set_visible(False) + if len(errors) > 0: + self.error_placeholder.set_visible(False) + self.ok.set_sensitive(False) + + def populate(self, prereqs: "Prerequisites") -> None: + mods = prereqs.mods self.tree.populate(mods) - total = len(mods) + total_mods = len(mods) - self._set_warnings() - - info = res.get_info() - name = info.server_name + name = prereqs.name self.title.set_text(name) - if total < 1: - self.tree_frame.set_visible(False) + + if total_mods < 1: + self.scrolled.set_visible(False) self.mod_count.set_visible(False) - return + self.mods_placeholder.set_visible(True) + self.ok.set_label(preconnect.connect) else: - self.tree.set_visible(True) + self.scrolled.set_visible(True) self.mod_count.set_visible(True) + self.mods_placeholder.set_visible(False) + # TODO: print no. of mods that need updating prefix = preconnect.total_mods - self.mod_count.set_text(f"{prefix}{str(total)}") + self.mod_count.set_text(f"{prefix}{str(total_mods)}") - """ - blocking warning types: - - build mismatch - - version mismatch - - not enough drive space - passing warning types: - - dayz is running - - steam is not running - - server has password - """ - # TODO: if errors > 1, disable buttons + self._process_warnings(prereqs) - def _set_warnings(self) -> None: - self.warning_tree.append(["Password protected", "Some other error", "Error 3"]) - self.error_tree.append(["Password protected", "Some other error", "Error 3"]) - pass + def add_errors(self, errors: list[str]) -> None: + self.error_tree.extend(errors) - def download_mods(self) -> None: - pass - - def connect_server(self) -> None: - # TODO: add to history file and list store - # TODO: concat mods - """ - spawn dialog in thread - watch for subprocess - return to prior page when finished - """ - pass + def add_warnings(self, warnings: list[str]) -> None: + self.warning_tree.extend(warnings) From 0e6caed64de758e20cf78eebe3852ed4726f8742 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 7 May 2026 02:56:38 +0900 Subject: [PATCH 244/309] chore: update warning messages in preconnect dialog --- dzgui/views/pages/preconnect.py | 35 ++++++++++++++++++++++----------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/dzgui/views/pages/preconnect.py b/dzgui/views/pages/preconnect.py index 8dc5792..f6cdaea 100644 --- a/dzgui/views/pages/preconnect.py +++ b/dzgui/views/pages/preconnect.py @@ -103,7 +103,6 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): self.cancel = Gtk.Button( label=preconnect.cancel, halign=Gtk.Align.END, sensitive=False, hexpand=True ) - # TODO: dynamic button text if no mods needed self.ok = Gtk.Button(label=preconnect.update_mods, halign=Gtk.Align.END) self.button_box = Gtk.Box( @@ -122,7 +121,13 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): add_class(self.title, "preconnect-heading") self.tree = ServerModTreeView(self.controller) - self.mod_count = Gtk.Label(label="", halign=Gtk.Align.START, margin_start=5) + self.mod_count = Gtk.Label( + label="", + halign=Gtk.Align.START, + margin_start=10, + margin_top=10, + margin_bottom=10, + ) # TODO: live count of remaining downloads # "Steam is downloading: {mod_name}" # mention whether manual or auto mod is active @@ -142,17 +147,18 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): self.tree_frame = HeadingFrame(self.tree_box, preconnect.mods) # TODO: abstract into components + + # TODO: strings self.warning_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) self.warning_tree = MaskedTree(WARNING) - # TODO: strings self.warning_placeholder = Placeholder("No warnings.") self.warning_box.add(self.warning_tree) self.warning_box.add(self.warning_placeholder) self.warning_frame = HeadingFrame(self.warning_box, preconnect.warnings) + # TODO: strings self.error_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) self.error_tree = MaskedTree(ERROR) - # TODO: strings self.error_placeholder = Placeholder("No errors.") self.error_box.add(self.error_tree) self.error_box.add(self.error_placeholder) @@ -171,6 +177,7 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): self.connect("map", self._on_map) def _on_map(self, widget: Self) -> None: + self.grab_focus() widgets = ( self.tree_frame, self.mod_count, @@ -180,6 +187,7 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): for widget in widgets: widget.set_visible(True) self.ok.set_sensitive(True) + self.ok.set_label(preconnect.update_mods) def _on_keypress(self, widget: Self, event: Gdk.EventKey) -> None: if event.keyval == Gdk.KEY_Escape: @@ -204,30 +212,29 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): if prereqs.binary_missing: errors.append( - f"Remote server is running the build '{prereqs.build}', but it is not installed" + f"Remote server is running the build '{prereqs.build}', but it is not installed." ) elif prereqs.local_version != prereqs.remote_version: - print("versions do not match") errors.append( - f"Local client version '{prereqs.local_version}' does not match remote version '{prereqs.remote_version}'" + f"Local client version '{prereqs.local_version}' does not match remote version '{prereqs.remote_version}'." ) if prereqs.required_space > prereqs.available_space: required_pretty = number(prereqs.required_space) available_pretty = number(prereqs.available_space) errors.append( - f"Need to update {required_pretty} MiB of mods, but installation path only has {available_pretty} MiB" + f"Need to update {required_pretty} MiB of mods, but installation path only has {available_pretty} MiB." ) if prereqs.passworded: warnings.append( - "Protected: you will be prompted for a password when connecting to this server" + "Protected: you will be prompted for a password when connecting to this server." ) if prereqs.dayz_running is True: warnings.append( - "It looks like DayZ is already running in the background. Exit DayZ before connecting" + "It looks like DayZ is already running in the background. Exit DayZ before connecting." ) if prereqs.steam_running is False: warnings.append( - "It looks like Steam is not running. Launch Steam before connecting" + "It looks like Steam is not running. Launch Steam before connecting." ) self.add_warnings(warnings) @@ -258,8 +265,12 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): self.mods_placeholder.set_visible(False) # TODO: print no. of mods that need updating + suffix = "" + if prereqs.required_space > 0: + pretty = number(prereqs.required_space) + suffix = f" Need to download {pretty} MiB of mod updates." prefix = preconnect.total_mods - self.mod_count.set_text(f"{prefix}{str(total_mods)}") + self.mod_count.set_text(f"{prefix}{str(total_mods)}.{suffix}") self._process_warnings(prereqs) From fbbf0d9e66bcc766db3a46e582d8cb6b08b9f1aa Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 7 May 2026 06:30:33 +0900 Subject: [PATCH 245/309] feat: mod enqueue logic (WIP) --- dzgui/api/mods.py | 10 ++++ dzgui/api/steam.py | 87 ++++++++++------------------- dzgui/const/enum.py | 2 +- dzgui/controllers/mc.py | 6 +- dzgui/managers/connection.py | 49 +++++++++++----- dzgui/util/bash.py | 1 + dzgui/views/components/statusbar.py | 1 + dzgui/views/pages/preconnect.py | 84 ++++++++++++++++++++++------ 8 files changed, 148 insertions(+), 92 deletions(-) diff --git a/dzgui/api/mods.py b/dzgui/api/mods.py index 8d43e73..30eb2f6 100644 --- a/dzgui/api/mods.py +++ b/dzgui/api/mods.py @@ -160,3 +160,13 @@ def find_stale_mods(config: Path) -> list[int]: unique_mods = set(remote_mods) stale = [mod for mod in local if mod not in unique_mods] return stale + + +def get_mod_dir_size(path: Path) -> int: + size = 0 + for i in Path(path).iterdir(): + if i.is_file(): + size += i.stat().st_size + elif i.is_dir(): + size += get_mod_dir_size(i) + return size diff --git a/dzgui/api/steam.py b/dzgui/api/steam.py index fc310a9..55e2b50 100644 --- a/dzgui/api/steam.py +++ b/dzgui/api/steam.py @@ -8,50 +8,12 @@ from pathlib import Path from dzgui.const.constants import APP_NAME from dzgui.const.endpoints import STEAM_PUBLISHED_FILES -from dzgui.util.bash import concat_bash_args logger = logging.getLogger(APP_NAME) -def query_defunct() -> None: - 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[int]) -> str: +def concat_mods(mods: list[str]) -> str: for mod in mods: mods[mod] = f"@{mod}" return ";".join(mods) @@ -68,22 +30,32 @@ def get_local_signatures(version_file: Path) -> dict[str, int]: return hashes +def enqueue_mod(mod: str, appid: int) -> None: + args = [ + "steam", + f"steam://url/CommunityFilePage/{mod}+workshop_download_item", + str(appid), + mod, + ] + subprocess.Popen(["/usr/bin/env", "bash", *args]) + + def get_needs_update( - version_file: Path, remote_hashes: list[tuple[str, int, str]] -) -> list[tuple[str, int, str]]: + version_file: Path, remote_hashes: list[tuple[str, str, int, int]] +) -> list[tuple[str, str, int, int]]: local_hashes = get_local_signatures(version_file) needs_update: list[tuple[str, str]] = [] - for _id, _hash, size in remote_hashes: + for title, _id, _hash, size in remote_hashes: if _id not in local_hashes: - needs_update.append((_id, _hash, size)) + needs_update.append((title, _id, _hash, size)) elif _hash != local_hashes[_id]: - needs_update.append((_id, _hash, size)) + needs_update.append((title, _id, _hash, size)) else: continue return needs_update -def get_remote_signatures(mods: list[str]) -> list[tuple[str, int]]: +def get_remote_signatures(mods: list[str]) -> list[tuple[str, str, int, int]]: """ Attempts to continue connecting even if signatures are empty """ @@ -99,24 +71,26 @@ def get_remote_signatures(mods: list[str]) -> list[tuple[str, int]]: if r.status_code != 200: return [] - hashes: list[tuple[str, int, str]] = [] + hashes: list[tuple[str, int, int]] = [] j = r.json() rows = j["response"]["publishedfiledetails"] for row in rows: + title = row["title"] _id = row["publishedfileid"] time = row["time_updated"] - size = row["file_size"] - hashes.append((_id, time, size)) + size = int(row["file_size"]) + hashes.append((title, _id, time, size)) return hashes -# 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[int]) -> None: - # TODO: get name from configs - # TODO: concat_mods(mods): - # @;@; +# TODO:: set config to name=user, use official server and no mods, +# ensure that formatted string is identical to fixture with same hash +def connect(addr: str, appid: int, name: str, mods: list[str]) -> None: + concat = concat_mods(mods) params = [ + "steam", + "-applaunch", + appid, f"-connect={addr}", "-nolauncher", "-nosplash", @@ -124,10 +98,7 @@ def connect(addr: str, appid: int, name: str, mods: list[int]) -> None: f"-name={name}", f"-mod={concat}", ] - # TODO: get steam launch command from configs - # args = concat_bash_args() - # - proc = subprocess.Popen([*args, "-applaunch", appid, *params]) + proc = subprocess.Popen([*params]) # check proc.returncode diff --git a/dzgui/const/enum.py b/dzgui/const/enum.py index be62cb2..aad7698 100644 --- a/dzgui/const/enum.py +++ b/dzgui/const/enum.py @@ -112,7 +112,7 @@ class NotebookPage(EnumWithAttrs): OPTIONS = {"crumbs": strings.crumbs.options, "statusbar": False} SERVERS = {"crumbs": strings.crumbs.servers, "statusbar": True} THANKS = {"crumbs": strings.crumbs.thanks, "statusbar": True} - CONNECTION = {"crumbs": "Connect", "statusbar": False} + CONNECTION = {"crumbs": "Connect", "statusbar": True} class RowType(EnumWithAttrs): diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 36ea9c1..2bc6b32 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -20,6 +20,7 @@ from dzgui.controllers.emitter import Emitter from dzgui.managers.config import ConfigManager from dzgui.managers.connection import ConnectionManager from dzgui.managers.contextmenu import ContextMenuManager +from dzgui.managers.mods import ModManager from dzgui.managers.notes import NoteManager from dzgui.model.servers import ServerModelManager from dzgui.util.diag import write_diagnostic @@ -215,8 +216,6 @@ class Controller(GObject.GObject): open_user_workshop(uid, client) def load_mods(self) -> None: - from dzgui.managers.mods import ModManager - self.mod_man = ModManager(self) def uncolorize_mods(self) -> None: @@ -473,3 +472,6 @@ class Controller(GObject.GObject): def update_and_connect(self) -> None: self.connection_man.update_and_connect() + + def update_status(self, mod: str, mark_finished: bool = False) -> None: + self.mediator.preconnect.update_mod(mod, mark_finished) diff --git a/dzgui/managers/connection.py b/dzgui/managers/connection.py index 26f2a69..ce8ed9b 100644 --- a/dzgui/managers/connection.py +++ b/dzgui/managers/connection.py @@ -1,5 +1,6 @@ import logging import shutil +import time from dataclasses import dataclass from packaging.version import Version @@ -8,9 +9,9 @@ from typing import TYPE_CHECKING import dzgui.api.pefile as PeFile import dzgui.api.servers as Servers -from dzgui.api.steam import get_remote_signatures, get_needs_update +from dzgui.api.steam import enqueue_mod, get_remote_signatures, get_needs_update -from dzgui.api.mods import get_local_mod_ids +from dzgui.api.mods import get_mod_dir_size, get_local_mod_ids, get_local_mod_path from dzgui.const.constants import ( APP_NAME, APPID_DAYZ, @@ -29,7 +30,7 @@ from dzgui.views.dialogs.servers import ServerDetailsDialog, ServerModDialog import gi gi.require_version("Gtk", "3.0") -from gi.repository import Gtk # noqa E402 +from gi.repository import Gtk, GLib # noqa E402 if TYPE_CHECKING: from dzgui.api.servers import A2SInfo, Record @@ -62,6 +63,7 @@ class ConnectionManager: self.appid: int self.record: Record + self.workshop: Path self.remote_mod_ids: list[str] = [] self.missing_mods: list[str] = [] @@ -101,6 +103,7 @@ class ConnectionManager: free_mib = 0.0 steam_path = Path(self.controller.query_config(Preferences.DEFAULT)) + self.workshop = get_local_mod_path(steam_path) local_version = PeFile.get_pretty_version(steam_path, info.game_id) if local_version is None: local_version = "0.0.0" @@ -124,13 +127,12 @@ class ConnectionManager: pefile_path = PeFile.get_pefile_path(steam_path, info.game_id) total, used, free = shutil.disk_usage(pefile_path) if len(self.missing_mods) > 0: - required_size = sum(int(row[2]) for row in self.missing_mods) + required_size = sum(row[2] for row in self.missing_mods) required_mib = format_mib(required_size) free_mib = format_mib(required_size) dayz_running = is_dayz_running() steam_running = is_steam_running() - # TODO: is dayz downloading prereqs = Prerequisites( name=info.server_name, @@ -207,17 +209,38 @@ class ConnectionManager: print(self.record.ip) print(self.record.gameport) print(self.appid) - # TODO: convert mod ids to symlink hashes + # TODO: add to history file and list store # steam api, concat mods - # TODO: custom threading with glib idle callback + @call_on_thread("Waiting for Steam to update mods") def update_mods(self) -> None: - print(self.missing_mods) - # TODO: when downloading mods, create symlinks if missing - # TODO: pack a final PreReq struct with pre-processed values - # self.needs_update - # then connect - pass + # NOTE: fast enqueue all mods in auto mode + for title, mod, stamp, size in self.missing_mods: + # TODO: check for cancel event + enqueue_mod(mod, self.appid) + time.sleep(2) + + for title, mod, stamp, size in self.missing_mods: + mod_path = self.workshop / mod + + while mod_path.is_dir() is False: + time.sleep(1) + while True: + # NOTE: mods will finish at the same time + # TODO: check for cancel event + cur_size = get_mod_dir_size(mod_path) + if cur_size == size: + break + time.sleep(1) + + # TODO: update tree checkmarks + func = StoredFunc(self.controller.update_status, "All mods updated.") + self.thread_man.set_cleanup_func(func) + # TODO: after downloading mods, create all symlinks (and clone) + # GLib.idle_add(self._mark_finished) + + # def _mark_finished(self) -> None: + # self.controller.update_status("All mods updated.", mark_finished=True) def update_and_connect(self) -> None: if len(self.missing_mods) > 0: diff --git a/dzgui/util/bash.py b/dzgui/util/bash.py index 879e433..9c2a73d 100644 --- a/dzgui/util/bash.py +++ b/dzgui/util/bash.py @@ -1,4 +1,5 @@ import shlex + def concat_bash_args(command: str) -> list[str]: return shlex.split(command) diff --git a/dzgui/views/components/statusbar.py b/dzgui/views/components/statusbar.py index 4bf04d1..ebb3976 100644 --- a/dzgui/views/components/statusbar.py +++ b/dzgui/views/components/statusbar.py @@ -76,6 +76,7 @@ class Statusbar(Gtk.Grid): NotebookPage.THANKS, NotebookPage.CHANGELOG, NotebookPage.LOG, + NotebookPage.CONNECTION, ): self.set_by_context(enum, esc_to_return) return diff --git a/dzgui/views/pages/preconnect.py b/dzgui/views/pages/preconnect.py index f6cdaea..c0a8e36 100644 --- a/dzgui/views/pages/preconnect.py +++ b/dzgui/views/pages/preconnect.py @@ -5,6 +5,7 @@ from dzgui.const.constants import ( ERROR, WARNING, ) +from dzgui.const.enum import NotebookPage from dzgui.util.css import add_class from dzgui.util.localize import number from dzgui.strings import preconnect @@ -99,20 +100,33 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): self.controller.register_widget("preconnect", self) - self.back = Gtk.Button(label=preconnect.back, halign=Gtk.Align.START) - self.cancel = Gtk.Button( - label=preconnect.cancel, halign=Gtk.Align.END, sensitive=False, hexpand=True + self.back = Gtk.Button( + label=preconnect.back, halign=Gtk.Align.END, hexpand=True ) self.ok = Gtk.Button(label=preconnect.update_mods, halign=Gtk.Align.END) + # TODO: abstract + self.raise_window = Gtk.CheckButton( + label="Foreground DZGUI while downloading", + halign=Gtk.Align.END, + hexpand=True, + valign=Gtk.Align.END, + visible=False, + has_tooltip=True, + sensitive=False, + tooltip_text="This option is available if wmctrl or xdotool is installed on the system", + ) self.button_box = Gtk.Box( - orientation=Gtk.Orientation.HORIZONTAL, + orientation=Gtk.Orientation.VERTICAL, valign=Gtk.Align.END, vexpand=True, spacing=5, ) - for button in self.back, self.cancel, self.ok: - self.button_box.add(button) + box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5) + for button in self.back, self.ok: + box.add(button) + for el in self.raise_window, box: + self.button_box.add(el) self.back.connect("clicked", self._on_back_clicked) self.ok.connect("clicked", self._on_ok_clicked) @@ -128,6 +142,21 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): margin_top=10, margin_bottom=10, ) + self.spinner = Gtk.Spinner() + self.progress_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=15) + self.progress_box.add(self.mod_count) + self.progress_box.add(self.spinner) + self.cancel = Gtk.Button( + label=preconnect.cancel, + halign=Gtk.Align.START, + hexpand=True, + margin_bottom=5, + margin_top=5, + ) + self.progress_box.add(self.cancel) + self.cancel.connect("clicked", self._on_cancel_clicked) + self.cancel.set_visible(False) + # TODO: live count of remaining downloads # "Steam is downloading: {mod_name}" # mention whether manual or auto mod is active @@ -138,7 +167,7 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): self.tree_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) self.tree_box.add(self.scrolled) - self.tree_box.add(self.mod_count) + self.tree_box.add(self.progress_box) # TODO: strings self.mods_placeholder = Placeholder("This server has no mods.") @@ -180,12 +209,18 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): self.grab_focus() widgets = ( self.tree_frame, - self.mod_count, + self.progress_box, self.error_placeholder, self.warning_placeholder, ) for widget in widgets: widget.set_visible(True) + self.raise_window.set_visible(False) + + # TODO: enable button if wmctrl or xdotool is available + # TODO: check this at boot time and pass in via connection manager + + self.cancel.set_visible(False) self.ok.set_sensitive(True) self.ok.set_label(preconnect.update_mods) @@ -193,18 +228,24 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): if event.keyval == Gdk.KEY_Escape: self.back.emit("clicked") + def _on_cancel_clicked(self, button: Gtk.Button) -> None: + print("user canceled") + def _on_ok_clicked(self, button: Gtk.Button) -> None: - # TODO: update mod store in place with spinner/toast - # no dialog # TODO: cancel mod downloads - # TODO: add to history file and list store - # TODO: concat mods + # sets some kind of global event listener + if self.mod_count.get_visible(): + # TODO: set ready mode + # TODO: strings + self.mod_count.set_label("Enqueuing downloads") + self.spinner.start() + self.cancel.set_visible(True) + self.ok.set_label(preconnect.connect) self.controller.update_and_connect() pass def _on_back_clicked(self, button: Gtk.Button) -> None: - page = self.controller.get_prior_page() - self.controller.open_page(page) + self.controller.open_page(NotebookPage.SERVERS) def _process_warnings(self, prereqs: "Prerequisites") -> None: warnings: list[str] = [] @@ -256,16 +297,17 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): if total_mods < 1: self.scrolled.set_visible(False) - self.mod_count.set_visible(False) + self.progress_box.set_visible(False) self.mods_placeholder.set_visible(True) self.ok.set_label(preconnect.connect) else: self.scrolled.set_visible(True) - self.mod_count.set_visible(True) + self.progress_box.set_visible(True) + self.raise_window.set_visible(True) self.mods_placeholder.set_visible(False) - # TODO: print no. of mods that need updating - suffix = "" + # TODO: strings + suffix = "All mods are up to date." if prereqs.required_space > 0: pretty = number(prereqs.required_space) suffix = f" Need to download {pretty} MiB of mod updates." @@ -274,6 +316,12 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): self._process_warnings(prereqs) + def update_mod(self, text: str, mark_finished: bool = False) -> None: + self.mod_count.set_label(text) + if mark_finished: + self.cancel.set_visible(False) + self.spinner.stop() + def add_errors(self, errors: list[str]) -> None: self.error_tree.extend(errors) From d3e82fc4b1e5a33bc639ad176e56347e2bdd876e Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 7 May 2026 07:00:01 +0900 Subject: [PATCH 246/309] chore: add boilerplate --- dzgui/api/steam.py | 2 +- dzgui/init/proc.py | 4 ++-- dzgui/managers/connection.py | 32 ++++++++++++++++++++++---------- dzgui/util/symlink.py | 3 +-- 4 files changed, 26 insertions(+), 15 deletions(-) diff --git a/dzgui/api/steam.py b/dzgui/api/steam.py index 55e2b50..1cb6e5a 100644 --- a/dzgui/api/steam.py +++ b/dzgui/api/steam.py @@ -99,7 +99,7 @@ def connect(addr: str, appid: int, name: str, mods: list[str]) -> None: f"-mod={concat}", ] proc = subprocess.Popen([*params]) - # check proc.returncode + return proc.returncode def find_user_id(path: Path) -> str | None: diff --git a/dzgui/init/proc.py b/dzgui/init/proc.py index a3e6c1f..a50be49 100644 --- a/dzgui/init/proc.py +++ b/dzgui/init/proc.py @@ -7,7 +7,7 @@ from dzgui.util.strings import init # TODO: simplify -def is_dayz_running(dialog: bool = False) -> None: +def is_dayz_running(dialog: bool = False) -> bool | None: if dialog is False: return is_running(DAYZ_BINARY) if is_running(DAYZ_BINARY) is True: @@ -15,7 +15,7 @@ def is_dayz_running(dialog: bool = False) -> None: sys.exit(1) -def is_steam_running(dialog: bool = False) -> None: +def is_steam_running(dialog: bool = False) -> bool | None: # TODO: check proc name of flatpak steam if dialog is False: return is_running(STEAM_CMD) diff --git a/dzgui/managers/connection.py b/dzgui/managers/connection.py index ce8ed9b..f769c69 100644 --- a/dzgui/managers/connection.py +++ b/dzgui/managers/connection.py @@ -9,6 +9,8 @@ from typing import TYPE_CHECKING import dzgui.api.pefile as PeFile import dzgui.api.servers as Servers +import dzgui.api.steam.connect as Connect + from dzgui.api.steam import enqueue_mod, get_remote_signatures, get_needs_update from dzgui.api.mods import get_mod_dir_size, get_local_mod_ids, get_local_mod_path @@ -24,6 +26,7 @@ from dzgui.init.proc import is_dayz_running, is_steam_running from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager from dzgui.util.format import format_mib from dzgui.util.strings import dialog, server_timeout, checkmark +from dzgui.util.symlink import rebuild_symlinks from dzgui.views.dialogs.generic import ExceptionDialog from dzgui.views.dialogs.servers import ServerDetailsDialog, ServerModDialog @@ -209,14 +212,22 @@ class ConnectionManager: print(self.record.ip) print(self.record.gameport) print(self.appid) + + addr = f"{self.record.ip}:{self.record.gameport}" + playername = "test" + proc = Connect(addr, self.appid, playername, self.remote_mod_ids) + # TODO: test returncode + # proc.returncode + # TODO: wait for DAYZ_BINARY to load + # TODO: add to history file and list store - # steam api, concat mods @call_on_thread("Waiting for Steam to update mods") def update_mods(self) -> None: # NOTE: fast enqueue all mods in auto mode for title, mod, stamp, size in self.missing_mods: - # TODO: check for cancel event + # TODO: boolean cancel button on threadman dialog sets event inside threadman + # TODO: check for early cancel event via sigint enqueue_mod(mod, self.appid) time.sleep(2) @@ -227,20 +238,21 @@ class ConnectionManager: time.sleep(1) while True: # NOTE: mods will finish at the same time - # TODO: check for cancel event + # TODO: check for early cancel event cur_size = get_mod_dir_size(mod_path) if cur_size == size: break time.sleep(1) - # TODO: update tree checkmarks - func = StoredFunc(self.controller.update_status, "All mods updated.") - self.thread_man.set_cleanup_func(func) - # TODO: after downloading mods, create all symlinks (and clone) - # GLib.idle_add(self._mark_finished) + # TODO: get config path or just push steam path directly + rebuild_symlinks(self.controller.get_prefs().paths.config) + # TODO: update version file - # def _mark_finished(self) -> None: - # self.controller.update_status("All mods updated.", mark_finished=True) + # TODO: update tree checkmarks + func = StoredFunc( + self.controller.update_status, "All mods updated.", mark_finished=True + ) + self.thread_man.set_cleanup_func(func) def update_and_connect(self) -> None: if len(self.missing_mods) > 0: diff --git a/dzgui/util/symlink.py b/dzgui/util/symlink.py index 1665441..ad96e0a 100644 --- a/dzgui/util/symlink.py +++ b/dzgui/util/symlink.py @@ -14,7 +14,6 @@ logger = logging.getLogger(APP_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) # NOTE: unlink stale symlinks for file in dayz_path.iterdir(): @@ -33,7 +32,7 @@ def rebuild_symlinks(config: Path) -> None: def clone_symlinks(steam_path: Path) -> None: """ - Used after any symlink operation + Shares symlinks between builds. Used after any symlink operation """ try: dayz_path = PeFile.get_nested_app_path(steam_path, APPID_DAYZ) From 9fdb54b65a2fbf864ba5783d43653f01c48aacd7 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 7 May 2026 07:02:53 +0900 Subject: [PATCH 247/309] chore: update changelog --- CHANGELOG.md | 9 +++++---- dzgui/managers/connection.py | 14 +++++++++----- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b03c65..75a62ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,20 +32,23 @@ - Refresh servers button - Early load alerts button - Preconnect dialog +- Preconnect warnings/failsafes like filesize ## Changed - Reduce padding on keys button - Boldface breadcrumbs - Bold labels inside frames -- Sidebar buttos do not steal focus +- Sidebar buttons do not steal focus - Copy IP copies IP:queryport only instead of IP:gameport:queryport, mimics syntax needed by add by ip method - Load new model into view without flushing - Cull servers with abnormal queue values (integer overflow: 2147483647) - Packaging structure +- Suppress log messages from imported modules ## Dropped - Debug mode - Branch switching +- Manual mod install mode (describe rationale) ## Fixed - Longstanding issue with left clicks not registering as tree selection changes after spamming keyboard input @@ -57,12 +60,10 @@ - Load offline mods - Choose to jump into splash screen instead of server - Setup wizard -- Move debug mode to developers only - Local documentation +- Raw debug command in context menu ## Developer-facing - Add pyproject.toml file - Show deprecation warnings - Options -> Dev page -- Raw debug command -- Moved debug log to this mode diff --git a/dzgui/managers/connection.py b/dzgui/managers/connection.py index f769c69..b66c730 100644 --- a/dzgui/managers/connection.py +++ b/dzgui/managers/connection.py @@ -9,9 +9,13 @@ from typing import TYPE_CHECKING import dzgui.api.pefile as PeFile import dzgui.api.servers as Servers -import dzgui.api.steam.connect as Connect -from dzgui.api.steam import enqueue_mod, get_remote_signatures, get_needs_update +from dzgui.api.steam import ( + connect, + enqueue_mod, + get_remote_signatures, + get_needs_update, +) from dzgui.api.mods import get_mod_dir_size, get_local_mod_ids, get_local_mod_path from dzgui.const.constants import ( @@ -208,14 +212,14 @@ class ConnectionManager: dialog = ExceptionDialog(self.controller, server_timeout) dialog.run() - def connect(self) -> None: + def connect_steam(self) -> None: print(self.record.ip) print(self.record.gameport) print(self.appid) addr = f"{self.record.ip}:{self.record.gameport}" playername = "test" - proc = Connect(addr, self.appid, playername, self.remote_mod_ids) + proc = connect(addr, self.appid, playername, self.remote_mod_ids) # TODO: test returncode # proc.returncode # TODO: wait for DAYZ_BINARY to load @@ -258,4 +262,4 @@ class ConnectionManager: if len(self.missing_mods) > 0: self.update_mods() else: - self.connect() + self.connect_steam() From 90a1740bf0626ce8d981fe2d338d575551f0832f Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 7 May 2026 07:26:08 +0900 Subject: [PATCH 248/309] fix: use MenuShell signal --- dzgui/views/mixins/context_mixin.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/dzgui/views/mixins/context_mixin.py b/dzgui/views/mixins/context_mixin.py index a384c1f..6a0a577 100644 --- a/dzgui/views/mixins/context_mixin.py +++ b/dzgui/views/mixins/context_mixin.py @@ -99,8 +99,8 @@ class ContextMixin(TreeView): if not is_navkey(event.keyval): return False menu = self.context_menu - sel = menu.get_selected_item() children = menu.get_children() + sel = menu.get_selected_item() for i, child in enumerate(children): if sel is child: ind = i @@ -108,18 +108,17 @@ class ContextMixin(TreeView): match event.keyval: case Gdk.KEY_j: - if ind == len(children) - 1: - return True - menu.select_item(children[ind + 1]) + if ind + 1 > len(children) - 1: + return False + menu.emit("move-current", Gtk.MenuDirectionType.NEXT) case Gdk.KEY_k: if ind - 1 < 0: - return True - menu.select_item(children[ind - 1]) + return False + menu.emit("move-current", Gtk.MenuDirectionType.PREV) case Gdk.KEY_g: menu.select_item(children[0]) case Gdk.KEY_G: - ind = len(children) - 1 - menu.select_item(children[ind]) + menu.select_item(children[-1]) case _: return False return True From 2b10fcaaada9a954034796ec9a08c51d73c1a0f1 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 7 May 2026 16:09:41 +0900 Subject: [PATCH 249/309] feat: raise window (WIP) --- dzgui/api/mods.py | 5 +++- dzgui/api/servers.py | 2 +- dzgui/config/userprefs.py | 12 ++++++--- dzgui/const/constants.py | 1 + dzgui/controllers/mc.py | 4 +-- dzgui/init/proc.py | 19 ++++++++++++++ dzgui/main.py | 34 +++++++++++++++++-------- dzgui/managers/connection.py | 17 ++++++++++--- dzgui/views/dialogs/note.py | 2 +- dzgui/views/pages/preconnect.py | 45 ++++++++++++++++----------------- 10 files changed, 95 insertions(+), 46 deletions(-) diff --git a/dzgui/api/mods.py b/dzgui/api/mods.py index 30eb2f6..4ff646b 100644 --- a/dzgui/api/mods.py +++ b/dzgui/api/mods.py @@ -49,7 +49,6 @@ def get_local_mods(workshop_path: Path) -> list[Path]: return mods -# TODO: TEST: mock bad meta files with fixtures and skip them def parse_meta(file: Path) -> ModMeta: mod = file / "meta.cpp" if mod.exists() is False: @@ -163,6 +162,10 @@ def find_stale_mods(config: Path) -> list[int]: def get_mod_dir_size(path: Path) -> int: + """ + This is not a guarantee of parity, as user may adjusted contents of local mods, + so upstream epoch time is still used + """ size = 0 for i in Path(path).iterdir(): if i.is_file(): diff --git a/dzgui/api/servers.py b/dzgui/api/servers.py index 6c5f6e0..b3eebd1 100644 --- a/dzgui/api/servers.py +++ b/dzgui/api/servers.py @@ -276,7 +276,7 @@ def query_direct(ip: str, qport: int, timeout: float = 3.0) -> dict[str, Any] | info = a2s.info((ip, qport), timeout) return source_info_to_dict(ip, qport, info) except Exception as e: - logger.critical(e) + logger.warning(e) return None diff --git a/dzgui/config/userprefs.py b/dzgui/config/userprefs.py index c28b63c..b0d7c4a 100644 --- a/dzgui/config/userprefs.py +++ b/dzgui/config/userprefs.py @@ -1,6 +1,9 @@ from dataclasses import dataclass -from dzgui.config.xdg import Xdg -from dzgui.util.ip import Coords +from typing import TYPE_CHECKING, Union + +if TYPE_CHECKING: + from dzgui.config.xdg import Xdg + from dzgui.util.ip import Coords # NOTE: mutable dataclass, 'use_miles' key changes on demand @@ -9,8 +12,9 @@ class UserPrefs: is_steam_deck: bool is_game_mode: bool is_debug: bool - coords: Coords | None + coords: Union["Coords", None] version: str allow_updates: bool - paths: Xdg + paths: "Xdg" use_miles: bool + foreground_cmd: str | None diff --git a/dzgui/const/constants.py b/dzgui/const/constants.py index c50f0fe..5cbda52 100644 --- a/dzgui/const/constants.py +++ b/dzgui/const/constants.py @@ -65,3 +65,4 @@ CHANGELOG_PATH = "data/CHANGELOG.md" CSS_PATH = "data/app.css" LOG_FILTERS = ("CRITICAL", "WARNING", "INFO", "DEBUG") +FOREGROUND_CMDS = ("wmctrlu", "xdotool") diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 2bc6b32..5cb5d9a 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -470,8 +470,8 @@ class Controller(GObject.GObject): ind = self.config_man.get_start_tab() self.get_servers().notebook.set_current_page(ind) - def update_and_connect(self) -> None: - self.connection_man.update_and_connect() + def update_and_connect(self, raise_window: bool) -> None: + self.connection_man.update_and_connect(raise_window) def update_status(self, mod: str, mark_finished: bool = False) -> None: self.mediator.preconnect.update_mod(mod, mark_finished) diff --git a/dzgui/init/proc.py b/dzgui/init/proc.py index a50be49..e23c80d 100644 --- a/dzgui/init/proc.py +++ b/dzgui/init/proc.py @@ -1,10 +1,14 @@ import psutil +import subprocess +import shutil 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 +# TODO: move to util.proc + # TODO: simplify def is_dayz_running(dialog: bool = False) -> bool | None: @@ -29,3 +33,18 @@ def is_running(proc: str) -> bool: if process.info["name"] == proc: return True return False + + +def has_cmd(cmd: str) -> bool: + if shutil.which(cmd) is not None: + return True + return False + + +def foreground(cmd: str, pid: int) -> None: + if cmd == "wmctrl": + # TODO: convert wid from hexadecimal + args = [cmd, "-a", "DZGUI"] + elif cmd == "xdotool": + args = [cmd, "search", "--onlyvisible", "--name", "DZGUI", "windowactivate"] + subprocess.Popen([*args]) diff --git a/dzgui/main.py b/dzgui/main.py index da6ead1..47278df 100644 --- a/dzgui/main.py +++ b/dzgui/main.py @@ -7,7 +7,7 @@ import warnings from typing import TYPE_CHECKING from dzgui.api.mods import remove_stale_signatures -from dzgui.const.constants import APP_NAME +from dzgui.const.constants import APP_NAME, FOREGROUND_CMDS from dzgui.const.enum import Preferences from dzgui.config.ipdb import get_ipdb from dzgui.config.query import lookup @@ -25,7 +25,7 @@ from dzgui.init.migrate import ( ) 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.proc import has_cmd, is_dayz_running, is_steam_running from dzgui.init.update import allow_updates, check_updates from dzgui.util.map_count import get_map_count @@ -142,15 +142,29 @@ def main() -> None: local_coords = get_local_coords(XDG.ips) use_miles = lookup(XDG.config, Preferences.DIST) + tool = None + for cmd in FOREGROUND_CMDS: + if has_cmd(cmd) is True: + tool = cmd + break + + print(os.getpid()) + # TODO: try getting pid on demand later + # pid = os.getpid() + + # TODO: push is_game_mode to preconnect dialog prefs = UserPrefs( - _is_steam_deck, - _is_game_mode, - args.debug, - local_coords, - version, - allow, - XDG, - use_miles, + is_steam_deck=_is_steam_deck, + is_game_mode=_is_game_mode, + is_debug=args.debug, + coords=local_coords, + version=version, + allow_updates=allow, + paths=XDG, + use_miles=use_miles, + foreground_cmd=tool, ) + # TODO: drop allow updates + # TODO: strings print("All OK. Loading UI...") App(prefs) diff --git a/dzgui/managers/connection.py b/dzgui/managers/connection.py index b66c730..ee72e17 100644 --- a/dzgui/managers/connection.py +++ b/dzgui/managers/connection.py @@ -1,4 +1,5 @@ import logging +import os import shutil import time @@ -26,7 +27,7 @@ from dzgui.const.constants import ( APPNAME_DAYZ_EXP, ) from dzgui.const.enum import Preferences -from dzgui.init.proc import is_dayz_running, is_steam_running +from dzgui.init.proc import foreground, is_dayz_running, is_steam_running from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager from dzgui.util.format import format_mib from dzgui.util.strings import dialog, server_timeout, checkmark @@ -60,6 +61,7 @@ class Prerequisites: dayz_running: bool steam_running: bool mods: list[str] + foreground_cmd: str | None class ConnectionManager: @@ -141,6 +143,8 @@ class ConnectionManager: dayz_running = is_dayz_running() steam_running = is_steam_running() + self.foreground_cmd = self.controller.get_prefs().foreground_cmd + prereqs = Prerequisites( name=info.server_name, appid=info.game_id, @@ -154,6 +158,7 @@ class ConnectionManager: dayz_running=dayz_running, steam_running=steam_running, mods=remote_mods, + foreground_cmd=self.foreground_cmd, # TODO: change to bool ) func = StoredFunc(self.controller.open_connection_assistant, prereqs) @@ -227,7 +232,7 @@ class ConnectionManager: # TODO: add to history file and list store @call_on_thread("Waiting for Steam to update mods") - def update_mods(self) -> None: + def update_mods(self, raise_window: bool) -> None: # NOTE: fast enqueue all mods in auto mode for title, mod, stamp, size in self.missing_mods: # TODO: boolean cancel button on threadman dialog sets event inside threadman @@ -235,6 +240,10 @@ class ConnectionManager: enqueue_mod(mod, self.appid) time.sleep(2) + if self.foreground_cmd is not None and raise_window is True: + pid = os.getpid() + foreground(self.foreground_cmd, pid) + for title, mod, stamp, size in self.missing_mods: mod_path = self.workshop / mod @@ -258,8 +267,8 @@ class ConnectionManager: ) self.thread_man.set_cleanup_func(func) - def update_and_connect(self) -> None: + def update_and_connect(self, raise_window: bool) -> None: if len(self.missing_mods) > 0: - self.update_mods() + self.update_mods(raise_window) else: self.connect_steam() diff --git a/dzgui/views/dialogs/note.py b/dzgui/views/dialogs/note.py index bda0b75..04f9f42 100644 --- a/dzgui/views/dialogs/note.py +++ b/dzgui/views/dialogs/note.py @@ -43,7 +43,7 @@ class NoteDialog(GenericDialog): self.entry.set_text(note) if len(note) > 0: delete_button.set_sensitive(True) - self.format_secondary_text(f"Current note: {note}") + self.format_secondary_text(f"Current note: '{note}'") elif len(note) == 0: self.ok.set_sensitive(False) diff --git a/dzgui/views/pages/preconnect.py b/dzgui/views/pages/preconnect.py index c0a8e36..f8dee6e 100644 --- a/dzgui/views/pages/preconnect.py +++ b/dzgui/views/pages/preconnect.py @@ -115,6 +115,7 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): has_tooltip=True, sensitive=False, tooltip_text="This option is available if wmctrl or xdotool is installed on the system", + active=True, ) self.button_box = Gtk.Box( orientation=Gtk.Orientation.VERTICAL, @@ -142,20 +143,18 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): margin_top=10, margin_bottom=10, ) - self.spinner = Gtk.Spinner() self.progress_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=15) self.progress_box.add(self.mod_count) - self.progress_box.add(self.spinner) - self.cancel = Gtk.Button( - label=preconnect.cancel, - halign=Gtk.Align.START, - hexpand=True, - margin_bottom=5, - margin_top=5, - ) - self.progress_box.add(self.cancel) - self.cancel.connect("clicked", self._on_cancel_clicked) - self.cancel.set_visible(False) + # self.cancel = Gtk.Button( + # label=preconnect.cancel, + # halign=Gtk.Align.START, + # hexpand=True, + # margin_bottom=5, + # margin_top=5, + # ) + # self.progress_box.add(self.cancel) + # self.cancel.connect("clicked", self._on_cancel_clicked) + # self.cancel.set_visible(False) # TODO: live count of remaining downloads # "Steam is downloading: {mod_name}" @@ -219,8 +218,7 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): # TODO: enable button if wmctrl or xdotool is available # TODO: check this at boot time and pass in via connection manager - - self.cancel.set_visible(False) + self.raise_window.set_sensitive(False) self.ok.set_sensitive(True) self.ok.set_label(preconnect.update_mods) @@ -228,8 +226,8 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): if event.keyval == Gdk.KEY_Escape: self.back.emit("clicked") - def _on_cancel_clicked(self, button: Gtk.Button) -> None: - print("user canceled") + # def _on_cancel_clicked(self, button: Gtk.Button) -> None: + # print("user canceled") def _on_ok_clicked(self, button: Gtk.Button) -> None: # TODO: cancel mod downloads @@ -237,11 +235,10 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): if self.mod_count.get_visible(): # TODO: set ready mode # TODO: strings - self.mod_count.set_label("Enqueuing downloads") - self.spinner.start() - self.cancel.set_visible(True) + # self.mod_count.set_label("Enqueuing downloads") + # self.cancel.set_visible(True) self.ok.set_label(preconnect.connect) - self.controller.update_and_connect() + self.controller.update_and_connect(self.raise_window.get_active()) pass def _on_back_clicked(self, button: Gtk.Button) -> None: @@ -295,6 +292,9 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): name = prereqs.name self.title.set_text(name) + if prereqs.foreground_cmd is not None: + self.raise_window.set_sensitive(True) + if total_mods < 1: self.scrolled.set_visible(False) self.progress_box.set_visible(False) @@ -318,9 +318,8 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): def update_mod(self, text: str, mark_finished: bool = False) -> None: self.mod_count.set_label(text) - if mark_finished: - self.cancel.set_visible(False) - self.spinner.stop() + # if mark_finished: + # self.cancel.set_visible(False) def add_errors(self, errors: list[str]) -> None: self.error_tree.extend(errors) From c0708b9358234861858ab8a74db71ec2075728dc Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 7 May 2026 22:08:55 +0900 Subject: [PATCH 250/309] feat: foreground window (WIP) --- dzgui/const/constants.py | 2 +- dzgui/init/proc.py | 12 +++++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/dzgui/const/constants.py b/dzgui/const/constants.py index 5cbda52..2035553 100644 --- a/dzgui/const/constants.py +++ b/dzgui/const/constants.py @@ -65,4 +65,4 @@ CHANGELOG_PATH = "data/CHANGELOG.md" CSS_PATH = "data/app.css" LOG_FILTERS = ("CRITICAL", "WARNING", "INFO", "DEBUG") -FOREGROUND_CMDS = ("wmctrlu", "xdotool") +FOREGROUND_CMDS = ("wmctrl", "xdotool") diff --git a/dzgui/init/proc.py b/dzgui/init/proc.py index e23c80d..8d094c3 100644 --- a/dzgui/init/proc.py +++ b/dzgui/init/proc.py @@ -43,8 +43,14 @@ def has_cmd(cmd: str) -> bool: def foreground(cmd: str, pid: int) -> None: if cmd == "wmctrl": - # TODO: convert wid from hexadecimal - args = [cmd, "-a", "DZGUI"] + proc = subprocess.run(["wmctrl", "-ilp"], capture_output=True, text=True) + lines = proc.stdout.splitlines() + for line in lines: + els = line.split(" ") + if str(pid) in els: + wid = els[0] + break + subprocess.run(["wmctrl", "-ia", wid]) elif cmd == "xdotool": args = [cmd, "search", "--onlyvisible", "--name", "DZGUI", "windowactivate"] - subprocess.Popen([*args]) + subprocess.Popen([*args]) From b2cec8cbdfcada5db7859f9090757077fae2cf1d Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Fri, 8 May 2026 11:44:38 +0900 Subject: [PATCH 251/309] fix: apply float format func to correct column --- dzgui/views/trees/tree_mods.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/dzgui/views/trees/tree_mods.py b/dzgui/views/trees/tree_mods.py index 8e9d165..17f12fd 100644 --- a/dzgui/views/trees/tree_mods.py +++ b/dzgui/views/trees/tree_mods.py @@ -38,7 +38,7 @@ class ModTreeView(ModsMixin, ContextMixin, TreeView): # type: ignore renderer = Gtk.CellRendererText() column = Gtk.TreeViewColumn(column_title, renderer, text=i) column.set_cell_data_func(renderer, self._format_color, func_data=None) - if i == 4: + if i == 3: column.set_cell_data_func(renderer, self._format_float, func_data=None) if column_title == "Mod": @@ -74,7 +74,9 @@ class ModTreeView(ModsMixin, ContextMixin, TreeView): # type: ignore mod = model.get(tree_iter, 2)[0] return str(mod) - def _parent_selection_changed(self, base_class: TreeView, sel: Gtk.TreeSelection) -> None: + def _parent_selection_changed( + self, base_class: TreeView, sel: Gtk.TreeSelection + ) -> None: pass def _format_color( From a0f5d3ea23c494202d10c505aed3863b1bc7a407 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Fri, 8 May 2026 11:44:56 +0900 Subject: [PATCH 252/309] fix: grab focus on tree --- dzgui/views/pages/preconnect.py | 1 + 1 file changed, 1 insertion(+) diff --git a/dzgui/views/pages/preconnect.py b/dzgui/views/pages/preconnect.py index f8dee6e..fb6db0e 100644 --- a/dzgui/views/pages/preconnect.py +++ b/dzgui/views/pages/preconnect.py @@ -305,6 +305,7 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): self.progress_box.set_visible(True) self.raise_window.set_visible(True) self.mods_placeholder.set_visible(False) + self.tree.grab_focus() # TODO: strings suffix = "All mods are up to date." From a67bbd66f9563b6178ce952e3f3da0fff701047b Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Fri, 8 May 2026 11:45:08 +0900 Subject: [PATCH 253/309] chore: remove boilerplate --- dzgui/main.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/dzgui/main.py b/dzgui/main.py index 47278df..1e1c2be 100644 --- a/dzgui/main.py +++ b/dzgui/main.py @@ -148,10 +148,6 @@ def main() -> None: tool = cmd break - print(os.getpid()) - # TODO: try getting pid on demand later - # pid = os.getpid() - # TODO: push is_game_mode to preconnect dialog prefs = UserPrefs( is_steam_deck=_is_steam_deck, From ddf976734d9d6e1d6cacedee4829ed7e9261da6e Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 9 May 2026 19:58:40 +0900 Subject: [PATCH 254/309] chore: update env var in documentation --- docs/source/locale.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/locale.rst b/docs/source/locale.rst index 4d04242..7a981ac 100644 --- a/docs/source/locale.rst +++ b/docs/source/locale.rst @@ -7,6 +7,6 @@ This is used for thousands separators in long numbers and decimal separators in If you wish to use a specific regional numbering preference while retaining a different base system language (e.g., English language with German-style numbering), pass the desired locale as a variable before launching DZGUI: -``LC_ALL=de_DE.UTF-8 ./dzgui.sh`` +``LC_NUMERIC=de_DE.UTF-8 ./dzgui`` If you intend to use this frequently, you could wrap the above in a script or alias. From 7c62aed6b0b8123b3d2c447a90c77813440c73e4 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 9 May 2026 19:58:59 +0900 Subject: [PATCH 255/309] feat: get client name via controller --- dzgui/controllers/mc.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 5cb5d9a..ae76fa4 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -70,6 +70,7 @@ class AppNavigation: logtreeview: "LogTreeView" filters: "FilterPanel" preconnect: "PreConnectionAssistant" + options: "Options" class Controller(GObject.GObject): @@ -475,3 +476,6 @@ class Controller(GObject.GObject): def update_status(self, mod: str, mark_finished: bool = False) -> None: self.mediator.preconnect.update_mod(mod, mark_finished) + + def get_steam_client_name(self) -> str: + return self.mediator.options.get_client_name() From 77c641327fa14c7a0abe5ccc7dc3d203ba19f3a4 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 9 May 2026 19:59:40 +0900 Subject: [PATCH 256/309] chore: simplify process checking logic --- dzgui/init/proc.py | 59 ++++++++++++++++++++++++++++++---------------- dzgui/main.py | 6 ++--- 2 files changed, 41 insertions(+), 24 deletions(-) diff --git a/dzgui/init/proc.py b/dzgui/init/proc.py index 8d094c3..cc02a88 100644 --- a/dzgui/init/proc.py +++ b/dzgui/init/proc.py @@ -3,29 +3,40 @@ import subprocess import shutil import sys -from dzgui.const.constants import DAYZ_BINARY, STEAM_CMD +from dzgui.const.constants import ( + DAYZ_BINARY, + STEAM_CMD, + FLATPAK_APPID, + FLATPAK_RUN_CMD, + FLATPAK_SANDBOX, +) from dzgui.views.dialogs.early_alert import EarlyAlertDialog from dzgui.util.strings import init # TODO: move to util.proc +def is_dayz_running() -> bool: + return is_running(DAYZ_BINARY) -# TODO: simplify -def is_dayz_running(dialog: bool = False) -> bool | None: - if dialog is False: - return is_running(DAYZ_BINARY) - if is_running(DAYZ_BINARY) is True: - EarlyAlertDialog(init.is_dayz_running) - sys.exit(1) - - -def is_steam_running(dialog: bool = False) -> bool | None: - # TODO: check proc name of flatpak steam - if dialog is False: +def is_steam_running(cmd: str) -> bool: + if cmd == STEAM_CMD: + if has_cmd(STEAM_CMD) is False: + return False return is_running(STEAM_CMD) - if is_running(STEAM_CMD) is False: - EarlyAlertDialog(init.is_steam_running) - sys.exit(1) + elif cmd == FLATPAK_RUN_CMD or FLATPAK_SANDBOX: + return is_flatpak_steam_running() + else: + raise TypeError("Not a valid Steam client selection") + + +def is_flatpak_steam_running() -> bool: + if has_cmd(FLATPAK_CMD) is False: + return False + proc = subprocess.check_output([FLATPAK_CMD, "ps"], capture_output=True, text=True) + lines = proc.stdout.splitlines() + if FLATPAK_APPID in lines: + return True + return False def is_running(proc: str) -> bool: @@ -43,8 +54,10 @@ def has_cmd(cmd: str) -> bool: def foreground(cmd: str, pid: int) -> None: if cmd == "wmctrl": - proc = subprocess.run(["wmctrl", "-ilp"], capture_output=True, text=True) - lines = proc.stdout.splitlines() + proc = subprocess.check_output( + ["wmctrl", "-ilp"], capture_output=True, text=True + ) + lines = proc.splitlines() for line in lines: els = line.split(" ") if str(pid) in els: @@ -52,5 +65,11 @@ def foreground(cmd: str, pid: int) -> None: break subprocess.run(["wmctrl", "-ia", wid]) elif cmd == "xdotool": - args = [cmd, "search", "--onlyvisible", "--name", "DZGUI", "windowactivate"] - subprocess.Popen([*args]) + args = [cmd, "search", "--pid", str(pid)] + proc = subprocess.check_output([*args], stderr=subprocess.DEVNULL) + lines = proc.splitlines() + ## NOTE: some forked subprocesses may fail, so skip over them + for line in lines: + subprocess.run( + ["xdotool", "windowactivate", line], stderr=subprocess.DEVNULL + ) diff --git a/dzgui/main.py b/dzgui/main.py index 1e1c2be..e3f0ce0 100644 --- a/dzgui/main.py +++ b/dzgui/main.py @@ -122,6 +122,8 @@ def main() -> None: if allow is True: check_updates(version) + # TODO: config wizard: check has_steam_client() prior to VDF exploration + if _is_steam_deck is False: # TODO: sudo escalation dialog count = get_map_count() @@ -129,10 +131,6 @@ def main() -> None: if has_steam_client() is False: EarlyAlertDialog(init.requires_steam) - is_dayz_installed(XDG.config) - is_dayz_running(dialog=True) - is_steam_running(dialog=True) - # NOTE: clear versions file of unlinked mods rebuild_symlinks(XDG.config) remove_stale_signatures(XDG.config, XDG.version) From 3a22ad630aac981f0ae09430f4fe05a800bc331c Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 9 May 2026 20:00:31 +0900 Subject: [PATCH 257/309] feat: block connection if client is absent or not running --- dzgui/managers/connection.py | 34 +++++++++++++++++++----- dzgui/strings/server_mods.py | 2 +- dzgui/views/pages/options.py | 6 +++++ dzgui/views/pages/preconnect.py | 38 ++++++++++----------------- dzgui/views/trees/tree_server_mods.py | 2 +- 5 files changed, 49 insertions(+), 33 deletions(-) diff --git a/dzgui/managers/connection.py b/dzgui/managers/connection.py index ee72e17..a4e0fd1 100644 --- a/dzgui/managers/connection.py +++ b/dzgui/managers/connection.py @@ -47,6 +47,12 @@ if TYPE_CHECKING: logger = logging.getLogger(APP_NAME) +@dataclass(slots=True, frozen=True) +class SteamProcess: + name: str + is_running: bool + + @dataclass(slots=True, frozen=True) class Prerequisites: name: str @@ -59,9 +65,10 @@ class Prerequisites: available_space: float passworded: bool dayz_running: bool - steam_running: bool + steam_proc: SteamProcess mods: list[str] foreground_cmd: str | None + game_mode: bool class ConnectionManager: @@ -118,7 +125,8 @@ class ConnectionManager: local_version = "0.0.0" binary_missing = True - remote_mods: list[str, str, str] = [] + prefs = self.controller.get_prefs() + remote_mods: list[list[str]] = [] if res.is_modded(): try: remote_mods = self._query_modlist(record) @@ -129,9 +137,14 @@ class ConnectionManager: return hashes = get_remote_signatures(self.remote_mod_ids) - version_file = self.controller.get_prefs().paths.version + version_file = prefs.paths.version self.missing_mods = get_needs_update(version_file, hashes) + # TODO: walk through missing mods and update remote_mods store + for mod in remote_mods: + if any(mod[1] in tuple for tuple in self.missing_mods): + mod[2] = "Needs updating" + if local_version is not None: pefile_path = PeFile.get_pefile_path(steam_path, info.game_id) total, used, free = shutil.disk_usage(pefile_path) @@ -141,9 +154,15 @@ class ConnectionManager: free_mib = format_mib(required_size) dayz_running = is_dayz_running() - steam_running = is_steam_running() - self.foreground_cmd = self.controller.get_prefs().foreground_cmd + client_name = self.controller.get_steam_client_name() + client = self.controller.query_config(Preferences.CLIENT) + running = is_steam_running(client) + steam_proc = SteamProcess(client_name, running) + # /home/USER/.var/app/com.valvesoftware.Steam + + self.foreground_cmd = prefs.foreground_cmd + game_mode = prefs.is_game_mode prereqs = Prerequisites( name=info.server_name, @@ -156,9 +175,10 @@ class ConnectionManager: available_space=free_mib, passworded=info.password_protected, dayz_running=dayz_running, - steam_running=steam_running, + steam_proc=steam_proc, mods=remote_mods, foreground_cmd=self.foreground_cmd, # TODO: change to bool + game_mode=game_mode, ) func = StoredFunc(self.controller.open_connection_assistant, prereqs) @@ -261,7 +281,7 @@ class ConnectionManager: rebuild_symlinks(self.controller.get_prefs().paths.config) # TODO: update version file - # TODO: update tree checkmarks + # TODO: update tree checkmarks when finished func = StoredFunc( self.controller.update_status, "All mods updated.", mark_finished=True ) diff --git a/dzgui/strings/server_mods.py b/dzgui/strings/server_mods.py index c1a3106..c652fac 100644 --- a/dzgui/strings/server_mods.py +++ b/dzgui/strings/server_mods.py @@ -1,4 +1,4 @@ mod = "Mod" mod_id = "ID" -installed = "Installed" +up_to_date = "Up to date" modlist = "Modlist" diff --git a/dzgui/views/pages/options.py b/dzgui/views/pages/options.py index 3bd54c0..65d6292 100644 --- a/dzgui/views/pages/options.py +++ b/dzgui/views/pages/options.py @@ -49,6 +49,7 @@ class Options(Gtk.Box): ) self.controller = controller + self.controller.register_widget("options", self) emitter = controller.get_emitter() emitter.connect("api_change_failed", self._on_api_change_failed) @@ -207,6 +208,11 @@ class Options(Gtk.Box): self.scrollable.add(grid) self.add(self.scrollable) + def get_client_name(self) -> str: + model = self.client_combo.get_model() + ind = self.client_combo.get_active() + return model[ind][0] + def block_text_entry(self) -> None: for entry in self.steam_entry, self.bm_entry: entry.set_position(-1) diff --git a/dzgui/views/pages/preconnect.py b/dzgui/views/pages/preconnect.py index fb6db0e..250d265 100644 --- a/dzgui/views/pages/preconnect.py +++ b/dzgui/views/pages/preconnect.py @@ -145,20 +145,6 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): ) self.progress_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=15) self.progress_box.add(self.mod_count) - # self.cancel = Gtk.Button( - # label=preconnect.cancel, - # halign=Gtk.Align.START, - # hexpand=True, - # margin_bottom=5, - # margin_top=5, - # ) - # self.progress_box.add(self.cancel) - # self.cancel.connect("clicked", self._on_cancel_clicked) - # self.cancel.set_visible(False) - - # TODO: live count of remaining downloads - # "Steam is downloading: {mod_name}" - # mention whether manual or auto mod is active self.scrolled = Gtk.ScrolledWindow() self.scrolled.add(self.tree) @@ -175,7 +161,6 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): self.tree_frame = HeadingFrame(self.tree_box, preconnect.mods) # TODO: abstract into components - # TODO: strings self.warning_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) self.warning_tree = MaskedTree(WARNING) @@ -226,9 +211,6 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): if event.keyval == Gdk.KEY_Escape: self.back.emit("clicked") - # def _on_cancel_clicked(self, button: Gtk.Button) -> None: - # print("user canceled") - def _on_ok_clicked(self, button: Gtk.Button) -> None: # TODO: cancel mod downloads # sets some kind of global event listener @@ -248,6 +230,9 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): warnings: list[str] = [] errors: list[str] = [] + # TODO: check availability of currently selected steam cmd + + """Errors""" if prereqs.binary_missing: errors.append( f"Remote server is running the build '{prereqs.build}', but it is not installed." @@ -262,6 +247,17 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): errors.append( f"Need to update {required_pretty} MiB of mods, but installation path only has {available_pretty} MiB." ) + if len(prereqs.mods) > 0 and prereqs.game_mode: + errors.append("Use Desktop Mode to download mods on Steam Deck") + + print(prereqs.steam_proc) + if prereqs.steam_proc.is_running is False: + client = prereqs.steam_proc.name + errors.append( + f"'{client}' is set as the default Steam client, but it either not installed or not running." + ) + + """Warnings""" if prereqs.passworded: warnings.append( "Protected: you will be prompted for a password when connecting to this server." @@ -270,10 +266,6 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): warnings.append( "It looks like DayZ is already running in the background. Exit DayZ before connecting." ) - if prereqs.steam_running is False: - warnings.append( - "It looks like Steam is not running. Launch Steam before connecting." - ) self.add_warnings(warnings) self.add_errors(errors) @@ -319,8 +311,6 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): def update_mod(self, text: str, mark_finished: bool = False) -> None: self.mod_count.set_label(text) - # if mark_finished: - # self.cancel.set_visible(False) def add_errors(self, errors: list[str]) -> None: self.error_tree.extend(errors) diff --git a/dzgui/views/trees/tree_server_mods.py b/dzgui/views/trees/tree_server_mods.py index edeba08..e815c85 100644 --- a/dzgui/views/trees/tree_server_mods.py +++ b/dzgui/views/trees/tree_server_mods.py @@ -32,7 +32,7 @@ class ServerModTreeView(ContextMixin, TreeView): # type: ignore columns = [ server_mods.mod, server_mods.mod_id, - server_mods.installed, + server_mods.up_to_date, ] for i, column_title in enumerate(columns): renderer = Gtk.CellRendererText(ellipsize=Pango.EllipsizeMode.END) From b51b858e051c65eb23ee51de481195499016f8a8 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 9 May 2026 20:02:33 +0900 Subject: [PATCH 258/309] fix: typo in error message --- dzgui/views/pages/preconnect.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dzgui/views/pages/preconnect.py b/dzgui/views/pages/preconnect.py index 250d265..fd9abc8 100644 --- a/dzgui/views/pages/preconnect.py +++ b/dzgui/views/pages/preconnect.py @@ -254,7 +254,7 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): if prereqs.steam_proc.is_running is False: client = prereqs.steam_proc.name errors.append( - f"'{client}' is set as the default Steam client, but it either not installed or not running." + f"'{client}' is set as the default Steam client, but it is either not installed or not running." ) """Warnings""" From 6e29a716a5270415faa09b4982596bba60272572 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 9 May 2026 20:09:26 +0900 Subject: [PATCH 259/309] chore: clear typehinting errors --- dzgui/managers/connection.py | 6 +++--- dzgui/views/pages/preconnect.py | 5 ++--- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/dzgui/managers/connection.py b/dzgui/managers/connection.py index a4e0fd1..6a520ea 100644 --- a/dzgui/managers/connection.py +++ b/dzgui/managers/connection.py @@ -82,7 +82,7 @@ class ConnectionManager: self.workshop: Path self.remote_mod_ids: list[str] = [] - self.missing_mods: list[str] = [] + self.missing_mods: list[str, str, int, int] = [] @call_on_thread(dialog.querying) def connect_by_id(self, _id: int, key: str) -> None: @@ -101,12 +101,12 @@ class ConnectionManager: def _prepare_connection(self, res: "A2SInfo") -> None: failure_func = StoredFunc(self._server_timeout) - if res.get_info() is None: + info = res.get_info() + if info is None: self.thread_man.set_cleanup_func(failure_func, destroy_first=True) return record = res.get_record() - info = res.get_info() # NOTE: store metadata for later connection self.appid = info.game_id diff --git a/dzgui/views/pages/preconnect.py b/dzgui/views/pages/preconnect.py index fd9abc8..82b741d 100644 --- a/dzgui/views/pages/preconnect.py +++ b/dzgui/views/pages/preconnect.py @@ -197,8 +197,8 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): self.error_placeholder, self.warning_placeholder, ) - for widget in widgets: - widget.set_visible(True) + for child in widgets: + child.set_visible(True) self.raise_window.set_visible(False) # TODO: enable button if wmctrl or xdotool is available @@ -250,7 +250,6 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): if len(prereqs.mods) > 0 and prereqs.game_mode: errors.append("Use Desktop Mode to download mods on Steam Deck") - print(prereqs.steam_proc) if prereqs.steam_proc.is_running is False: client = prereqs.steam_proc.name errors.append( From 65df40ee5dfe6fd93fe91af71513fe9dcfe2e672 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 9 May 2026 21:45:20 +0900 Subject: [PATCH 260/309] chore: update preconnect strings --- dzgui/controllers/mc.py | 4 ++-- dzgui/init/proc.py | 11 +++++------ dzgui/managers/connection.py | 18 ++++++++---------- dzgui/strings/preconnect.py | 1 + dzgui/strings/server_mods.py | 1 + dzgui/util/strings.py | 1 - dzgui/views/pages/preconnect.py | 15 ++++++--------- dzgui/views/trees/tree_servers.py | 2 +- 8 files changed, 24 insertions(+), 29 deletions(-) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index ae76fa4..cd77e76 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -474,8 +474,8 @@ class Controller(GObject.GObject): def update_and_connect(self, raise_window: bool) -> None: self.connection_man.update_and_connect(raise_window) - def update_status(self, mod: str, mark_finished: bool = False) -> None: - self.mediator.preconnect.update_mod(mod, mark_finished) + def update_status(self) -> None: + self.mediator.preconnect.mark_finished() def get_steam_client_name(self) -> str: return self.mediator.options.get_client_name() diff --git a/dzgui/init/proc.py b/dzgui/init/proc.py index cc02a88..a41917c 100644 --- a/dzgui/init/proc.py +++ b/dzgui/init/proc.py @@ -13,6 +13,7 @@ from dzgui.const.constants import ( from dzgui.views.dialogs.early_alert import EarlyAlertDialog from dzgui.util.strings import init + # TODO: move to util.proc def is_dayz_running() -> bool: return is_running(DAYZ_BINARY) @@ -54,10 +55,8 @@ def has_cmd(cmd: str) -> bool: def foreground(cmd: str, pid: int) -> None: if cmd == "wmctrl": - proc = subprocess.check_output( - ["wmctrl", "-ilp"], capture_output=True, text=True - ) - lines = proc.splitlines() + proc = subprocess.run(["wmctrl", "-ilp"], capture_output=True, text=True) + lines = proc.stdout.splitlines() for line in lines: els = line.split(" ") if str(pid) in els: @@ -66,8 +65,8 @@ def foreground(cmd: str, pid: int) -> None: subprocess.run(["wmctrl", "-ia", wid]) elif cmd == "xdotool": args = [cmd, "search", "--pid", str(pid)] - proc = subprocess.check_output([*args], stderr=subprocess.DEVNULL) - lines = proc.splitlines() + proc = subprocess.run([*args], capture_output=True, text=True) + lines = proc.stdout.splitlines() ## NOTE: some forked subprocesses may fail, so skip over them for line in lines: subprocess.run( diff --git a/dzgui/managers/connection.py b/dzgui/managers/connection.py index 6a520ea..cdf6379 100644 --- a/dzgui/managers/connection.py +++ b/dzgui/managers/connection.py @@ -29,8 +29,9 @@ from dzgui.const.constants import ( from dzgui.const.enum import Preferences from dzgui.init.proc import foreground, is_dayz_running, is_steam_running from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager +from dzgui.strings.server_mods import checkmark from dzgui.util.format import format_mib -from dzgui.util.strings import dialog, server_timeout, checkmark +from dzgui.util.strings import dialog, server_timeout from dzgui.util.symlink import rebuild_symlinks from dzgui.views.dialogs.generic import ExceptionDialog from dzgui.views.dialogs.servers import ServerDetailsDialog, ServerModDialog @@ -140,10 +141,10 @@ class ConnectionManager: version_file = prefs.paths.version self.missing_mods = get_needs_update(version_file, hashes) - # TODO: walk through missing mods and update remote_mods store for mod in remote_mods: if any(mod[1] in tuple for tuple in self.missing_mods): - mod[2] = "Needs updating" + # TODO: store in strings + mod[2] = "⟳" if local_version is not None: pefile_path = PeFile.get_pefile_path(steam_path, info.game_id) @@ -159,7 +160,6 @@ class ConnectionManager: client = self.controller.query_config(Preferences.CLIENT) running = is_steam_running(client) steam_proc = SteamProcess(client_name, running) - # /home/USER/.var/app/com.valvesoftware.Steam self.foreground_cmd = prefs.foreground_cmd game_mode = prefs.is_game_mode @@ -248,7 +248,6 @@ class ConnectionManager: # TODO: test returncode # proc.returncode # TODO: wait for DAYZ_BINARY to load - # TODO: add to history file and list store @call_on_thread("Waiting for Steam to update mods") @@ -262,6 +261,7 @@ class ConnectionManager: if self.foreground_cmd is not None and raise_window is True: pid = os.getpid() + logger.info(f"Raising window with '{self.foreground_cmd}'") foreground(self.foreground_cmd, pid) for title, mod, stamp, size in self.missing_mods: @@ -277,14 +277,12 @@ class ConnectionManager: break time.sleep(1) + # TODO: update version file # TODO: get config path or just push steam path directly rebuild_symlinks(self.controller.get_prefs().paths.config) - # TODO: update version file - # TODO: update tree checkmarks when finished - func = StoredFunc( - self.controller.update_status, "All mods updated.", mark_finished=True - ) + # TODO: make this internal to preconnect page + func = StoredFunc(self.controller.update_status) self.thread_man.set_cleanup_func(func) def update_and_connect(self, raise_window: bool) -> None: diff --git a/dzgui/strings/preconnect.py b/dzgui/strings/preconnect.py index ea87fec..4d63739 100644 --- a/dzgui/strings/preconnect.py +++ b/dzgui/strings/preconnect.py @@ -6,3 +6,4 @@ warnings = "Warnings" errors = "Errors" mods = "Mods" total_mods = "Total mods: " +all_updated = "All mods updated." diff --git a/dzgui/strings/server_mods.py b/dzgui/strings/server_mods.py index c652fac..a3173e2 100644 --- a/dzgui/strings/server_mods.py +++ b/dzgui/strings/server_mods.py @@ -2,3 +2,4 @@ mod = "Mod" mod_id = "ID" up_to_date = "Up to date" modlist = "Modlist" +checkmark = "✓" diff --git a/dzgui/util/strings.py b/dzgui/util/strings.py index 95ee9c3..22027cb 100644 --- a/dzgui/util/strings.py +++ b/dzgui/util/strings.py @@ -460,7 +460,6 @@ crumbs = Crumbs( default="Servers > ", ) -checkmark = "✓" no_mods = "No local mods found." no_servers = "No server metadata to list." diff --git a/dzgui/views/pages/preconnect.py b/dzgui/views/pages/preconnect.py index 82b741d..cee8276 100644 --- a/dzgui/views/pages/preconnect.py +++ b/dzgui/views/pages/preconnect.py @@ -8,6 +8,7 @@ from dzgui.const.constants import ( from dzgui.const.enum import NotebookPage from dzgui.util.css import add_class from dzgui.util.localize import number +from dzgui.strings.server_mods import checkmark from dzgui.strings import preconnect from dzgui.views.components.frame import HeadingFrame from dzgui.views.trees.tree_server_mods import ServerModTreeView @@ -214,14 +215,7 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): def _on_ok_clicked(self, button: Gtk.Button) -> None: # TODO: cancel mod downloads # sets some kind of global event listener - if self.mod_count.get_visible(): - # TODO: set ready mode - # TODO: strings - # self.mod_count.set_label("Enqueuing downloads") - # self.cancel.set_visible(True) - self.ok.set_label(preconnect.connect) self.controller.update_and_connect(self.raise_window.get_active()) - pass def _on_back_clicked(self, button: Gtk.Button) -> None: self.controller.open_page(NotebookPage.SERVERS) @@ -308,8 +302,11 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): self._process_warnings(prereqs) - def update_mod(self, text: str, mark_finished: bool = False) -> None: - self.mod_count.set_label(text) + def mark_finished(self) -> None: + self.mod_count.set_label(preconnect.all_updated) + model = self.tree.get_model() + for row in model: + row[2] = checkmark def add_errors(self, errors: list[str]) -> None: self.error_tree.extend(errors) diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index 9ddc691..76e8f08 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -307,8 +307,8 @@ class ServerTreeView(ContextMixin, TreeView): # type: ignore def get_record(self) -> Record | None: if self.loaded is False: return None - r = self.get_record_string() try: + r = self.get_record_string() ip, gameport, qport = r.split(":") return Record(ip, int(gameport), int(qport)) except ValueError as e: From 7dde9dc0ce30803fc6bd79cafc6ebfa22dd138bc Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 9 May 2026 21:48:22 +0900 Subject: [PATCH 261/309] chore: restore dropped check --- dzgui/main.py | 3 ++- dzgui/managers/connection.py | 5 ++--- dzgui/strings/server_mods.py | 1 + 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/dzgui/main.py b/dzgui/main.py index e3f0ce0..d9b2be4 100644 --- a/dzgui/main.py +++ b/dzgui/main.py @@ -25,7 +25,7 @@ from dzgui.init.migrate import ( ) from dzgui.init.prefix import get_version from dzgui.init.prereqs import has_steam_client -from dzgui.init.proc import has_cmd, is_dayz_running, is_steam_running +from dzgui.init.proc import has_cmd from dzgui.init.update import allow_updates, check_updates from dzgui.util.map_count import get_map_count @@ -135,6 +135,7 @@ def main() -> None: rebuild_symlinks(XDG.config) remove_stale_signatures(XDG.config, XDG.version) + is_dayz_installed() # TODO: handle IP DB failure and use coords fallback get_ipdb(XDG.ips) local_coords = get_local_coords(XDG.ips) diff --git a/dzgui/managers/connection.py b/dzgui/managers/connection.py index cdf6379..2da6244 100644 --- a/dzgui/managers/connection.py +++ b/dzgui/managers/connection.py @@ -29,7 +29,7 @@ from dzgui.const.constants import ( from dzgui.const.enum import Preferences from dzgui.init.proc import foreground, is_dayz_running, is_steam_running from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager -from dzgui.strings.server_mods import checkmark +from dzgui.strings.server_mods import checkmark, resync from dzgui.util.format import format_mib from dzgui.util.strings import dialog, server_timeout from dzgui.util.symlink import rebuild_symlinks @@ -143,8 +143,7 @@ class ConnectionManager: for mod in remote_mods: if any(mod[1] in tuple for tuple in self.missing_mods): - # TODO: store in strings - mod[2] = "⟳" + mod[2] = resync if local_version is not None: pefile_path = PeFile.get_pefile_path(steam_path, info.game_id) diff --git a/dzgui/strings/server_mods.py b/dzgui/strings/server_mods.py index a3173e2..c41b443 100644 --- a/dzgui/strings/server_mods.py +++ b/dzgui/strings/server_mods.py @@ -3,3 +3,4 @@ mod_id = "ID" up_to_date = "Up to date" modlist = "Modlist" checkmark = "✓" +resync="⟳" From 6e320763c9feca8d192033cf2daa92f0e0ed6cb0 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 9 May 2026 21:49:39 +0900 Subject: [PATCH 262/309] fix: restore missing argument --- dzgui/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dzgui/main.py b/dzgui/main.py index d9b2be4..7380be8 100644 --- a/dzgui/main.py +++ b/dzgui/main.py @@ -135,7 +135,7 @@ def main() -> None: rebuild_symlinks(XDG.config) remove_stale_signatures(XDG.config, XDG.version) - is_dayz_installed() + is_dayz_installed(XDG.config) # TODO: handle IP DB failure and use coords fallback get_ipdb(XDG.ips) local_coords = get_local_coords(XDG.ips) From 1268c388b0a621fbe8ddbc38fd1889c9a040e814 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 9 May 2026 22:46:41 +0900 Subject: [PATCH 263/309] feat: version update logic --- dzgui/api/mods.py | 27 +++++++++++++++++++++++++++ dzgui/api/steam.py | 2 +- dzgui/managers/connection.py | 20 ++++++++++++++------ dzgui/managers/mods.py | 2 +- 4 files changed, 43 insertions(+), 8 deletions(-) diff --git a/dzgui/api/mods.py b/dzgui/api/mods.py index 4ff646b..19929a7 100644 --- a/dzgui/api/mods.py +++ b/dzgui/api/mods.py @@ -173,3 +173,30 @@ def get_mod_dir_size(path: Path) -> int: elif i.is_dir(): size += get_mod_dir_size(i) return size + + +def version_file_to_dict(file: Path) -> dict[str, str]: + versions = file.read_text().splitlines() + d: dict[str, str] = {} + for version in versions: + line = version.split(",") + mod = line[0] + stamp = line[1] + d[mod] = stamp + return d + + +def update_signatures( + mods: list[tuple[str, str, int, int]], version_file: Path +) -> None: + d = version_file_to_dict(version_file) + for _title, mod, stamp, size in mods: + d[mod] = str(stamp) + versions: list[str] = [] + for k, v in d.items(): + row = ",".join([k, v]) + versions.append(row) + + with open(version_file, "w") as f: + for version in versions: + f.write(f"{version}\n") diff --git a/dzgui/api/steam.py b/dzgui/api/steam.py index 1cb6e5a..c906587 100644 --- a/dzgui/api/steam.py +++ b/dzgui/api/steam.py @@ -25,7 +25,7 @@ def get_local_signatures(version_file: Path) -> dict[str, int]: for line in lines: line = line.split(",") _id = line[0] - _hash = line[1] + _hash = int(line[1]) hashes[_id] = _hash return hashes diff --git a/dzgui/managers/connection.py b/dzgui/managers/connection.py index 2da6244..296bb79 100644 --- a/dzgui/managers/connection.py +++ b/dzgui/managers/connection.py @@ -18,7 +18,12 @@ from dzgui.api.steam import ( get_needs_update, ) -from dzgui.api.mods import get_mod_dir_size, get_local_mod_ids, get_local_mod_path +from dzgui.api.mods import ( + get_mod_dir_size, + get_local_mod_ids, + get_local_mod_path, + update_signatures, +) from dzgui.const.constants import ( APP_NAME, APPID_DAYZ, @@ -236,7 +241,7 @@ class ConnectionManager: dialog = ExceptionDialog(self.controller, server_timeout) dialog.run() - def connect_steam(self) -> None: + def _connect_steam(self) -> None: print(self.record.ip) print(self.record.gameport) print(self.appid) @@ -250,8 +255,9 @@ class ConnectionManager: # TODO: add to history file and list store @call_on_thread("Waiting for Steam to update mods") - def update_mods(self, raise_window: bool) -> None: + def _update_mods(self, raise_window: bool) -> None: # NOTE: fast enqueue all mods in auto mode + prefs = self.controller.get_prefs() for title, mod, stamp, size in self.missing_mods: # TODO: boolean cancel button on threadman dialog sets event inside threadman # TODO: check for early cancel event via sigint @@ -277,8 +283,10 @@ class ConnectionManager: time.sleep(1) # TODO: update version file + update_signatures(self.missing_mods, prefs.paths.version) + # TODO: get config path or just push steam path directly - rebuild_symlinks(self.controller.get_prefs().paths.config) + rebuild_symlinks(prefs.paths.config) # TODO: update tree checkmarks when finished # TODO: make this internal to preconnect page func = StoredFunc(self.controller.update_status) @@ -286,6 +294,6 @@ class ConnectionManager: def update_and_connect(self, raise_window: bool) -> None: if len(self.missing_mods) > 0: - self.update_mods(raise_window) + self._update_mods(raise_window) else: - self.connect_steam() + self._connect_steam() diff --git a/dzgui/managers/mods.py b/dzgui/managers/mods.py index f43e669..c73f489 100644 --- a/dzgui/managers/mods.py +++ b/dzgui/managers/mods.py @@ -92,7 +92,7 @@ class ModManager: if model is None: return None tree_iter = model.get_iter(tree_path) - mod = model.get_value(tree_iter, 2)[0] + mod = model.get_value(tree_iter, 2) return mod, tree_iter def delete_single_mod(self, tree_path: Gtk.TreePath) -> Gtk.TreeIter | None: From ed8e79455bc9dc9da0fe11590ab77004fe60558d Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 9 May 2026 23:53:14 +0900 Subject: [PATCH 264/309] feat: concatenate launch parameters --- dzgui/api/servers.py | 6 +++-- dzgui/api/steam.py | 8 ++++-- dzgui/managers/connection.py | 35 +++++++++++++------------ dzgui/managers/threading.py | 3 +++ dzgui/model/servers.py | 9 +++---- dzgui/views/base.py | 3 ++- dzgui/views/components/connect_panel.py | 3 ++- dzgui/views/dialogs/generic.py | 3 +++ dzgui/views/mixins/context_mixin.py | 4 +-- dzgui/views/mixins/cursor_mixin.py | 3 ++- dzgui/views/pages/preconnect.py | 4 +++ dzgui/views/trees/tree_servers.py | 3 ++- 12 files changed, 52 insertions(+), 32 deletions(-) diff --git a/dzgui/api/servers.py b/dzgui/api/servers.py index b3eebd1..c335ab4 100644 --- a/dzgui/api/servers.py +++ b/dzgui/api/servers.py @@ -462,7 +462,7 @@ def query_playercount(record: Record) -> tuple[int, int] | None: def query_by_ip(addr: str) -> A2SInfo: record = short_ip_to_record(addr) - return query_by_record(record) + return query_by_record(record, update_gameport=True) def query_by_id(server_id: int, key: str) -> dict[Any] | None: """ @@ -472,9 +472,11 @@ def query_by_id(server_id: int, key: str) -> dict[Any] | None: return query_by_record(record) -def query_by_record(record: Record) -> A2SInfo: +def query_by_record(record: Record, update_gameport: bool = False) -> A2SInfo: try: info = a2s.info((record.ip, record.qport), 3.0) + if update_gameport: + record = Record(record.ip, info.port, record.qport) return A2SInfo(record, info) except Exception as e: logger.warning(e) diff --git a/dzgui/api/steam.py b/dzgui/api/steam.py index c906587..8946b72 100644 --- a/dzgui/api/steam.py +++ b/dzgui/api/steam.py @@ -14,9 +14,13 @@ logger = logging.getLogger(APP_NAME) def concat_mods(mods: list[str]) -> str: + from dzgui.util.symlink import _hash + + hashes = [] for mod in mods: - mods[mod] = f"@{mod}" - return ";".join(mods) + md5sum = _hash(mod) + hashes.append(md5sum) + return ";".join(hashes) def get_local_signatures(version_file: Path) -> dict[str, int]: diff --git a/dzgui/managers/connection.py b/dzgui/managers/connection.py index 296bb79..b819a88 100644 --- a/dzgui/managers/connection.py +++ b/dzgui/managers/connection.py @@ -30,9 +30,10 @@ from dzgui.const.constants import ( APPID_DAYZ_EXP, APPNAME_DAYZ, APPNAME_DAYZ_EXP, + DAYZ_BINARY, ) from dzgui.const.enum import Preferences -from dzgui.init.proc import foreground, is_dayz_running, is_steam_running +from dzgui.init.proc import foreground, is_dayz_running, is_steam_running, is_running from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager from dzgui.strings.server_mods import checkmark, resync from dzgui.util.format import format_mib @@ -241,26 +242,29 @@ class ConnectionManager: dialog = ExceptionDialog(self.controller, server_timeout) dialog.run() + # TODO: threading def _connect_steam(self) -> None: - print(self.record.ip) - print(self.record.gameport) - print(self.appid) - addr = f"{self.record.ip}:{self.record.gameport}" - playername = "test" + playername = self.controller.query_config(Preferences.NAME) proc = connect(addr, self.appid, playername, self.remote_mod_ids) # TODO: test returncode - # proc.returncode - # TODO: wait for DAYZ_BINARY to load - # TODO: add to history file and list store - @call_on_thread("Waiting for Steam to update mods") + # self.thread_man.update_dialog("Waiting for DayZ to launch") + # while not is_running(DAYZ_BINARY): + # time.sleep(1) + # TODO: wait for DAYZ_BINARY to load + # on cleanup: + # TODO: add to history file and list store + # proxy_model.append_to_history() + def _update_mods(self, raise_window: bool) -> None: # NOTE: fast enqueue all mods in auto mode prefs = self.controller.get_prefs() + + # TODO: increment dialog progress message for title, mod, stamp, size in self.missing_mods: # TODO: boolean cancel button on threadman dialog sets event inside threadman - # TODO: check for early cancel event via sigint + # check for early cancel event via sigint enqueue_mod(mod, self.appid) time.sleep(2) @@ -282,18 +286,15 @@ class ConnectionManager: break time.sleep(1) - # TODO: update version file update_signatures(self.missing_mods, prefs.paths.version) - # TODO: get config path or just push steam path directly rebuild_symlinks(prefs.paths.config) - # TODO: update tree checkmarks when finished - # TODO: make this internal to preconnect page - func = StoredFunc(self.controller.update_status) - self.thread_man.set_cleanup_func(func) + @call_on_thread("Waiting for Steam to update mods") def update_and_connect(self, raise_window: bool) -> None: if len(self.missing_mods) > 0: self._update_mods(raise_window) else: self._connect_steam() + func = StoredFunc(self.controller.update_status) + self.thread_man.set_cleanup_func(func) diff --git a/dzgui/managers/threading.py b/dzgui/managers/threading.py index f451715..52f884a 100644 --- a/dzgui/managers/threading.py +++ b/dzgui/managers/threading.py @@ -72,6 +72,9 @@ class ThreadingManager: def set_job_count(self, jobs: int) -> None: self.jobs = jobs + def update_dialog(self, msg: str) -> None: + GLib.idle_add(lambda: self.wait_dialog.update_text(msg)) + def increment_dialog(self) -> None: GLib.idle_add(self.wait_dialog.increment) diff --git a/dzgui/model/servers.py b/dzgui/model/servers.py index 4341936..86bd973 100644 --- a/dzgui/model/servers.py +++ b/dzgui/model/servers.py @@ -40,7 +40,6 @@ class NewPlayerCount: queue: int - class ServerModelManager: def __init__(self, controller: "Controller", tv: "ServerTreeView") -> None: @@ -174,9 +173,11 @@ class ServerModelManager: ] for future in as_completed(futures): res = future.result(timeout=API_TIMEOUT) + self.thread_man.increment_dialog() # NOTE: failing entries are culled if res is None: + # TODO: log which servers failed continue servers.append(res) if len(servers) == 0: @@ -257,9 +258,7 @@ class ServerModelManager: proxy_man = self._get_proxy_man() proxy_man.update_playercount(self.playercount) - def _parse_single_record( - self, response: "A2SInfo", delete: bool = False - ) -> None: + def _parse_single_record(self, response: "A2SInfo", delete: bool = False) -> None: self.preserve_on_fail = True row = response.as_row() if row is None: @@ -275,7 +274,7 @@ class ServerModelManager: proxy_man = self._get_proxy_man() config_man = self.controller.get_config_man() fqip = Servers.response_to_fqip(row) - record = response.get_record() #Servers.response_to_record(row) + record = response.get_record() # Servers.response_to_record(row) # TODO: less convoluted if delete: diff --git a/dzgui/views/base.py b/dzgui/views/base.py index 35119c6..7672a4c 100644 --- a/dzgui/views/base.py +++ b/dzgui/views/base.py @@ -9,6 +9,7 @@ from dzgui.const.enum import NotebookPage from dzgui.controllers.emitter import Emitter from dzgui.controllers.mc import Controller from dzgui.util import css, strings +from dzgui.util.keys import is_ctrl_mask from dzgui.views.components.connect_panel import ConnectPanel from dzgui.views.components.crumbs import Breadcrumbs from dzgui.views.components.right_panel import RightPanel @@ -164,7 +165,7 @@ class Notebook(ScrollableMixin, Gtk.Notebook): # type: ignore def _on_keypress(self, widget: Gtk.Widget, event: Gdk.EventKey) -> bool: match event.keyval: case Gdk.KEY_Right | Gdk.KEY_l: - if event.state is Gdk.ModifierType.CONTROL_MASK: + if is_ctrl_mask(event): return False MainController.get_emitter().emit("request_button_box_focus") return True diff --git a/dzgui/views/components/connect_panel.py b/dzgui/views/components/connect_panel.py index 1ebf1b2..fbf8f6a 100644 --- a/dzgui/views/components/connect_panel.py +++ b/dzgui/views/components/connect_panel.py @@ -3,6 +3,7 @@ from typing import TYPE_CHECKING from dzgui.const.constants import UDP_PORT from dzgui.model.servers import ServerModelManager from dzgui.strings import connect_panel +from dzgui.util.keys import is_ctrl_mask from dzgui.views.components.buttons import ( AddButton, ClipboardButton, @@ -78,7 +79,7 @@ class LanPanel(Gtk.Frame): self.entry.set_sensitive(False) def _on_lan_keypress(self, widget: Gtk.Entry, event: Gdk.EventKey) -> bool: - if event.state & Gdk.ModifierType.CONTROL_MASK: + if is_ctrl_mask(event): if event.keyval == Gdk.KEY_d: self.default_radio.set_active(True) self.controller.grab_active_treeview() diff --git a/dzgui/views/dialogs/generic.py b/dzgui/views/dialogs/generic.py index 17029c1..c95d2d0 100644 --- a/dzgui/views/dialogs/generic.py +++ b/dzgui/views/dialogs/generic.py @@ -109,6 +109,9 @@ class WaitDialog(GenericDialog): else: spinner.start() + def update_text(self, msg: str) -> None: + self.format_secondary_text(msg) + def increment(self, msg: str = "") -> None: if msg != "": self.format_secondary_text(msg) diff --git a/dzgui/views/mixins/context_mixin.py b/dzgui/views/mixins/context_mixin.py index 6a0a577..bfee8b9 100644 --- a/dzgui/views/mixins/context_mixin.py +++ b/dzgui/views/mixins/context_mixin.py @@ -2,7 +2,7 @@ import logging from dzgui.const.constants import APP_NAME from dzgui.const.enum import ContextMenu -from dzgui.util.keys import is_navkey +from dzgui.util.keys import is_ctrl_mask, is_navkey from dzgui.util.strings import edit_note from dzgui.views.trees.tree_base import TreeView @@ -30,7 +30,7 @@ class ContextMixin(TreeView): return False self._process_button_event(event) case Gdk.EventType.KEY_PRESS: - if event.state is not Gdk.ModifierType.CONTROL_MASK: + if not is_ctrl_mask(event): return False if event.keyval is not Gdk.KEY_l: return False diff --git a/dzgui/views/mixins/cursor_mixin.py b/dzgui/views/mixins/cursor_mixin.py index 58087e9..e9be244 100644 --- a/dzgui/views/mixins/cursor_mixin.py +++ b/dzgui/views/mixins/cursor_mixin.py @@ -1,5 +1,6 @@ from dzgui.const.enum import CursorPosition from dzgui.const.constants import SEPARATOR +from dzgui.util.keys import is_ctrl_mask import gi @@ -19,7 +20,7 @@ class CursorMixin: case Gdk.KEY_k: self._move_cursor(CursorPosition.UP) case Gdk.KEY_l | Gdk.KEY_Right: - if event.state is Gdk.ModifierType.CONTROL_MASK: + if is_ctrl_mask(event): return self.emitter.emit("request_button_box_focus") # type: ignore case _: diff --git a/dzgui/views/pages/preconnect.py b/dzgui/views/pages/preconnect.py index cee8276..b16e427 100644 --- a/dzgui/views/pages/preconnect.py +++ b/dzgui/views/pages/preconnect.py @@ -7,6 +7,7 @@ from dzgui.const.constants import ( ) from dzgui.const.enum import NotebookPage from dzgui.util.css import add_class +from dzgui.util.keys import is_ctrl_mask from dzgui.util.localize import number from dzgui.strings.server_mods import checkmark from dzgui.strings import preconnect @@ -211,6 +212,9 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): def _on_keypress(self, widget: Self, event: Gdk.EventKey) -> None: if event.keyval == Gdk.KEY_Escape: self.back.emit("clicked") + if is_ctrl_mask(event): + if event.keyval == Gdk.KEY_u: + self.ok.emit("clicked") def _on_ok_clicked(self, button: Gtk.Button) -> None: # TODO: cancel mod downloads diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index 76e8f08..cf1ada5 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -12,6 +12,7 @@ from dzgui.managers.filter import FilterManager from dzgui.model.proxy_model import ProxyModelManager from dzgui.util import strings from dzgui.util.dist import CalcDist +from dzgui.util.keys import is_ctrl_mask from dzgui.views.mixins.context_mixin import ContextMixin from typing import Literal, TYPE_CHECKING @@ -229,7 +230,7 @@ class ServerTreeView(ContextMixin, TreeView): # type: ignore return True def _on_server_keypress(self, treeview: Gtk.TreeView, event: Gdk.EventKey) -> None: - if event.state is Gdk.ModifierType.CONTROL_MASK: + if is_ctrl_mask(event): match event.keyval: case Gdk.KEY_f: self.emitter.emit("request_keyword_focus") From ff0ced1c3345868dd268b8396a1ed7d54d0ecfec Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 10 May 2026 00:24:17 +0900 Subject: [PATCH 265/309] fix: cast int to str --- dzgui/api/steam.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dzgui/api/steam.py b/dzgui/api/steam.py index 8946b72..33d6745 100644 --- a/dzgui/api/steam.py +++ b/dzgui/api/steam.py @@ -94,7 +94,7 @@ def connect(addr: str, appid: int, name: str, mods: list[str]) -> None: params = [ "steam", "-applaunch", - appid, + str(appid), f"-connect={addr}", "-nolauncher", "-nosplash", From fb5397ae918340e025d05bd440433b25c4b0ea95 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 10 May 2026 01:15:57 +0900 Subject: [PATCH 266/309] feat: wait for DayZ process --- dzgui/api/steam.py | 2 +- dzgui/controllers/mc.py | 1 + dzgui/init/proc.py | 9 ++++++++- dzgui/managers/connection.py | 27 +++++++++++++++++---------- 4 files changed, 27 insertions(+), 12 deletions(-) diff --git a/dzgui/api/steam.py b/dzgui/api/steam.py index 33d6745..bf705c4 100644 --- a/dzgui/api/steam.py +++ b/dzgui/api/steam.py @@ -102,7 +102,7 @@ def connect(addr: str, appid: int, name: str, mods: list[str]) -> None: f"-name={name}", f"-mod={concat}", ] - proc = subprocess.Popen([*params]) + proc = subprocess.run([*params]) return proc.returncode diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index cd77e76..093da58 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -479,3 +479,4 @@ class Controller(GObject.GObject): def get_steam_client_name(self) -> str: return self.mediator.options.get_client_name() + diff --git a/dzgui/init/proc.py b/dzgui/init/proc.py index a41917c..36eda25 100644 --- a/dzgui/init/proc.py +++ b/dzgui/init/proc.py @@ -16,7 +16,14 @@ from dzgui.util.strings import init # TODO: move to util.proc def is_dayz_running() -> bool: - return is_running(DAYZ_BINARY) + procs = [] + substring = DAYZ_BINARY + for proc in psutil.process_iter(): + try: + procs.append(proc.cmdline()) + except Exception as e: + continue + return any(substring in item for sublist in procs for item in sublist) def is_steam_running(cmd: str) -> bool: diff --git a/dzgui/managers/connection.py b/dzgui/managers/connection.py index b819a88..3778ce2 100644 --- a/dzgui/managers/connection.py +++ b/dzgui/managers/connection.py @@ -32,9 +32,10 @@ from dzgui.const.constants import ( APPNAME_DAYZ_EXP, DAYZ_BINARY, ) -from dzgui.const.enum import Preferences +from dzgui.const.enum import NotebookPage, Preferences from dzgui.init.proc import foreground, is_dayz_running, is_steam_running, is_running from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager +from dzgui.strings.dialogs import waiting_for_launch from dzgui.strings.server_mods import checkmark, resync from dzgui.util.format import format_mib from dzgui.util.strings import dialog, server_timeout @@ -246,16 +247,22 @@ class ConnectionManager: def _connect_steam(self) -> None: addr = f"{self.record.ip}:{self.record.gameport}" playername = self.controller.query_config(Preferences.NAME) - proc = connect(addr, self.appid, playername, self.remote_mod_ids) - # TODO: test returncode + rc = connect(addr, self.appid, playername, self.remote_mod_ids) + if rc != 0: + # TODO: log/pop the error + func = StoredFunc(self.controller.update_status) + self.thread_man.set_cleanup_func(func) + return - # self.thread_man.update_dialog("Waiting for DayZ to launch") - # while not is_running(DAYZ_BINARY): - # time.sleep(1) - # TODO: wait for DAYZ_BINARY to load - # on cleanup: + self.thread_man.update_dialog(waiting_for_launch) + while True: + if is_dayz_running(): + break + time.sleep(1) # TODO: add to history file and list store # proxy_model.append_to_history() + func = StoredFunc(self.controller.open_page, NotebookPage.SERVERS) + self.thread_man.set_cleanup_func(func) def _update_mods(self, raise_window: bool) -> None: # NOTE: fast enqueue all mods in auto mode @@ -294,7 +301,7 @@ class ConnectionManager: def update_and_connect(self, raise_window: bool) -> None: if len(self.missing_mods) > 0: self._update_mods(raise_window) + self._connect_steam() else: self._connect_steam() - func = StoredFunc(self.controller.update_status) - self.thread_man.set_cleanup_func(func) + # TODO: use this only if going back before launching From a1e2876251abc3a5d16275e17e0ef271efaccb73 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 10 May 2026 01:26:18 +0900 Subject: [PATCH 267/309] fix: update ok label text if no missing mods --- dzgui/managers/connection.py | 5 +++-- dzgui/model/servers.py | 10 ++++++++++ dzgui/views/pages/preconnect.py | 3 ++- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/dzgui/managers/connection.py b/dzgui/managers/connection.py index 3778ce2..928de00 100644 --- a/dzgui/managers/connection.py +++ b/dzgui/managers/connection.py @@ -75,6 +75,7 @@ class Prerequisites: dayz_running: bool steam_proc: SteamProcess mods: list[str] + missing_mods: int foreground_cmd: str | None game_mode: bool @@ -183,6 +184,7 @@ class ConnectionManager: dayz_running=dayz_running, steam_proc=steam_proc, mods=remote_mods, + missing_mods=len(self.missing_mods), foreground_cmd=self.foreground_cmd, # TODO: change to bool game_mode=game_mode, ) @@ -259,7 +261,7 @@ class ConnectionManager: if is_dayz_running(): break time.sleep(1) - # TODO: add to history file and list store + # TODO: add self.record to history file and list store # proxy_model.append_to_history() func = StoredFunc(self.controller.open_page, NotebookPage.SERVERS) self.thread_man.set_cleanup_func(func) @@ -304,4 +306,3 @@ class ConnectionManager: self._connect_steam() else: self._connect_steam() - # TODO: use this only if going back before launching diff --git a/dzgui/model/servers.py b/dzgui/model/servers.py index 86bd973..1cc6206 100644 --- a/dzgui/model/servers.py +++ b/dzgui/model/servers.py @@ -214,6 +214,16 @@ class ServerModelManager: res = Servers.query_by_record(record) self._parse_single_record(res, delete=True) + # TODO: + # def add_to_history(self, record: Servers.Record) -> None: + # functionally similar to remove action + # # needs to be called from controller against history tree, not current tree + # proxy_man = self._get_proxy_man() + # # proxy_man.append_row_to_history + # control_model = proxy_man.get_control() + # config_man = self.controller.get_config_man() + # config_man.update_history_file(control_model) + def remove_from_history(self, record: Servers.Record) -> None: """Fully unthreaded, just removes a row""" proxy_man = self._get_proxy_man() diff --git a/dzgui/views/pages/preconnect.py b/dzgui/views/pages/preconnect.py index b16e427..a354faf 100644 --- a/dzgui/views/pages/preconnect.py +++ b/dzgui/views/pages/preconnect.py @@ -284,11 +284,12 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): if prereqs.foreground_cmd is not None: self.raise_window.set_sensitive(True) + if prereqs.missing_mods < 1: + self.ok.set_label(preconnect.connect) if total_mods < 1: self.scrolled.set_visible(False) self.progress_box.set_visible(False) self.mods_placeholder.set_visible(True) - self.ok.set_label(preconnect.connect) else: self.scrolled.set_visible(True) self.progress_box.set_visible(True) From 4b291c8cd0ab958d389976024e4a92c15f2bca9c Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Mon, 11 May 2026 00:51:26 +0900 Subject: [PATCH 268/309] chore: add psutil typing stubs --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 5694ed6..3510afc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,6 +52,7 @@ lint = [ "ruff==0.11.13", "PyGObject-stubs==2.13.0", "types-requests>=2.32.4", + "types-psutil==7.2.2.20260508", ] dev = [ "dzgui[docs,test,lint]" From 319966f8e1cadf1c2ef7c42db65c7699c241b03d Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Mon, 11 May 2026 00:51:58 +0900 Subject: [PATCH 269/309] chore: clear typehinting errors --- dzgui/views/mixins/cursor_mixin.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/dzgui/views/mixins/cursor_mixin.py b/dzgui/views/mixins/cursor_mixin.py index e9be244..d5a9a41 100644 --- a/dzgui/views/mixins/cursor_mixin.py +++ b/dzgui/views/mixins/cursor_mixin.py @@ -9,20 +9,25 @@ from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa E402 class CursorMixin: - def _vim_nav(self, event: Gdk.EventKey): + def _vim_nav(self, event: Gdk.EventKey) -> bool: match event.keyval: case Gdk.KEY_g: self._move_cursor(CursorPosition.TOP) + return True case Gdk.KEY_G: self._move_cursor(CursorPosition.BOTTOM) + return True case Gdk.KEY_j: self._move_cursor(CursorPosition.DOWN) + return True case Gdk.KEY_k: self._move_cursor(CursorPosition.UP) + return True case Gdk.KEY_l | Gdk.KEY_Right: if is_ctrl_mask(event): - return + return False self.emitter.emit("request_button_box_focus") # type: ignore + return True case _: return False @@ -56,5 +61,4 @@ class CursorMixin: dest = end path = Gtk.TreePath.new_from_indices([dest]) - self.set_cursor(path) # type: ignore - return True + self.set_cursor(path) # type: ignore return True From 2e5860650fbc9fff24e302c1f2d542d62443cfe5 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Mon, 11 May 2026 00:52:48 +0900 Subject: [PATCH 270/309] feat: catch cancel and shutdown events --- dzgui/controllers/mc.py | 12 +++++++- dzgui/init/proc.py | 10 +++---- dzgui/managers/connection.py | 17 +++++++---- dzgui/managers/threading.py | 20 ++++++++++--- dzgui/model/model_factory.py | 3 +- dzgui/views/components/statusbar.py | 4 ++- dzgui/views/dialogs/generic.py | 21 +++++++++++++- dzgui/views/dialogs/servers.py | 2 +- dzgui/views/pages/preconnect.py | 45 +++++++++++++++-------------- 9 files changed, 94 insertions(+), 40 deletions(-) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 093da58..eebabf9 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -47,6 +47,7 @@ if TYPE_CHECKING: from dzgui.views.components.filter_panel import FilterPanel from dzgui.views.components.right_panel import RightPanel from dzgui.views.components.statusbar import Statusbar + from dzgui.views.pages.options import Options from dzgui.views.pages.preconnect import PreConnectionAssistant from dzgui.views.pages.servers import ServerNotebook from dzgui.views.trees.tree_log import LogTreeView @@ -90,6 +91,7 @@ class Controller(GObject.GObject): self.pending_jobs = 1 self.exit_event = threading.Event() + self.cancel_event = threading.Event() self.connection_man: ConnectionManager def get_emitter(self) -> Emitter: @@ -463,6 +465,15 @@ class Controller(GObject.GObject): def set_exit_event(self) -> None: self.exit_event.set() + def set_cancel_event(self) -> None: + self.cancel_event.set() + + def get_cancel_event(self) -> threading.Event: + return self.cancel_event + + def clear_cancel_event(self) -> None: + self.cancel_event.clear() + def open_connection_assistant(self, prereqs: "Prerequisites") -> None: self.open_page(NotebookPage.CONNECTION) self.mediator.preconnect.populate(prereqs) @@ -479,4 +490,3 @@ class Controller(GObject.GObject): def get_steam_client_name(self) -> str: return self.mediator.options.get_client_name() - diff --git a/dzgui/init/proc.py b/dzgui/init/proc.py index 36eda25..b16b994 100644 --- a/dzgui/init/proc.py +++ b/dzgui/init/proc.py @@ -1,27 +1,26 @@ import psutil import subprocess import shutil -import sys from dzgui.const.constants import ( DAYZ_BINARY, STEAM_CMD, FLATPAK_APPID, + FLATPAK_CMD, FLATPAK_RUN_CMD, FLATPAK_SANDBOX, ) -from dzgui.views.dialogs.early_alert import EarlyAlertDialog -from dzgui.util.strings import init # TODO: move to util.proc def is_dayz_running() -> bool: + """Subprocesses spawned from Steam will not show up in regular process tree""" procs = [] substring = DAYZ_BINARY for proc in psutil.process_iter(): try: procs.append(proc.cmdline()) - except Exception as e: + except Exception: continue return any(substring in item for sublist in procs for item in sublist) @@ -37,10 +36,11 @@ def is_steam_running(cmd: str) -> bool: raise TypeError("Not a valid Steam client selection") +# CHORE: test alternate clients def is_flatpak_steam_running() -> bool: if has_cmd(FLATPAK_CMD) is False: return False - proc = subprocess.check_output([FLATPAK_CMD, "ps"], capture_output=True, text=True) + proc = subprocess.check_output([FLATPAK_CMD, "ps"], text=True) lines = proc.stdout.splitlines() if FLATPAK_APPID in lines: return True diff --git a/dzgui/managers/connection.py b/dzgui/managers/connection.py index 928de00..5675e67 100644 --- a/dzgui/managers/connection.py +++ b/dzgui/managers/connection.py @@ -75,7 +75,6 @@ class Prerequisites: dayz_running: bool steam_proc: SteamProcess mods: list[str] - missing_mods: int foreground_cmd: str | None game_mode: bool @@ -184,7 +183,6 @@ class ConnectionManager: dayz_running=dayz_running, steam_proc=steam_proc, mods=remote_mods, - missing_mods=len(self.missing_mods), foreground_cmd=self.foreground_cmd, # TODO: change to bool game_mode=game_mode, ) @@ -245,7 +243,6 @@ class ConnectionManager: dialog = ExceptionDialog(self.controller, server_timeout) dialog.run() - # TODO: threading def _connect_steam(self) -> None: addr = f"{self.record.ip}:{self.record.gameport}" playername = self.controller.query_config(Preferences.NAME) @@ -256,8 +253,12 @@ class ConnectionManager: self.thread_man.set_cleanup_func(func) return + self.thread_man.show_cancel(False) self.thread_man.update_dialog(waiting_for_launch) while True: + if self.controller.get_exit_event().is_set(): + # TODO: some facility to also close spawned steam process + return if is_dayz_running(): break time.sleep(1) @@ -290,6 +291,12 @@ class ConnectionManager: while True: # NOTE: mods will finish at the same time # TODO: check for early cancel event + # TODO: check global sigint event + if self.controller.get_exit_event().is_set(): + return + if self.controller.get_cancel_event().is_set(): + self.controller.clear_cancel_event() + return cur_size = get_mod_dir_size(mod_path) if cur_size == size: break @@ -298,11 +305,11 @@ class ConnectionManager: update_signatures(self.missing_mods, prefs.paths.version) # TODO: get config path or just push steam path directly rebuild_symlinks(prefs.paths.config) + self._connect_steam() - @call_on_thread("Waiting for Steam to update mods") + @call_on_thread("Waiting for Steam to update mods", show_cancel=True) def update_and_connect(self, raise_window: bool) -> None: if len(self.missing_mods) > 0: self._update_mods(raise_window) - self._connect_steam() else: self._connect_steam() diff --git a/dzgui/managers/threading.py b/dzgui/managers/threading.py index 52f884a..6c6e20f 100644 --- a/dzgui/managers/threading.py +++ b/dzgui/managers/threading.py @@ -20,7 +20,9 @@ from gi.repository import Gtk, GLib # noqa E402 logger = logging.getLogger(APP_NAME) -def call_on_thread(dialog_str: str, show_dialog: bool = True) -> Callable: +def call_on_thread( + dialog_str: str, show_dialog: bool = True, show_cancel: bool = False +) -> Callable: def decorator(func: Callable) -> Callable: @wraps(func) def wrapper(*args: Any, **kwargs: Any) -> None: @@ -32,7 +34,7 @@ def call_on_thread(dialog_str: str, show_dialog: bool = True) -> Callable: raise TypeError( "Attribute 'thread_man' must be of type 'ThreadingManager'" ) - self.thread_man.call_on_thread(dialog_str, stored, show_dialog) + self.thread_man.call_on_thread(dialog_str, stored, show_dialog, show_cancel) return wrapper @@ -57,15 +59,22 @@ class ThreadingManager: self.destroy_first = False def call_on_thread( - self, dialog_str: str, func: StoredFunc, show_dialog: bool = True + self, + dialog_str: str, + func: StoredFunc, + show_dialog: bool = True, + show_cancel: bool = False, ) -> None: def callback() -> None: func.call() GLib.idle_add(self._destroy_on_idle) if show_dialog: - self.wait_dialog = WaitDialog(self.controller, dialog_str, jobs=self.jobs) + self.wait_dialog = WaitDialog( + self.controller, dialog_str, jobs=self.jobs, show_cancel=show_cancel + ) self.wait_dialog.show_all() + thread = threading.Thread(target=callback) thread.start() @@ -113,3 +122,6 @@ class ThreadingManager: def get_wait_dialog(self) -> WaitDialog: return self.wait_dialog + + def show_cancel(self, state: bool) -> None: + GLib.idle_add(self.wait_dialog.show_cancel, state) diff --git a/dzgui/model/model_factory.py b/dzgui/model/model_factory.py index 75e45e8..598327a 100644 --- a/dzgui/model/model_factory.py +++ b/dzgui/model/model_factory.py @@ -64,7 +64,7 @@ class ServerModCols: @dataclass(slots=True, frozen=True) class MenuCols: name: str - hidden: object # GObject.TYPE_PYOBJECT + hidden: object class FastInsertListStore(ListStore): @@ -108,6 +108,7 @@ class FastInsertListStore(ListStore): self.append(row) def append(self, row: list[Any] | tuple[Any, ...] | None = ...) -> TreeIter: + # FIXME: argument cannot be none """ Optimized for speed, but makes no assurances about row homogeneity and may segfault if types and length are not identical to ListStore. diff --git a/dzgui/views/components/statusbar.py b/dzgui/views/components/statusbar.py index ebb3976..642ff12 100644 --- a/dzgui/views/components/statusbar.py +++ b/dzgui/views/components/statusbar.py @@ -91,6 +91,7 @@ class Statusbar(Gtk.Grid): self.set_by_context(enum, bar) + # TODO: unused def _on_notebook_page_returned( self, statusbar: Self, prior_context: NotebookPage ) -> None: @@ -109,8 +110,9 @@ class Statusbar(Gtk.Grid): context: Union["ServerTab", NotebookPage], ) -> None: self.spinner.stop() + # TODO: CalcDist is being called when table is not loaded if dist is None: - self.set_by_context(context, self.playercount) + self.set_by_context(context, "") else: pretty = self.append_distance(dist) self.set_by_context(context, pretty) diff --git a/dzgui/views/dialogs/generic.py b/dzgui/views/dialogs/generic.py index c95d2d0..7e25da2 100644 --- a/dzgui/views/dialogs/generic.py +++ b/dzgui/views/dialogs/generic.py @@ -87,7 +87,13 @@ class NotifyDialog(GenericDialog): class WaitDialog(GenericDialog): - def __init__(self, controller: "Controller", secondary: str, jobs: int = 1): + def __init__( + self, + controller: "Controller", + secondary: str, + jobs: int = 1, + show_cancel: bool = False, + ): super().__init__( controller=controller, text=strings.wait, @@ -96,19 +102,29 @@ class WaitDialog(GenericDialog): secondary=secondary, ) + self.controller = controller self.jobs = jobs self.cur_job = 1 + self.cancel = Gtk.Button(label="Cancel", halign=Gtk.Align.CENTER) + self.cancel.connect("clicked", lambda _: self.controller.set_cancel_event()) + self.connect("delete-event", lambda widget, event: True) content = self.get_content_area() spinner = Gtk.Spinner() self.prog = Gtk.ProgressBar() + + content.pack_end(self.cancel, NO_EXPAND, NO_FILL, 0) content.pack_end(spinner, NO_EXPAND, NO_FILL, 0) + if self.jobs > 1: content.pack_end(self.prog, NO_EXPAND, NO_FILL, 0) else: spinner.start() + if show_cancel is False: + self.connect("realize", lambda _: self.cancel.set_visible(False)) + def update_text(self, msg: str) -> None: self.format_secondary_text(msg) @@ -119,6 +135,9 @@ class WaitDialog(GenericDialog): self.prog.set_fraction(fraction) self.cur_job += 1 + def show_cancel(self, state: bool) -> None: + self.cancel.set_visible(state) + class QuitDialog(GenericDialog): def __init__(self, controller: "Controller", secondary: str): diff --git a/dzgui/views/dialogs/servers.py b/dzgui/views/dialogs/servers.py index c2089ea..fc9f1a1 100644 --- a/dzgui/views/dialogs/servers.py +++ b/dzgui/views/dialogs/servers.py @@ -135,7 +135,7 @@ class ServerModDialog(ServerDialog): columns = [ server_mods.mod, server_mods.mod_id, - server_mods.installed, + server_mods.up_to_date, ] for i, column_title in enumerate(columns): renderer = Gtk.CellRendererText(ellipsize=Pango.EllipsizeMode.END) diff --git a/dzgui/views/pages/preconnect.py b/dzgui/views/pages/preconnect.py index a354faf..c57b5c1 100644 --- a/dzgui/views/pages/preconnect.py +++ b/dzgui/views/pages/preconnect.py @@ -203,8 +203,6 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): child.set_visible(True) self.raise_window.set_visible(False) - # TODO: enable button if wmctrl or xdotool is available - # TODO: check this at boot time and pass in via connection manager self.raise_window.set_sensitive(False) self.ok.set_sensitive(True) self.ok.set_label(preconnect.update_mods) @@ -228,8 +226,6 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): warnings: list[str] = [] errors: list[str] = [] - # TODO: check availability of currently selected steam cmd - """Errors""" if prereqs.binary_missing: errors.append( @@ -273,6 +269,17 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): self.error_placeholder.set_visible(False) self.ok.set_sensitive(False) + def _hide_mod_area(self) -> None: + self.scrolled.set_visible(False) + self.progress_box.set_visible(False) + self.mods_placeholder.set_visible(True) + + def _show_mod_area(self) -> None: + self.scrolled.set_visible(True) + self.progress_box.set_visible(True) + self.mods_placeholder.set_visible(False) + self.tree.grab_focus() + def populate(self, prereqs: "Prerequisites") -> None: mods = prereqs.mods self.tree.populate(mods) @@ -281,27 +288,23 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): name = prereqs.name self.title.set_text(name) - if prereqs.foreground_cmd is not None: - self.raise_window.set_sensitive(True) + suffix = "All mods are up to date." - if prereqs.missing_mods < 1: - self.ok.set_label(preconnect.connect) if total_mods < 1: - self.scrolled.set_visible(False) - self.progress_box.set_visible(False) - self.mods_placeholder.set_visible(True) + self._hide_mod_area() else: - self.scrolled.set_visible(True) - self.progress_box.set_visible(True) - self.raise_window.set_visible(True) - self.mods_placeholder.set_visible(False) - self.tree.grab_focus() + self._show_mod_area() - # TODO: strings - suffix = "All mods are up to date." - if prereqs.required_space > 0: - pretty = number(prereqs.required_space) - suffix = f" Need to download {pretty} MiB of mod updates." + if prereqs.required_space == 0: + self.ok.set_label(preconnect.connect) + self.raise_window.set_visible(False) + else: + self.raise_window.set_visible(True) + if prereqs.foreground_cmd is not None: + self.raise_window.set_sensitive(True) + + pretty = number(prereqs.required_space) + suffix = f" Need to download {pretty} MiB of mod updates." prefix = preconnect.total_mods self.mod_count.set_text(f"{prefix}{str(total_mods)}.{suffix}") From bc4fc8005d0f7feebc38e7aebfec049f9b8c1ab2 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Mon, 11 May 2026 18:57:36 +0900 Subject: [PATCH 271/309] fix: abort on empty model --- dzgui/views/mixins/cursor_mixin.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dzgui/views/mixins/cursor_mixin.py b/dzgui/views/mixins/cursor_mixin.py index d5a9a41..ac07eb4 100644 --- a/dzgui/views/mixins/cursor_mixin.py +++ b/dzgui/views/mixins/cursor_mixin.py @@ -32,12 +32,12 @@ class CursorMixin: return False def _move_cursor(self, position: CursorPosition) -> bool: - cur_row = self.get_focused_row_index() # type: ignore model = self.get_model() # type: ignore - if model: + if len(model) > 0: end = len(model) - 1 else: - return True + return False + cur_row = self.get_focused_row_index() # type: ignore if position == CursorPosition.DOWN: if cur_row == end: From e298703471646fe52a38b497a98b47d766f80657 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Mon, 11 May 2026 18:58:00 +0900 Subject: [PATCH 272/309] fix: grab tree --- dzgui/views/pages/preconnect.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/dzgui/views/pages/preconnect.py b/dzgui/views/pages/preconnect.py index c57b5c1..7916a4f 100644 --- a/dzgui/views/pages/preconnect.py +++ b/dzgui/views/pages/preconnect.py @@ -192,7 +192,6 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): self.connect("map", self._on_map) def _on_map(self, widget: Self) -> None: - self.grab_focus() widgets = ( self.tree_frame, self.progress_box, @@ -278,7 +277,6 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): self.scrolled.set_visible(True) self.progress_box.set_visible(True) self.mods_placeholder.set_visible(False) - self.tree.grab_focus() def populate(self, prereqs: "Prerequisites") -> None: mods = prereqs.mods @@ -309,6 +307,10 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): self.mod_count.set_text(f"{prefix}{str(total_mods)}.{suffix}") self._process_warnings(prereqs) + if self.tree.is_visible: + self.tree.grab_focus() + else: + self.grab_focus() def mark_finished(self) -> None: self.mod_count.set_label(preconnect.all_updated) From cdcd38c3269314007ed2f66e97847963b02fbb31 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Mon, 11 May 2026 18:58:15 +0900 Subject: [PATCH 273/309] chore: update comments --- dzgui/managers/connection.py | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/dzgui/managers/connection.py b/dzgui/managers/connection.py index 5675e67..04214bf 100644 --- a/dzgui/managers/connection.py +++ b/dzgui/managers/connection.py @@ -35,7 +35,7 @@ from dzgui.const.constants import ( from dzgui.const.enum import NotebookPage, Preferences from dzgui.init.proc import foreground, is_dayz_running, is_steam_running, is_running from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager -from dzgui.strings.dialogs import waiting_for_launch +from dzgui.strings.dialogs import waiting_for_launch, waiting_for_mods from dzgui.strings.server_mods import checkmark, resync from dzgui.util.format import format_mib from dzgui.util.strings import dialog, server_timeout @@ -256,12 +256,13 @@ class ConnectionManager: self.thread_man.show_cancel(False) self.thread_man.update_dialog(waiting_for_launch) while True: + # TODO: check cancel event if self.controller.get_exit_event().is_set(): # TODO: some facility to also close spawned steam process return if is_dayz_running(): break - time.sleep(1) + time.sleep(1) # TODO: add self.record to history file and list store # proxy_model.append_to_history() func = StoredFunc(self.controller.open_page, NotebookPage.SERVERS) @@ -271,10 +272,8 @@ class ConnectionManager: # NOTE: fast enqueue all mods in auto mode prefs = self.controller.get_prefs() - # TODO: increment dialog progress message for title, mod, stamp, size in self.missing_mods: - # TODO: boolean cancel button on threadman dialog sets event inside threadman - # check for early cancel event via sigint + # TODO: check cancel and exit events enqueue_mod(mod, self.appid) time.sleep(2) @@ -286,12 +285,10 @@ class ConnectionManager: for title, mod, stamp, size in self.missing_mods: mod_path = self.workshop / mod + # NOTE: Steam updates mod chunks in parallel, will finish at the same time while mod_path.is_dir() is False: time.sleep(1) while True: - # NOTE: mods will finish at the same time - # TODO: check for early cancel event - # TODO: check global sigint event if self.controller.get_exit_event().is_set(): return if self.controller.get_cancel_event().is_set(): @@ -303,11 +300,11 @@ class ConnectionManager: time.sleep(1) update_signatures(self.missing_mods, prefs.paths.version) - # TODO: get config path or just push steam path directly + # TODO: just push steam path directly rebuild_symlinks(prefs.paths.config) self._connect_steam() - @call_on_thread("Waiting for Steam to update mods", show_cancel=True) + @call_on_thread(waiting_for_mods, show_cancel=True) def update_and_connect(self, raise_window: bool) -> None: if len(self.missing_mods) > 0: self._update_mods(raise_window) From f7fe62e661f06bc974acc6c17c85a22d9c5f2bc4 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Mon, 11 May 2026 21:56:21 +0900 Subject: [PATCH 274/309] feat: update history on connect --- dzgui/controllers/mc.py | 6 ++++++ dzgui/managers/connection.py | 10 +++++++--- dzgui/model/proxy_model.py | 25 +++++++++++++++++++++---- dzgui/model/servers.py | 26 ++++++++++++-------------- dzgui/util/strings.py | 1 + 5 files changed, 47 insertions(+), 21 deletions(-) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index eebabf9..fef5184 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -389,7 +389,13 @@ class Controller(GObject.GObject): saved_tree = self.get_servers().get_saved() ServerModelManager(self, saved_tree).remove_by_record(record) + def add_to_history(self, record: dict[str, Any]) -> None: + tv = self.get_servers().get_recent() + ServerModelManager(self, tv).add_to_history(record) + def remove_from_history(self, record: "Record") -> None: + # NOTE: remove action is only possible from history tree context menu, + # i.e., the active tree tv = self.get_active_treeview() ServerModelManager(self, tv).remove_from_history(record) diff --git a/dzgui/managers/connection.py b/dzgui/managers/connection.py index 04214bf..37a2508 100644 --- a/dzgui/managers/connection.py +++ b/dzgui/managers/connection.py @@ -114,6 +114,7 @@ class ConnectionManager: self.thread_man.set_cleanup_func(failure_func, destroy_first=True) return + self.history = res.as_row() record = res.get_record() # NOTE: store metadata for later connection @@ -263,11 +264,14 @@ class ConnectionManager: if is_dayz_running(): break time.sleep(1) - # TODO: add self.record to history file and list store - # proxy_model.append_to_history() - func = StoredFunc(self.controller.open_page, NotebookPage.SERVERS) + + func = StoredFunc(self._add_to_history_and_return) self.thread_man.set_cleanup_func(func) + def _add_to_history_and_return(self) -> None: + self.controller.add_to_history(self.history) + self.controller.open_page(NotebookPage.SERVERS) + def _update_mods(self, raise_window: bool) -> None: # NOTE: fast enqueue all mods in auto mode prefs = self.controller.get_prefs() diff --git a/dzgui/model/proxy_model.py b/dzgui/model/proxy_model.py index 623590e..9927019 100644 --- a/dzgui/model/proxy_model.py +++ b/dzgui/model/proxy_model.py @@ -55,10 +55,27 @@ class ProxyModelManager: self.proxy_model[treeiter][4] = playercount.players self.proxy_model[treeiter][6] = playercount.queue - # def append_to_history(self, row: list) -> None: - # if len(self.control_model) == 10: - # del self.control_model[0] - # self.control_model.append(row) + def append_row_to_history( + self, history: list[str, str, str, str, int, int, int, str, int, int, str, bool] + ) -> None: + addr = history[7] + qport = history[8] + + found = False + for i, row in enumerate(self.control_model): + if addr == row[7] and qport == row[8]: + print("record exists in history") + item = self.control_model.pop(i) + self.control_model.append(item) + found = True + break + + if found is False: + self.control_model.append(history) + if len(self.control_model) == 11: + del self.control_model[0] + + self.filter(FilterMode.INITIAL, skip_cache=True) def remove_row_from_control(self, record: "Record") -> None: if self.control_model is None: diff --git a/dzgui/model/servers.py b/dzgui/model/servers.py index 1cc6206..1174f3c 100644 --- a/dzgui/model/servers.py +++ b/dzgui/model/servers.py @@ -214,22 +214,9 @@ class ServerModelManager: res = Servers.query_by_record(record) self._parse_single_record(res, delete=True) - # TODO: - # def add_to_history(self, record: Servers.Record) -> None: - # functionally similar to remove action - # # needs to be called from controller against history tree, not current tree - # proxy_man = self._get_proxy_man() - # # proxy_man.append_row_to_history - # control_model = proxy_man.get_control() - # config_man = self.controller.get_config_man() - # config_man.update_history_file(control_model) - - def remove_from_history(self, record: Servers.Record) -> None: - """Fully unthreaded, just removes a row""" + def update_history(self) -> None: proxy_man = self._get_proxy_man() - proxy_man.remove_row_from_control(record) control_model = proxy_man.get_control() - config_man = self.controller.get_config_man() config_man.update_history_file(control_model) @@ -246,6 +233,17 @@ class ServerModelManager: self.first_iteration = False self.emitter.emit("servers_loaded_init") + def add_to_history(self, record: dict[str, Any]) -> None: + proxy_man = self._get_proxy_man() + row = Servers.parse_json([record]) + proxy_man.append_row_to_history(row[0]) + self.update_history() + + def remove_from_history(self, record: Servers.Record) -> None: + proxy_man = self._get_proxy_man() + proxy_man.remove_row_from_control(record) + self.update_history() + def add_by_str(self, addr: str) -> None: if addr.isdigit(): self.add_by_id(addr) diff --git a/dzgui/util/strings.py b/dzgui/util/strings.py index 22027cb..176d259 100644 --- a/dzgui/util/strings.py +++ b/dzgui/util/strings.py @@ -134,6 +134,7 @@ servers = { "Ctrl-m": "jump to maps field", "Ctrl-d": "select default LAN port", "Ctrl-n": "select custom LAN port", + "Ctrl-u": "connect to server from preconnect dialog", "Ctrl-i": "jump to IP insert field", "n": "navigate to next tab", "p": "navigate to previous tab", From e52664cf9b3c988a111d72a076a3eed4532b5139 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Tue, 12 May 2026 15:58:09 +0900 Subject: [PATCH 275/309] chore: clear typehinting errors --- dzgui/api/pefile.py | 28 ++++++++++++++-------------- dzgui/api/servers.py | 2 +- dzgui/api/steam.py | 4 ++-- dzgui/config/convert.py | 3 ++- dzgui/managers/config.py | 4 ++-- dzgui/managers/connection.py | 8 ++++---- dzgui/views/components/crumbs.py | 5 ++++- dzgui/views/dialogs/note.py | 3 +-- dzgui/views/mixins/cursor_mixin.py | 3 ++- dzgui/views/pages/preconnect.py | 2 ++ dzgui/views/trees/tree_menu.py | 2 +- 11 files changed, 35 insertions(+), 29 deletions(-) diff --git a/dzgui/api/pefile.py b/dzgui/api/pefile.py index 84edc7d..9e04b2d 100644 --- a/dzgui/api/pefile.py +++ b/dzgui/api/pefile.py @@ -4,7 +4,7 @@ import struct from dataclasses import dataclass from enum import Enum from pathlib import Path -from typing import BinaryIO, Union +from typing import BinaryIO, Self, Union from packaging.version import Version @@ -69,7 +69,7 @@ class i64: class PackedData: @classmethod - def unpack(cls, data: BinaryIO): + def unpack(cls, data: BinaryIO) -> Self: r = [] for key, value in cls.__annotations__.items(): if value is str: @@ -449,18 +449,18 @@ def get_app_path(folders_path: Path, appid: int) -> 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 +#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 def get_pretty_version(steam_path: Path, appid: int) -> str | None: diff --git a/dzgui/api/servers.py b/dzgui/api/servers.py index c335ab4..acb73e6 100644 --- a/dzgui/api/servers.py +++ b/dzgui/api/servers.py @@ -464,7 +464,7 @@ def query_by_ip(addr: str) -> A2SInfo: record = short_ip_to_record(addr) return query_by_record(record, update_gameport=True) -def query_by_id(server_id: int, key: str) -> dict[Any] | None: +def query_by_id(server_id: int, key: str) -> A2SInfo: """ Used with numeric Battlemetrics IDs """ diff --git a/dzgui/api/steam.py b/dzgui/api/steam.py index bf705c4..7f86f46 100644 --- a/dzgui/api/steam.py +++ b/dzgui/api/steam.py @@ -89,7 +89,7 @@ def get_remote_signatures(mods: list[str]) -> list[tuple[str, str, int, int]]: # TODO:: set config to name=user, use official server and no mods, # ensure that formatted string is identical to fixture with same hash -def connect(addr: str, appid: int, name: str, mods: list[str]) -> None: +def connect(addr: str, appid: int, name: str, mods: list[str]) -> int: concat = concat_mods(mods) params = [ "steam", @@ -121,7 +121,7 @@ def find_user_id(path: Path) -> str | None: def vdf2json(path: Path) -> str: - def _istr(indent: int, string: str): + def _istr(indent: int, string: str) -> str: return (indent * " ") + string jbuf = "{\n" diff --git a/dzgui/config/convert.py b/dzgui/config/convert.py index 5231bf7..b22bd20 100644 --- a/dzgui/config/convert.py +++ b/dzgui/config/convert.py @@ -1,6 +1,7 @@ import json import shlex from pathlib import Path +from typing import Any """ @@ -34,7 +35,7 @@ def rc2json(file: Path) -> str: lex = shlex.shlex(f.read()) lex.whitespace += "=" - keys = {} + keys: dict[str, Any] = {} ips: list[str] = [] toggles = ["auto_install", "fullscreen"] diff --git a/dzgui/managers/config.py b/dzgui/managers/config.py index 4169496..e309bc3 100644 --- a/dzgui/managers/config.py +++ b/dzgui/managers/config.py @@ -95,8 +95,8 @@ class ConfigManager: ) return - def get_start_tab(self) -> int: - return self.lookup(Preferences.START_TAB) + def get_start_tab(self) -> Any: + return int(self.lookup(Preferences.START_TAB)) def get_favorites(self) -> list[str]: return list(self.lookup(Preferences.IP_LIST)) diff --git a/dzgui/managers/connection.py b/dzgui/managers/connection.py index 5675e67..af50785 100644 --- a/dzgui/managers/connection.py +++ b/dzgui/managers/connection.py @@ -74,7 +74,7 @@ class Prerequisites: passworded: bool dayz_running: bool steam_proc: SteamProcess - mods: list[str] + mods: list[list[str]] foreground_cmd: str | None game_mode: bool @@ -90,7 +90,7 @@ class ConnectionManager: self.workshop: Path self.remote_mod_ids: list[str] = [] - self.missing_mods: list[str, str, int, int] = [] + self.missing_mods: list[tuple[str, str, int, int]] = [] @call_on_thread(dialog.querying) def connect_by_id(self, _id: int, key: str) -> None: @@ -206,7 +206,7 @@ class ConnectionManager: mods = Servers.get_rules(record) steam_path = self.controller.query_config(Preferences.DEFAULT) local = get_local_mod_ids(steam_path) - alpha_mods = [ + alpha_mods: list[list[str]] = [ [ mod.name, str(mod.workshop_id), @@ -231,7 +231,7 @@ class ConnectionManager: destroy_first=True, ) - def _present_modlist_dialog(self, mods: list[str]) -> None: + def _present_modlist_dialog(self, mods: list[list[str]]) -> None: dialog = ServerModDialog(self.controller, mods) dialog.run() diff --git a/dzgui/views/components/crumbs.py b/dzgui/views/components/crumbs.py index 6588b93..cd57426 100644 --- a/dzgui/views/components/crumbs.py +++ b/dzgui/views/components/crumbs.py @@ -12,6 +12,7 @@ from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa E402 if TYPE_CHECKING: from dzgui.controllers.mc import Controller from dzgui.views.base import Notebook + from dzgui.views.pages.servers import ServerNotebook class Breadcrumbs(Gtk.Label): @@ -30,10 +31,12 @@ class Breadcrumbs(Gtk.Label): self, notebook: "Notebook", page: Gtk.Widget, index: int ) -> None: label = notebook.get_tab_label_text(page) + if label is None: + return self.set_server_crumbs(label) def _on_notebook_page_changed( - self, notebook: "Notebook", page: Gtk.Widget, index: int + self, notebook: "Notebook", page: "ServerNotebook", index: int ) -> None: enum = notebook.get_page_by_enum() if enum == NotebookPage.SERVERS: diff --git a/dzgui/views/dialogs/note.py b/dzgui/views/dialogs/note.py index 04f9f42..b595e90 100644 --- a/dzgui/views/dialogs/note.py +++ b/dzgui/views/dialogs/note.py @@ -30,8 +30,7 @@ class NoteDialog(GenericDialog): delete_button = Gtk.Button(label="Delete note") delete_button.set_sensitive(False) self.add_action_widget(delete_button, Gtk.ResponseType.CANCEL) - self.add_button("Update note", Gtk.ResponseType.OK) - self.ok = self.get_widget_for_response(Gtk.ResponseType.OK) + self.ok = self.add_button("Update note", Gtk.ResponseType.OK) self.ok.grab_default() self.entry = ValidatedEntry(controller, self.validate) diff --git a/dzgui/views/mixins/cursor_mixin.py b/dzgui/views/mixins/cursor_mixin.py index d5a9a41..5033c20 100644 --- a/dzgui/views/mixins/cursor_mixin.py +++ b/dzgui/views/mixins/cursor_mixin.py @@ -61,4 +61,5 @@ class CursorMixin: dest = end path = Gtk.TreePath.new_from_indices([dest]) - self.set_cursor(path) # type: ignore return True + self.set_cursor(path) # type: ignore + return True diff --git a/dzgui/views/pages/preconnect.py b/dzgui/views/pages/preconnect.py index c57b5c1..0bc148d 100644 --- a/dzgui/views/pages/preconnect.py +++ b/dzgui/views/pages/preconnect.py @@ -313,6 +313,8 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): def mark_finished(self) -> None: self.mod_count.set_label(preconnect.all_updated) model = self.tree.get_model() + if model is None: + return for row in model: row[2] = checkmark diff --git a/dzgui/views/trees/tree_menu.py b/dzgui/views/trees/tree_menu.py index 77af954..e800998 100644 --- a/dzgui/views/trees/tree_menu.py +++ b/dzgui/views/trees/tree_menu.py @@ -75,4 +75,4 @@ class MenuTreeView(TreeView): model = self.get_model() _iter = self.get_focused_row_iter() rowtype = model.get_value(_iter, 1) - return rowtype.dict["tooltip"] + return str(rowtype.dict["tooltip"]) From 897186faa0b69ad121e6e8a9a4696cbacbd451ac Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Tue, 12 May 2026 18:36:25 +0900 Subject: [PATCH 276/309] chore: clear typehinting errors --- dzgui/api/steam.py | 2 +- dzgui/config/uninstall.py | 3 ++- dzgui/init/update.py | 2 +- dzgui/main.py | 8 ++++---- dzgui/managers/connection.py | 3 +-- dzgui/views/components/buttons.py | 14 ++++++++++++-- dzgui/views/dialogs/link_dialog.py | 2 -- dzgui/views/pages/options.py | 13 ++++++++++--- tests/test_config.py | 3 +-- 9 files changed, 32 insertions(+), 18 deletions(-) diff --git a/dzgui/api/steam.py b/dzgui/api/steam.py index bf705c4..3438c6a 100644 --- a/dzgui/api/steam.py +++ b/dzgui/api/steam.py @@ -87,7 +87,7 @@ def get_remote_signatures(mods: list[str]) -> list[tuple[str, str, int, int]]: return hashes -# TODO:: set config to name=user, use official server and no mods, +# TODO: set config to name=user, use official server and no mods, # ensure that formatted string is identical to fixture with same hash def connect(addr: str, appid: int, name: str, mods: list[str]) -> None: concat = concat_mods(mods) diff --git a/dzgui/config/uninstall.py b/dzgui/config/uninstall.py index fb40d2f..005c519 100644 --- a/dzgui/config/uninstall.py +++ b/dzgui/config/uninstall.py @@ -1,6 +1,7 @@ -import shutil +# import shutil from pathlib import Path + def uninstall(path: Path) -> None: pass # TODO: use xdg object? diff --git a/dzgui/init/update.py b/dzgui/init/update.py index 92729cb..eb6d7db 100644 --- a/dzgui/init/update.py +++ b/dzgui/init/update.py @@ -4,7 +4,7 @@ import subprocess import sys from importlib import resources -from packaging.version import Version, InvalidVersion +from packaging.version import Version from dzgui.const.constants import APP_NAME, APP_NAME_LOWER, REQUEST_TIMEOUT from dzgui.const.endpoints import GITHUB_RELEASES, CODEBERG_RELEASES diff --git a/dzgui/main.py b/dzgui/main.py index 7380be8..27a56b8 100644 --- a/dzgui/main.py +++ b/dzgui/main.py @@ -28,6 +28,8 @@ from dzgui.init.prereqs import has_steam_client from dzgui.init.proc import has_cmd from dzgui.init.update import allow_updates, check_updates +from dzgui.strings import init + 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 @@ -126,7 +128,7 @@ def main() -> None: if _is_steam_deck is False: # TODO: sudo escalation dialog - count = get_map_count() + # count = get_map_count() # TODO: move into module if has_steam_client() is False: EarlyAlertDialog(init.requires_steam) @@ -147,7 +149,6 @@ def main() -> None: tool = cmd break - # TODO: push is_game_mode to preconnect dialog prefs = UserPrefs( is_steam_deck=_is_steam_deck, is_game_mode=_is_game_mode, @@ -160,6 +161,5 @@ def main() -> None: foreground_cmd=tool, ) # TODO: drop allow updates - # TODO: strings - print("All OK. Loading UI...") + print(init.all_ok) App(prefs) diff --git a/dzgui/managers/connection.py b/dzgui/managers/connection.py index 37a2508..324dfa9 100644 --- a/dzgui/managers/connection.py +++ b/dzgui/managers/connection.py @@ -30,10 +30,9 @@ from dzgui.const.constants import ( APPID_DAYZ_EXP, APPNAME_DAYZ, APPNAME_DAYZ_EXP, - DAYZ_BINARY, ) from dzgui.const.enum import NotebookPage, Preferences -from dzgui.init.proc import foreground, is_dayz_running, is_steam_running, is_running +from dzgui.init.proc import foreground, is_dayz_running, is_steam_running from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager from dzgui.strings.dialogs import waiting_for_launch, waiting_for_mods from dzgui.strings.server_mods import checkmark, resync diff --git a/dzgui/views/components/buttons.py b/dzgui/views/components/buttons.py index 399130a..b922994 100644 --- a/dzgui/views/components/buttons.py +++ b/dzgui/views/components/buttons.py @@ -1,8 +1,12 @@ -from typing import Callable, Literal, Self, TYPE_CHECKING +from typing import Callable, Self, TYPE_CHECKING from dzgui.util.clip import copy_clipboard from dzgui.util.format import pluralize -from dzgui.util.strings import alert_button_tooltip, atomic_buttons, connect_panel +from dzgui.util.strings import ( + alert_button_tooltip, + atomic_buttons, + connect_panel, +) from dzgui.const.constants import ( CLIPBOARD, INPUT_KEYBOARD, @@ -163,6 +167,12 @@ class SteamTextButton(SteamConnectButton): self.set_label(label) +class SteamWorkshopButton(SteamTextButton): + def __init__(self) -> None: + super().__init__(label="Steam Workshop") + self.set_tooltip_text("connect to workshop") + + class AddButton(IconTextButton): def __init__(self) -> None: super().__init__(icon=LIST_ADD, label=connect_panel.add) diff --git a/dzgui/views/dialogs/link_dialog.py b/dzgui/views/dialogs/link_dialog.py index fa36460..294765c 100644 --- a/dzgui/views/dialogs/link_dialog.py +++ b/dzgui/views/dialogs/link_dialog.py @@ -50,8 +50,6 @@ class WorkshopLinkDialog(GenericDialog): def _on_button_clicked(self, button: Gtk.Button, uid: str) -> None: # TODO: currently checks user id on instantiation self.controller.open_user_workshop(uid) - # 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: diff --git a/dzgui/views/pages/options.py b/dzgui/views/pages/options.py index 65d6292..263bc10 100644 --- a/dzgui/views/pages/options.py +++ b/dzgui/views/pages/options.py @@ -145,11 +145,18 @@ class Options(Gtk.Box): self.force_button.set_sensitive(False) eb = InfoEventBox(strings.options.dl_eventbox, controller) - eb2 = InfoEventBox(strings.options.force_eventbox, controller) + # eb2 = InfoEventBox(strings.options.force_eventbox, controller) + from dzgui.views.components.buttons import SteamWorkshopButton + + workshop_button = SteamWorkshopButton() # label=strings.self_workshop) + workshop_button.connect( + "clicked", lambda _: self.controller.open_user_workshop(self.uid) + ) mod_rows = [ - [LeftLabel(strings.options.install_mode), self.mod_install_toggle, eb], - [LeftLabel(strings.options.force_update), self.force_button, eb2], + [LeftLabel("Steam Workshop"), workshop_button, eb], + # [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) diff --git a/tests/test_config.py b/tests/test_config.py index a596945..601eb9b 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -21,7 +21,6 @@ def keys(): "fav_server", "fav_label", "name", - "branch", "fullscreen", "steam_api", "auto_install", @@ -29,7 +28,7 @@ def keys(): "client", "ip_list", "use_miles", - "start_tab" + "start_tab", ] From ede750a8d41e56f2099232e78eda570df85636a0 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Tue, 12 May 2026 18:37:53 +0900 Subject: [PATCH 277/309] fix: namespace collision --- dzgui/main.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dzgui/main.py b/dzgui/main.py index 27a56b8..4ebdbb9 100644 --- a/dzgui/main.py +++ b/dzgui/main.py @@ -28,7 +28,7 @@ from dzgui.init.prereqs import has_steam_client from dzgui.init.proc import has_cmd from dzgui.init.update import allow_updates, check_updates -from dzgui.strings import init +from dzgui.strings import boot from dzgui.util.map_count import get_map_count from dzgui.util.deck import is_steam_deck, is_game_mode @@ -161,5 +161,5 @@ def main() -> None: foreground_cmd=tool, ) # TODO: drop allow updates - print(init.all_ok) + print(boot.all_ok) App(prefs) From 9989b8a8809d809a9f32c3da470a882aedd41207 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Tue, 12 May 2026 21:06:35 +0900 Subject: [PATCH 278/309] chore: add strings files --- dzgui/strings/boot.py | 1 + dzgui/strings/dialogs.py | 2 ++ 2 files changed, 3 insertions(+) create mode 100644 dzgui/strings/boot.py create mode 100644 dzgui/strings/dialogs.py diff --git a/dzgui/strings/boot.py b/dzgui/strings/boot.py new file mode 100644 index 0000000..8a85083 --- /dev/null +++ b/dzgui/strings/boot.py @@ -0,0 +1 @@ +all_ok = "All OK. Loading UI..." diff --git a/dzgui/strings/dialogs.py b/dzgui/strings/dialogs.py new file mode 100644 index 0000000..c791a47 --- /dev/null +++ b/dzgui/strings/dialogs.py @@ -0,0 +1,2 @@ +waiting_for_launch = "Waiting for DayZ to launch" +waiting_for_mods = "Waiting for Steam to update mods" From 31ad575f97ff335ac9da610024af9cb37ea2c56b Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Tue, 12 May 2026 21:06:51 +0900 Subject: [PATCH 279/309] chore: update gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 3f9aba7..456bad6 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ build/ .mypy_cache/ notes.otl uv.lock +.coverage From 67eff054a3a76023b977404cb17d19b69475c666 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 13 May 2026 08:04:10 +0900 Subject: [PATCH 280/309] chore: drop old dialog --- dzgui/views/dialogs/server_mods.py | 92 ------------------------------ 1 file changed, 92 deletions(-) delete mode 100644 dzgui/views/dialogs/server_mods.py diff --git a/dzgui/views/dialogs/server_mods.py b/dzgui/views/dialogs/server_mods.py deleted file mode 100644 index 63e967d..0000000 --- a/dzgui/views/dialogs/server_mods.py +++ /dev/null @@ -1,92 +0,0 @@ -import gi -import textwrap -from typing import TYPE_CHECKING - -from dzgui.const.constants import EXPAND, FILL -from dzgui.model.model_factory import ModelFactory -from dzgui.util import strings -from dzgui.views.dialogs.generic import GenericDialog - -gi.require_version("Gtk", "3.0") -from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa - -if TYPE_CHECKING: - from dzgui.api.servers import Record - - -class ServerModDialog(GenericDialog): - def __init__(self, controller, mods: list[str]): - - # TODO: center secondary text - msg = textwrap.dedent(strings.workshop) - super().__init__( - controller=controller, - text=msg, - buttons="Gtk.ButtonsType.OK", - mtype="Gtk.MessageType.INFO", - secondary="", - ) - - self.mod_store = ModelFactory().make_server_mod_store() - - dialogBox = self.get_content_area() - self.set_default_response(Gtk.ResponseType.OK) - self.set_size_request(800, 700) - - 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.connect("response", self._on_response) - self.view.connect("row-activated", self._on_row_activated) - self.view.set_model(self.mod_store) - - 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) - - mod_count = len(mods) - self.set_markup(f"Modlist ({mod_count} mods)") - for mod in mods: - self.mod_store.append(mod) - - self.show_all() - - # TODO: handle response a la details dialog - - def _on_keypress(self, view: Gtk.TreeView, event: Gdk.EventKey) -> None: - if event.keyval == Gdk.KEY_Escape: - self.destroy() - return True - return False - - 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) - print(mod_id) - # call_bash_func("open_workshop_page", mod_id) From cf913eff1b0e207c3291e27bcb3d20bfd1105cc5 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 13 May 2026 08:06:12 +0900 Subject: [PATCH 281/309] chore: boilerplate --- dzgui/api/pefile.py | 17 ----------------- dzgui/api/servers.py | 4 ++-- dzgui/controllers/mc.py | 1 + dzgui/main.py | 3 +-- dzgui/views/dialogs/filepicker.py | 9 +++++---- dzgui/views/dialogs/servers.py | 1 + pyproject.toml | 2 +- 7 files changed, 11 insertions(+), 26 deletions(-) diff --git a/dzgui/api/pefile.py b/dzgui/api/pefile.py index 9e04b2d..508bbb8 100644 --- a/dzgui/api/pefile.py +++ b/dzgui/api/pefile.py @@ -6,8 +6,6 @@ from enum import Enum from pathlib import Path from typing import BinaryIO, Self, Union -from packaging.version import Version - from dzgui.const.constants import ( APPID_DAYZ, APPID_DAYZ_EXP, @@ -448,21 +446,6 @@ def get_app_path(folders_path: Path, appid: int) -> Path: 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 - - def get_pretty_version(steam_path: Path, appid: int) -> str | None: try: pe_file_path = get_pefile_path(steam_path, appid) diff --git a/dzgui/api/servers.py b/dzgui/api/servers.py index acb73e6..3c5e1b0 100644 --- a/dzgui/api/servers.py +++ b/dzgui/api/servers.py @@ -221,7 +221,7 @@ def parse_json(json: list) -> list: mapname = row["map"].lower() players = row["players"] max_players = row["max_players"] - raw = [ + raw = ( name, mapname, view, @@ -234,7 +234,7 @@ def parse_json(json: list) -> list: ping, provider, modded, - ] + ) rows.append(raw) return rows diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index fef5184..abc1ac6 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -323,6 +323,7 @@ class Controller(GObject.GObject): def _on_servers_loaded_init(self, emitter: "Emitter") -> None: """Triggered after servers load but prior to maps loading""" # FIXME: wipe maps store when changing tabs if model is none + # e.g. select recent, toggle map, then select lan -> not wiped tv = self.get_active_treeview() if tv.loaded is False: return diff --git a/dzgui/main.py b/dzgui/main.py index 4ebdbb9..3426d27 100644 --- a/dzgui/main.py +++ b/dzgui/main.py @@ -83,13 +83,12 @@ def main() -> None: if args.version is True: print(get_version()) sys.exit(0) - if args.uninstall is True: uninstall() sys.exit(0) - if args.debug is True: warnings.filterwarnings("default", category=DeprecationWarning) + version = get_version() set_locale() diff --git a/dzgui/views/dialogs/filepicker.py b/dzgui/views/dialogs/filepicker.py index 75f7a22..0691bc8 100644 --- a/dzgui/views/dialogs/filepicker.py +++ b/dzgui/views/dialogs/filepicker.py @@ -1,11 +1,12 @@ 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 +import gi # noqa E402 + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk # noqa E402 + class FilePicker(Gtk.FileChooserDialog): def __init__(self, parent: Gtk.Window) -> None: diff --git a/dzgui/views/dialogs/servers.py b/dzgui/views/dialogs/servers.py index fc9f1a1..fc6942e 100644 --- a/dzgui/views/dialogs/servers.py +++ b/dzgui/views/dialogs/servers.py @@ -45,6 +45,7 @@ class ServerDialog(GenericDialog): self.content = self.get_content_area() self.content.pack_start(self.scrollable_tree, EXPAND, FILL, 0) + # TODO: # def pack(self, widget: Gtk.Widget) -> None: # self.content.pack_start(widget, EXPAND, FILL, NO_PADDING) diff --git a/pyproject.toml b/pyproject.toml index 3510afc..1bf83b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,7 +65,7 @@ build-backend = "setuptools.build_meta" [tool.ruff] line-length = 88 indent-width = 4 -extend-exclude = ["tree_server_mods.py", "dialogs", "*lib/a2s"] +extend-exclude = ["*lib/a2s"] lint.unfixable = ["F401"] [tool.ruff.format] From c5a005000466d6490b2949e2332e8d66db65a567 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 13 May 2026 18:00:35 +0900 Subject: [PATCH 282/309] chore: update strings --- dzgui/strings/preconnect.py | 2 ++ dzgui/views/components/buttons.py | 5 +++-- dzgui/views/dialogs/servers.py | 4 ---- dzgui/views/pages/options.py | 12 ++++-------- dzgui/views/pages/preconnect.py | 11 ++++++++--- 5 files changed, 17 insertions(+), 17 deletions(-) diff --git a/dzgui/strings/preconnect.py b/dzgui/strings/preconnect.py index 4d63739..17c8a5e 100644 --- a/dzgui/strings/preconnect.py +++ b/dzgui/strings/preconnect.py @@ -7,3 +7,5 @@ errors = "Errors" mods = "Mods" total_mods = "Total mods: " all_updated = "All mods updated." +"If you recently installed {build} or moved it to a different drive, " +"restart Steam to allow these changes to synchronize, then try again." diff --git a/dzgui/views/components/buttons.py b/dzgui/views/components/buttons.py index b922994..6f60814 100644 --- a/dzgui/views/components/buttons.py +++ b/dzgui/views/components/buttons.py @@ -2,6 +2,7 @@ from typing import Callable, Self, TYPE_CHECKING from dzgui.util.clip import copy_clipboard from dzgui.util.format import pluralize +from dzgui.strings import buttons from dzgui.util.strings import ( alert_button_tooltip, atomic_buttons, @@ -169,8 +170,8 @@ class SteamTextButton(SteamConnectButton): class SteamWorkshopButton(SteamTextButton): def __init__(self) -> None: - super().__init__(label="Steam Workshop") - self.set_tooltip_text("connect to workshop") + super().__init__(label=buttons.workshop) + self.set_tooltip_text(buttons.workshop_tooltip) class AddButton(IconTextButton): diff --git a/dzgui/views/dialogs/servers.py b/dzgui/views/dialogs/servers.py index fc6942e..9ea36bf 100644 --- a/dzgui/views/dialogs/servers.py +++ b/dzgui/views/dialogs/servers.py @@ -45,10 +45,6 @@ class ServerDialog(GenericDialog): self.content = self.get_content_area() self.content.pack_start(self.scrollable_tree, EXPAND, FILL, 0) - # TODO: - # def pack(self, widget: Gtk.Widget) -> None: - # self.content.pack_start(widget, EXPAND, FILL, NO_PADDING) - def _on_keypress(self, view: Gtk.TreeView, event: Gdk.EventKey) -> bool: # NOTE: ESC normally unfocuses treeview instead of destroying dialog if event.keyval == Gdk.KEY_Escape: diff --git a/dzgui/views/pages/options.py b/dzgui/views/pages/options.py index 263bc10..0243713 100644 --- a/dzgui/views/pages/options.py +++ b/dzgui/views/pages/options.py @@ -20,8 +20,9 @@ from dzgui.const.constants import ( ) from dzgui.const.endpoints import STEAM_API_SETUP, BM_API_SETUP from dzgui.const.enum import Preferences, ServerTab -from dzgui.strings import errors +from dzgui.strings import errors, options from dzgui.util import strings, css, open_links +from dzgui.views.components.buttons import SteamWorkshopButton from dzgui.views.components.labels import LeftLabel from dzgui.views.components.eventbox import InfoEventBox from dzgui.views.components.buttons import WebButton @@ -144,19 +145,14 @@ class Options(Gtk.Box): # NOTE: sensitivity state is updated after config file is loaded self.force_button.set_sensitive(False) - eb = InfoEventBox(strings.options.dl_eventbox, controller) - # eb2 = InfoEventBox(strings.options.force_eventbox, controller) - - from dzgui.views.components.buttons import SteamWorkshopButton + eb = InfoEventBox(options.workshop_eventbox, controller) workshop_button = SteamWorkshopButton() # label=strings.self_workshop) workshop_button.connect( "clicked", lambda _: self.controller.open_user_workshop(self.uid) ) mod_rows = [ - [LeftLabel("Steam Workshop"), workshop_button, eb], - # [LeftLabel(strings.options.install_mode), self.mod_install_toggle, eb], - # [LeftLabel(strings.options.force_update), self.force_button, eb2], + [LeftLabel(options.workshop_label), workshop_button, eb], ] self.dayz_version_label = Gtk.Label(label=strings.null) diff --git a/dzgui/views/pages/preconnect.py b/dzgui/views/pages/preconnect.py index 624c82f..01207d9 100644 --- a/dzgui/views/pages/preconnect.py +++ b/dzgui/views/pages/preconnect.py @@ -225,14 +225,19 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): warnings: list[str] = [] errors: list[str] = [] + resync_msg = ( + f"If you recently installed {prereqs.build} or moved it to a different drive,\n" + "restart Steam to allow these changes to synchronize, then try again." + ) + """Errors""" if prereqs.binary_missing: errors.append( - f"Remote server is running the build '{prereqs.build}', but it is not installed." + f"Remote server is running the build '{prereqs.build}', but it is not installed.\n{resync_msg}" ) elif prereqs.local_version != prereqs.remote_version: errors.append( - f"Local client version '{prereqs.local_version}' does not match remote version '{prereqs.remote_version}'." + f"Local client version '{prereqs.local_version}' does not match remote version '{prereqs.remote_version}'.\n{resync_msg}" ) if prereqs.required_space > prereqs.available_space: required_pretty = number(prereqs.required_space) @@ -307,7 +312,7 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): self.mod_count.set_text(f"{prefix}{str(total_mods)}.{suffix}") self._process_warnings(prereqs) - if self.tree.is_visible: + if self.tree.is_visible(): self.tree.grab_focus() else: self.grab_focus() From 5e0e6e4592d32b4aa56ec2ebdb5401c01fc5606a Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 13 May 2026 18:12:31 +0900 Subject: [PATCH 283/309] chore: update options page strings --- CHANGELOG.md | 3 +++ dzgui/views/pages/options.py | 38 ++++++++++++++++++++++-------------- 2 files changed, 26 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 75a62ec..30c1729 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ - Preconnect dialog - Preconnect warnings/failsafes like filesize + ## Changed - Reduce padding on keys button - Boldface breadcrumbs @@ -44,11 +45,13 @@ - Cull servers with abnormal queue values (integer overflow: 2147483647) - Packaging structure - Suppress log messages from imported modules +- Embed Workshop link in Options menu ## Dropped - Debug mode - Branch switching - Manual mod install mode (describe rationale) +- Force update mods ## Fixed - Longstanding issue with left clicks not registering as tree selection changes after spamming keyboard input diff --git a/dzgui/views/pages/options.py b/dzgui/views/pages/options.py index 0243713..fbb20c3 100644 --- a/dzgui/views/pages/options.py +++ b/dzgui/views/pages/options.py @@ -41,6 +41,13 @@ if TYPE_CHECKING: from dzgui.controllers.emitter import Emitter +class ShortHBox(Gtk.Box): + def __init__(self, widget: Gtk.Widget) -> None: + super().__init__(spacing=5, halign=Gtk.Align.START) + + self.pack_start(widget, NO_EXPAND, NO_FILL, NO_PADDING) + + class Options(Gtk.Box): def __init__(self, controller: "Controller"): super().__init__( @@ -96,15 +103,16 @@ class Options(Gtk.Box): ) 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)") + for text in ( + options.steam_combo, + options.flatpak_combo, + options.flatpak_container_combo, + ): + self.client_combo.append_text(text) self.client_combo.set_active(0) self.client_combo.connect("changed", self._on_client_changed) - # TODO: short hbox class - client_hbox = Gtk.Box(spacing=5, halign=Gtk.Align.START) - client_hbox.pack_start(self.client_combo, NO_EXPAND, NO_FILL, NO_PADDING) + + client_hbox = ShortHBox(self.client_combo) self.distance_toggle = self.make_binary_radio( strings.options.km, strings.options.mi, Preferences.DIST @@ -112,10 +120,10 @@ class Options(Gtk.Box): combo_store = Gtk.ListStore(str, object) tabs = ( - ("Server browser", ServerTab.BROWSER), - ("Saved servers", ServerTab.SAVED), - ("Recent", ServerTab.RECENT), - ("LAN", ServerTab.LAN), + (options.server_combo, ServerTab.BROWSER), + (options.saved_combo, ServerTab.SAVED), + (options.recent_combo, ServerTab.RECENT), + (options.lan_combo, ServerTab.LAN), ) for tab in tabs: combo_store.append(tab) @@ -124,15 +132,15 @@ class Options(Gtk.Box): self.start_tab_combo.pack_start(renderer_text, True) self.start_tab_combo.add_attribute(renderer_text, "text", 0) self.start_tab_combo.set_active(0) - start_tab_hbox = Gtk.Box(spacing=5, halign=Gtk.Align.START) - start_tab_hbox.pack_start(self.start_tab_combo, NO_EXPAND, NO_FILL, NO_PADDING) + + start_tab_hbox = ShortHBox(self.start_tab_combo) self.start_tab_combo.connect("changed", self._on_start_tab_changed) pref_rows = [ [LeftLabel(strings.options.client), client_hbox], [LeftLabel(strings.options.window_size), self.fullscreen_toggle], [LeftLabel(strings.options.distance), self.distance_toggle], - [LeftLabel("Start tab"), start_tab_hbox], + [LeftLabel(options.start_tab), start_tab_hbox], [LeftLabel(strings.options.name), self.player_box], ] @@ -191,7 +199,7 @@ class Options(Gtk.Box): hexpand=True, ) - developers = Gtk.Button(label="Developers", halign=Gtk.Align.START) + developers = Gtk.Button(label=options.developers, halign=Gtk.Align.START) developers.connect("clicked", self._on_developers_clicked) prefs = self.controller.get_prefs() From 3857b69b704a744049d03abf6b2e5b392fdcae5b Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 13 May 2026 18:28:00 +0900 Subject: [PATCH 284/309] fix: interpolate client args --- dzgui/api/steam.py | 8 +++++--- dzgui/managers/connection.py | 3 ++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/dzgui/api/steam.py b/dzgui/api/steam.py index ae4b1e2..5b33775 100644 --- a/dzgui/api/steam.py +++ b/dzgui/api/steam.py @@ -8,6 +8,7 @@ from pathlib import Path from dzgui.const.constants import APP_NAME from dzgui.const.endpoints import STEAM_PUBLISHED_FILES +from dzgui.util.bash import concat_bash_args logger = logging.getLogger(APP_NAME) @@ -89,10 +90,11 @@ def get_remote_signatures(mods: list[str]) -> list[tuple[str, str, int, int]]: # TODO: set config to name=user, use official server and no mods, # ensure that formatted string is identical to fixture with same hash -def connect(addr: str, appid: int, name: str, mods: list[str]) -> int: +def connect(client: str, addr: str, appid: int, name: str, mods: list[str]) -> int: concat = concat_mods(mods) + client_args = concat_bash_args(client) + # TODO: test flatpak arg expansion params = [ - "steam", "-applaunch", str(appid), f"-connect={addr}", @@ -102,7 +104,7 @@ def connect(addr: str, appid: int, name: str, mods: list[str]) -> int: f"-name={name}", f"-mod={concat}", ] - proc = subprocess.run([*params]) + proc = subprocess.run([*client_args, *params]) return proc.returncode diff --git a/dzgui/managers/connection.py b/dzgui/managers/connection.py index c40a663..bbfc3cf 100644 --- a/dzgui/managers/connection.py +++ b/dzgui/managers/connection.py @@ -246,7 +246,8 @@ class ConnectionManager: def _connect_steam(self) -> None: addr = f"{self.record.ip}:{self.record.gameport}" playername = self.controller.query_config(Preferences.NAME) - rc = connect(addr, self.appid, playername, self.remote_mod_ids) + client = self.controller.query_config(Preferences.CLIENT) + rc = connect(client, addr, self.appid, playername, self.remote_mod_ids) if rc != 0: # TODO: log/pop the error func = StoredFunc(self.controller.update_status) From f8e62d05a8194696cf03d55c1b8445acedfdf0db Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 13 May 2026 18:28:14 +0900 Subject: [PATCH 285/309] change: use ListStore for combos --- dzgui/views/pages/options.py | 43 +++++++++++++++++++++--------------- 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/dzgui/views/pages/options.py b/dzgui/views/pages/options.py index fbb20c3..01f8f11 100644 --- a/dzgui/views/pages/options.py +++ b/dzgui/views/pages/options.py @@ -102,13 +102,28 @@ class Options(Gtk.Box): Preferences.WINDOW, ) - self.client_combo = Gtk.ComboBoxText() - for text in ( - options.steam_combo, - options.flatpak_combo, - options.flatpak_container_combo, - ): - self.client_combo.append_text(text) + # TODO: make this an abstract class + client_store = Gtk.ListStore(str, str) + clients = ( + ( + options.steam_combo, + STEAM_CMD, + ), + ( + options.flatpak_combo, + FLATPAK_RUN_CMD, + ), + ( + options.flatpak_container_combo, + FLATPAK_SANDBOX, + ), + ) + for client in clients: + client_store.append(client) + self.client_combo = Gtk.ComboBox.new_with_model(client_store) # Text() + renderer_text = Gtk.CellRendererText() + self.client_combo.pack_start(renderer_text, True) + self.client_combo.add_attribute(renderer_text, "text", 0) self.client_combo.set_active(0) self.client_combo.connect("changed", self._on_client_changed) @@ -342,16 +357,9 @@ class Options(Gtk.Box): self.controller.update_config(Preferences.START_TAB, index) def _on_client_changed(self, combo: Gtk.ComboBoxText) -> None: - # TODO: use two columns or constants here, not strings - 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) + _iter = combo.get_active_iter() + real_cmd = combo.get_model()[_iter][1] + self.controller.update_config(Preferences.CLIENT, real_cmd) def _on_radio_toggled(self, button: Gtk.RadioButton, context: Preferences) -> None: try: @@ -501,7 +509,6 @@ class Options(Gtk.Box): # TODO: not happy with this active_combo = query.get_client_index(config["client"]) self.client_combo.set_active(active_combo) - # active_combo = 0 start_tab = self.controller.query_config(Preferences.START_TAB) self.start_tab_combo.set_active(start_tab) From add93a6b0ae34c0f29350ef9c5064e95f005f100 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 13 May 2026 19:27:56 +0900 Subject: [PATCH 286/309] chore: add strings files --- dzgui/strings/buttons.py | 2 ++ dzgui/strings/options.py | 12 ++++++++++++ 2 files changed, 14 insertions(+) create mode 100644 dzgui/strings/buttons.py create mode 100644 dzgui/strings/options.py diff --git a/dzgui/strings/buttons.py b/dzgui/strings/buttons.py new file mode 100644 index 0000000..faa00a0 --- /dev/null +++ b/dzgui/strings/buttons.py @@ -0,0 +1,2 @@ +workshop = "Steam Workshop" +workshop_tooltip = "Open your Workshop subscriptions" diff --git a/dzgui/strings/options.py b/dzgui/strings/options.py new file mode 100644 index 0000000..ec28776 --- /dev/null +++ b/dzgui/strings/options.py @@ -0,0 +1,12 @@ +developers = "Developers" +start_tab = "Start tab" +workshop_label = "Subscribed mods" +workshop_eventbox = "If you manually subscribed to mods on Steam prior to using DZGUI, you can unsubscribe via this link." +steam_combo = "Steam" +flatpak_combo = "Flatpak" +flatpak_container_combo = "Flatpak (container)" + +server_combo = "Server browser" +saved_combo = "Saved servers" +recent_combo = "Recent" +lan_combo = "LAN" From fe0a6711f0df7063157d9d4699069c97a1a3346d Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 13 May 2026 19:28:36 +0900 Subject: [PATCH 287/309] docs: developers boilerplate --- DEVELOPERS.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 DEVELOPERS.md diff --git a/DEVELOPERS.md b/DEVELOPERS.md new file mode 100644 index 0000000..da5b8d6 --- /dev/null +++ b/DEVELOPERS.md @@ -0,0 +1,13 @@ +# TODO: link to here from sphinx docs + +# Fetching submodules +git submodule update --recursive --init + +# Installing dev dependencies +uv pip install -e .[dev] + +# Building documentation +sphinx-build -M html source build -a + +# Building installer package +TBD From 866c88fbb0eba10e381421d84ef7c4fbd177c57f Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 13 May 2026 20:13:06 +0900 Subject: [PATCH 288/309] chore: clear typehinting errors --- dzgui/api/bm.py | 4 ++-- dzgui/api/mods.py | 4 +++- dzgui/api/pefile.py | 23 ++++++++++++----------- dzgui/api/servers.py | 6 +++--- dzgui/api/steam.py | 18 +++++++++--------- dzgui/config/convert.py | 11 +++++++++-- dzgui/config/query.py | 4 ++-- dzgui/init/proc.py | 2 +- dzgui/model/proxy_model.py | 8 ++++++-- dzgui/model/servers.py | 10 ++++++---- dzgui/util/dist.py | 7 ++++--- dzgui/util/ip.py | 2 +- dzgui/views/components/buttonbox.py | 8 ++++++-- dzgui/views/trees/tree_server_mods.py | 1 + 14 files changed, 65 insertions(+), 43 deletions(-) diff --git a/dzgui/api/bm.py b/dzgui/api/bm.py index 67dbd63..8e70bb2 100644 --- a/dzgui/api/bm.py +++ b/dzgui/api/bm.py @@ -33,8 +33,8 @@ def map_id_to_record(key: str, uid: int) -> Optional["Record"]: try: record = get_attributes(key, uid) ip = record["ip"] - port = record["port"] - qport = record["portQuery"] + port = int(record["port"]) + qport = int(record["portQuery"]) return Record(ip, port, qport) except Exception: return None diff --git a/dzgui/api/mods.py b/dzgui/api/mods.py index 19929a7..0c0e83d 100644 --- a/dzgui/api/mods.py +++ b/dzgui/api/mods.py @@ -49,7 +49,7 @@ def get_local_mods(workshop_path: Path) -> list[Path]: return mods -def parse_meta(file: Path) -> ModMeta: +def parse_meta(file: Path) -> ModMeta | None: mod = file / "meta.cpp" if mod.exists() is False: return None @@ -135,6 +135,8 @@ def remove_stale_signatures(config: Path, versions: Path) -> None: def find_stale_mods(config: Path) -> list[int]: def push_record(rec: str) -> list: record = fqip_to_record(rec) + if record is None: + return [] try: mods = get_rules(record) except Exception: diff --git a/dzgui/api/pefile.py b/dzgui/api/pefile.py index 508bbb8..e8150eb 100644 --- a/dzgui/api/pefile.py +++ b/dzgui/api/pefile.py @@ -33,35 +33,35 @@ class VersionMatch(Enum): SAME_VERSION = 3 -class u8: +class u8(int): fmt = "B" -class u16: +class u16(int): fmt = "H" -class u32: +class u32(int): fmt = "L" -class u64: +class u64(int): fmt = "Q" -class i8: +class i8(int): fmt = "b" -class i16: +class i16(int): fmt = "h" -class i32: +class i32(int): fmt = "l" -class i64: +class i64(int): fmt = "q" @@ -330,6 +330,7 @@ def get_version(file: Path) -> FileVersion: magic = hex(struct.unpack(" FileVersion: table = RESOURCE_DIRECTORY_TABLE.unpack(f) total = table.number_of_name_entries + table.number_of_id_entries - for entry in range(total): + for _iter in range(total): entry = RESOURCE_DIRECTORY_ENTRY.unpack(f) if entry.name_or_id == VERSION_RESOURCE: while entry.data_or_subdir & (1 << 31): @@ -371,7 +372,7 @@ def get_version(file: Path) -> FileVersion: 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): + for _iter in range(total): entry = RESOURCE_DIRECTORY_ENTRY.unpack(f) break if entry.name_or_id > VERSION_RESOURCE: @@ -383,7 +384,7 @@ def get_version(file: Path) -> FileVersion: 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) + 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") diff --git a/dzgui/api/servers.py b/dzgui/api/servers.py index 3c5e1b0..baca26c 100644 --- a/dzgui/api/servers.py +++ b/dzgui/api/servers.py @@ -81,9 +81,9 @@ class A2SInfo: def get_info(self) -> a2s.SourceInfo | None: return self.info - def as_row(self) -> dict[str, Any] | None: + def as_row(self) -> dict[str, Any]: if self.info is None: - return None + raise AttributeError("No record to convert") ip = self.record.ip qport = self.record.qport return source_info_to_dict(ip, qport, self.info) @@ -239,7 +239,7 @@ def parse_json(json: list) -> list: return rows -def source_info_to_dict(ip: str, qport: int, info: "SourceInfo") -> dict[str, Any]: +def source_info_to_dict(ip: str, qport: int, info: "SourceInfo") -> dict[str, Any] | None: try: name = info.server_name mapname = info.map_name diff --git a/dzgui/api/steam.py b/dzgui/api/steam.py index 5b33775..746da7d 100644 --- a/dzgui/api/steam.py +++ b/dzgui/api/steam.py @@ -28,9 +28,9 @@ def get_local_signatures(version_file: Path) -> dict[str, int]: hashes: dict[str, int] = {} lines = version_file.read_text().splitlines() for line in lines: - line = line.split(",") - _id = line[0] - _hash = int(line[1]) + data = line.split(",") + _id = data[0] + _hash = int(data[1]) hashes[_id] = _hash return hashes @@ -49,7 +49,7 @@ def get_needs_update( version_file: Path, remote_hashes: list[tuple[str, str, int, int]] ) -> list[tuple[str, str, int, int]]: local_hashes = get_local_signatures(version_file) - needs_update: list[tuple[str, str]] = [] + needs_update: list[tuple[str, str, int, int]] = [] for title, _id, _hash, size in remote_hashes: if _id not in local_hashes: needs_update.append((title, _id, _hash, size)) @@ -64,7 +64,7 @@ def get_remote_signatures(mods: list[str]) -> list[tuple[str, str, int, int]]: """ Attempts to continue connecting even if signatures are empty """ - payload: dict[str, str] = {} + payload: dict[str, str | int] = {} payload["itemcount"] = len(mods) for i, mod in enumerate(mods): payload[f"publishedfileids[{i}]"] = mod @@ -76,13 +76,13 @@ def get_remote_signatures(mods: list[str]) -> list[tuple[str, str, int, int]]: if r.status_code != 200: return [] - hashes: list[tuple[str, int, int]] = [] + hashes: list[tuple[str, str, int, int]] = [] j = r.json() rows = j["response"]["publishedfiledetails"] for row in rows: - title = row["title"] - _id = row["publishedfileid"] - time = row["time_updated"] + title = str(row["title"]) + _id = str(row["publishedfileid"]) + time = int(row["time_updated"]) size = int(row["file_size"]) hashes.append((title, _id, time, size)) return hashes diff --git a/dzgui/config/convert.py b/dzgui/config/convert.py index b22bd20..cbbc320 100644 --- a/dzgui/config/convert.py +++ b/dzgui/config/convert.py @@ -38,8 +38,15 @@ def rc2json(file: Path) -> str: keys: dict[str, Any] = {} ips: list[str] = [] - toggles = ["auto_install", "fullscreen"] - deprecated = ["staging_dir", "src_path", "steam_path", "debug", "branch"] + toggles = ["fullscreen"] + deprecated = [ + "staging_dir", + "src_path", + "steam_path", + "debug", + "branch", + "auto_install", + ] while True: tok = lex.get_token() diff --git a/dzgui/config/query.py b/dzgui/config/query.py index 9c836cd..eb59cde 100644 --- a/dzgui/config/query.py +++ b/dzgui/config/query.py @@ -24,12 +24,12 @@ def lookup(path: Path, enum: Preferences) -> Any: def get_config(path: Path) -> dict: - # TODO: is this being called twice? + # TODO: is this being called multiple times? try: json = read_json(path) + return json except Exception as e: raise e - return json def get_favorites(path: Path) -> list[str]: diff --git a/dzgui/init/proc.py b/dzgui/init/proc.py index b16b994..adb7d97 100644 --- a/dzgui/init/proc.py +++ b/dzgui/init/proc.py @@ -41,7 +41,7 @@ def is_flatpak_steam_running() -> bool: if has_cmd(FLATPAK_CMD) is False: return False proc = subprocess.check_output([FLATPAK_CMD, "ps"], text=True) - lines = proc.stdout.splitlines() + lines = proc.splitlines() if FLATPAK_APPID in lines: return True return False diff --git a/dzgui/model/proxy_model.py b/dzgui/model/proxy_model.py index 9927019..1cdc3dc 100644 --- a/dzgui/model/proxy_model.py +++ b/dzgui/model/proxy_model.py @@ -28,7 +28,9 @@ class ProxyModelManager: """ def __init__(self, filter_man: "FilterManager") -> None: - self.filter_cache = {} + self.filter_cache: dict[tuple[str], tuple["FastInsertListStore", list[Any]]] = ( + {} + ) self.proxy_model: "FastInsertListStore" = None self.filter_man = filter_man @@ -55,16 +57,18 @@ class ProxyModelManager: self.proxy_model[treeiter][4] = playercount.players self.proxy_model[treeiter][6] = playercount.queue + # FIXME: typehint -> tuple def append_row_to_history( self, history: list[str, str, str, str, int, int, int, str, int, int, str, bool] ) -> None: addr = history[7] qport = history[8] + if self.control_model is None: + raise AttributeError("Trying to append row to empty model") found = False for i, row in enumerate(self.control_model): if addr == row[7] and qport == row[8]: - print("record exists in history") item = self.control_model.pop(i) self.control_model.append(item) found = True diff --git a/dzgui/model/servers.py b/dzgui/model/servers.py index 1174f3c..bf7837d 100644 --- a/dzgui/model/servers.py +++ b/dzgui/model/servers.py @@ -268,8 +268,10 @@ class ServerModelManager: def _parse_single_record(self, response: "A2SInfo", delete: bool = False) -> None: self.preserve_on_fail = True - row = response.as_row() - if row is None: + try: + row = response.as_row() + except Exception as e: + logger.warning(e) self.thread_man.set_cleanup_func(StoredFunc(self._cleanup_on_failure)) return @@ -301,7 +303,7 @@ class ServerModelManager: return config_man.add_saved_server(fqip) # 2026-05-04 - # TODO: this is valid if saved servers tab is already open, + # FIXME: this is valid if saved servers tab is already open, # but not if app was just booted if proxy_man.has_control_model() is False: self._get_proxy_man().push(records) @@ -393,7 +395,7 @@ class ServerModelManager: # TODO: distinguish signals, e.g. "servers_failed_to_load", "servers_loaded_empty" # customize statusbar and dialog accordingly self.emitter.emit("servers_loaded", self.enum) - # TODO: destroy wait dialog first + # FIXME: destroy wait dialog first # see threadman.set_cleanup_func(_, destroy_first=True) if show_dialog: dialog = ExceptionDialog(self.controller, api_warn_msg) diff --git a/dzgui/util/dist.py b/dzgui/util/dist.py index 984dedb..eb511fb 100644 --- a/dzgui/util/dist.py +++ b/dzgui/util/dist.py @@ -13,9 +13,10 @@ gi.require_version("Gtk", "3.0") from gi.repository import Gtk # noqa E402 if TYPE_CHECKING: + from queue import Queue from dzgui.const.enum import ServerTab from dzgui.controllers.mc import Controller - from queue import Queue + from dzgui.util.ip import Coords logger = logging.getLogger(APP_NAME) @@ -74,13 +75,13 @@ class CalcDist: dist = self.compare(self.ip) self.result_queue.put([self.addr, dist, self.enum]) - def compare(self, remote: str) -> int | None: + def compare(self, remote_ip: str) -> Haversine | None: prefs = self.controller.get_prefs() local = prefs.coords if local is None: return None try: - remote = get_coords(prefs.paths.ips, remote) + remote = get_coords(prefs.paths.ips, remote_ip) except Exception: return None diff --git a/dzgui/util/ip.py b/dzgui/util/ip.py index d2edf89..2548475 100644 --- a/dzgui/util/ip.py +++ b/dzgui/util/ip.py @@ -130,7 +130,7 @@ def is_valid_port(port: str) -> bool: @deprecated("use ips.csv") -def get_local_coords(ip: str): +def get_local_coords(ip: str) -> str: url = COORDS_API + "/" + ip # local res=$(curl -Ls "$url" | jq -r '"\(.lat)\n\(.lon)"') return url diff --git a/dzgui/views/components/buttonbox.py b/dzgui/views/components/buttonbox.py index f14dfcd..b268c9e 100644 --- a/dzgui/views/components/buttonbox.py +++ b/dzgui/views/components/buttonbox.py @@ -12,11 +12,15 @@ from gi.repository import Gtk, Gdk # noqa E402 logger = logging.getLogger(APP_NAME) if TYPE_CHECKING: + from dzgui.const.enum import NotebookPage + from dzgui.controller.mc import Controller from dzgui.controllers.emitter import Emitter class ContextualButton(Gtk.Button): - def __init__(self, label, opens, tooltip, context): + def __init__( + self, label: str, opens: "NotebookPage", tooltip: str, context: ButtonType + ) -> None: super().__init__( label=label, tooltip_text=tooltip, @@ -28,7 +32,7 @@ class ContextualButton(Gtk.Button): class ButtonBox(Gtk.Box): - def __init__(self, controller) -> None: + def __init__(self, controller: "Controller") -> None: super().__init__( spacing=6, margin_top=0, diff --git a/dzgui/views/trees/tree_server_mods.py b/dzgui/views/trees/tree_server_mods.py index e815c85..f527164 100644 --- a/dzgui/views/trees/tree_server_mods.py +++ b/dzgui/views/trees/tree_server_mods.py @@ -62,6 +62,7 @@ class ServerModTreeView(ContextMixin, TreeView): # type: ignore path = self.get_focused_row_path() model = self.get_model() tree_iter = model.get_iter(path) + # FIXME: https://docs.gtk.org/gtk3/method.TreeModel.get.html mod = model.get(tree_iter, 1)[0] return str(mod) From fb62fe285dd33dd5c15554e2b3c7094684d616e5 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 13 May 2026 20:48:50 +0900 Subject: [PATCH 289/309] change: use Gtk.present_window_with_time() --- dzgui/config/userprefs.py | 1 - dzgui/controllers/mc.py | 3 +++ dzgui/init/proc.py | 1 + dzgui/main.py | 9 +-------- dzgui/managers/connection.py | 12 ++++-------- dzgui/views/pages/preconnect.py | 5 ++--- 6 files changed, 11 insertions(+), 20 deletions(-) diff --git a/dzgui/config/userprefs.py b/dzgui/config/userprefs.py index b0d7c4a..a06094c 100644 --- a/dzgui/config/userprefs.py +++ b/dzgui/config/userprefs.py @@ -17,4 +17,3 @@ class UserPrefs: allow_updates: bool paths: "Xdg" use_miles: bool - foreground_cmd: str | None diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index abc1ac6..5de87dc 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -497,3 +497,6 @@ class Controller(GObject.GObject): def get_steam_client_name(self) -> str: return self.mediator.options.get_client_name() + + def present_window(self) -> None: + self.mediator.window.present_with_time(Gdk.CURRENT_TIME) diff --git a/dzgui/init/proc.py b/dzgui/init/proc.py index adb7d97..bff06c9 100644 --- a/dzgui/init/proc.py +++ b/dzgui/init/proc.py @@ -60,6 +60,7 @@ def has_cmd(cmd: str) -> bool: return False +@deprecated("dropped in favor of Gtk native methods") def foreground(cmd: str, pid: int) -> None: if cmd == "wmctrl": proc = subprocess.run(["wmctrl", "-ilp"], capture_output=True, text=True) diff --git a/dzgui/main.py b/dzgui/main.py index 3426d27..988307a 100644 --- a/dzgui/main.py +++ b/dzgui/main.py @@ -7,7 +7,7 @@ import warnings from typing import TYPE_CHECKING from dzgui.api.mods import remove_stale_signatures -from dzgui.const.constants import APP_NAME, FOREGROUND_CMDS +from dzgui.const.constants import APP_NAME from dzgui.const.enum import Preferences from dzgui.config.ipdb import get_ipdb from dzgui.config.query import lookup @@ -142,12 +142,6 @@ def main() -> None: local_coords = get_local_coords(XDG.ips) use_miles = lookup(XDG.config, Preferences.DIST) - tool = None - for cmd in FOREGROUND_CMDS: - if has_cmd(cmd) is True: - tool = cmd - break - prefs = UserPrefs( is_steam_deck=_is_steam_deck, is_game_mode=_is_game_mode, @@ -157,7 +151,6 @@ def main() -> None: allow_updates=allow, paths=XDG, use_miles=use_miles, - foreground_cmd=tool, ) # TODO: drop allow updates print(boot.all_ok) diff --git a/dzgui/managers/connection.py b/dzgui/managers/connection.py index bbfc3cf..e5eb715 100644 --- a/dzgui/managers/connection.py +++ b/dzgui/managers/connection.py @@ -32,7 +32,7 @@ from dzgui.const.constants import ( APPNAME_DAYZ_EXP, ) from dzgui.const.enum import NotebookPage, Preferences -from dzgui.init.proc import foreground, is_dayz_running, is_steam_running +from dzgui.init.proc import is_dayz_running, is_steam_running from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager from dzgui.strings.dialogs import waiting_for_launch, waiting_for_mods from dzgui.strings.server_mods import checkmark, resync @@ -74,7 +74,6 @@ class Prerequisites: dayz_running: bool steam_proc: SteamProcess mods: list[list[str]] - foreground_cmd: str | None game_mode: bool @@ -167,7 +166,6 @@ class ConnectionManager: running = is_steam_running(client) steam_proc = SteamProcess(client_name, running) - self.foreground_cmd = prefs.foreground_cmd game_mode = prefs.is_game_mode prereqs = Prerequisites( @@ -183,7 +181,6 @@ class ConnectionManager: dayz_running=dayz_running, steam_proc=steam_proc, mods=remote_mods, - foreground_cmd=self.foreground_cmd, # TODO: change to bool game_mode=game_mode, ) @@ -281,10 +278,9 @@ class ConnectionManager: enqueue_mod(mod, self.appid) time.sleep(2) - if self.foreground_cmd is not None and raise_window is True: - pid = os.getpid() - logger.info(f"Raising window with '{self.foreground_cmd}'") - foreground(self.foreground_cmd, pid) + if raise_window is True: + logger.info(f"Bringing window to foreground") + GLib.idle_add(self.controller.present_window) for title, mod, stamp, size in self.missing_mods: mod_path = self.workshop / mod diff --git a/dzgui/views/pages/preconnect.py b/dzgui/views/pages/preconnect.py index 01207d9..cda5a77 100644 --- a/dzgui/views/pages/preconnect.py +++ b/dzgui/views/pages/preconnect.py @@ -116,7 +116,7 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): visible=False, has_tooltip=True, sensitive=False, - tooltip_text="This option is available if wmctrl or xdotool is installed on the system", + tooltip_text="Foreground the DZGUI window after mod downloads are queued", active=True, ) self.button_box = Gtk.Box( @@ -303,8 +303,7 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): self.raise_window.set_visible(False) else: self.raise_window.set_visible(True) - if prereqs.foreground_cmd is not None: - self.raise_window.set_sensitive(True) + self.raise_window.set_sensitive(True) pretty = number(prereqs.required_space) suffix = f" Need to download {pretty} MiB of mod updates." From ea1b9facd214147575b71f78cd70c965ff1a7954 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 13 May 2026 20:49:23 +0900 Subject: [PATCH 290/309] fix: add missing import --- dzgui/init/proc.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/dzgui/init/proc.py b/dzgui/init/proc.py index bff06c9..40d3848 100644 --- a/dzgui/init/proc.py +++ b/dzgui/init/proc.py @@ -2,6 +2,8 @@ import psutil import subprocess import shutil +from warnings import deprecated + from dzgui.const.constants import ( DAYZ_BINARY, STEAM_CMD, From 2ebe8cad84f3eae011e8987afdf1aa5516ee95d8 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 14 May 2026 16:15:22 +0900 Subject: [PATCH 291/309] chore: clear typehinting errors --- dzgui/api/bm.py | 4 ++-- dzgui/api/servers.py | 2 +- dzgui/config/convert.py | 5 ++-- dzgui/config/query.py | 4 ++-- dzgui/config/xdg.py | 5 +++- dzgui/model/model_factory.py | 2 +- dzgui/views/components/buttonbox.py | 2 +- dzgui/views/mixins/context_mixin.py | 2 +- dzgui/views/trees/tree_base.py | 36 ++++++++++++++++------------- 9 files changed, 35 insertions(+), 27 deletions(-) diff --git a/dzgui/api/bm.py b/dzgui/api/bm.py index 8e70bb2..c19a6eb 100644 --- a/dzgui/api/bm.py +++ b/dzgui/api/bm.py @@ -1,6 +1,6 @@ import logging import requests -from typing import Optional, TYPE_CHECKING +from typing import Any, Optional, TYPE_CHECKING from dzgui.const.constants import APP_NAME from dzgui.const.endpoints import BM_SERVERS @@ -11,7 +11,7 @@ if TYPE_CHECKING: from dzgui.api.servers import Record -def get_attributes(key: str, uid: int) -> str: +def get_attributes(key: str, uid: int) -> Any: # TODO: handle if key is not set # TODO: tests for malformed IDs/values diff --git a/dzgui/api/servers.py b/dzgui/api/servers.py index baca26c..33c602f 100644 --- a/dzgui/api/servers.py +++ b/dzgui/api/servers.py @@ -381,7 +381,7 @@ def ping(ip: str, qport: int) -> int: if res is None: return 9999 else: - return res["ping"] + return int(res["ping"]) except Exception: return 9999 diff --git a/dzgui/config/convert.py b/dzgui/config/convert.py index cbbc320..6aed650 100644 --- a/dzgui/config/convert.py +++ b/dzgui/config/convert.py @@ -50,7 +50,7 @@ def rc2json(file: Path) -> str: while True: tok = lex.get_token() - ntok = lex.get_token() + ntok: str | bool | None = lex.get_token() if ntok is not None: ntok = ntok.strip('""') @@ -58,7 +58,8 @@ def rc2json(file: Path) -> str: if tok in deprecated: continue elif tok in toggles: - ntok = str2bool(ntok) + if ntok is not None: + ntok = str2bool(ntok) elif tok == "preferred_client": tok = "client" elif tok == "api_key": diff --git a/dzgui/config/query.py b/dzgui/config/query.py index eb59cde..602b082 100644 --- a/dzgui/config/query.py +++ b/dzgui/config/query.py @@ -23,7 +23,7 @@ def lookup(path: Path, enum: Preferences) -> Any: return None -def get_config(path: Path) -> dict: +def get_config(path: Path) -> Any: # TODO: is this being called multiple times? try: json = read_json(path) @@ -37,7 +37,7 @@ def get_favorites(path: Path) -> list[str]: conf = get_config(path) except Exception: pass - return conf["ip_list"] + return list(conf["ip_list"]) # def is_in_favs(record: str, path: Path) -> bool: diff --git a/dzgui/config/xdg.py b/dzgui/config/xdg.py index 52ac6b9..2826a0b 100644 --- a/dzgui/config/xdg.py +++ b/dzgui/config/xdg.py @@ -54,7 +54,10 @@ def get_xdg_paths() -> dict: 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 + if rp is not None and is_writeable(rp): + resolved_paths[path] = Path(rp) + else: + resolved_paths[path] = xdg_paths[path] / APP_NAME_LOWER return resolved_paths diff --git a/dzgui/model/model_factory.py b/dzgui/model/model_factory.py index 598327a..333db09 100644 --- a/dzgui/model/model_factory.py +++ b/dzgui/model/model_factory.py @@ -107,7 +107,7 @@ class FastInsertListStore(ListStore): for row in rows: self.append(row) - def append(self, row: list[Any] | tuple[Any, ...] | None = ...) -> TreeIter: + def append(self, row: list[Any] | tuple[Any, ...] | None = ...) -> TreeIter: # type: ignore # FIXME: argument cannot be none """ Optimized for speed, but makes no assurances about row homogeneity diff --git a/dzgui/views/components/buttonbox.py b/dzgui/views/components/buttonbox.py index b268c9e..3e056f6 100644 --- a/dzgui/views/components/buttonbox.py +++ b/dzgui/views/components/buttonbox.py @@ -13,7 +13,7 @@ logger = logging.getLogger(APP_NAME) if TYPE_CHECKING: from dzgui.const.enum import NotebookPage - from dzgui.controller.mc import Controller + from dzgui.controllers.mc import Controller from dzgui.controllers.emitter import Emitter diff --git a/dzgui/views/mixins/context_mixin.py b/dzgui/views/mixins/context_mixin.py index bfee8b9..66c6eed 100644 --- a/dzgui/views/mixins/context_mixin.py +++ b/dzgui/views/mixins/context_mixin.py @@ -93,7 +93,7 @@ class ContextMixin(TreeView): return False def _on_menu_click(self, widget: Gtk.MenuItem, enum: ContextMenu) -> None: - self.controller.menu_action(enum, self) + self.controller.menu_action(enum, self) # type: ignore def _on_key(self, menu: Gtk.Menu, event: Gdk.EventKey) -> bool | None: if not is_navkey(event.keyval): diff --git a/dzgui/views/trees/tree_base.py b/dzgui/views/trees/tree_base.py index 9603137..86cd5b9 100644 --- a/dzgui/views/trees/tree_base.py +++ b/dzgui/views/trees/tree_base.py @@ -67,6 +67,8 @@ class TreeView(CursorMixin, Gtk.TreeView): # type: ignore def get_focused_row_iter(self) -> Gtk.TreeIter: path = self.get_focused_row_path() model = self.get_model() + if model is None: + raise AttributeError("No model attached to tree") return model.get_iter(path) def get_focused_row_path(self) -> Gtk.TreePath: @@ -106,9 +108,10 @@ class TreeView(CursorMixin, Gtk.TreeView): # type: ignore # self.get_selection().select_iter(it) # return True - if self.get_model() is None: + model = self.get_model() + if model is None: return - if len(self.get_model()) < 2: + if len(model) < 2: return if is_navkey(event.keyval): if self.sel_blocked is True: @@ -144,7 +147,7 @@ class TreeView(CursorMixin, Gtk.TreeView): # type: ignore path = pathlist[0] tree_iter = model.get_iter(path) value = model.get_value(tree_iter, index) - return value + return str(value) def get_name(self) -> str: name = self.get_value_at_index(0) @@ -161,12 +164,12 @@ class TreeView(CursorMixin, Gtk.TreeView): # type: ignore return (model, pathlist) @deprecated("Currently unused") - def get_mpath(self) -> Optional[Gtk.TreePath]: - (model, pathlist) = self.get_model_and_pathlist() - if len(pathlist) < 1: - return None - path = pathlist[0] - return path + #def get_mpath(self) -> Optional[Gtk.TreePath]: + # (model, pathlist) = self.get_model_and_pathlist() + # if len(pathlist) < 1: + # return None + # path = pathlist[0] + # return path def _on_row_activated( self, @@ -182,13 +185,14 @@ class TreeView(CursorMixin, Gtk.TreeView): # type: ignore return True return False - def get_selected_row(self) -> Optional[Gtk.TreeModelRow]: - ind = self.get_selected_row_index() - model = self.get_model() - if model is None: - return None - row = model[ind] - return row + @deprecated("unused") + #def get_selected_row(self) -> Optional[Gtk.TreeModelRow]: + # ind = self.get_selected_row_index() + # model = self.get_model() + # if model is None: + # return None + # row = model[ind] + # return row def get_col_value_by_path_index(self, path: Gtk.TreePath, index: int) -> Any: model = self.get_model() From 88c4758c6b8282420310f12294b88304b60c5ce8 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 14 May 2026 16:24:33 +0900 Subject: [PATCH 292/309] chore: use explicit button vars --- dzgui/views/components/mod_panel.py | 40 +++++++++++++---------------- 1 file changed, 18 insertions(+), 22 deletions(-) diff --git a/dzgui/views/components/mod_panel.py b/dzgui/views/components/mod_panel.py index e8b1372..1b56c37 100644 --- a/dzgui/views/components/mod_panel.py +++ b/dzgui/views/components/mod_panel.py @@ -22,6 +22,13 @@ class EnumeratedModButton(Gtk.Button): self.set_label(enum.dict["label"]) self.set_tooltip_text(enum.dict["tooltip"]) +class ModPanelButton(EnumeratedModButton): + def __init__(self, enum: ModButton) -> None: + super().__init__(enum=enum) + + if enum is not ModButton.HIGHLIGHT_STALE: + self.set_sensitive(False) + class ModSelectionPanel(Gtk.Box): def __init__(self, controller: "Controller") -> None: @@ -47,16 +54,11 @@ class ModSelectionPanel(Gtk.Box): self.main_panel.pack_start(b, NO_EXPAND, FILL, NO_PADDING) self.stale_panel = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) - buttons = ( - ModButton.HIGHLIGHT_STALE, - ModButton.SELECT_STALE, - ModButton.UNHIGHLIGHT_STALE, - ) - for button in buttons: - b = EnumeratedModButton(button) - b.connect("clicked", self._on_button_clicked) - if button is not ModButton.HIGHLIGHT_STALE: - b.set_sensitive(False) + self.highlight_stale_button = ModPanelButton(ModButton.HIGHLIGHT_STALE) + self.unhighlight_stale_button = ModPanelButton(ModButton.UNHIGHLIGHT_STALE) + self.select_stale_button = ModPanelButton(ModButton.SELECT_STALE) + + for b in (self.highlight_stale_button, self.unhighlight_stale_button, self.select_stale_button): self.stale_panel.pack_start(b, NO_EXPAND, FILL, NO_PADDING) for el in header, self.main_panel, self.stale_panel: @@ -65,13 +67,9 @@ class ModSelectionPanel(Gtk.Box): self.connect("map", self._reinit_button) def _reinit_button(self, widget: Self) -> None: - for child in self.stale_panel.get_children(): - if child.enum == ModButton.UNHIGHLIGHT_STALE: - child.set_sensitive(False) - elif child.enum == ModButton.SELECT_STALE: - child.set_sensitive(False) - else: - child.set_sensitive(True) + self.unhighlight_stale_button.set_sensitive(False) + self.select_stale_button.set_sensitive(False) + self.highlight_stale_button.set_sensitive(True) def _on_mods_updated(self, emitter: "Emitter", msg: str, mods: int) -> None: if mods < 1: @@ -85,11 +83,9 @@ class ModSelectionPanel(Gtk.Box): self.set_visible(state) def swap_sensitive(self, state: bool) -> None: - for child in self.stale_panel.get_children(): - if child.enum == ModButton.HIGHLIGHT_STALE: - child.set_sensitive(not state) - else: - child.set_sensitive(state) + self.highlight_stale_button.set_sensitive(not state) + self.unhighlight_stale_button.set_sensitive(state) + self.select_stale_button.set_sensitive(state) def _on_button_clicked(self, button: EnumeratedModButton) -> None: match button.enum: From aa6b94905a83c49edf0c2e9908671f757e5286ee Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 14 May 2026 16:44:37 +0900 Subject: [PATCH 293/309] chore: raise error on Nonetype model --- dzgui/views/trees/tree_mods.py | 2 ++ dzgui/views/trees/tree_server_mods.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/dzgui/views/trees/tree_mods.py b/dzgui/views/trees/tree_mods.py index 17f12fd..108f9ef 100644 --- a/dzgui/views/trees/tree_mods.py +++ b/dzgui/views/trees/tree_mods.py @@ -70,6 +70,8 @@ class ModTreeView(ModsMixin, ContextMixin, TreeView): # type: ignore def get_selected_mod(self) -> str: path = self.get_focused_row_path() model = self.get_model() + if model is None: + raise AttributeError("Trying to call a method on a non-existent model") tree_iter = model.get_iter(path) mod = model.get(tree_iter, 2)[0] return str(mod) diff --git a/dzgui/views/trees/tree_server_mods.py b/dzgui/views/trees/tree_server_mods.py index f527164..d3eb436 100644 --- a/dzgui/views/trees/tree_server_mods.py +++ b/dzgui/views/trees/tree_server_mods.py @@ -61,6 +61,8 @@ class ServerModTreeView(ContextMixin, TreeView): # type: ignore def get_selected_mod(self) -> str: path = self.get_focused_row_path() model = self.get_model() + if model is None: + raise AttributeError("Trying to call a method on a non-existent model") tree_iter = model.get_iter(path) # FIXME: https://docs.gtk.org/gtk3/method.TreeModel.get.html mod = model.get(tree_iter, 1)[0] From f77d36ea442a3c69273d4ab772281dc8bf3644c3 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 14 May 2026 16:48:16 +0900 Subject: [PATCH 294/309] change: convert TreeModelRow to tuple directly --- dzgui/views/trees/tree_log.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/dzgui/views/trees/tree_log.py b/dzgui/views/trees/tree_log.py index 1d7dae4..b17e202 100644 --- a/dzgui/views/trees/tree_log.py +++ b/dzgui/views/trees/tree_log.py @@ -90,9 +90,10 @@ class LogTreeView(ContextMixin, TreeView): # type: ignore if len(records) < 1: return None for record in records: - raw_record = model[record] - r = [el for el in raw_record] - concat = strings.delimiter.join(r) + #raw_record = model[record] + #r = [el for el in raw_record] + els = tuple(record) + concat = strings.delimiter.join(els) final.append(concat) text = "\n".join(final) return text From 796e4c5de66701d73b2c1858425058475495a5c2 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 14 May 2026 17:23:23 +0900 Subject: [PATCH 295/309] fix: cast data types correctly --- dzgui/views/trees/tree_base.py | 2 +- dzgui/views/trees/tree_log.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/dzgui/views/trees/tree_base.py b/dzgui/views/trees/tree_base.py index 86cd5b9..6b66222 100644 --- a/dzgui/views/trees/tree_base.py +++ b/dzgui/views/trees/tree_base.py @@ -147,7 +147,7 @@ class TreeView(CursorMixin, Gtk.TreeView): # type: ignore path = pathlist[0] tree_iter = model.get_iter(path) value = model.get_value(tree_iter, index) - return str(value) + return value def get_name(self) -> str: name = self.get_value_at_index(0) diff --git a/dzgui/views/trees/tree_log.py b/dzgui/views/trees/tree_log.py index b17e202..538eb4f 100644 --- a/dzgui/views/trees/tree_log.py +++ b/dzgui/views/trees/tree_log.py @@ -90,10 +90,10 @@ class LogTreeView(ContextMixin, TreeView): # type: ignore if len(records) < 1: return None for record in records: - #raw_record = model[record] + raw_record = model[record] #r = [el for el in raw_record] - els = tuple(record) - concat = strings.delimiter.join(els) + els = tuple(raw_record) + concat = strings.delimiter.join(map(str, els)) final.append(concat) text = "\n".join(final) return text From cacfab35bf274325f84e74f9af61d5f10252e8c9 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Fri, 15 May 2026 18:57:16 +0900 Subject: [PATCH 296/309] chore: boilerplate --- dzgui/init/migrate.py | 1 + dzgui/managers/connection.py | 3 +-- dzgui/util/dist.py | 1 - dzgui/views/pages/preconnect.py | 4 +++- dzgui/views/trees/tree_log.py | 1 - 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/dzgui/init/migrate.py b/dzgui/init/migrate.py index 1e20c21..3f69fbf 100644 --- a/dzgui/init/migrate.py +++ b/dzgui/init/migrate.py @@ -7,6 +7,7 @@ from dzgui.config.convert import rc2json from dzgui.util._json import read_json, write_json +# TODO: move to setup wizard def migrate_legacy_conf(config: Path) -> None: old_conf = Path.home() / LEGACY_CONFIG_PATH if old_conf.is_file(): diff --git a/dzgui/managers/connection.py b/dzgui/managers/connection.py index e5eb715..71a1217 100644 --- a/dzgui/managers/connection.py +++ b/dzgui/managers/connection.py @@ -1,5 +1,4 @@ import logging -import os import shutil import time @@ -279,7 +278,7 @@ class ConnectionManager: time.sleep(2) if raise_window is True: - logger.info(f"Bringing window to foreground") + logger.info("Bringing window to foreground") GLib.idle_add(self.controller.present_window) for title, mod, stamp, size in self.missing_mods: diff --git a/dzgui/util/dist.py b/dzgui/util/dist.py index eb511fb..6a58d82 100644 --- a/dzgui/util/dist.py +++ b/dzgui/util/dist.py @@ -16,7 +16,6 @@ if TYPE_CHECKING: from queue import Queue from dzgui.const.enum import ServerTab from dzgui.controllers.mc import Controller - from dzgui.util.ip import Coords logger = logging.getLogger(APP_NAME) diff --git a/dzgui/views/pages/preconnect.py b/dzgui/views/pages/preconnect.py index cda5a77..d0fb7b9 100644 --- a/dzgui/views/pages/preconnect.py +++ b/dzgui/views/pages/preconnect.py @@ -291,12 +291,14 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): name = prereqs.name self.title.set_text(name) - suffix = "All mods are up to date." + # FIXME: append to tree status bar if total_mods < 1: self._hide_mod_area() else: self._show_mod_area() + msg = "All mods are up to date." + self.mod_count.set_text(msg) if prereqs.required_space == 0: self.ok.set_label(preconnect.connect) diff --git a/dzgui/views/trees/tree_log.py b/dzgui/views/trees/tree_log.py index 538eb4f..855b41e 100644 --- a/dzgui/views/trees/tree_log.py +++ b/dzgui/views/trees/tree_log.py @@ -91,7 +91,6 @@ class LogTreeView(ContextMixin, TreeView): # type: ignore return None for record in records: raw_record = model[record] - #r = [el for el in raw_record] els = tuple(raw_record) concat = strings.delimiter.join(map(str, els)) final.append(concat) From 2d265c4b9bc06f72fdb003c8bda4435ab494194a Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Fri, 15 May 2026 19:53:00 +0900 Subject: [PATCH 297/309] chore: clear typehinting errors --- dzgui/model/model_factory.py | 9 ++++++--- dzgui/model/proxy_model.py | 9 +++++---- dzgui/model/servers.py | 1 + dzgui/views/base.py | 1 + dzgui/views/components/buttonbox.py | 6 +++--- dzgui/views/dialogs/early_alert.py | 13 +++++++++---- dzgui/views/dialogs/generic.py | 4 +++- dzgui/views/dialogs/servers.py | 23 ++++++++++++++++++----- dzgui/views/mixins/context_mixin.py | 2 ++ dzgui/views/pages/devs.py | 8 +------- dzgui/views/pages/options.py | 6 +++++- dzgui/views/pages/servers.py | 4 ++-- dzgui/views/trees/tree_base.py | 17 ++++++++++------- dzgui/views/trees/tree_log.py | 2 +- dzgui/views/trees/tree_menu.py | 4 +++- dzgui/views/trees/tree_mods.py | 2 +- dzgui/views/trees/tree_server_mods.py | 2 +- dzgui/views/trees/tree_servers.py | 2 +- tests/test_config.py | 3 +-- 19 files changed, 74 insertions(+), 44 deletions(-) diff --git a/dzgui/model/model_factory.py b/dzgui/model/model_factory.py index 333db09..d395cd8 100644 --- a/dzgui/model/model_factory.py +++ b/dzgui/model/model_factory.py @@ -115,9 +115,12 @@ class FastInsertListStore(ListStore): For this reason, it is recommended to use the extend() method to insert an entire list of lists """ - total = len(row) - i = len(self) - tree_iter = self.insert_with_values(i, tuple(range(total)), row) + if row is not None: + total = len(row) + i = len(self) + tree_iter = self.insert_with_values(i, tuple(range(total)), row) + else: + tree_iter = super().append(row) return tree_iter diff --git a/dzgui/model/proxy_model.py b/dzgui/model/proxy_model.py index 1cdc3dc..2585d1d 100644 --- a/dzgui/model/proxy_model.py +++ b/dzgui/model/proxy_model.py @@ -57,9 +57,10 @@ class ProxyModelManager: self.proxy_model[treeiter][4] = playercount.players self.proxy_model[treeiter][6] = playercount.queue - # FIXME: typehint -> tuple + # TODO: use dataclass for record rows def append_row_to_history( - self, history: list[str, str, str, str, int, int, int, str, int, int, str, bool] + self, + history: tuple[str, str, str, str, int, int, int, str, int, int, str, bool], ) -> None: addr = history[7] qport = history[8] @@ -247,8 +248,8 @@ class ProxyModelManager: self.filter_cache[filters] = (model, rows) @deprecated("Currently unused") - def convert_model_to_list(self, model: "FastInsertListStore") -> list: - return [[el for el in row] for row in model] + # def convert_model_to_list(self, model: "FastInsertListStore") -> list: + # return [[el for el in row] for row in model] def set_filtered(self, rows: list | None) -> None: if rows is None: diff --git a/dzgui/model/servers.py b/dzgui/model/servers.py index bf7837d..2e23e16 100644 --- a/dzgui/model/servers.py +++ b/dzgui/model/servers.py @@ -236,6 +236,7 @@ class ServerModelManager: def add_to_history(self, record: dict[str, Any]) -> None: proxy_man = self._get_proxy_man() row = Servers.parse_json([record]) + print(type(row[0])) proxy_man.append_row_to_history(row[0]) self.update_history() diff --git a/dzgui/views/base.py b/dzgui/views/base.py index 7672a4c..082d218 100644 --- a/dzgui/views/base.py +++ b/dzgui/views/base.py @@ -199,6 +199,7 @@ class Notebook(ScrollableMixin, Gtk.Notebook): # type: ignore for k, v in self.indexes.items(): if v == self.get_current_page(): return k + raise ValueError("No notebook pages set") def toggle_keybindings(self) -> None: cur_page = self.get_page_by_enum() diff --git a/dzgui/views/components/buttonbox.py b/dzgui/views/components/buttonbox.py index 3e056f6..496f237 100644 --- a/dzgui/views/components/buttonbox.py +++ b/dzgui/views/components/buttonbox.py @@ -68,7 +68,7 @@ class ButtonBox(Gtk.Box): def _focus_first_button(self, emitter: "Emitter") -> None: self.buttons[0].grab_focus() - def _on_selection_button_clicked(self, button: Gtk.Button) -> None: + def _on_selection_button_clicked(self, button: ContextualButton) -> None: self.controller.open_page_by_button(button) def _walk_buttons(self, increment: int) -> None: @@ -79,8 +79,8 @@ class ButtonBox(Gtk.Box): return if n == -1: return - n = self.buttons[n] - n.grab_focus() + b = self.buttons[n] + b.grab_focus() return def _on_keypress(self, widget: Gtk.Widget, event: Gdk.EventKey) -> None: diff --git a/dzgui/views/dialogs/early_alert.py b/dzgui/views/dialogs/early_alert.py index 3d68925..a404a32 100644 --- a/dzgui/views/dialogs/early_alert.py +++ b/dzgui/views/dialogs/early_alert.py @@ -5,22 +5,26 @@ from typing import Self from dzgui.util.strings import dialog_error, dialog_header import gi + gi.require_version("Gtk", "3.0") from gi.repository import Gtk # noqa E402 + class EarlyAlertDialog(Gtk.MessageDialog): def __init__(self, string: str) -> None: super().__init__( title=dialog_header, text=dialog_error, transient_for=None, - buttons=Gtk.ButtonsType.OK + buttons=Gtk.ButtonsType.OK, ) msg = textwrap.fill(string, 50) self.format_secondary_text(msg) - self.action_area.set_margin_bottom(20) + aa = self.get_action_area() + aa.set_margin_bottom(20) + # 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) @@ -28,7 +32,8 @@ class EarlyAlertDialog(Gtk.MessageDialog): self.set_default_size(250, 100) abort = self.get_widget_for_response(Gtk.ResponseType.OK) - abort.set_label("Exit") + if abort is not None and hasattr(abort, "set_label"): + abort.set_label("Exit") self.connect("response", self._on_response) self.run() @@ -41,10 +46,10 @@ class EarlyAlertDialog(Gtk.MessageDialog): case Gtk.ResponseType.CANCEL: return + class EarlyIgnoreDialog(EarlyAlertDialog): def __init__(self, string: str) -> None: super().__init__(string=string) # TODO: reverse order self.add_button("Ignore", Gtk.ResponseType.CANCEL) - diff --git a/dzgui/views/dialogs/generic.py b/dzgui/views/dialogs/generic.py index 7e25da2..eb10d58 100644 --- a/dzgui/views/dialogs/generic.py +++ b/dzgui/views/dialogs/generic.py @@ -205,7 +205,9 @@ class ExceptionDialog(GenericDialog): self.add_button("OK", Gtk.ResponseType.OK) self.show_all() - self.action_area.get_children()[1].grab_focus() + self.ok = self.get_widget_for_response(Gtk.ResponseType.OK) + if self.ok is not None: + self.ok.grab_focus() self.connect("response", self._on_response) def get_trace(self) -> str: diff --git a/dzgui/views/dialogs/servers.py b/dzgui/views/dialogs/servers.py index 9ea36bf..14cb719 100644 --- a/dzgui/views/dialogs/servers.py +++ b/dzgui/views/dialogs/servers.py @@ -1,6 +1,7 @@ from typing import Self, TYPE_CHECKING from dzgui.const.constants import EXPAND, FILL +from dzgui.const.enum import ContextMenuGroup from dzgui.model.model_factory import ModelFactory from dzgui.util import css from dzgui.util import strings @@ -20,7 +21,13 @@ if TYPE_CHECKING: class ServerDialog(GenericDialog): - def __init__(self, controller: "Controller", title: str, secondary: str) -> None: + def __init__( + self, + controller: "Controller", + title: str, + secondary: str, + menu: ContextMenuGroup | None, + ) -> None: super().__init__( controller=controller, text=title, @@ -32,7 +39,7 @@ class ServerDialog(GenericDialog): self.set_default_response(Gtk.ResponseType.OK) self.set_size_request(800, 700) - self.view = TreeView(controller) + self.view = TreeView(controller, menu) self.view.set_fixed_height_mode(True) self.connect("response", self._on_response) @@ -59,7 +66,7 @@ class ServerDialog(GenericDialog): class ServerDetailsDialog(ServerDialog): def __init__(self, controller: "Controller", details: "Details"): name = controller.get_server_name() - super().__init__(controller, strings.server_details, name) + super().__init__(controller, strings.server_details, name, menu=None) self.store = Gtk.ListStore(str, str, Pango.Weight) self.view.connect("row-activated", self._on_row_activated) @@ -116,10 +123,16 @@ class ServerDetailsDialog(ServerDialog): class ServerModDialog(ServerDialog): - def __init__(self, controller: "Controller", mods: list[list[str]]): + def __init__( + self, + controller: "Controller", + mods: list[list[str]], + ): name = controller.get_server_name() - super().__init__(controller, server_mods.modlist, name) + super().__init__( + controller, server_mods.modlist, name, menu=ContextMenuGroup.SERVER_MOD + ) self.controller = controller self.mod_store = ModelFactory().make_server_mod_store() diff --git a/dzgui/views/mixins/context_mixin.py b/dzgui/views/mixins/context_mixin.py index 66c6eed..0d1144d 100644 --- a/dzgui/views/mixins/context_mixin.py +++ b/dzgui/views/mixins/context_mixin.py @@ -21,6 +21,8 @@ class ContextMixin(TreeView): event: Gdk.EventButton | Gdk.EventKey, ) -> bool: + if self.menu is None: + return False if self.is_selection_empty(): return False diff --git a/dzgui/views/pages/devs.py b/dzgui/views/pages/devs.py index a58adcf..7c95255 100644 --- a/dzgui/views/pages/devs.py +++ b/dzgui/views/pages/devs.py @@ -64,7 +64,7 @@ class Developers(Gtk.ScrolledWindow): self.controller.open_page(NotebookPage.OPTIONS) def _make_tree(self, prefs: Union["Xdg", "UserPrefs"]) -> Gtk.TreeView: - tree = TreeView(self.controller) + tree = TreeView(self.controller, menu=None) renderer = Gtk.CellRendererText() for i, col in enumerate(developers.columns): column = Gtk.TreeViewColumn(col, renderer, text=i) @@ -73,12 +73,6 @@ class Developers(Gtk.ScrolledWindow): store = self._make_store(prefs) tree.set_model(store) - # store = Gtk.ListStore(str, str) - # for field in fields(prefs): - # if field.name == "paths": - # continue - # k, v = field.name, getattr(prefs, field.name) - # store.append((k, str(v))) return tree diff --git a/dzgui/views/pages/options.py b/dzgui/views/pages/options.py index 01f8f11..367fe12 100644 --- a/dzgui/views/pages/options.py +++ b/dzgui/views/pages/options.py @@ -237,7 +237,7 @@ class Options(Gtk.Box): def get_client_name(self) -> str: model = self.client_combo.get_model() ind = self.client_combo.get_active() - return model[ind][0] + return str(model[ind][0]) def block_text_entry(self) -> None: for entry in self.steam_entry, self.bm_entry: @@ -352,12 +352,16 @@ class Options(Gtk.Box): def _on_start_tab_changed(self, combo: Gtk.ComboBoxText) -> None: _iter = combo.get_active_iter() + if _iter is None: + raise ValueError(f"No active iterator set on {combo}") enum = combo.get_model()[_iter][1] index = enum.value self.controller.update_config(Preferences.START_TAB, index) def _on_client_changed(self, combo: Gtk.ComboBoxText) -> None: _iter = combo.get_active_iter() + if _iter is None: + raise ValueError(f"No active iterator set on {combo}") real_cmd = combo.get_model()[_iter][1] self.controller.update_config(Preferences.CLIENT, real_cmd) diff --git a/dzgui/views/pages/servers.py b/dzgui/views/pages/servers.py index 63fb091..4c0ef6c 100644 --- a/dzgui/views/pages/servers.py +++ b/dzgui/views/pages/servers.py @@ -153,14 +153,14 @@ class ServerNotebook(Gtk.ScrolledWindow): if tv is not None: self.controller.populate_model(tv) - def get_active_treeview(self) -> ServerTreeView | None: + def get_active_treeview(self) -> ServerTreeView: index = self.notebook.get_current_page() scrollable = self.notebook.get_nth_page(index) if scrollable is not None and hasattr(scrollable, "get_children"): tv = scrollable.get_children()[0] if isinstance(tv, ServerTreeView): return tv - return None + raise ValueError("No treeview set") def add_notification(self) -> None: saved = self.notebook.get_nth_page(1) diff --git a/dzgui/views/trees/tree_base.py b/dzgui/views/trees/tree_base.py index 6b66222..ac0733a 100644 --- a/dzgui/views/trees/tree_base.py +++ b/dzgui/views/trees/tree_base.py @@ -21,7 +21,7 @@ if TYPE_CHECKING: class TreeView(CursorMixin, Gtk.TreeView): # type: ignore def __init__( - self, controller: "Controller", menu: Optional["ContextMenuGroup"] = None + self, controller: "Controller", menu: Optional["ContextMenuGroup"] ) -> None: super().__init__( enable_search=False, @@ -72,10 +72,13 @@ class TreeView(CursorMixin, Gtk.TreeView): # type: ignore return model.get_iter(path) def get_focused_row_path(self) -> Gtk.TreePath: - return self.get_cursor().path + path, column = self.get_cursor() + return path def get_focused_row_index(self) -> int: - return self.get_cursor().path[0] + path, column = self.get_cursor() + index = path.get_indices()[0] + return index def get_selected_records(self) -> list: sel = self.get_selection() @@ -140,7 +143,7 @@ class TreeView(CursorMixin, Gtk.TreeView): # type: ignore path = Gtk.TreePath.new_from_indices([0]) self.set_cursor(path) - def get_value_at_index(self, index: int) -> str: + def get_value_at_index(self, index: int) -> Any: (model, pathlist) = self.get_model_and_pathlist() if len(pathlist) < 1: return "" @@ -151,7 +154,7 @@ class TreeView(CursorMixin, Gtk.TreeView): # type: ignore def get_name(self) -> str: name = self.get_value_at_index(0) - return name + return str(name) def select_first_row(self) -> None: sel = self.get_selection() @@ -164,7 +167,7 @@ class TreeView(CursorMixin, Gtk.TreeView): # type: ignore return (model, pathlist) @deprecated("Currently unused") - #def get_mpath(self) -> Optional[Gtk.TreePath]: + # def get_mpath(self) -> Optional[Gtk.TreePath]: # (model, pathlist) = self.get_model_and_pathlist() # if len(pathlist) < 1: # return None @@ -186,7 +189,7 @@ class TreeView(CursorMixin, Gtk.TreeView): # type: ignore return False @deprecated("unused") - #def get_selected_row(self) -> Optional[Gtk.TreeModelRow]: + # def get_selected_row(self) -> Optional[Gtk.TreeModelRow]: # ind = self.get_selected_row_index() # model = self.get_model() # if model is None: diff --git a/dzgui/views/trees/tree_log.py b/dzgui/views/trees/tree_log.py index 855b41e..c4c71f7 100644 --- a/dzgui/views/trees/tree_log.py +++ b/dzgui/views/trees/tree_log.py @@ -91,7 +91,7 @@ class LogTreeView(ContextMixin, TreeView): # type: ignore return None for record in records: raw_record = model[record] - els = tuple(raw_record) + els: tuple[Any] = tuple(raw_record) # type: ignore concat = strings.delimiter.join(map(str, els)) final.append(concat) text = "\n".join(final) diff --git a/dzgui/views/trees/tree_menu.py b/dzgui/views/trees/tree_menu.py index e800998..04a17f2 100644 --- a/dzgui/views/trees/tree_menu.py +++ b/dzgui/views/trees/tree_menu.py @@ -25,7 +25,7 @@ class MenuTreeView(TreeView): """ def __init__(self, controller: "Controller") -> None: - super().__init__(controller) + super().__init__(controller, menu=None) self.controller = controller @@ -73,6 +73,8 @@ class MenuTreeView(TreeView): def get_row_enum(self) -> str: # NOTE: col 1 contains a RowType enum model = self.get_model() + if model is None: + raise ValueError("Trying to call a method on non-existent model") _iter = self.get_focused_row_iter() rowtype = model.get_value(_iter, 1) return str(rowtype.dict["tooltip"]) diff --git a/dzgui/views/trees/tree_mods.py b/dzgui/views/trees/tree_mods.py index 108f9ef..130708f 100644 --- a/dzgui/views/trees/tree_mods.py +++ b/dzgui/views/trees/tree_mods.py @@ -73,7 +73,7 @@ class ModTreeView(ModsMixin, ContextMixin, TreeView): # type: ignore if model is None: raise AttributeError("Trying to call a method on a non-existent model") tree_iter = model.get_iter(path) - mod = model.get(tree_iter, 2)[0] + mod = model.get_value(tree_iter, 2) return str(mod) def _parent_selection_changed( diff --git a/dzgui/views/trees/tree_server_mods.py b/dzgui/views/trees/tree_server_mods.py index d3eb436..570f9f1 100644 --- a/dzgui/views/trees/tree_server_mods.py +++ b/dzgui/views/trees/tree_server_mods.py @@ -65,7 +65,7 @@ class ServerModTreeView(ContextMixin, TreeView): # type: ignore raise AttributeError("Trying to call a method on a non-existent model") tree_iter = model.get_iter(path) # FIXME: https://docs.gtk.org/gtk3/method.TreeModel.get.html - mod = model.get(tree_iter, 1)[0] + mod = model.get_value(tree_iter, 1) return str(mod) def populate(self, mods: list[list[str]]) -> None: diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index cf1ada5..8020506 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -292,7 +292,7 @@ class ServerTreeView(ContextMixin, TreeView): # type: ignore self.start_distcalc() def get_name(self) -> str: - return self.get_value_at_index(0) + return str(self.get_value_at_index(0)) def get_simplified_ip(self) -> str: addr = self.get_value_at_index(7) diff --git a/tests/test_config.py b/tests/test_config.py index 601eb9b..4cfd754 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -23,7 +23,6 @@ def keys(): "name", "fullscreen", "steam_api", - "auto_install", "default_steam_path", "client", "ip_list", @@ -32,6 +31,7 @@ def keys(): ] +# TODO: use a static fixture instead of system config @pytest.fixture def config(): paths = get_xdg_paths() @@ -69,7 +69,6 @@ def test_bool_conversion(fixture, expect): j = convert.rc2json(fixture) j = json.loads(j) assert j["fullscreen"] == expect[0] - assert j["auto_install"] == expect[2] assert not j["use_miles"] From 156c1f8ec46bf2e520b09ca3ae694bd7a2ed06a4 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Fri, 15 May 2026 23:26:47 +0900 Subject: [PATCH 298/309] chore: clear typehinting errors --- dzgui/api/mods.py | 9 ++++-- dzgui/api/pefile.py | 11 ++----- dzgui/api/servers.py | 17 +++++++++-- dzgui/api/steam.py | 17 ++++++----- dzgui/config/convert.py | 18 ++++++++---- dzgui/main.py | 3 +- dzgui/managers/connection.py | 7 ++++- dzgui/model/model_factory.py | 1 - dzgui/model/proxy_model.py | 20 +++++++++---- dzgui/model/servers.py | 1 - dzgui/util/ip.py | 4 +-- dzgui/views/components/statusbar.py | 10 ++++--- dzgui/views/mixins/context_mixin.py | 6 ++-- dzgui/views/pages/options.py | 41 ++++----------------------- dzgui/views/pages/preconnect.py | 2 -- dzgui/views/trees/tree_server_mods.py | 1 - 16 files changed, 83 insertions(+), 85 deletions(-) diff --git a/dzgui/api/mods.py b/dzgui/api/mods.py index 0c0e83d..944df15 100644 --- a/dzgui/api/mods.py +++ b/dzgui/api/mods.py @@ -70,8 +70,11 @@ def parse_meta(file: Path) -> ModMeta | None: if ntok == "-": ntok += str(lex.get_token()) elif tok == "name": - ntok = lex.get_token().split('"')[1] - v.append(ntok) + ntok = lex.get_token() + if ntok is not None: + ntok = ntok.split('"')[1] + if ntok is not None: + v.append(ntok) meta = ModMeta(*v) return meta @@ -99,7 +102,7 @@ def get_delimited_mods(steam_path: Path) -> list[Any]: size = get_mod_size(mod) # NOTE: final col is cell renderer highlight toggle clean.append([meta.name, symlink, mod_dir, size, False]) - clean.sort(key=lambda row: row[0].casefold()) + clean.sort(key=lambda row: str(row[0]).casefold()) return clean diff --git a/dzgui/api/pefile.py b/dzgui/api/pefile.py index e8150eb..cbbd090 100644 --- a/dzgui/api/pefile.py +++ b/dzgui/api/pefile.py @@ -431,20 +431,15 @@ def get_app_path(folders_path: Path, appid: int) -> Path: for obj in j["libraryfolders"]: if str(appid) in j["libraryfolders"][obj]["apps"]: app_path = j["libraryfolders"][obj]["path"] - break + if Path(app_path).exists(): + 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 + return Path(app_path) def get_pretty_version(steam_path: Path, appid: int) -> str | None: diff --git a/dzgui/api/servers.py b/dzgui/api/servers.py index 33c602f..75df672 100644 --- a/dzgui/api/servers.py +++ b/dzgui/api/servers.py @@ -86,7 +86,11 @@ class A2SInfo: raise AttributeError("No record to convert") ip = self.record.ip qport = self.record.qport - return source_info_to_dict(ip, qport, self.info) + try: + return source_info_to_dict(ip, qport, self.info) + except Exception as e: + logger.warning(e) + raise e def is_modded(self) -> bool: if self.info is None: @@ -239,7 +243,7 @@ def parse_json(json: list) -> list: return rows -def source_info_to_dict(ip: str, qport: int, info: "SourceInfo") -> dict[str, Any] | None: +def source_info_to_dict(ip: str, qport: int, info: "SourceInfo") -> dict[str, Any]: try: name = info.server_name mapname = info.map_name @@ -268,7 +272,7 @@ def source_info_to_dict(ip: str, qport: int, info: "SourceInfo") -> dict[str, An except Exception as e: # TODO: generalized function logger.critical(f"{type(e).__name__}: {e} ({ip}:{qport})") - return None + raise e def query_direct(ip: str, qport: int, timeout: float = 3.0) -> dict[str, Any] | None: @@ -448,6 +452,8 @@ def get_rules(record: Record) -> list["DayzMod"]: def query_playercount(record: Record) -> tuple[int, int] | None: try: res = query_direct(record.ip, record.qport) + if res is None: + return None players = int(res["players"]) r = res["gametype"].split("lqs") try: @@ -462,6 +468,8 @@ def query_playercount(record: Record) -> tuple[int, int] | None: def query_by_ip(addr: str) -> A2SInfo: record = short_ip_to_record(addr) + if record is None: + return A2SInfo(Record("0", 0, 0), None) return query_by_record(record, update_gameport=True) def query_by_id(server_id: int, key: str) -> A2SInfo: @@ -469,6 +477,8 @@ def query_by_id(server_id: int, key: str) -> A2SInfo: Used with numeric Battlemetrics IDs """ record = map_id_to_record(key, server_id) + if record is None: + return A2SInfo(Record("0", 0, 0), None) return query_by_record(record) @@ -485,6 +495,7 @@ def query_by_record(record: Record, update_gameport: bool = False) -> A2SInfo: def short_ip_to_record(addr: str) -> Optional[Record]: r = addr.split(":") + # TODO: raise exceptions instead of Nonetype if len(r) != 2: return None return Record(r[0], 0, int(r[1])) diff --git a/dzgui/api/steam.py b/dzgui/api/steam.py index 746da7d..4ede7cb 100644 --- a/dzgui/api/steam.py +++ b/dzgui/api/steam.py @@ -141,7 +141,8 @@ def vdf2json(path: Path) -> str: indent -= 1 jbuf += _istr(indent, "}") ntok = lex.get_token() - lex.push_token(ntok) + if ntok is not None: + lex.push_token(ntok) if ntok and ntok != "}": jbuf += "," jbuf += "\n" @@ -151,12 +152,14 @@ def vdf2json(path: Path) -> str: 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" + if ntok is not None: + jbuf += _istr(indent, tok + ": " + ntok) + ntok = lex.get_token() + if ntok is not None: + lex.push_token(ntok) + if ntok != "}": + jbuf += "," + jbuf += "\n" def gen_shortcut() -> None: diff --git a/dzgui/config/convert.py b/dzgui/config/convert.py index 6aed650..8061adb 100644 --- a/dzgui/config/convert.py +++ b/dzgui/config/convert.py @@ -50,35 +50,41 @@ def rc2json(file: Path) -> str: while True: tok = lex.get_token() - ntok: str | bool | None = lex.get_token() + ntok = lex.get_token() + value: str | bool if ntok is not None: ntok = ntok.strip('""') + value = ntok if tok in deprecated: continue elif tok in toggles: if ntok is not None: - ntok = str2bool(ntok) + value = 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('""') + ntok = lex.get_token() + if ntok is not None: + ntok = ntok.strip('""') + value = ntok if ntok == ")": break # TODO: make test for this # NOTE: strip malformed records from ancient config file versions - if len(ntok.split(":")) == 3 and ntok.split(":")[2] != "": - ips.append(ntok) + if ntok is not None: + if len(ntok.split(":")) == 3 and ntok.split(":")[2] != "": + ips.append(ntok) continue if not tok: break - keys[tok] = ntok + keys[tok] = value keys["ip_list"] = ips keys["use_miles"] = False diff --git a/dzgui/main.py b/dzgui/main.py index 988307a..e0a5b61 100644 --- a/dzgui/main.py +++ b/dzgui/main.py @@ -25,7 +25,6 @@ from dzgui.init.migrate import ( ) from dzgui.init.prefix import get_version from dzgui.init.prereqs import has_steam_client -from dzgui.init.proc import has_cmd from dzgui.init.update import allow_updates, check_updates from dzgui.strings import boot @@ -37,7 +36,7 @@ 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, EarlyIgnoreDialog +from dzgui.views.dialogs.early_alert import EarlyAlertDialog if TYPE_CHECKING: from pathlib import Path diff --git a/dzgui/managers/connection.py b/dzgui/managers/connection.py index 71a1217..1a7f35b 100644 --- a/dzgui/managers/connection.py +++ b/dzgui/managers/connection.py @@ -111,7 +111,12 @@ class ConnectionManager: self.thread_man.set_cleanup_func(failure_func, destroy_first=True) return - self.history = res.as_row() + try: + self.history = res.as_row() + except Exception: + self.thread_man.set_cleanup_func(failure_func, destroy_first=True) + return + record = res.get_record() # NOTE: store metadata for later connection diff --git a/dzgui/model/model_factory.py b/dzgui/model/model_factory.py index d395cd8..c0b87f3 100644 --- a/dzgui/model/model_factory.py +++ b/dzgui/model/model_factory.py @@ -108,7 +108,6 @@ class FastInsertListStore(ListStore): self.append(row) def append(self, row: list[Any] | tuple[Any, ...] | None = ...) -> TreeIter: # type: ignore - # FIXME: argument cannot be none """ Optimized for speed, but makes no assurances about row homogeneity and may segfault if types and length are not identical to ListStore. diff --git a/dzgui/model/proxy_model.py b/dzgui/model/proxy_model.py index 2585d1d..c92b4cc 100644 --- a/dzgui/model/proxy_model.py +++ b/dzgui/model/proxy_model.py @@ -32,7 +32,7 @@ class ProxyModelManager: {} ) - self.proxy_model: "FastInsertListStore" = None + self.proxy_model: "FastInsertListStore" self.filter_man = filter_man # TODO: list typehints @@ -49,6 +49,8 @@ class ProxyModelManager: self.proxy_model.append(row) def append_row_to_control(self, row: list) -> None: + if self.control_model is None: + raise AttributeError("Trying to add rows to a non-existent model") self.control_model.append(row) self.filter(FilterMode.INITIAL, skip_cache=True) @@ -97,8 +99,10 @@ class ProxyModelManager: def clear_proxy_model(self) -> None: self.proxy_model.clear() - def get_proxy_model(self) -> "FastInsertListStore": - return self.proxy_model + def get_proxy_model(self) -> Union["FastInsertListStore", None]: + if hasattr(self, "proxy_model"): + return self.proxy_model + return None def filter( self, mode: FilterMode, skip_cache: bool = False @@ -243,7 +247,7 @@ class ProxyModelManager: return self.filtered def set_cache( - self, filters: tuple, model: Optional["FastInsertListStore"], rows: list + self, filters: tuple, model: "FastInsertListStore", rows: list ) -> None: self.filter_cache[filters] = (model, rows) @@ -260,7 +264,7 @@ class ProxyModelManager: def get_filtered(self) -> list: return self.filtered - def set_proxy_model(self, model: Optional["FastInsertListStore"]) -> None: + def set_proxy_model(self, model: "FastInsertListStore") -> None: """ FastInsertListStore representation of the raw model after filtration """ @@ -272,7 +276,11 @@ class ProxyModelManager: """ self.control_model = rows - def get_control(self) -> list: + def get_control(self) -> list[Any]: + if self.control_model is None: + raise AttributeError( + "Expected a populated control model, but it is Nonetype" + ) return self.control_model def wipe_cache(self, full: bool = False) -> None: diff --git a/dzgui/model/servers.py b/dzgui/model/servers.py index 2e23e16..154c90a 100644 --- a/dzgui/model/servers.py +++ b/dzgui/model/servers.py @@ -303,7 +303,6 @@ class ServerModelManager: self.thread_man.set_cleanup_func(func) return config_man.add_saved_server(fqip) - # 2026-05-04 # FIXME: this is valid if saved servers tab is already open, # but not if app was just booted if proxy_man.has_control_model() is False: diff --git a/dzgui/util/ip.py b/dzgui/util/ip.py index 2548475..9cf92cd 100644 --- a/dzgui/util/ip.py +++ b/dzgui/util/ip.py @@ -54,9 +54,9 @@ def get_coords(ips: "Path", ip: str) -> Coords: prefix = f"^{split[0]}.{split[1]}." if shutil.which("rg") is not None: - args = ["/usr/bin/rg", prefix, ips] + args = ["/usr/bin/rg", prefix, str(ips)] else: - args = ["/usr/bin/grep", "-E", prefix, ips] + args = ["/usr/bin/grep", "-E", prefix, str(ips)] proc = subprocess.run(args, capture_output=True, text=True) if proc.returncode != 0: diff --git a/dzgui/views/components/statusbar.py b/dzgui/views/components/statusbar.py index 642ff12..ae4544f 100644 --- a/dzgui/views/components/statusbar.py +++ b/dzgui/views/components/statusbar.py @@ -1,4 +1,5 @@ from typing import Self, Union, TYPE_CHECKING +from warnings import deprecated from dzgui.const.enum import NotebookPage, ServerTab from dzgui.util.strings import esc_to_return, question_to_return @@ -131,10 +132,11 @@ class Statusbar(Gtk.Grid): cid = self.statusbar.get_context_id(str(context)) self.statusbar.pop(cid) - def get_text(self) -> str: - area = self.statusbar.get_message_area() - label = area.get_children()[0] - return str(label.get_text()) + @deprecated("currently unused") + # def get_text(self) -> str: + # area = self.statusbar.get_message_area() + # label = area.get_children()[0] + # return str(label.get_text()) def set_by_context( self, context: Union[NotebookPage, "ServerTab"], string: str diff --git a/dzgui/views/mixins/context_mixin.py b/dzgui/views/mixins/context_mixin.py index 0d1144d..f8c4b83 100644 --- a/dzgui/views/mixins/context_mixin.py +++ b/dzgui/views/mixins/context_mixin.py @@ -30,11 +30,11 @@ class ContextMixin(TreeView): case Gdk.EventType.BUTTON_PRESS: if event.button != 3: return False - self._process_button_event(event) + self._process_button_event(event) # type: ignore case Gdk.EventType.KEY_PRESS: - if not is_ctrl_mask(event): + if not is_ctrl_mask(event): # type: ignore return False - if event.keyval is not Gdk.KEY_l: + if event.keyval is not Gdk.KEY_l: # type: ignore return False case _: return False diff --git a/dzgui/views/pages/options.py b/dzgui/views/pages/options.py index 367fe12..1cfdd1c 100644 --- a/dzgui/views/pages/options.py +++ b/dzgui/views/pages/options.py @@ -94,7 +94,7 @@ class Options(Gtk.Box): ) self.player_box.set_halign(Gtk.Align.START) # TODO: make submit field a standalone class - self.player_box.get_children()[0].set_width_chars(30) + self.player_box.get_children()[0].set_width_chars(30) # type: ignore self.fullscreen_toggle = self.make_binary_radio( strings.options.last_used, @@ -159,18 +159,9 @@ class Options(Gtk.Box): [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(options.workshop_eventbox, controller) - workshop_button = SteamWorkshopButton() # label=strings.self_workshop) + workshop_button = SteamWorkshopButton() workshop_button.connect( "clicked", lambda _: self.controller.open_user_workshop(self.uid) ) @@ -346,10 +337,6 @@ class Options(Gtk.Box): self.bm_entry.set_text(self.old_bm) pass - def _on_force_update_clicked(self, button: Gtk.Button) -> None: - # TODO: unimplemented - print("UNIMPLEMENTED") - def _on_start_tab_changed(self, combo: Gtk.ComboBoxText) -> None: _iter = combo.get_active_iter() if _iter is None: @@ -373,18 +360,6 @@ class Options(Gtk.Box): 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 @@ -458,11 +433,10 @@ class Options(Gtk.Box): config = query.get_config(prefs.paths.config) # TODO: use newer config enums - name = config["name"] - default_steam_path = config["default_steam_path"] - steam = config["steam_api"] - bm = config["bm_api"] - install = config["auto_install"] + name = self.controller.query_config(Preferences.NAME) + default_steam_path = self.controller.query_config(Preferences.DEFAULT) + steam = self.controller.query_config(Preferences.STEAM) + bm = self.controller.query_config(Preferences.BM) steam_path = Path(default_steam_path) # NOTE: this is a best effort guess at the most recent user @@ -481,9 +455,7 @@ class Options(Gtk.Box): # 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"]), ]: @@ -519,7 +491,6 @@ class Options(Gtk.Box): def _suppress_toggles(self, state: bool) -> None: for toggle in [ - self.mod_install_toggle, self.fullscreen_toggle, self.distance_toggle, ]: diff --git a/dzgui/views/pages/preconnect.py b/dzgui/views/pages/preconnect.py index d0fb7b9..756209a 100644 --- a/dzgui/views/pages/preconnect.py +++ b/dzgui/views/pages/preconnect.py @@ -291,8 +291,6 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): name = prereqs.name self.title.set_text(name) - # FIXME: append to tree status bar - if total_mods < 1: self._hide_mod_area() else: diff --git a/dzgui/views/trees/tree_server_mods.py b/dzgui/views/trees/tree_server_mods.py index 570f9f1..0659c31 100644 --- a/dzgui/views/trees/tree_server_mods.py +++ b/dzgui/views/trees/tree_server_mods.py @@ -64,7 +64,6 @@ class ServerModTreeView(ContextMixin, TreeView): # type: ignore if model is None: raise AttributeError("Trying to call a method on a non-existent model") tree_iter = model.get_iter(path) - # FIXME: https://docs.gtk.org/gtk3/method.TreeModel.get.html mod = model.get_value(tree_iter, 1) return str(mod) From 8fc7aa51650f3770066ee00e9f854e644894d0ac Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Fri, 15 May 2026 23:34:43 +0900 Subject: [PATCH 299/309] fix: rewrite tests --- dzgui/api/pefile.py | 4 ++++ tests/test_changelog.py | 16 ++++++++++++++-- tests/test_pefile.py | 24 +++++++++++++++--------- 3 files changed, 33 insertions(+), 11 deletions(-) diff --git a/dzgui/api/pefile.py b/dzgui/api/pefile.py index cbbd090..ff7a619 100644 --- a/dzgui/api/pefile.py +++ b/dzgui/api/pefile.py @@ -438,6 +438,10 @@ def get_app_path(folders_path: Path, appid: int) -> Path: raise AppNotInstalledError( f"Failed to find a libraryfolder for the appid {appid}" ) + if Path(app_path).exists() is False: + raise AppMovedError( + f"The location '{app_path}' pointed to by '{appid}' no longer exists and may have been changed on the disk." + ) return Path(app_path) diff --git a/tests/test_changelog.py b/tests/test_changelog.py index e3ee98d..3eae186 100644 --- a/tests/test_changelog.py +++ b/tests/test_changelog.py @@ -1,5 +1,17 @@ -def test_headings(): - with open("CHANGELOG.md", "r") as f: +import pytest + +from importlib import resources +from dzgui.const.constants import APP_NAME_LOWER, CHANGELOG_PATH + + +@pytest.fixture +def changelog(): + path = resources.files(APP_NAME_LOWER).joinpath(CHANGELOG_PATH) + return path + + +def test_headings(changelog): + with open(changelog, "r") as f: lines = f.readlines() for line in lines: if line.startswith("#"): diff --git a/tests/test_pefile.py b/tests/test_pefile.py index afd5fa3..47edc2e 100644 --- a/tests/test_pefile.py +++ b/tests/test_pefile.py @@ -2,13 +2,14 @@ import pytest from pathlib import Path import dzgui.api.pefile as PeFile -from dzgui.api.pefile import (VDFLoadError, AppNotInstalledError, AppMovedError) +from dzgui.api.pefile import VDFLoadError, AppNotInstalledError, AppMovedError from dzgui.config.query import get_config from dzgui.config.xdg import get_xdg_paths, parse_filepaths from dzgui.const.constants import APPID_DAYZ from tests.fixtures import fixture_path + pytestmark = pytest.mark.pefile @@ -16,6 +17,7 @@ pytestmark = pytest.mark.pefile def second_drive(): return fixture_path("in_library_on_second_drive.vdf") + @pytest.fixture def default_steam_path(): paths = get_xdg_paths() @@ -23,17 +25,17 @@ def default_steam_path(): conf = get_config(xdg.config) return conf["default_steam_path"] + def test_pefile_length(default_steam_path): try: - pe_file_path = PeFile.get_pefile_path( - Path(default_steam_path), APPID_DAYZ - ) + pe_file_path = PeFile.get_pefile_path(Path(default_steam_path), APPID_DAYZ) vers = PeFile.get_dayz_version(pe_file_path) st = PeFile.dayz_version_to_str(vers).split(".") except Exception as e: raise e assert len(st) == 3 + def test_invalid_pefile_path(): with pytest.raises(VDFLoadError): try: @@ -42,11 +44,14 @@ def test_invalid_pefile_path(): raise e -@pytest.mark.parametrize("fixture, exception", [ - ("not_in_library.vdf", AppNotInstalledError), - ("in_library_but_bad_path.vdf", AppMovedError), - ("malformed.vdf", VDFLoadError), - ]) +@pytest.mark.parametrize( + "fixture, exception", + [ + ("not_in_library.vdf", AppNotInstalledError), + ("in_library_but_bad_path.vdf", AppMovedError), + ("malformed.vdf", VDFLoadError), + ], +) def test_not_in_library(fixture, exception): fixture = fixture_path(fixture) with pytest.raises(exception): @@ -55,6 +60,7 @@ def test_not_in_library(fixture, exception): except Exception as e: raise e + def test_on_second_drive(second_drive): path = PeFile.get_app_path(second_drive, APPID_DAYZ) assert path == Path("/tmp") From a3875789c142bacec4a03b2a608a11547de0022a Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 16 May 2026 00:39:02 +0900 Subject: [PATCH 300/309] chore: clear typehinting errors --- dzgui/model/proxy_model.py | 17 +++++++++++------ dzgui/model/servers.py | 9 ++++----- dzgui/views/components/filter_panel.py | 3 ++- dzgui/views/pages/options.py | 1 - dzgui/views/trees/tree_servers.py | 4 ++-- 5 files changed, 19 insertions(+), 15 deletions(-) diff --git a/dzgui/model/proxy_model.py b/dzgui/model/proxy_model.py index c92b4cc..4e41e20 100644 --- a/dzgui/model/proxy_model.py +++ b/dzgui/model/proxy_model.py @@ -1,6 +1,6 @@ import re -from typing import Any, Optional, TYPE_CHECKING, Union +from typing import Any, TYPE_CHECKING, Union from warnings import deprecated from dzgui.const.enum import FilterMode @@ -14,7 +14,6 @@ if TYPE_CHECKING: from dzgui.model.servers import NewPlayerCount -# TODO: annotate list contents (list[list[Any]]) class ProxyModelManager: """ Manages access to cached FastInsertListStore resources and @@ -35,9 +34,13 @@ class ProxyModelManager: self.proxy_model: "FastInsertListStore" self.filter_man = filter_man - # TODO: list typehints - self.control_model: list | None = None - self.filtered: list = [] + self.control_model: ( + list[tuple[str, str, str, str, int, int, int, str, int, int, str, bool]] + | None + ) = None + self.filtered: list[ + tuple[str, str, str, str, int, int, int, str, int, int, str, bool] + ] = [] self.success = True def has_control_model(self) -> bool: @@ -48,7 +51,9 @@ class ProxyModelManager: def append_row(self, row: list) -> None: self.proxy_model.append(row) - def append_row_to_control(self, row: list) -> None: + def append_row_to_control( + self, row: tuple[str, str, str, str, int, int, int, str, int, int, str, bool] + ) -> None: if self.control_model is None: raise AttributeError("Trying to add rows to a non-existent model") self.control_model.append(row) diff --git a/dzgui/model/servers.py b/dzgui/model/servers.py index 154c90a..c2f4837 100644 --- a/dzgui/model/servers.py +++ b/dzgui/model/servers.py @@ -60,6 +60,7 @@ class ServerModelManager: There may be cases where you want to instantiate this class without dumping servers, e.g., adding saved servers from another tab """ + self.first_iteration = True match self.enum: case ServerTab.BROWSER: @@ -92,12 +93,10 @@ class ServerModelManager: futures = [executor.submit(job, key, APPID_DAYZ, param) for param in params] for future in as_completed(futures): try: - # NOTE: faciliates early aborting via sigint # TODO: make this logic available to all dump contexts if self.controller.get_exit_event().is_set(): return - self.thread_man.increment_dialog() res = future.result(timeout=API_TIMEOUT) if res.status != 200 or not res.parsed: @@ -236,7 +235,6 @@ class ServerModelManager: def add_to_history(self, record: dict[str, Any]) -> None: proxy_man = self._get_proxy_man() row = Servers.parse_json([record]) - print(type(row[0])) proxy_man.append_row_to_history(row[0]) self.update_history() @@ -285,7 +283,7 @@ class ServerModelManager: proxy_man = self._get_proxy_man() config_man = self.controller.get_config_man() fqip = Servers.response_to_fqip(row) - record = response.get_record() # Servers.response_to_record(row) + record = response.get_record() # TODO: less convoluted if delete: @@ -394,7 +392,8 @@ class ServerModelManager: # TODO: distinguish signals, e.g. "servers_failed_to_load", "servers_loaded_empty" # customize statusbar and dialog accordingly - self.emitter.emit("servers_loaded", self.enum) + # TODO: drop, causes errors + # self.emitter.emit("servers_loaded", self.enum) # FIXME: destroy wait dialog first # see threadman.set_cleanup_func(_, destroy_first=True) if show_dialog: diff --git a/dzgui/views/components/filter_panel.py b/dzgui/views/components/filter_panel.py index 222c61d..eba6735 100644 --- a/dzgui/views/components/filter_panel.py +++ b/dzgui/views/components/filter_panel.py @@ -45,7 +45,7 @@ class ButtonGrid(Gtk.Grid): checkbox = Gtk.CheckButton(label=check) label = checkbox.get_child() if label is not None: - label.set_ellipsize(Pango.EllipsizeMode.END) # type: ignore + label.set_ellipsize(Pango.EllipsizeMode.END) # type: ignore if defaults[check]: checkbox.set_active(True) @@ -188,6 +188,7 @@ class FilterPanel(Gtk.Box): self.maps_entry: Gtk.Entry = self.maps_combo.get_child() # type: ignore self.maps_entry.set_completion(completion) + # TODO: strings 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) diff --git a/dzgui/views/pages/options.py b/dzgui/views/pages/options.py index 1cfdd1c..4e35801 100644 --- a/dzgui/views/pages/options.py +++ b/dzgui/views/pages/options.py @@ -28,7 +28,6 @@ from dzgui.views.components.eventbox import InfoEventBox from dzgui.views.components.buttons import WebButton from dzgui.views.components.frame import HeadingFrame from dzgui.views.dialogs.generic import ExceptionDialog -from dzgui.views.dialogs.link_dialog import WorkshopLinkDialog import gi diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index 8020506..57220bd 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -78,8 +78,8 @@ class ServerTreeView(ContextMixin, TreeView): # type: ignore "IP": 240, } - # TODO: abstract - # FIXME: resize col width func causes snapping behavior + # TODO: abstract column population logic + # TODO: resize col width func causes snapping behavior browser_cols = strings.browser_cols for i, column_title in enumerate(browser_cols): renderer = Gtk.CellRendererText() From ef459c2fd11d9072a5fd3ac19af735e321a469d7 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 16 May 2026 01:55:18 +0900 Subject: [PATCH 301/309] fix: do not swap list store in worker thread --- dzgui/model/proxy_model.py | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/dzgui/model/proxy_model.py b/dzgui/model/proxy_model.py index 4e41e20..11b21df 100644 --- a/dzgui/model/proxy_model.py +++ b/dzgui/model/proxy_model.py @@ -27,9 +27,10 @@ class ProxyModelManager: """ def __init__(self, filter_man: "FilterManager") -> None: - self.filter_cache: dict[tuple[str], tuple["FastInsertListStore", list[Any]]] = ( - {} - ) + self.filter_cache: dict[ + tuple[str], + list[tuple[str, str, str, str, int, int, int, str, int, int, str, bool]], + ] = {} self.proxy_model: "FastInsertListStore" self.filter_man = filter_man @@ -124,9 +125,11 @@ class ProxyModelManager: if skip_cache is False: if filters in self.filter_cache: - cache = self.filter_cache[filters] - self.set_proxy_model(cache[0]) - self.set_filtered(cache[1]) + cached_rows = self.filter_cache[filters] + clone = ModelFactory().make_server_store() + clone.extend(cached_rows) + self.set_proxy_model(clone) + self.set_filtered(cached_rows) return None match mode: @@ -145,7 +148,7 @@ class ProxyModelManager: rows = self.sort_rows(rows) clone.extend(rows) - self.set_cache(filters, clone, rows) + self.set_cache(filters, rows) self.set_proxy_model(clone) return clone @@ -241,7 +244,7 @@ class ProxyModelManager: rows = [row for row in rows if not row[11]] return rows - def filter_toggle_on(self, filters: tuple) -> list: + def filter_toggle_on(self, filters: tuple[str]) -> list: """Effectively applies all filters""" self.set_filtered(self.control_model) self.set_filtered(self.filter_map(filters)) @@ -251,10 +254,8 @@ class ProxyModelManager: self.set_filtered(self.filter_toggle_off(filters, f)) return self.filtered - def set_cache( - self, filters: tuple, model: "FastInsertListStore", rows: list - ) -> None: - self.filter_cache[filters] = (model, rows) + def set_cache(self, filters: tuple[str], filtered_rows: list[tuple]) -> None: + self.filter_cache[filters] = filtered_rows @deprecated("Currently unused") # def convert_model_to_list(self, model: "FastInsertListStore") -> list: From fa759f4714db2a769adadea93aa84c11336543ee Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 16 May 2026 01:55:41 +0900 Subject: [PATCH 302/309] chore: clean up server threading --- dzgui/managers/mods.py | 7 ++++-- dzgui/model/servers.py | 55 ++++++++++++++++++------------------------ 2 files changed, 28 insertions(+), 34 deletions(-) diff --git a/dzgui/managers/mods.py b/dzgui/managers/mods.py index c73f489..180df0a 100644 --- a/dzgui/managers/mods.py +++ b/dzgui/managers/mods.py @@ -51,13 +51,14 @@ class ModManager: self.thread_man = ThreadingManager(controller) self._get_mods() - # TODO: strings + # TODO: dialog strings @call_on_thread("getting mods") def _get_mods(self) -> None: mods = get_delimited_mods(self.path) if len(mods) < 1: msg = self.format_mod_statusbar() - self.emitter.emit("mods_updated", msg, 0) + func = StoredFunc(lambda: self.emitter.emit("mods_updated", msg, 0)) + self.thread_man.set_cleanup_func(func) return func = StoredFunc(self._on_mods_loaded, mods) @@ -74,6 +75,8 @@ class ModManager: # TODO: strings @call_on_thread("deleting mods") def delete_mods(self) -> None: + # FIXME: do not read from GTK in worker thread + # break into function that iterates through paths and one that is threaded sel = self.treeview.get_selection() model, pathlist = sel.get_selected_rows() # NOTE: reverse when multiple selection diff --git a/dzgui/model/servers.py b/dzgui/model/servers.py index c2f4837..ec00391 100644 --- a/dzgui/model/servers.py +++ b/dzgui/model/servers.py @@ -83,7 +83,8 @@ class ServerModelManager: @call_on_thread(dialog.fetching) def _dump_api(self) -> None: - # TODO: pass api key a priori in .load() call + # TODO: pass api key a priori in .load() call? + failure_func = StoredFunc(self._cleanup_on_failure) config_man = self.controller.get_config_man() key = config_man.lookup(Preferences.STEAM) job = Servers.query_api @@ -101,7 +102,7 @@ class ServerModelManager: res = future.result(timeout=API_TIMEOUT) if res.status != 200 or not res.parsed: self.thread_man.set_cleanup_func( - StoredFunc(self._cleanup_on_failure) + failure_func, destroy_first=True ) return j = res.json @@ -109,9 +110,7 @@ class ServerModelManager: servers.extend(j["response"]["servers"]) except Exception as e: logger.critical(e) - self.thread_man.set_cleanup_func( - StoredFunc(self._cleanup_on_failure) - ) + self.thread_man.set_cleanup_func(failure_func, destroy_first=True) return # NOTE: this step is allowed to fail, since this metadata is incidental @@ -128,6 +127,7 @@ class ServerModelManager: def dump_lan(self, port: int, early_abort: bool) -> None: servers = [] ports = range(1, 256) + failure_func = StoredFunc(self._cleanup_on_failure) event = threading.Event() with ThreadPoolExecutor() as executor: @@ -135,7 +135,9 @@ class ServerModelManager: for future in as_completed(futures): try: res = future.result(timeout=LAN_TIMEOUT) - if res is not None and early_abort is True: + if res is None: + continue + if early_abort is True: # NOTE: on first non-empty hit, flag pending threads to close event.set() servers.append(res) @@ -143,16 +145,12 @@ class ServerModelManager: StoredFunc(self._cleanup_on_success) ) return - if res is None: - continue servers.append(res) except Exception as e: logger.critical(e) - self.thread_man.set_cleanup_func( - StoredFunc(self._cleanup_on_failure) - ) + self.thread_man.set_cleanup_func(failure_func, destroy_first=True) if len(servers) == 0: - self.thread_man.set_cleanup_func(StoredFunc(self._cleanup_on_failure)) + self.thread_man.set_cleanup_func(failure_func, destroy_first=True) return parsed = Servers.parse_json(servers) self._push_data(parsed) @@ -181,7 +179,7 @@ class ServerModelManager: servers.append(res) if len(servers) == 0: self.thread_man.set_cleanup_func( - StoredFunc(self._cleanup_on_failure) + StoredFunc(self._cleanup_on_failure), destroy_first=True ) return @@ -202,9 +200,7 @@ class ServerModelManager: @call_on_thread(dialog.querying) def add_by_record(self, record: Servers.Record) -> None: - """ - Rationale: a Record as shown in server browser may resolve to a different IP - """ + # NOTE: rationale: a raw Record as shown in server browser may resolve to a different IP res = Servers.query_by_record(record) self._parse_single_record(res) @@ -271,7 +267,9 @@ class ServerModelManager: row = response.as_row() except Exception as e: logger.warning(e) - self.thread_man.set_cleanup_func(StoredFunc(self._cleanup_on_failure)) + self.thread_man.set_cleanup_func( + StoredFunc(self._cleanup_on_failure), destroy_first=True + ) return # NOTE: expected to only contain one item @@ -302,9 +300,9 @@ class ServerModelManager: return config_man.add_saved_server(fqip) # FIXME: this is valid if saved servers tab is already open, - # but not if app was just booted + # but not if app was just booted; causes single row to appear prematurely if proxy_man.has_control_model() is False: - self._get_proxy_man().push(records) + proxy_man.push(records) else: proxy_man.append_row_to_control(server) @@ -322,14 +320,13 @@ class ServerModelManager: rows = [row.rstrip("\n") for row in f] except OSError: self.thread_man.set_cleanup_func( - StoredFunc(self._cleanup_on_failure, False) + StoredFunc(self._cleanup_on_failure, False), destroy_first=True ) return if len(rows) == 0: # TODO: customize statusbar to mention how records are added after connecting - # cf. cleanup on empty self.thread_man.set_cleanup_func( - StoredFunc(self._cleanup_on_failure, False) + StoredFunc(self._cleanup_on_failure, False), destroy_first=True ) return self.thread_man.set_job_count(len(rows)) @@ -343,7 +340,7 @@ class ServerModelManager: if len(ips) == 0: # TODO: customize statusbar to mention how records can be added via contextmenu self.thread_man.set_cleanup_func( - StoredFunc(self._cleanup_on_failure, False) + StoredFunc(self._cleanup_on_failure, False), destroy_first=True ) return self._dump_ips(ips) @@ -381,21 +378,15 @@ class ServerModelManager: def _cleanup_on_failure(self, show_dialog: bool = True) -> None: # TODO: disable map, keyword, and filter widgets if model is None - # -> signal driven (servers_empty, servers_failed_to_load) + # signal driven (servers_empty, servers_failed_to_load) if self.preserve_on_fail is False: - # CHORE: test if maps are cleared on failure self.tv.set_model(None) - # filter_man = self.tv.get_filter_man() - # filter_man.set_unique_maps([]) - # TODO: emit signal to not disable widget sensitivity + filter_man = self.tv.get_filter_man() + filter_man.set_unique_maps([]) # TODO: distinguish signals, e.g. "servers_failed_to_load", "servers_loaded_empty" # customize statusbar and dialog accordingly - # TODO: drop, causes errors - # self.emitter.emit("servers_loaded", self.enum) - # FIXME: destroy wait dialog first - # see threadman.set_cleanup_func(_, destroy_first=True) if show_dialog: dialog = ExceptionDialog(self.controller, api_warn_msg) dialog.run() From b2180c6a5bcfe28bcb6819621ce9e338327dda3c Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 16 May 2026 02:07:27 +0900 Subject: [PATCH 303/309] fix: do not iterater treepaths in worker thread --- dzgui/managers/mods.py | 33 +++++++++++++++++---------------- dzgui/model/proxy_model.py | 1 + 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/dzgui/managers/mods.py b/dzgui/managers/mods.py index 180df0a..bcbc810 100644 --- a/dzgui/managers/mods.py +++ b/dzgui/managers/mods.py @@ -72,21 +72,18 @@ class ModManager: total_mods = len(self.store) self.emitter.emit("mods_updated", msg, total_mods) - # TODO: strings - @call_on_thread("deleting mods") def delete_mods(self) -> None: - # FIXME: do not read from GTK in worker thread - # break into function that iterates through paths and one that is threaded sel = self.treeview.get_selection() model, pathlist = sel.get_selected_rows() # NOTE: reverse when multiple selection - iters = [] + mods: list[tuple[str, Gtk.TreeIter]] = [] for path in reversed(pathlist): - _iter = self.delete_single_mod(path) - iters.append(_iter) - - func = StoredFunc(self._on_mods_deleted, iters) - self.thread_man.set_cleanup_func(func) + res = self.get_mod_from_tree_path(path) + if res is None: + continue + mod, _iter = res + mods.append((mod, _iter)) + self.delete_mods_on_system(mods) def get_mod_from_tree_path( self, tree_path: Gtk.TreePath @@ -98,12 +95,17 @@ class ModManager: mod = model.get_value(tree_iter, 2) return mod, tree_iter - def delete_single_mod(self, tree_path: Gtk.TreePath) -> Gtk.TreeIter | None: - res = self.get_mod_from_tree_path(tree_path) - if res is None: - return None - mod, _iter = res + # TODO: strings + @call_on_thread("deleting mods") + def delete_mods_on_system(self, mods: list[tuple[str, Gtk.TreeIter]]) -> None: + for mod, _iter in mods: + self.delete_single_mod(mod) + iters = [_iter for mod, _iter in mods] + func = StoredFunc(self._on_mods_deleted, iters) + self.thread_man.set_cleanup_func(func) + + def delete_single_mod(self, mod: str): steam_path = Path(self.path) mods_path = get_local_mod_path(steam_path) app_path = PeFile.get_nested_app_path(steam_path, APPID_DAYZ) @@ -121,7 +123,6 @@ class ModManager: symlink.unlink() except PeFile.AppNotInstalledError: pass - return _iter def _on_mods_deleted(self, iters: list[Gtk.TreeIter]) -> None: for _iter in iters: diff --git a/dzgui/model/proxy_model.py b/dzgui/model/proxy_model.py index 11b21df..9639789 100644 --- a/dzgui/model/proxy_model.py +++ b/dzgui/model/proxy_model.py @@ -250,6 +250,7 @@ class ProxyModelManager: self.set_filtered(self.filter_map(filters)) self.set_filtered(self.filter_keyword(filters)) + f: str for f in filters[2:]: self.set_filtered(self.filter_toggle_off(filters, f)) return self.filtered From 3befb1276c3f0f3a235465f642ebecf71ffa154c Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 16 May 2026 02:08:39 +0900 Subject: [PATCH 304/309] chore: add return type annotation --- dzgui/managers/mods.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dzgui/managers/mods.py b/dzgui/managers/mods.py index bcbc810..e1199b2 100644 --- a/dzgui/managers/mods.py +++ b/dzgui/managers/mods.py @@ -105,7 +105,7 @@ class ModManager: func = StoredFunc(self._on_mods_deleted, iters) self.thread_man.set_cleanup_func(func) - def delete_single_mod(self, mod: str): + def delete_single_mod(self, mod: str) -> None: steam_path = Path(self.path) mods_path = get_local_mod_path(steam_path) app_path = PeFile.get_nested_app_path(steam_path, APPID_DAYZ) From 73d66ab5e7b643c626c2ce39f1150b2efac5b49b Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 16 May 2026 20:10:26 +0900 Subject: [PATCH 305/309] fix: support empty lists in extend() --- dzgui/model/model_factory.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/dzgui/model/model_factory.py b/dzgui/model/model_factory.py index c0b87f3..ed1ccdc 100644 --- a/dzgui/model/model_factory.py +++ b/dzgui/model/model_factory.py @@ -95,6 +95,8 @@ class FastInsertListStore(ListStore): expected_types = [ GTYPE_TO_PYTHON[self.get_column_type(i)] for i in range(n_cols) ] + if len(rows) == 0: + return if not self._is_same_length(rows): raise ValueError("Sublists are not of uniform length") if not self._is_type_homogeneous(rows): From 5bb4ab688e896d384cc520791c46bb2cecac1051 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 16 May 2026 20:11:02 +0900 Subject: [PATCH 306/309] feat: capture sigint events in server loops --- dzgui/model/servers.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/dzgui/model/servers.py b/dzgui/model/servers.py index ec00391..3c3ba8a 100644 --- a/dzgui/model/servers.py +++ b/dzgui/model/servers.py @@ -57,7 +57,7 @@ class ServerModelManager: def load(self) -> None: """ - There may be cases where you want to instantiate this class without dumping servers, + Load is not called on init. There may be cases where you want to instantiate this class without dumping servers, e.g., adding saved servers from another tab """ @@ -134,6 +134,8 @@ class ServerModelManager: futures = [executor.submit(Servers.test_ip, i, port, event) for i in ports] for future in as_completed(futures): try: + if self.controller.get_exit_event().is_set(): + return res = future.result(timeout=LAN_TIMEOUT) if res is None: continue @@ -169,8 +171,10 @@ class ServerModelManager: for ip in ips ] for future in as_completed(futures): + # TODO: wrap except + if self.controller.get_exit_event().is_set(): + return res = future.result(timeout=API_TIMEOUT) - self.thread_man.increment_dialog() # NOTE: failing entries are culled if res is None: From 08b62e1cfd59d0b380c89a568b58c622b20a9a59 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 16 May 2026 20:11:11 +0900 Subject: [PATCH 307/309] chore: add boilerplate --- dzgui/const/constants.py | 2 ++ dzgui/controllers/emitter.py | 5 ++--- dzgui/init/migrate.py | 1 + dzgui/init/proc.py | 1 + dzgui/main.py | 1 + dzgui/managers/mods.py | 2 +- dzgui/views/trees/tree_servers.py | 1 + 7 files changed, 9 insertions(+), 4 deletions(-) diff --git a/dzgui/const/constants.py b/dzgui/const/constants.py index 2035553..47a0e5e 100644 --- a/dzgui/const/constants.py +++ b/dzgui/const/constants.py @@ -45,6 +45,7 @@ WEB_BROWSER = "web-browser-symbolic" SEPARATOR = "SEPARATOR" +# TODO: drop NO_PADDING = 0 NO_EXPAND = False NO_FILL = False @@ -65,4 +66,5 @@ CHANGELOG_PATH = "data/CHANGELOG.md" CSS_PATH = "data/app.css" LOG_FILTERS = ("CRITICAL", "WARNING", "INFO", "DEBUG") +# TODO: drop FOREGROUND_CMDS = ("wmctrl", "xdotool") diff --git a/dzgui/controllers/emitter.py b/dzgui/controllers/emitter.py index 3da22a7..a089351 100644 --- a/dzgui/controllers/emitter.py +++ b/dzgui/controllers/emitter.py @@ -9,14 +9,14 @@ from gi.repository import Gdk, GObject # noqa E402 if TYPE_CHECKING: from dzgui.const.enum import NotebookPage, ServerTab -# TODO: rename signals to e.g. maps_keybinding_pressed +# TODO: if servers fail to load, may leave dangling widgets waiting for a signal class Emitter(GObject.GObject): def __init__(self) -> None: super().__init__() - # TODO: rename request verbs + # TODO: rename signals to e.g. maps_keybinding_pressed @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=()) def request_keyword_focus(self) -> None: """User invoked Ctrl-f keybinding from ServerTreeView""" @@ -150,4 +150,3 @@ class Emitter(GObject.GObject): def keyword_set(self, keyword: str) -> None: pass - # TODO: if servers fail to load, may leave dangling widgets waiting for a signal diff --git a/dzgui/init/migrate.py b/dzgui/init/migrate.py index 3f69fbf..554e793 100644 --- a/dzgui/init/migrate.py +++ b/dzgui/init/migrate.py @@ -52,4 +52,5 @@ def copy_ipdb(ips_path: Path) -> None: return if legacy.is_file() is False: return + # TODO: ensure that month file is copied shutil.copy(legacy, ips_path) diff --git a/dzgui/init/proc.py b/dzgui/init/proc.py index 40d3848..78acbf1 100644 --- a/dzgui/init/proc.py +++ b/dzgui/init/proc.py @@ -39,6 +39,7 @@ def is_steam_running(cmd: str) -> bool: # CHORE: test alternate clients +# TODO: has_flatpak_steam def is_flatpak_steam_running() -> bool: if has_cmd(FLATPAK_CMD) is False: return False diff --git a/dzgui/main.py b/dzgui/main.py index e0a5b61..516d5d5 100644 --- a/dzgui/main.py +++ b/dzgui/main.py @@ -102,6 +102,7 @@ def main() -> None: make_parents(XDG.debug) if has_new_config(XDG.config) is False: + # TODO: handle this in assistant migrate_legacy_conf(XDG.config) migrate_cols_file(XDG.columns) # TODO: copy notes file diff --git a/dzgui/managers/mods.py b/dzgui/managers/mods.py index e1199b2..bb1519f 100644 --- a/dzgui/managers/mods.py +++ b/dzgui/managers/mods.py @@ -181,7 +181,7 @@ class ModManager: model[path][4] = True self.emitter.emit("mods_highlighted") - # TODO: strings + # TODO: dialog strings @call_on_thread("working") def highlight_stale(self) -> None: stale = find_stale_mods(self.prefs.paths.config) diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index 57220bd..7013d0c 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -215,6 +215,7 @@ class ServerTreeView(ContextMixin, TreeView): # type: ignore def _check_result_queue(self) -> Literal[True]: latest_result = None + # FIXME: empty results cause statusbar to be emptied while not self.queue.empty(): latest_result = self.queue.get() From c4160bbbd8f91b45e5ad0d97e2d58146abcde57f Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 16 May 2026 20:49:43 +0900 Subject: [PATCH 308/309] fix: zero-pad revision number --- dzgui/api/pefile.py | 15 ++++++++++----- dzgui/const/constants.py | 3 ++- dzgui/managers/connection.py | 4 ++-- dzgui/views/pages/options.py | 4 ++-- dzgui/views/pages/preconnect.py | 4 +--- 5 files changed, 17 insertions(+), 13 deletions(-) diff --git a/dzgui/api/pefile.py b/dzgui/api/pefile.py index ff7a619..d9498eb 100644 --- a/dzgui/api/pefile.py +++ b/dzgui/api/pefile.py @@ -239,7 +239,7 @@ class FileVersion: major: int minor: int build: int - revision: int + revision: str @dataclass(slots=True, frozen=True) @@ -281,8 +281,11 @@ def parse_version_number(data: BinaryIO) -> FileVersion: minor = struct.unpack("> 16 & 0xFFFF major = struct.unpack("> 0 & 0xFFFF build = struct.unpack("> 0 & 0xFFFF - revision = struct.unpack("> 16 & 0xFFFF - return FileVersion(major, minor, build, revision) + # NOTE: 0-pad revision number + revision = str(struct.unpack("> 16 & 0xFFFF) + offset = 3 - len(revision) + padded = "0" * offset + revision + return FileVersion(major, minor, build, padded) def seek_to_hex(address: str, data: BinaryIO) -> None: @@ -311,7 +314,8 @@ def get_dayz_version(file: Path) -> DayZVersion: def dayz_version_to_str(v: DayZVersion) -> str: - return ".".join(str(el) for el in [v.major, v.minor, v.patch]) + return ".".join(el for el in list(map(str, [v.major, v.minor, v.patch]))) + # return ".".join(str(el) for el in [v.major, v.minor, v.patch]) def dayz_version_from_str(v: str) -> DayZVersion: @@ -452,5 +456,6 @@ def get_pretty_version(steam_path: Path, appid: int) -> str | None: vers = get_dayz_version(pe_file_path) dayz_version = dayz_version_to_str(vers) return dayz_version - except Exception: + except Exception as e: + print(e) return None diff --git a/dzgui/const/constants.py b/dzgui/const/constants.py index 47a0e5e..f536ec4 100644 --- a/dzgui/const/constants.py +++ b/dzgui/const/constants.py @@ -7,7 +7,8 @@ MIN_COUNT = 1048576 REQUEST_TIMEOUT = 10 APPNAME_DAYZ = "DayZ" -APPNAME_DAYZ_EXP = "DayZ Experimental" +APPNAME_DAYZ_EXP = "DayZ Exp" +APPNAME_DAYZ_EXP_HUMAN = "DayZ Experimental" DAYZ_BINARY = "DayZ_x64.exe" LIBRARYFOLDERS_PATH = "steamapps/libraryfolders.vdf" diff --git a/dzgui/managers/connection.py b/dzgui/managers/connection.py index 1a7f35b..43e2293 100644 --- a/dzgui/managers/connection.py +++ b/dzgui/managers/connection.py @@ -28,7 +28,7 @@ from dzgui.const.constants import ( APPID_DAYZ, APPID_DAYZ_EXP, APPNAME_DAYZ, - APPNAME_DAYZ_EXP, + APPNAME_DAYZ_EXP_HUMAN, ) from dzgui.const.enum import NotebookPage, Preferences from dzgui.init.proc import is_dayz_running, is_steam_running @@ -123,7 +123,7 @@ class ConnectionManager: self.appid = info.game_id self.record = record - builds = {APPID_DAYZ: APPNAME_DAYZ, APPID_DAYZ_EXP: APPNAME_DAYZ_EXP} + builds = {APPID_DAYZ: APPNAME_DAYZ, APPID_DAYZ_EXP: APPNAME_DAYZ_EXP_HUMAN} build = builds[self.appid] binary_missing = False required_mib = 0.0 diff --git a/dzgui/views/pages/options.py b/dzgui/views/pages/options.py index 4e35801..0ae777e 100644 --- a/dzgui/views/pages/options.py +++ b/dzgui/views/pages/options.py @@ -8,7 +8,7 @@ from dzgui.const.constants import ( APPID_DAYZ, APPID_DAYZ_EXP, APPNAME_DAYZ, - APPNAME_DAYZ_EXP, + APPNAME_DAYZ_EXP_HUMAN, FLATPAK_RUN_CMD, FLATPAK_SANDBOX, NO_EXPAND, @@ -173,7 +173,7 @@ class Options(Gtk.Box): version_rows = [ [LeftLabel(APPNAME_DAYZ), self.dayz_version_label], - [LeftLabel(APPNAME_DAYZ_EXP), self.dayz_exp_version_label], + [LeftLabel(APPNAME_DAYZ_EXP_HUMAN), self.dayz_exp_version_label], ] api_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) diff --git a/dzgui/views/pages/preconnect.py b/dzgui/views/pages/preconnect.py index 756209a..bad0cd4 100644 --- a/dzgui/views/pages/preconnect.py +++ b/dzgui/views/pages/preconnect.py @@ -200,8 +200,8 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): ) for child in widgets: child.set_visible(True) - self.raise_window.set_visible(False) + self.raise_window.set_visible(False) self.raise_window.set_sensitive(False) self.ok.set_sensitive(True) self.ok.set_label(preconnect.update_mods) @@ -214,8 +214,6 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): self.ok.emit("clicked") def _on_ok_clicked(self, button: Gtk.Button) -> None: - # TODO: cancel mod downloads - # sets some kind of global event listener self.controller.update_and_connect(self.raise_window.get_active()) def _on_back_clicked(self, button: Gtk.Button) -> None: From da004c6ab4c8020ae2bc05b595e6ce5d368b3453 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 16 May 2026 21:28:10 +0900 Subject: [PATCH 309/309] fix: add row to history before model is initialized --- dzgui/model/servers.py | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/dzgui/model/servers.py b/dzgui/model/servers.py index 3c3ba8a..6b82ee9 100644 --- a/dzgui/model/servers.py +++ b/dzgui/model/servers.py @@ -213,13 +213,24 @@ class ServerModelManager: res = Servers.query_by_record(record) self._parse_single_record(res, delete=True) - def update_history(self) -> None: + def update_history( + self, + rows: ( + list[tuple[str, str, str, str, int, int, int, str, int, int, str, bool]] + | None + ) = None, + ) -> None: proxy_man = self._get_proxy_man() - control_model = proxy_man.get_control() config_man = self.controller.get_config_man() - config_man.update_history_file(control_model) + if rows is not None: + records = rows + else: + control_model = proxy_man.get_control() + records = control_model - self._sort_unique_maps(control_model) + config_man.update_history_file(records) + config_man.update_history_file(records) + self._sort_unique_maps(records) proxy = proxy_man.get_proxy_model() self.tv.set_model(proxy) @@ -234,8 +245,12 @@ class ServerModelManager: def add_to_history(self, record: dict[str, Any]) -> None: proxy_man = self._get_proxy_man() - row = Servers.parse_json([record]) - proxy_man.append_row_to_history(row[0]) + rows = Servers.parse_json([record]) + try: + proxy_man.append_row_to_history(rows[0]) + except Exception: + self.update_history(rows) + return self.update_history() def remove_from_history(self, record: Servers.Record) -> None: