From 42feeae8d068bb9388fabc3b27a6657baccbff9b Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 9 Aug 2026 21:07:37 +0900 Subject: [PATCH 001/139] fix: html escape server desc --- dzgui/views/dialogs/servers.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dzgui/views/dialogs/servers.py b/dzgui/views/dialogs/servers.py index 14cb719..1457ca5 100644 --- a/dzgui/views/dialogs/servers.py +++ b/dzgui/views/dialogs/servers.py @@ -1,3 +1,4 @@ +import html from typing import Self, TYPE_CHECKING from dzgui.const.constants import EXPAND, FILL @@ -109,7 +110,7 @@ class ServerDetailsDialog(ServerDialog): self.view.set_model(self.store) text = details.description text = format_hyperlinks(text) - self.description.set_markup(text) + self.description.set_markup(html.escape(text)) self.show_all() From cf70182733b0a4d6353c92e2007877bb10e7191e Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 9 Aug 2026 23:48:17 +0900 Subject: [PATCH 002/139] feat: unset fav server by button --- dzgui/const/constants.py | 1 + dzgui/controllers/mc.py | 8 +++++++ dzgui/managers/config.py | 7 ++++++ dzgui/views/components/buttons.py | 9 ++++++++ dzgui/views/components/connect_panel.py | 29 ++++++++++++++++++++----- 5 files changed, 49 insertions(+), 5 deletions(-) diff --git a/dzgui/const/constants.py b/dzgui/const/constants.py index d0843fc..5be3d0b 100644 --- a/dzgui/const/constants.py +++ b/dzgui/const/constants.py @@ -38,6 +38,7 @@ HEX_ORANGE = "#FFAC1C" CARET_DOWN = "go-down-symbolic" CARET_UP = "go-up-symbolic" CLIPBOARD = "edit-copy-symbolic" +CLOSE = "window-close-symbolic" FOLDER = "folder-symbolic" EDIT_DELETE = "edit-delete-symbolic" ERROR = "dialog-error-symbolic" diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index e167ba9..b2c1a08 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -402,6 +402,9 @@ class Controller(GObject.GObject): def get_menu(self) -> "MenuTreeView": return self.mediator.menu + def unset_fav(self) -> None: + self.config_man.unset_fav() + def has_favorites(self) -> bool: favs = self.config_man.get_favorites() if len(favs) < 1: @@ -459,6 +462,9 @@ class Controller(GObject.GObject): open_workshop_page(mod, cmd) def has_note(self) -> bool: + # TODO: get record string, store in memory + # get note by record, get is in favs, etc. store as a block + # this is only used by context mixin note = self.get_note() if len(note) > 0: return True @@ -473,11 +479,13 @@ class Controller(GObject.GObject): return self.notes_man.get_note(record) def add_note(self, note: str) -> None: + # TODO: record should be cached upon creation of dialog tv = self.get_active_treeview() record = tv.get_record_string() self.notes_man.add_note(record, note) def delete_note(self) -> None: + # TODO: record should be cached upon creation of dialog tv = self.get_active_treeview() record = tv.get_record_string() self.notes_man.delete_note(record) diff --git a/dzgui/managers/config.py b/dzgui/managers/config.py index 1395dd5..2e81f28 100644 --- a/dzgui/managers/config.py +++ b/dzgui/managers/config.py @@ -160,6 +160,13 @@ class ConfigManager: except Exception: return + def unset_fav(self) -> None: + try: + self.write_config(Preferences.FAV_LBL, "") + self.write_config(Preferences.FAV_SRV, "") + except Exception: + return + def write_config(self, key: Preferences, value: str) -> None: try: real_key = self.enum_to_key(key) diff --git a/dzgui/views/components/buttons.py b/dzgui/views/components/buttons.py index 8674820..c940b7f 100644 --- a/dzgui/views/components/buttons.py +++ b/dzgui/views/components/buttons.py @@ -10,6 +10,7 @@ from dzgui.util.strings import ( ) from dzgui.const.constants import ( CLIPBOARD, + CLOSE, INPUT_KEYBOARD, LIST_ADD, REFRESH_ICON, @@ -128,6 +129,14 @@ class CopyIpButton(ClipboardButton): self.set_tooltip_text("Copy IP to clipboard") +class CloseButton(IconTextButton): + def __init__(self, label: str) -> None: + super().__init__(icon=CLOSE, label=label) + + # TODO: strings + self.set_tooltip_text("Unset this server as favorite") + + class WebButton(IconTextButton): def __init__(self, label: str) -> None: super().__init__(icon=WEB_BROWSER, label=label) diff --git a/dzgui/views/components/connect_panel.py b/dzgui/views/components/connect_panel.py index 0e4e6f4..17d0a71 100644 --- a/dzgui/views/components/connect_panel.py +++ b/dzgui/views/components/connect_panel.py @@ -6,6 +6,7 @@ from dzgui.strings import connect_panel from dzgui.util.keys import is_ctrl_mask from dzgui.views.components.buttons import ( AddButton, + CloseButton, CopyIpButton, Icon, IconButton, @@ -187,17 +188,27 @@ class FavPanel(Gtk.Frame): self.fav_button = SteamConnectButton() self.fav_button.connect("clicked", self._on_connect_clicked) self.copy_button = CopyIpButton(self.controller, self.get_fav_ip) + + # TODO: strings + self.unset_button = CloseButton("Unset") + self.unset_button.connect("clicked", self._on_unset_clicked) + 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.scrollable_label = Gtk.ScrolledWindow( + vscrollbar_policy=Gtk.PolicyType.NEVER, overlay_scrolling=False + ) + self.scrollable_label.add(self.fav_label) grid = Gtk.Grid(margin=10, vexpand=False, column_spacing=15, row_spacing=5) - grid.attach(scrollable_label, 0, 0, 3, ROWS) + grid.attach(self.scrollable_label, 0, 0, 3, ROWS) grid.attach_next_to( - self.copy_button, scrollable_label, Gtk.PositionType.RIGHT, COLS, ROWS + self.unset_button, self.scrollable_label, Gtk.PositionType.RIGHT, COLS, ROWS + ) + grid.attach_next_to( + self.copy_button, self.unset_button, Gtk.PositionType.RIGHT, COLS, ROWS ) grid.attach_next_to( self.fav_button, self.copy_button, Gtk.PositionType.RIGHT, COLS, ROWS @@ -205,8 +216,16 @@ class FavPanel(Gtk.Frame): self.add(grid) + def _on_unset_clicked(self, button: Gtk.Button) -> None: + try: + self.controller.unset_fav() + except Exception: + return + self.fav_label.set_text(connect_panel.favs_empty) + self.toggle_buttons(False) + def toggle_buttons(self, state: bool) -> None: - for button in self.fav_button, self.copy_button: + for button in self.fav_button, self.copy_button, self.unset_button: button.set_sensitive(state) def _on_connect_clicked(self, button: Gtk.Button) -> None: From f2501871ee2e60334be01645a12d2649c004b849 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:43:53 +0900 Subject: [PATCH 003/139] feat: update dialog on cancel action --- dzgui/views/dialogs/generic.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/dzgui/views/dialogs/generic.py b/dzgui/views/dialogs/generic.py index 73da729..c890589 100644 --- a/dzgui/views/dialogs/generic.py +++ b/dzgui/views/dialogs/generic.py @@ -107,7 +107,7 @@ class WaitDialog(GenericDialog): 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.cancel.connect("clicked", self._on_cancel_clicked) self.connect("delete-event", lambda widget, event: True) content = self.get_content_area() @@ -138,6 +138,11 @@ class WaitDialog(GenericDialog): self.prog.set_fraction(fraction) self.cur_job += 1 + def _on_cancel_clicked(self, button: Gtk.Button) -> None: + # TODO: strings + GLib.idle_add(self.update_text, "Caught cancel signal, cleaning up") + self.controller.set_cancel_event() + def show_cancel(self, state: bool) -> None: self.cancel.set_visible(state) From 5a0ab929852c3f5bbf7cdeaca7ba748c134d503f Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:44:08 +0900 Subject: [PATCH 004/139] chore: add tech debt comment --- dzgui/views/dialogs/servers.py | 1 + 1 file changed, 1 insertion(+) diff --git a/dzgui/views/dialogs/servers.py b/dzgui/views/dialogs/servers.py index 1457ca5..d957158 100644 --- a/dzgui/views/dialogs/servers.py +++ b/dzgui/views/dialogs/servers.py @@ -66,6 +66,7 @@ class ServerDialog(GenericDialog): class ServerDetailsDialog(ServerDialog): def __init__(self, controller: "Controller", details: "Details"): + # TODO: server name should also be packed in details struct name = controller.get_server_name() super().__init__(controller, strings.server_details, name, menu=None) From 4ada06d305def328bdaf865e9bda484639a2c2c5 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:44:47 +0900 Subject: [PATCH 005/139] chore: test html escaping in descriptions --- tests/test_html_escaping.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 tests/test_html_escaping.py diff --git a/tests/test_html_escaping.py b/tests/test_html_escaping.py new file mode 100644 index 0000000..f5c0805 --- /dev/null +++ b/tests/test_html_escaping.py @@ -0,0 +1,30 @@ +from dzgui.api.servers import Details +from dzgui.views.dialogs.servers import ServerDetailsDialog + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk # noqa E402 + + +class MockController: + def __init__(self) -> None: + self.window = Gtk.Window() + + def get_window(self) -> Gtk.Window: + return self.window + + def get_server_name(self) -> str: + return "My server" + + def get_emitter(self) -> None: + return None + + +def test_html_escaping() -> None: + text = " Game & mods" + details = Details([["0", "1"]], text, True) + controller = MockController() + dialog = ServerDetailsDialog(controller, details) + + assert dialog.description.get_text() == text From 095af395f9af54d8efbeeb604f0d044eb8cebf20 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Tue, 11 Aug 2026 22:23:18 +0900 Subject: [PATCH 006/139] feat: dynamic radio sensitivity on steam deck --- dzgui/views/pages/options.py | 87 +++++++++++++++++++++--------------- 1 file changed, 51 insertions(+), 36 deletions(-) diff --git a/dzgui/views/pages/options.py b/dzgui/views/pages/options.py index c8f482a..f17737c 100644 --- a/dzgui/views/pages/options.py +++ b/dzgui/views/pages/options.py @@ -21,6 +21,7 @@ from dzgui.strings import errors, options from dzgui.util import strings, css, open_links from dzgui.views.components.box import VBox +from dzgui.views.components.eventbox import InfoEventBox from dzgui.views.components.labels import LeftLabel from dzgui.views.components.buttons import WebButton from dzgui.views.components.frame import HeadingFrame @@ -39,6 +40,37 @@ if TYPE_CHECKING: from dzgui.controllers.emitter import Emitter +class ToggleField(Gtk.Box): + def __init__( + self, + controller: "Controller", + first_option: str, + second_option: str, + context: Preferences, + ) -> None: + super().__init__(spacing=5, halign=Gtk.Align.START) + + self.controller = controller + self.context = context + self.radio1 = Gtk.RadioButton.new_with_label(None, first_option) + self.radio2 = Gtk.RadioButton.new_from_widget(self.radio1) + self.radio2.set_label(second_option) + self.pack_start(self.radio1, NO_EXPAND, NO_FILL, NO_PADDING) + self.pack_start(self.radio2, NO_EXPAND, NO_FILL, NO_PADDING) + + def set_suboption_active(self, state: bool) -> None: + # NOTE: defer connection of signal until after state is set + self.radio2.set_active(state) + self.radio1.connect("toggled", self._on_radio_toggled, self.context) + + def _on_radio_toggled(self, button: Gtk.RadioButton, context: Preferences) -> None: + self.controller.toggle_config(context) + + def set_sensitive(self, state: bool) -> None: + for el in self.radio1, self.radio2: + el.set_sensitive(state) + + class ShortHBox(Gtk.Box): def __init__(self, widget: Gtk.Widget) -> None: super().__init__(spacing=5, halign=Gtk.Align.START) @@ -80,19 +112,26 @@ class Options(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore # TODO: make submit field a standalone class self.player_box.get_children()[0].set_width_chars(30) # type: ignore - self.fullscreen_toggle = self.make_binary_radio( + self.fullscreen_toggle = ToggleField( + self.controller, strings.options.last_used, strings.options.always_fs, Preferences.WINDOW, ) + # TODO: strings + eb = InfoEventBox( + "This option is not available on Steam Deck.", self.controller + ) + self.fullscreen_toggle.add(eb) + self.client_combo = ClientCombo() self.client_combo.connect("changed", self._on_client_changed) client_hbox = ShortHBox(self.client_combo) - self.distance_toggle = self.make_binary_radio( - strings.options.km, strings.options.mi, Preferences.DIST + self.distance_toggle = ToggleField( + self.controller, strings.options.km, strings.options.mi, Preferences.DIST ) combo_store = Gtk.ListStore(str, object) @@ -293,14 +332,6 @@ class Options(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore 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: - self.controller.toggle_config(context) - except Exception: - button.handler_block_by_func(self._on_radio_toggled) - self.populate_settings() - button.handler_unblock_by_func(self._on_radio_toggled) - def _is_valid_text(self, text: str, context: Preferences) -> bool: if text.isspace(): return False @@ -344,23 +375,6 @@ class Options(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore state = self._is_valid_text(text, context) button.set_sensitive(state) - def make_binary_radio( - self, - first_option: str, - second_option: str, - context: Preferences, - ) -> Gtk.Box: - - hbox = Gtk.Box(spacing=5, halign=Gtk.Align.START) - radio1 = Gtk.RadioButton.new_with_label(None, first_option) - radio2 = Gtk.RadioButton.new_from_widget(radio1) - radio2.set_label(second_option) - radio1.connect("toggled", self._on_radio_toggled, context) - hbox.pack_start(radio1, NO_EXPAND, NO_FILL, NO_PADDING) - hbox.pack_start(radio2, NO_EXPAND, NO_FILL, NO_PADDING) - - return hbox - def populate_settings(self) -> None: prefs = self.controller.get_prefs() # NOTE: re-check in case file was removed by user between runs @@ -386,14 +400,15 @@ class Options(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore if hasattr(p, "set_text"): p.set_text(name) - # NOTE: suppress toggle signal until radios are built - self._suppress_toggles(True) - for el, conf_state in [ - (self.fullscreen_toggle, config["fullscreen"]), - (self.distance_toggle, config["use_miles"]), - ]: - el.get_children()[conf_state].set_active(True) - self._suppress_toggles(False) + fs = config["fullscreen"] + miles = config["use_miles"] + + if prefs.is_steam_deck is False: + self.fullscreen_toggle.set_sensitive(False) + fs = True + + self.fullscreen_toggle.set_suboption_active(fs) + self.distance_toggle.set_suboption_active(miles) # NOTE: disable buttons if no text is set for field in ( From fc28d419bf72e3cdd00a1b289ee82bcf07915a3e Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Tue, 11 Aug 2026 22:23:49 +0900 Subject: [PATCH 007/139] fix: change bool state --- dzgui/views/pages/options.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dzgui/views/pages/options.py b/dzgui/views/pages/options.py index f17737c..91c16c0 100644 --- a/dzgui/views/pages/options.py +++ b/dzgui/views/pages/options.py @@ -403,7 +403,7 @@ class Options(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore fs = config["fullscreen"] miles = config["use_miles"] - if prefs.is_steam_deck is False: + if prefs.is_steam_deck: self.fullscreen_toggle.set_sensitive(False) fs = True From 9d8d37e110644149fbefb1c5b2734072ed9aeb27 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 12 Aug 2026 00:54:14 +0900 Subject: [PATCH 008/139] chore: move ShortHBox to generics --- dzgui/views/components/box.py | 7 +++++++ dzgui/views/pages/options.py | 9 +-------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/dzgui/views/components/box.py b/dzgui/views/components/box.py index be64f82..4e1772f 100644 --- a/dzgui/views/components/box.py +++ b/dzgui/views/components/box.py @@ -23,3 +23,10 @@ class HBox(GenericBox): class VBox(GenericBox): def __init__(self, spacing: int = 0) -> None: super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=spacing) + +class ShortHBox(HBox): + def __init__(self, widget: Gtk.Widget) -> None: + super().__init__(spacing=5) + + self.set_halign(Gtk.Align.START) + self.pack_start(widget, expand=False, fill=False, padding=0) diff --git a/dzgui/views/pages/options.py b/dzgui/views/pages/options.py index 91c16c0..ff97e2e 100644 --- a/dzgui/views/pages/options.py +++ b/dzgui/views/pages/options.py @@ -20,7 +20,7 @@ from dzgui.const.enum import Preferences, ServerTab from dzgui.strings import errors, options from dzgui.util import strings, css, open_links -from dzgui.views.components.box import VBox +from dzgui.views.components.box import ShortHBox, VBox from dzgui.views.components.eventbox import InfoEventBox from dzgui.views.components.labels import LeftLabel from dzgui.views.components.buttons import WebButton @@ -71,13 +71,6 @@ class ToggleField(Gtk.Box): el.set_sensitive(state) -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(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore def __init__(self, controller: "Controller"): super().__init__( From 2413dd1f147f24f8dbbb36c25a346b4605ee8185 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 12 Aug 2026 05:03:36 +0900 Subject: [PATCH 009/139] feat: dedicated ignore dialog --- dzgui/views/dialogs/early_alert.py | 31 +++++++++++++++++++----------- dzgui/views/dialogs/generic.py | 1 + 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/dzgui/views/dialogs/early_alert.py b/dzgui/views/dialogs/early_alert.py index 1f911c7..4328590 100644 --- a/dzgui/views/dialogs/early_alert.py +++ b/dzgui/views/dialogs/early_alert.py @@ -1,5 +1,6 @@ import textwrap import sys +from typing import TYPE_CHECKING from typing import Self from dzgui.util.strings import dialog_error, dialog_header @@ -9,14 +10,17 @@ import gi gi.require_version("Gtk", "3.0") from gi.repository import Gtk # noqa E402 +if TYPE_CHECKING: + from gi.repository import Gdk -class EarlyAlertDialog(Gtk.MessageDialog): - def __init__(self, string: str) -> None: + +class AbortDialog(Gtk.MessageDialog): + def __init__(self, string: str, buttons: Gtk.ButtonsType) -> None: super().__init__( title=dialog_header, text=dialog_error, transient_for=None, - buttons=Gtk.ButtonsType.OK, + buttons=buttons, ) msg = textwrap.fill(string, 50) @@ -24,7 +28,8 @@ class EarlyAlertDialog(Gtk.MessageDialog): aa = self.get_action_area() aa.set_margin_bottom(20) - # self.action_area.set_margin_bottom(20) + aa.set_layout(Gtk.ButtonBoxStyle.CENTER) + self.outer = self.get_content_area() self.outer.set_margin_start(30) self.outer.set_margin_end(30) @@ -32,26 +37,30 @@ class EarlyAlertDialog(Gtk.MessageDialog): self.set_default_size(250, 100) abort = self.get_widget_for_response(Gtk.ResponseType.OK) + ignore = self.get_widget_for_response(Gtk.ResponseType.CANCEL) if abort is not None and hasattr(abort, "set_label"): abort.set_label("Exit") + if ignore is not None and hasattr(ignore, "set_label"): + ignore.set_label("Ignore") self.connect("response", self._on_response) + self.run() self.destroy() def _on_response(self, dialog: Self, response: Gtk.ResponseType) -> None: match response: - case Gtk.ResponseType.OK: + case Gtk.ResponseType.OK | Gtk.ResponseType.DELETE_EVENT: sys.exit(1) case Gtk.ResponseType.CANCEL: + print("response was cancel") return +class EarlyAlertDialog(AbortDialog): + def __init__(self, string: str, buttons: Gtk.ButtonsType) -> None: + super().__init__(string=string, buttons=Gtk.ButtonsType.OK) -class EarlyIgnoreDialog(EarlyAlertDialog): +class EarlyIgnoreDialog(AbortDialog): def __init__(self, string: str) -> None: - super().__init__(string=string) + super().__init__(string=string, buttons=Gtk.ButtonsType.OK_CANCEL) - # TODO: reverse order - self.add_button("Ignore", Gtk.ResponseType.CANCEL) - - # TODO: if exit, sys.exit(1), else pass diff --git a/dzgui/views/dialogs/generic.py b/dzgui/views/dialogs/generic.py index c890589..c9b890c 100644 --- a/dzgui/views/dialogs/generic.py +++ b/dzgui/views/dialogs/generic.py @@ -161,6 +161,7 @@ class QuitDialog(GenericDialog): self.add_button(strings.exit_app, Gtk.ResponseType.OK) self.connect("response", self._on_response) + # TODO: superfluous with the above self.connect("delete-event", self._on_response) def _on_response(self, dialog: Self, response: Gtk.ResponseType) -> None: From be6ab2c9cb41dc00cd75a97b8ff661d60435060b Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 12 Aug 2026 05:04:06 +0900 Subject: [PATCH 010/139] feat: test and set map count --- dzgui/util/map_count.py | 60 ++++++++++++++++++++++++++++++++--------- 1 file changed, 48 insertions(+), 12 deletions(-) diff --git a/dzgui/util/map_count.py b/dzgui/util/map_count.py index 80fff84..15e1865 100644 --- a/dzgui/util/map_count.py +++ b/dzgui/util/map_count.py @@ -1,7 +1,20 @@ import subprocess +import sys +import tempfile from pathlib import Path from dzgui.const.constants import VM_FILE, MIN_COUNT +from dzgui.util.bash import concat_bash_args +from dzgui.views.dialogs.early_alert import EarlyIgnoreDialog + + +def is_map_count_valid() -> bool: + count = get_map_count() + if count is None: + # NOTE: permit if count was unreadable + return True + return count >= MIN_COUNT + def get_map_count() -> int | None: path = Path(VM_FILE) @@ -10,19 +23,42 @@ def get_map_count() -> int | None: count = int(path.read_text()) return count - # TODO: unfinished - #if count < MIN_COUNT: - # print("needs sudo escalation") - # # pop prompt - # # get response - # set_map_count() - #return count -# TODO: unfinished +def test_map_count() -> None: + if is_map_count_valid(): + return + msg = ( + "System map count is not high enough to run DayZ.\n" + "Please exit and run 'dzgui -m' to update map count." + ) + EarlyIgnoreDialog(msg) + + def set_map_count() -> None: + valid = is_map_count_valid() + if valid is None: + return + elif valid: + print("System map count already meets the minimum.") + return + conf = "/etc/sysctl.d/dayz.conf" count = f"vm.max_map_count={MIN_COUNT}" - with open(conf, "w") as f: - f.write(count) - # TODO: use concat_bash_args() - subprocess.run(["/usr/bin/sudo", "sysctl", "-p", conf]) + try: + msg = ( + f"Updated map count will be written to the file '{conf}'.\n" + "Enter sudo password to proceed." + ) + print(msg) + with tempfile.NamedTemporaryFile(delete=False) as f: + tmp = f.name + Path(tmp).write_text(count) + args = concat_bash_args(f"sudo mv {tmp} {conf}") + subprocess.run([*args]) + args = concat_bash_args(f"sudo sysctl -p {conf}") + subprocess.run([*args]) + except Exception as e: + print(e) + except KeyboardInterrupt: + print("User exit") + sys.exit(0) From ba9d6e1d3b47594916fce7b15d078d3014bf041d Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 12 Aug 2026 05:04:16 +0900 Subject: [PATCH 011/139] chore: update strings --- dzgui/util/strings.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/dzgui/util/strings.py b/dzgui/util/strings.py index d87394a..6a06416 100644 --- a/dzgui/util/strings.py +++ b/dzgui/util/strings.py @@ -467,13 +467,15 @@ class Flags: version: str uninstall: str debug: str + map_count: str flags = Flags( description="DayZ server browser and mod manager", version="Print version information", - uninstall="Uninstall data files (use prior to 'pip uninstall')", - debug="Enables developer debugging features", + uninstall="Clean up state/config files", + debug="Enable developer debugging features", + map_count="Check and update system map count value" ) From c2fc9b2ccaa0c37b6a328e98c4e118319c6d2ac7 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 12 Aug 2026 05:04:28 +0900 Subject: [PATCH 012/139] feat: test map count at boot --- dzgui/main.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/dzgui/main.py b/dzgui/main.py index 2deeba5..0e1c127 100644 --- a/dzgui/main.py +++ b/dzgui/main.py @@ -5,12 +5,14 @@ import warnings from dzgui.const.constants import APP_NAME from dzgui.init.libgi import test_libgi_missing from dzgui.init.prefix import get_version +from dzgui.util.map_count import set_map_count, test_map_count from dzgui.util.strings import flags 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) +parser.add_argument("-m", "--map", action="store_true", help=flags.map_count) args = parser.parse_args() @@ -24,6 +26,7 @@ def uninstall() -> None: def main() -> None: + # TODO: isolate single flags if args.version is True: print(get_version()) sys.exit(0) @@ -32,7 +35,11 @@ def main() -> None: sys.exit(0) if args.debug is True: warnings.filterwarnings("default", category=DeprecationWarning) + if args.map is True: + set_map_count() + sys.exit(0) + test_map_count() version = get_version() print(f"{APP_NAME} {version}") From c5b41346caa4fa52555bb303dee9ab145635aa09 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 12 Aug 2026 05:04:47 +0900 Subject: [PATCH 013/139] chore: drop unused import --- dzgui/app_init.py | 1 - 1 file changed, 1 deletion(-) diff --git a/dzgui/app_init.py b/dzgui/app_init.py index 8a74466..8ea21ef 100644 --- a/dzgui/app_init.py +++ b/dzgui/app_init.py @@ -18,7 +18,6 @@ from dzgui.init.flock import lock_acquire from dzgui.init.prereqs import has_steam_client 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 from dzgui.util.dirs import make_parents from dzgui.util.localize import set_locale From 22c82677cb68ca771ec139e9270b03dbf313b1cb Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 12 Aug 2026 05:05:18 +0900 Subject: [PATCH 014/139] chore: drop unused import --- dzgui/views/dialogs/early_alert.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/dzgui/views/dialogs/early_alert.py b/dzgui/views/dialogs/early_alert.py index 4328590..52384f1 100644 --- a/dzgui/views/dialogs/early_alert.py +++ b/dzgui/views/dialogs/early_alert.py @@ -10,10 +10,6 @@ import gi gi.require_version("Gtk", "3.0") from gi.repository import Gtk # noqa E402 -if TYPE_CHECKING: - from gi.repository import Gdk - - class AbortDialog(Gtk.MessageDialog): def __init__(self, string: str, buttons: Gtk.ButtonsType) -> None: super().__init__( From a1c0592c7d856caed6a321780032eebc31b65804 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 12 Aug 2026 05:05:28 +0900 Subject: [PATCH 015/139] chore: drop unused import --- dzgui/views/dialogs/early_alert.py | 1 - 1 file changed, 1 deletion(-) diff --git a/dzgui/views/dialogs/early_alert.py b/dzgui/views/dialogs/early_alert.py index 52384f1..6e0fa73 100644 --- a/dzgui/views/dialogs/early_alert.py +++ b/dzgui/views/dialogs/early_alert.py @@ -1,6 +1,5 @@ import textwrap import sys -from typing import TYPE_CHECKING from typing import Self from dzgui.util.strings import dialog_error, dialog_header From 65e11ddd0384b3800ffd5c50039bb3e12fb54753 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 12 Aug 2026 05:07:01 +0900 Subject: [PATCH 016/139] chore: clear typehinting errors --- dzgui/views/dialogs/early_alert.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/dzgui/views/dialogs/early_alert.py b/dzgui/views/dialogs/early_alert.py index 6e0fa73..960f53a 100644 --- a/dzgui/views/dialogs/early_alert.py +++ b/dzgui/views/dialogs/early_alert.py @@ -9,6 +9,7 @@ import gi gi.require_version("Gtk", "3.0") from gi.repository import Gtk # noqa E402 + class AbortDialog(Gtk.MessageDialog): def __init__(self, string: str, buttons: Gtk.ButtonsType) -> None: super().__init__( @@ -23,7 +24,7 @@ class AbortDialog(Gtk.MessageDialog): aa = self.get_action_area() aa.set_margin_bottom(20) - aa.set_layout(Gtk.ButtonBoxStyle.CENTER) + aa.set_layout(Gtk.ButtonBoxStyle.CENTER) # type: ignore self.outer = self.get_content_area() self.outer.set_margin_start(30) @@ -51,11 +52,12 @@ class AbortDialog(Gtk.MessageDialog): print("response was cancel") return + class EarlyAlertDialog(AbortDialog): - def __init__(self, string: str, buttons: Gtk.ButtonsType) -> None: + def __init__(self, string: str) -> None: super().__init__(string=string, buttons=Gtk.ButtonsType.OK) + class EarlyIgnoreDialog(AbortDialog): def __init__(self, string: str) -> None: super().__init__(string=string, buttons=Gtk.ButtonsType.OK_CANCEL) - From 1c346a6c3cb83e625e3362441f489501b76f2f40 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 13 Aug 2026 13:22:05 +0900 Subject: [PATCH 017/139] chore: change sort order of CLI args --- dzgui/main.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dzgui/main.py b/dzgui/main.py index 0e1c127..e579017 100644 --- a/dzgui/main.py +++ b/dzgui/main.py @@ -9,10 +9,10 @@ from dzgui.util.map_count import set_map_count, test_map_count from dzgui.util.strings import flags 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) parser.add_argument("-m", "--map", action="store_true", help=flags.map_count) +parser.add_argument("-u", "--uninstall", action="store_true", help=flags.uninstall) +parser.add_argument("-v", "--version", action="store_true", help=flags.version) args = parser.parse_args() From 314fed559c2b39a5a765a9896f1cc01a9f76aa26 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 13 Aug 2026 13:22:41 +0900 Subject: [PATCH 018/139] chore: drop unused log file metadata --- dzgui/util/diag.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/dzgui/util/diag.py b/dzgui/util/diag.py index 354234f..97423da 100644 --- a/dzgui/util/diag.py +++ b/dzgui/util/diag.py @@ -54,8 +54,6 @@ def write_diagnostic(config: Path, outfile: Path) -> None: cpu = get_cpu_model() version = get_version() - debug = lookup(config, Preferences.DEBUG) - install = lookup(config, Preferences.INSTALL) default = lookup(config, Preferences.DEFAULT) steam_path = Path(default) @@ -79,8 +77,6 @@ def write_diagnostic(config: Path, outfile: Path) -> None: Kernel: {kernel} CPU: {cpu} - Debug: {debug} - Auto-install: {install} Steam path: {steam_redacted} Workshop path: {workshop_redacted} From c73864d545496d57bfbeb03b311b443a9dc76bc4 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 13 Aug 2026 13:23:29 +0900 Subject: [PATCH 019/139] fix: unreachable code --- dzgui/util/map_count.py | 49 +++++++++++++++++++++++------------------ 1 file changed, 28 insertions(+), 21 deletions(-) diff --git a/dzgui/util/map_count.py b/dzgui/util/map_count.py index 15e1865..280cb2b 100644 --- a/dzgui/util/map_count.py +++ b/dzgui/util/map_count.py @@ -1,15 +1,19 @@ +import logging import subprocess import sys import tempfile +import traceback from pathlib import Path -from dzgui.const.constants import VM_FILE, MIN_COUNT +from dzgui.const.constants import APP_NAME, VM_FILE, MIN_COUNT from dzgui.util.bash import concat_bash_args +from dzgui.strings import map_count from dzgui.views.dialogs.early_alert import EarlyIgnoreDialog +logger = logging.getLogger(APP_NAME) -def is_map_count_valid() -> bool: - count = get_map_count() + +def is_map_count_valid(count: int | None) -> bool: if count is None: # NOTE: permit if count was unreadable return True @@ -25,40 +29,43 @@ def get_map_count() -> int | None: def test_map_count() -> None: - if is_map_count_valid(): + count = get_map_count() + if is_map_count_valid(count): return - msg = ( - "System map count is not high enough to run DayZ.\n" - "Please exit and run 'dzgui -m' to update map count." - ) + msg = map_count.exit_msg EarlyIgnoreDialog(msg) def set_map_count() -> None: - valid = is_map_count_valid() - if valid is None: + count = get_map_count() + valid = is_map_count_valid(count) + if count is None: + print(map_count.failed_to_parse) return elif valid: - print("System map count already meets the minimum.") + msg = map_count.meets_minimum.format(count) + print(msg) return conf = "/etc/sysctl.d/dayz.conf" count = f"vm.max_map_count={MIN_COUNT}" try: - msg = ( - f"Updated map count will be written to the file '{conf}'.\n" - "Enter sudo password to proceed." - ) + 1/0 + msg = map_count.prompt.format(conf) print(msg) with tempfile.NamedTemporaryFile(delete=False) as f: tmp = f.name Path(tmp).write_text(count) - args = concat_bash_args(f"sudo mv {tmp} {conf}") - subprocess.run([*args]) - args = concat_bash_args(f"sudo sysctl -p {conf}") - subprocess.run([*args]) + + mv_cmd = f"sudo mv {tmp} {conf}" + reload_cmd = f"sudo sysctl -p {conf}" + for cmd in mv_cmd, reload_cmd: + args = concat_bash_args(cmd) + subprocess.run([*args]) except Exception as e: - print(e) + logger.debug(e) + trace = traceback.format_exc() + print(map_count.failed_to_update.format(trace)) except KeyboardInterrupt: - print("User exit") + print(map_count.user_exit) sys.exit(0) From b46d0614c9f4e03fece6acf2183edd012f1439be Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 13 Aug 2026 13:24:10 +0900 Subject: [PATCH 020/139] chore: add strings file --- dzgui/strings/map_count.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 dzgui/strings/map_count.py diff --git a/dzgui/strings/map_count.py b/dzgui/strings/map_count.py new file mode 100644 index 0000000..d97f4c4 --- /dev/null +++ b/dzgui/strings/map_count.py @@ -0,0 +1,19 @@ +exit_msg = ( + "System map count is not high enough to run DayZ. " + "Please exit and run 'dzgui -m' to update map count." +) +failed_to_parse = ( + "Failed to parse system map count.\n" + "This usually indicates that systemd is not installed." +) +failed_to_update = ( + "Failed to update system map count.\n" + "Please report the issue upstream and provide the following traceback.\n\n" + "{0}" +) +meets_minimum = "System map count of {0} already meets the minimum." +prompt = ( + "Updated map count will be written to the file {0}.\n" + "Enter sudo password to proceed." +) +user_exit = "User exit" From b61e806009d43faac9d6ae49a1af0b34e87eeb8a Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 13 Aug 2026 13:24:26 +0900 Subject: [PATCH 021/139] chore: lowercase launch flags for conformity with parser --- dzgui/util/strings.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/dzgui/util/strings.py b/dzgui/util/strings.py index 6a06416..696b2c0 100644 --- a/dzgui/util/strings.py +++ b/dzgui/util/strings.py @@ -472,10 +472,10 @@ class Flags: flags = Flags( description="DayZ server browser and mod manager", - version="Print version information", - uninstall="Clean up state/config files", - debug="Enable developer debugging features", - map_count="Check and update system map count value" + version="print version information", + uninstall="clean up state/config files", + debug="enable developer debugging features", + map_count="check and update system map count value", ) @@ -543,9 +543,7 @@ connect_panel = ConnectPanel( add="Add", add_con="Add/connect", placeholder="Enter IP (IP:Query port)", - entry_tooltip=( - "- IP: format as IP:Query port\ne.g. 192.168.1.1:27016" - ), + entry_tooltip=("- IP: format as IP:Query port\ne.g. 192.168.1.1:27016"), add_tooltip="Add to Saved Servers", connect_tooltip="Connect to this server", ) From 90b1c875dff3cd7ca745a185aa8dfb1ce9c7f848 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 13 Aug 2026 13:24:48 +0900 Subject: [PATCH 022/139] fix: center label in message area --- dzgui/views/dialogs/early_alert.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/dzgui/views/dialogs/early_alert.py b/dzgui/views/dialogs/early_alert.py index 960f53a..1ef51f9 100644 --- a/dzgui/views/dialogs/early_alert.py +++ b/dzgui/views/dialogs/early_alert.py @@ -22,6 +22,10 @@ class AbortDialog(Gtk.MessageDialog): msg = textwrap.fill(string, 50) self.format_secondary_text(msg) + ma = self.get_message_area() + label = ma.get_children()[1] + label.set_justify(Gtk.Justification.CENTER) + aa = self.get_action_area() aa.set_margin_bottom(20) aa.set_layout(Gtk.ButtonBoxStyle.CENTER) # type: ignore From 7994bf988c1c8600761188f9d0e896323f93c606 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 13 Aug 2026 13:27:40 +0900 Subject: [PATCH 023/139] chore: clear typehinting errors --- dzgui/managers/filter.py | 6 +++--- dzgui/util/map_count.py | 6 +++--- dzgui/views/components/filter_panel.py | 16 +++++++--------- dzgui/views/dialogs/early_alert.py | 2 +- 4 files changed, 14 insertions(+), 16 deletions(-) diff --git a/dzgui/managers/filter.py b/dzgui/managers/filter.py index b4c02e5..634bf8b 100644 --- a/dzgui/managers/filter.py +++ b/dzgui/managers/filter.py @@ -21,11 +21,11 @@ class FilterManager: strings.filter_3pp: True, strings.filter_night: True, strings.filter_full: show_full, - strings.filter_lowpop: True, - strings.filter_nonascii: False, - strings.filter_duplicate: False, strings.filter_official: True, + strings.filter_nonascii: False, + strings.filter_lowpop: True, strings.filter_unofficial: True, + strings.filter_duplicate: False, strings.filter_modded: True, } diff --git a/dzgui/util/map_count.py b/dzgui/util/map_count.py index 280cb2b..41897fa 100644 --- a/dzgui/util/map_count.py +++ b/dzgui/util/map_count.py @@ -48,14 +48,14 @@ def set_map_count() -> None: return conf = "/etc/sysctl.d/dayz.conf" - count = f"vm.max_map_count={MIN_COUNT}" + value = f"vm.max_map_count={MIN_COUNT}" try: - 1/0 + 1 / 0 msg = map_count.prompt.format(conf) print(msg) with tempfile.NamedTemporaryFile(delete=False) as f: tmp = f.name - Path(tmp).write_text(count) + Path(tmp).write_text(value) mv_cmd = f"sudo mv {tmp} {conf}" reload_cmd = f"sudo sysctl -p {conf}" diff --git a/dzgui/views/components/filter_panel.py b/dzgui/views/components/filter_panel.py index c79ee0c..4554cf8 100644 --- a/dzgui/views/components/filter_panel.py +++ b/dzgui/views/components/filter_panel.py @@ -33,15 +33,15 @@ class ButtonGrid(Gtk.Grid): 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 + flowbox = Gtk.FlowBox( + halign=Gtk.Align.CENTER, min_children_per_line=3, max_children_per_line=3 + ) + for check in defaults.keys(): checkbox = Gtk.CheckButton(label=check) label = checkbox.get_child() @@ -51,14 +51,12 @@ class ButtonGrid(Gtk.Grid): if defaults[check]: checkbox.set_active(True) - col = col + 1 - if col > 3: - row += 1 - col = 1 - self.attach(checkbox, col, row, 1, 1) checkbox.connect("toggled", self._on_check_toggled) + flowbox.add(checkbox) self.checks.append(checkbox) + self.add(flowbox) + def block_toggles(self, state: bool) -> None: for check in self.checks: self.controller.suppress_signal( diff --git a/dzgui/views/dialogs/early_alert.py b/dzgui/views/dialogs/early_alert.py index 1ef51f9..9e034ba 100644 --- a/dzgui/views/dialogs/early_alert.py +++ b/dzgui/views/dialogs/early_alert.py @@ -23,7 +23,7 @@ class AbortDialog(Gtk.MessageDialog): self.format_secondary_text(msg) ma = self.get_message_area() - label = ma.get_children()[1] + label = ma.get_children()[1] # type: ignore label.set_justify(Gtk.Justification.CENTER) aa = self.get_action_area() From e59b7e01fd0b2d5871bdb5226085099345926c21 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 13 Aug 2026 13:31:05 +0900 Subject: [PATCH 024/139] chore: drop debug message --- dzgui/views/dialogs/early_alert.py | 1 - 1 file changed, 1 deletion(-) diff --git a/dzgui/views/dialogs/early_alert.py b/dzgui/views/dialogs/early_alert.py index 9e034ba..e55c76b 100644 --- a/dzgui/views/dialogs/early_alert.py +++ b/dzgui/views/dialogs/early_alert.py @@ -53,7 +53,6 @@ class AbortDialog(Gtk.MessageDialog): case Gtk.ResponseType.OK | Gtk.ResponseType.DELETE_EVENT: sys.exit(1) case Gtk.ResponseType.CANCEL: - print("response was cancel") return From e5bb25cd600201aa48428d693f566678349e46b4 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 13 Aug 2026 13:32:08 +0900 Subject: [PATCH 025/139] chore: drop forced exception test --- dzgui/util/map_count.py | 1 - 1 file changed, 1 deletion(-) diff --git a/dzgui/util/map_count.py b/dzgui/util/map_count.py index 41897fa..de135f8 100644 --- a/dzgui/util/map_count.py +++ b/dzgui/util/map_count.py @@ -50,7 +50,6 @@ def set_map_count() -> None: conf = "/etc/sysctl.d/dayz.conf" value = f"vm.max_map_count={MIN_COUNT}" try: - 1 / 0 msg = map_count.prompt.format(conf) print(msg) with tempfile.NamedTemporaryFile(delete=False) as f: From b31896387abfa01c8c0cc1d24785ca94d264f23f Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 13 Aug 2026 13:37:21 +0900 Subject: [PATCH 026/139] chore: simplify strings --- dzgui/strings/map_count.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/dzgui/strings/map_count.py b/dzgui/strings/map_count.py index d97f4c4..b1c255d 100644 --- a/dzgui/strings/map_count.py +++ b/dzgui/strings/map_count.py @@ -12,8 +12,5 @@ failed_to_update = ( "{0}" ) meets_minimum = "System map count of {0} already meets the minimum." -prompt = ( - "Updated map count will be written to the file {0}.\n" - "Enter sudo password to proceed." -) +prompt = "Updated map count will be written to the file {0}." user_exit = "User exit" From 120703e702abfe620f0062a94cb6f3d6e59fe43b Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 13 Aug 2026 13:37:47 +0900 Subject: [PATCH 027/139] chore: move map count test to pre-config --- dzgui/app_init.py | 2 ++ dzgui/main.py | 3 +-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/dzgui/app_init.py b/dzgui/app_init.py index 8ea21ef..8a3999f 100644 --- a/dzgui/app_init.py +++ b/dzgui/app_init.py @@ -21,6 +21,7 @@ from dzgui.strings import boot from dzgui.util.deck import is_steam_deck, is_game_mode from dzgui.util.dirs import make_parents from dzgui.util.localize import set_locale +from dzgui.util.map_count import test_map_count from dzgui.util.redact import RedactionFilter, REDACTION_PATTERNS from dzgui.util.strings import init @@ -118,6 +119,7 @@ def load_gui(version: str, is_debug: bool) -> None: del os.environ["GTK_IM_MODULE"] if has_new_config(XDG.config) is False: + test_map_count() migrate_cols_file(XDG.columns) copy_state_files(xdg_paths["XDG_STATE_HOME"]) # TODO: add logging inside wizard diff --git a/dzgui/main.py b/dzgui/main.py index e579017..731f58e 100644 --- a/dzgui/main.py +++ b/dzgui/main.py @@ -5,7 +5,7 @@ import warnings from dzgui.const.constants import APP_NAME from dzgui.init.libgi import test_libgi_missing from dzgui.init.prefix import get_version -from dzgui.util.map_count import set_map_count, test_map_count +from dzgui.util.map_count import set_map_count from dzgui.util.strings import flags parser = argparse.ArgumentParser(description=flags.description) @@ -39,7 +39,6 @@ def main() -> None: set_map_count() sys.exit(0) - test_map_count() version = get_version() print(f"{APP_NAME} {version}") From 5532e4e267e4dbb2bee05e95180d981572255f30 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 13 Aug 2026 13:42:51 +0900 Subject: [PATCH 028/139] chore: simplify control flow --- dzgui/util/map_count.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/dzgui/util/map_count.py b/dzgui/util/map_count.py index de135f8..9adc00e 100644 --- a/dzgui/util/map_count.py +++ b/dzgui/util/map_count.py @@ -22,9 +22,11 @@ def is_map_count_valid(count: int | None) -> bool: def get_map_count() -> int | None: path = Path(VM_FILE) - if path.is_file() is False: + try: + count = int(path.read_text()) + except Exception as e: + logger.debug(e) return None - count = int(path.read_text()) return count @@ -38,11 +40,10 @@ def test_map_count() -> None: def set_map_count() -> None: count = get_map_count() - valid = is_map_count_valid(count) if count is None: print(map_count.failed_to_parse) return - elif valid: + if is_map_count_valid(count): msg = map_count.meets_minimum.format(count) print(msg) return From e9ce292368624bcf14290bcef4d8268b8c10fceb Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Fri, 14 Aug 2026 02:42:23 +0900 Subject: [PATCH 029/139] chore: clean up structure of panel strings and tooltips --- dzgui/strings/connect_panel.py | 7 +-- dzgui/util/strings.py | 76 ------------------------------- dzgui/views/components/buttons.py | 6 +-- dzgui/views/components/entry.py | 13 ++---- 4 files changed, 9 insertions(+), 93 deletions(-) diff --git a/dzgui/strings/connect_panel.py b/dzgui/strings/connect_panel.py index 870624f..94d9e7d 100644 --- a/dzgui/strings/connect_panel.py +++ b/dzgui/strings/connect_panel.py @@ -4,10 +4,7 @@ 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" -) -connect_entry_placeholder="Enter IP (IP:Query port)" +connect_entry_placeholder="Enter IP:Query port (e.g., 192.168.1.1:27016)" fav_heading="Favorite server" favs_empty="None set. Right click a server and select 'Set favorite' to set." @@ -18,6 +15,6 @@ 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_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 696b2c0..9056200 100644 --- a/dzgui/util/strings.py +++ b/dzgui/util/strings.py @@ -1,27 +1,6 @@ from dataclasses import dataclass from dzgui.const.constants import SYSTEM_LOG -# TODO: move to util.format.py - - -def build_missing(build: str) -> str: - msg = ( - f"This server is running {build}. You can install " - f"{build} by searching for it in your Steam library. " - f"If you recently installed {build} or moved it to a different drive, " - "restart Steam to allow these changes to synchronize, then try again." - ) - return msg - - -def build_path_invalid(build: str) -> str: - msg = ( - f"Steam is reporting that {build} is installed at a non-existent location. " - f"If you recently installed {build} or moved it to a different drive, " - "restart Steam to allow these changes to synchronize, then try again." - ) - return msg - # General dialog_header = "DZGUI - Dialog" @@ -527,61 +506,6 @@ server_labels = ServerLabels( ) -@dataclass(slots=True, frozen=True) -class ConnectPanel: - connect: str - add: str - add_con: str - placeholder: str - entry_tooltip: str - add_tooltip: str - connect_tooltip: str - - -connect_panel = ConnectPanel( - connect="Connect", - add="Add", - add_con="Add/connect", - placeholder="Enter IP (IP:Query port)", - entry_tooltip=("- IP: format as IP:Query port\ne.g. 192.168.1.1:27016"), - add_tooltip="Add to Saved 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 - scan_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", - scan_tooltip="Scan for servers", -) - distance_suffix = "Distance: calculating..." dialog_error = "ERROR" diff --git a/dzgui/views/components/buttons.py b/dzgui/views/components/buttons.py index c940b7f..8071ed7 100644 --- a/dzgui/views/components/buttons.py +++ b/dzgui/views/components/buttons.py @@ -6,7 +6,6 @@ from dzgui.strings import buttons from dzgui.util.strings import ( alert_button_tooltip, atomic_buttons, - connect_panel, ) from dzgui.const.constants import ( CLIPBOARD, @@ -18,6 +17,7 @@ from dzgui.const.constants import ( WARNING, WEB_BROWSER, ) +from dzgui.strings import connect_panel import gi @@ -222,7 +222,7 @@ class KeysButton(IconTextButton): class SteamConnectButton(LargeIconTextButton): def __init__(self) -> None: - super().__init__(icon=STEAM_ICON, label=connect_panel.connect) + super().__init__(icon=STEAM_ICON, label=connect_panel.connect_button) self.set_tooltip_text(connect_panel.connect_tooltip) @@ -242,7 +242,7 @@ class SteamWorkshopButton(SteamTextButton): class AddButton(IconTextButton): def __init__(self) -> None: - super().__init__(icon=LIST_ADD, label=connect_panel.add) + super().__init__(icon=LIST_ADD, label=connect_panel.add_button) self.set_tooltip_text(connect_panel.add_tooltip) diff --git a/dzgui/views/components/entry.py b/dzgui/views/components/entry.py index 131b457..e1b3e7e 100644 --- a/dzgui/views/components/entry.py +++ b/dzgui/views/components/entry.py @@ -3,7 +3,7 @@ from typing import Any, Callable, TYPE_CHECKING from dzgui.api.servers import validate_ip from dzgui.const.constants import VIEW_CONCEAL, VIEW_REVEAL from dzgui.util.css import add_class, remove_class -from dzgui.util.strings import connect_panel, lan_panel +from dzgui.strings import connect_panel from dzgui.strings.errors import api_popover import gi @@ -122,11 +122,8 @@ class IpEntry(ValidatedEntry): super().__init__( controller, func=validate_ip_truthy, - placeholder_text=connect_panel.placeholder, - tooltip_text=connect_panel.entry_tooltip, + placeholder_text=connect_panel.connect_entry_placeholder, ) - self.set_placeholder_text(connect_panel.placeholder) - self.set_tooltip_text(connect_panel.entry_tooltip) class PortEntry(ValidatedEntry): @@ -134,11 +131,9 @@ class PortEntry(ValidatedEntry): super().__init__( controller, func=validate_port, - placeholder_text=connect_panel.placeholder, - tooltip_text=connect_panel.entry_tooltip, + placeholder_text=connect_panel.lan_entry_placeholder, + tooltip_text=connect_panel.lan_entry_tooltip, ) - self.set_placeholder_text(lan_panel.placeholder) - self.set_tooltip_text(lan_panel.entry_tooltip) # TODO: backport to Options page From 22294d71254d4e66cb6628788f2e8ddd6036db16 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Fri, 14 Aug 2026 02:49:46 +0900 Subject: [PATCH 030/139] chore: format strings --- dzgui/views/pages/preconnect.py | 57 ++++++++++++--------------------- 1 file changed, 20 insertions(+), 37 deletions(-) diff --git a/dzgui/views/pages/preconnect.py b/dzgui/views/pages/preconnect.py index 47adfb6..2e07de0 100644 --- a/dzgui/views/pages/preconnect.py +++ b/dzgui/views/pages/preconnect.py @@ -150,8 +150,7 @@ class PreConnectionAssistant(Gtk.Box): self.tree_box.add(self.scrolled) self.tree_box.add(self.progress_box) - # TODO: strings - self.mods_placeholder = Placeholder("This server has no mods.") + self.mods_placeholder = Placeholder(preconnect.placeholder_no_mods) self.tree_box.add(self.mods_placeholder) self.tree_frame = HeadingFrame.new_with_widget_and_label( @@ -161,8 +160,7 @@ class PreConnectionAssistant(Gtk.Box): # TODO: abstract into components self.warning_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) self.warning_tree = MaskedTree(WARNING) - # TODO: strings - self.warning_placeholder = Placeholder("No warnings.") + self.warning_placeholder = Placeholder(preconnect.placeholder_no_warnings) self.warning_box.add(self.warning_tree) self.warning_box.add(self.warning_placeholder) self.warning_frame = HeadingFrame.new_with_widget_and_label( @@ -233,61 +231,46 @@ class PreConnectionAssistant(Gtk.Box): 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." - ) + resync_msg = preconnect.resync.format(prereqs.build) """Errors""" if len(prereqs.invalid_mods) > 0: pairs = [": ".join(sub) for sub in prereqs.invalid_mods] lines = "\n".join(pairs) - msg = ( - "Server has invalid mods that are not recognized by Steam.\n" - "Contact the server owner and include these mod IDs in your report:\n" - f"{lines}" - ) + msg = preconnect.invalid_mods.format(lines) errors.append(msg) if prereqs.binary_missing: - errors.append( - f"Remote server is running the build '{prereqs.build}', but it is not installed.\n{resync_msg}" - ) + msg = preconnect.version_missing.format(prereqs.build) + msg += f"\n{resync_msg}" + errors.append(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}'.\n{resync_msg}" + msg = preconnect.version_mismatch.format( + prereqs.local_version, prereqs.remote_version ) + msg += f"\n{resync_msg}" + errors.append(msg) 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." - ) + msg = preconnect.not_enough_space.format(required_pretty, available_pretty) + errors.append(msg) if len(prereqs.mods) > 0 and prereqs.game_mode: - errors.append("Use Desktop Mode to download mods on Steam Deck") + errors.append(preconnect.use_desktop_mode) 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 is either not installed or not running." - ) + msg = preconnect.steam_not_running.format(client) + errors.append(msg) """Warnings""" if prereqs.passworded: - warnings.append( - "Protected: you will be prompted for a password when connecting to this server." - ) + warnings.append(preconnect.protected_server) if prereqs.dayz_running is True: - warnings.append( - "It looks like DayZ is already running in the background. Exit DayZ before connecting." - ) + warnings.append(preconnect.dayz_running) allows_dl, running_app = prereqs.allows_downloads if len(prereqs.mods) > 0 and allows_dl is False: - msg = ( - f"The app '{running_app}' is currently running in Steam, but background downloads are not enabled.\n" - "Either stop the game first, or update your global Steam settings or the game's local settings.\n" - "Otherwise, mods may be queued for download but never update." - ) + msg = preconnect.running_app.format(running_app) warnings.append(msg) self.add_warnings(warnings) @@ -326,7 +309,7 @@ class PreConnectionAssistant(Gtk.Box): if prereqs.required_space != 0: pretty = number(prereqs.required_space) - suffix = f" Need to download {pretty} MiB of mod updates." + suffix = preconnect.required_space.format(pretty) prefix = preconnect.total_mods self.mod_count.set_text(f"{prefix}{str(total_mods)}.{suffix}") else: From 7102c21f5ab04f5c0dd0d6b96d26cb2c8b1b0e38 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Fri, 14 Aug 2026 02:51:12 +0900 Subject: [PATCH 031/139] chore: move strings into dedicated file --- dzgui/strings/preconnect.py | 39 ++++++++++++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/dzgui/strings/preconnect.py b/dzgui/strings/preconnect.py index 07e5e57..9e0f764 100644 --- a/dzgui/strings/preconnect.py +++ b/dzgui/strings/preconnect.py @@ -1,9 +1,7 @@ update_mods = "Update mods and connect" connect = "Connect" connect_last = "Load to menu screen" -connect_last_tooltip = ( - "Launch DayZ with these mods (if any) and\ngo to the menu screen without connecting." -) +connect_last_tooltip = "Launch DayZ with these mods (if any) and\ngo to the menu screen without connecting." back = "Back" cancel = "Cancel" warnings = "Warnings" @@ -14,3 +12,38 @@ up_to_date = "All mods are up to date." 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." +required_space = " Need to download {0} MiB of mod updates." + +placeholder_no_mods = "This server has no mods." +placeholder_no_warnings = "No warnings." + + +# Errors +invalid_mods = ( + "Server has invalid mods that are not recognized by Steam.\n" + "Contact the server owner and include these mod IDs in your report:\n" + "{0}" +) +not_enough_space = ( + "Need to update {0} MiB of mods, but installation path only has {1} MiB." +) +resync = ( + "If you recently installed {0} or moved it to a different drive,\n" + "restart Steam to allow these changes to synchronize, then try again." +) +steam_not_running = "'{0}' is set as the default Steam client, but it is either not installed or not running." +use_desktop_mode = "Use Desktop Mode to download mods on Steam Deck." +version_mismatch = "Local client version '{0}' does not match remote version '{1}'." +version_missing = "Remote server is running the build '{0}', but it is not installed." + + +# Warnings +protected_server = ( + "Protected: you will be prompted for a password when connecting to this server." +) +dayz_running = "It looks like DayZ is already running in the background. Exit DayZ before connecting." +running_app = ( + "The app '{0}' is currently running in Steam, but background downloads are not enabled.\n" + "Either stop the game first, or update your global Steam settings or the game's local settings.\n" + "Otherwise, mods may be queued for download but never update." +) From ea785960d880ecd0a55533b2b2d963e34af4d7c0 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Fri, 14 Aug 2026 02:52:12 +0900 Subject: [PATCH 032/139] feat: abstract wizard page into base class ScrolledWizardPage was abstracted into a more generic base class for use with other wizards, e.g., the uninstall wizard. First-time setup pages were subclassed from this into EnumeratedWizardPage, since they use a multi-page hierarchy. --- dzgui/views/dialogs/wizard.py | 58 ++++++++++++++++++----------------- 1 file changed, 30 insertions(+), 28 deletions(-) diff --git a/dzgui/views/dialogs/wizard.py b/dzgui/views/dialogs/wizard.py index 1df4ece..3a57570 100644 --- a/dzgui/views/dialogs/wizard.py +++ b/dzgui/views/dialogs/wizard.py @@ -52,7 +52,7 @@ class PageNum(Enum): class OptionalPageMixin: """Marks optional pages as advanceable""" - def _on_map(self, page: "ScrolledWizardPage") -> None: + def _on_map(self, page: "EnumeratedWizardPage") -> None: EMITTER.emit("step_complete") @@ -72,10 +72,9 @@ class Progress(Gtk.ProgressBar): class ScrolledWizardPage(Gtk.ScrolledWindow): - def __init__(self, enum: PageNum, heading: str, description: str): + def __init__(self, heading: str, description: str): super().__init__(overlay_scrolling=False) - self.enum = enum self.page_type: Gtk.AssistantPageType self.title = heading self.heading = Heading(heading) @@ -88,7 +87,6 @@ class ScrolledWizardPage(Gtk.ScrolledWindow): height=600, preserve_aspect_ratio=True, ) - image = Gtk.Image.new_from_pixbuf(pixbuf) self.box = Gtk.Box( orientation=Gtk.Orientation.VERTICAL, @@ -97,21 +95,10 @@ class ScrolledWizardPage(Gtk.ScrolledWindow): margin_top=50, spacing=20, ) + image = Gtk.Image.new_from_pixbuf(pixbuf) + self.box.pack_start(image, expand=False, fill=True, padding=0) self.add(self.box) - self.prog = Progress() - self.box.pack_end(self.prog, expand=False, fill=False, padding=0) - self.box.pack_start(image, expand=False, fill=True, padding=0) - self.box.pack_start(self.heading, expand=False, fill=True, padding=0) - self.box.pack_start(self.description, expand=False, fill=True, padding=0) - - self.connect("map", self._on_map) - - def get_enum(self) -> PageNum: - return self.enum - - def get_progress_bar(self) -> Progress: - return self.prog def get_page_type(self) -> Gtk.AssistantPageType: return self.page_type @@ -131,8 +118,23 @@ class ScrolledWizardPage(Gtk.ScrolledWindow): def get_box(self) -> Gtk.Box: return self.box - def _on_map(self, page: "ScrolledWizardPage") -> None: - pass + +class EnumeratedWizardPage(ScrolledWizardPage): + def __init__(self, enum: PageNum, heading: str, description: str) -> None: + super().__init__(heading=heading, description=description) + + + self.enum = enum + self.prog = Progress() + self.box.pack_end(self.prog, expand=False, fill=False, padding=0) + self.box.pack_start(self.heading, expand=False, fill=True, padding=0) + self.box.pack_start(self.description, expand=False, fill=True, padding=0) + + def get_enum(self) -> PageNum: + return self.enum + + def get_progress_bar(self) -> Progress: + return self.prog class NotificationFrame(Gtk.Frame): @@ -162,7 +164,7 @@ class NotificationFrame(Gtk.Frame): self.label.set_markup(wrapped) -class APIValidationPage(ScrolledWizardPage): +class APIValidationPage(EnumeratedWizardPage): def __init__( self, enum: PageNum, heading: str, description: str, link: str, func: Callable ) -> None: @@ -239,7 +241,7 @@ class SteamValidationPage(APIValidationPage): self.thread_man.set_cleanup_func(cleanup) -class IntroductionPage(ScrolledWizardPage): +class IntroductionPage(EnumeratedWizardPage): def __init__(self) -> None: super().__init__( enum=PageNum.INTRO, @@ -294,7 +296,7 @@ class RadioFrame(Gtk.Frame): return self.button -class ConfigMigrationPage(ScrolledWizardPage): +class ConfigMigrationPage(EnumeratedWizardPage): def __init__(self, config: Path) -> None: super().__init__( enum=PageNum.HAS_CONFIG, @@ -354,7 +356,7 @@ class ConfigMigrationPage(ScrolledWizardPage): EMITTER.emit("config", True) -class PreferencesPage(ScrolledWizardPage): +class PreferencesPage(EnumeratedWizardPage): def __init__(self) -> None: super().__init__( enum=PageNum.USER_PREFS, @@ -414,7 +416,7 @@ class PreferencesPage(ScrolledWizardPage): EMITTER.emit("step_complete") -class CompletionPage(ScrolledWizardPage): +class CompletionPage(EnumeratedWizardPage): def __init__(self) -> None: super().__init__( enum=PageNum.FINAL, @@ -539,7 +541,7 @@ class Assistant(Gtk.Assistant): return self.set_page_complete(page, True) - def _add_page(self, page: ScrolledWizardPage, ptype: Gtk.AssistantPageType) -> None: + def _add_page(self, page: EnumeratedWizardPage, ptype: Gtk.AssistantPageType) -> None: self.append_page(page) self.set_page_type(page, ptype) self.set_page_title(page, page.get_title()) @@ -548,7 +550,7 @@ class Assistant(Gtk.Assistant): def _set_config_state(self, emitter: "Emitter", state: bool) -> None: self.config = state - def _on_page_prepare(self: Self, wizard: Self, page: ScrolledWizardPage) -> None: + def _on_page_prepare(self: Self, wizard: Self, page: EnumeratedWizardPage) -> None: page_num = self.get_current_page() + 1 total = self.get_n_pages() fraction = page_num / total @@ -584,7 +586,7 @@ class CheckboxWithLabel(Gtk.Box): self.button.set_active(state) -class ShortcutCreationPage(OptionalPageMixin, ScrolledWizardPage): # type: ignore +class ShortcutCreationPage(OptionalPageMixin, EnumeratedWizardPage): # type: ignore def __init__(self, shortcut: Path) -> None: super().__init__( enum=PageNum.SHORTCUTS, @@ -642,7 +644,7 @@ class ShortcutCreationPage(OptionalPageMixin, ScrolledWizardPage): # type: igno freedesktop.write_desktop_shortcut(desktop_file) -class SteamPathPage(ScrolledWizardPage): +class SteamPathPage(EnumeratedWizardPage): def __init__(self) -> None: super().__init__( enum=PageNum.USER_PREFS, From 0eb47a04274bce418e65772ab33f2029095af5ce Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Fri, 14 Aug 2026 05:10:41 +0900 Subject: [PATCH 033/139] fix: do not start queue checker on phantom right click signals --- dzgui/views/trees/tree_servers.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index 12eaf8c..11c8c97 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -295,6 +295,11 @@ class ServerTreeView(ContextMixin, TreeView): # type: ignore ) -> None: if self.loaded is False: return + # NOTE: signal triggers twice on right-click events + # due to deselect-then-select behavior in GTK + model, sel = self.get_selection().get_selected_rows() + if len(sel) == 0: + return self.start_distcalc() def get_name(self) -> str: From bfb0ffc9adbad13c6f03b1f763b1866602dcca8f Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Fri, 14 Aug 2026 05:11:30 +0900 Subject: [PATCH 034/139] fix: set start tab prior to page change --- 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 0cba916..7e0faea 100644 --- a/dzgui/views/base.py +++ b/dzgui/views/base.py @@ -69,8 +69,8 @@ class OuterWindow(Gtk.Window): self.show_all() css.load_css() - MainController.open_page(NotebookPage.SERVERS) MainController.set_start_tab() + MainController.open_page(NotebookPage.SERVERS) self.grid.hide_widgets_on_init() MainController.loaded = True From b209c9912d74356b74543f0cd1e6ead424254e79 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Fri, 14 Aug 2026 05:20:32 +0900 Subject: [PATCH 035/139] chore: clear typehinting errors --- dzgui/views/trees/tree_servers.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index 11c8c97..2ec2f32 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -297,8 +297,9 @@ class ServerTreeView(ContextMixin, TreeView): # type: ignore return # NOTE: signal triggers twice on right-click events # due to deselect-then-select behavior in GTK - model, sel = self.get_selection().get_selected_rows() - if len(sel) == 0: + sel = self.get_selection() + model, row = sel.get_selected() + if row is None: return self.start_distcalc() From cd4bc7ff4edc3a0463df6fd0ae4a86325aff7539 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Fri, 14 Aug 2026 08:09:56 +0900 Subject: [PATCH 036/139] feat: add descriptive tooltips to filters --- dzgui/managers/filter.py | 20 ++++++++++++++++++++ dzgui/views/components/filter_panel.py | 12 ++++++++++-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/dzgui/managers/filter.py b/dzgui/managers/filter.py index 634bf8b..923dd79 100644 --- a/dzgui/managers/filter.py +++ b/dzgui/managers/filter.py @@ -29,6 +29,22 @@ class FilterManager: strings.filter_modded: True, } + # TODO: strings + self.tooltips = { + strings.filter_1pp: "First-person perspective", + strings.filter_day: "In-game time between 0700 and 1659", + strings.filter_empty: "Servers contain no players", + strings.filter_3pp: "Third-person perspective", + strings.filter_night: "In-game time between 1700 and 0659", + strings.filter_full: "Servers have no open slots", + strings.filter_official: "Bohemia official servers", + strings.filter_nonascii: "Server names using non-standard, complex glyphs", + strings.filter_lowpop: "Current population is under 30%", + strings.filter_unofficial: "Third-party servers", + strings.filter_duplicate: "Duplicate of existing servers (usually spoofed)", + strings.filter_modded: "Server has one or more mods", + } + self.active_keyword = "" self.active_map = (0, all_maps) self.prior_map = all_maps @@ -36,6 +52,9 @@ class FilterManager: self.filters: list self.enabled_filters = dict(self.default_filters) + def get_tooltips(self) -> dict[str, str]: + return self.tooltips + def set_prior_map(self, name: str) -> None: self.prior_map = name @@ -86,6 +105,7 @@ class FilterManager: self.reinit_map_store() maps.sort() for m in maps: + # TODO: strings if m == "All maps": continue self.append_map([m]) diff --git a/dzgui/views/components/filter_panel.py b/dzgui/views/components/filter_panel.py index 4554cf8..b93f06f 100644 --- a/dzgui/views/components/filter_panel.py +++ b/dzgui/views/components/filter_panel.py @@ -29,7 +29,7 @@ if TYPE_CHECKING: class ButtonGrid(Gtk.Grid): - def __init__(self, controller: "Controller", defaults: dict) -> None: + def __init__(self, controller: "Controller", defaults: dict[str, str], tooltips: dict[str, str]) -> None: super().__init__( halign=Gtk.Align.CENTER, column_spacing=5, column_homogeneous=True ) @@ -44,6 +44,13 @@ class ButtonGrid(Gtk.Grid): for check in defaults.keys(): checkbox = Gtk.CheckButton(label=check) + try: + tt = tooltips[check] + except Exception as e: + logger.debug(e) + tt = "" + + checkbox.set_tooltip_text(tt) label = checkbox.get_child() if label is not None: label.set_ellipsize(Pango.EllipsizeMode.END) # type: ignore @@ -161,11 +168,12 @@ class FilterPanel(Gtk.Box): filter_man = self.controller.get_filter_man() defaults = filter_man.get_default_filters() + tooltips = filter_man.get_tooltips() self.map_store = filter_man.get_map_store() self.enabled_filters = defaults self.keyword_entry = KeywordEntry(self.controller) - self.button_grid = ButtonGrid(self.controller, defaults) + self.button_grid = ButtonGrid(self.controller, defaults, tooltips) # TODO: strings self.filters_label = BoldLabel("Filters") From 2b8e0944a4c4139d6a9d0a0528ec5ee0b1eedf68 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Fri, 14 Aug 2026 08:20:38 +0900 Subject: [PATCH 037/139] fix: time regex --- dzgui/model/proxy_model.py | 4 ++-- tests/test_time.py | 26 ++++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) create mode 100644 tests/test_time.py diff --git a/dzgui/model/proxy_model.py b/dzgui/model/proxy_model.py index c07a711..997a59f 100644 --- a/dzgui/model/proxy_model.py +++ b/dzgui/model/proxy_model.py @@ -231,10 +231,10 @@ class ProxyModelManager: final.append(row) rows = final case strings.filter_day: - reg = r"([0][0-9]|[1][0-6])" + reg = r"([0][7-9]|[1][0-6])" rows = [row for row in rows if not re.match(reg, row[3])] case strings.filter_night: - reg = r"([0][0-4]|[1][8]|[2][0-3])" + reg = r"([0][0-6]|[1][7-9]|[2][0-3])" rows = [row for row in rows if not re.match(reg, row[3])] case strings.filter_nonascii: rows = [row for row in rows if row[0].isascii()] diff --git a/tests/test_time.py b/tests/test_time.py new file mode 100644 index 0000000..f67906d --- /dev/null +++ b/tests/test_time.py @@ -0,0 +1,26 @@ +import pytest +import re + +day = r"([0][7-9]|[1][0-6])" +night = r"([0][0-6]|[1][7-9]|[2][0-3])" + +pytestmark = pytest.mark.FOO + +def iterate(h: str, r: str) -> None: + for m in range(60): + time = f"{h:02}:{m:02}" + assert re.match(r, time) is not None + + +def test_day() -> None: + for h in range(17): + if h < 7: + continue + iterate(h, day) + + +def test_night() -> None: + for h in range(24): + if 6 < h < 17: + continue + iterate(h, night) From c179e3a9e92eb6235aac81332346e832ee50f7bd Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Fri, 14 Aug 2026 08:37:13 +0900 Subject: [PATCH 038/139] chore: drop unused test mark --- tests/test_time.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/test_time.py b/tests/test_time.py index f67906d..65257b3 100644 --- a/tests/test_time.py +++ b/tests/test_time.py @@ -1,11 +1,8 @@ -import pytest import re day = r"([0][7-9]|[1][0-6])" night = r"([0][0-6]|[1][7-9]|[2][0-3])" -pytestmark = pytest.mark.FOO - def iterate(h: str, r: str) -> None: for m in range(60): time = f"{h:02}:{m:02}" From 533def5061731b30a4553ec2e5f9feb309eeb328 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 16 Aug 2026 09:59:04 +0900 Subject: [PATCH 039/139] chore: drop unused vars and classes --- dzgui/controllers/mc.py | 1 - dzgui/views/components/connect_box.py | 68 --------------------------- 2 files changed, 69 deletions(-) delete mode 100644 dzgui/views/components/connect_box.py diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index b2c1a08..2a611e1 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -88,7 +88,6 @@ class Controller(GObject.GObject): # NOTE: suppress requests until entire UI is loaded self.loaded = False - self.pending_jobs = 1 self.exit_event = threading.Event() self.cancel_event = threading.Event() diff --git a/dzgui/views/components/connect_box.py b/dzgui/views/components/connect_box.py deleted file mode 100644 index 3f1200b..0000000 --- a/dzgui/views/components/connect_box.py +++ /dev/null @@ -1,68 +0,0 @@ -from dzgui.strings import preconnect -import gi - -gi.require_version("Gtk", "3.0") -from gi.repository import Gdk, Gtk # noqa E402 - - -class ConnectBox(Gtk.Box): - def __init__(self) -> None: - super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=5) - - self.back = Gtk.Button( - label=preconnect.back, halign=Gtk.Align.END, hexpand=True - ) - - # TODO: dynamically update this - self.ok = Gtk.Button(label="Launch", halign=Gtk.Align.END) - - for button in self.back, self.ok: - self.add(button) - - def get_back_button(self) -> Gtk.Button: - return self.back - - def get_ok_button(self) -> Gtk.Button: - return self.ok - - -class PreconnectBox(Gtk.Box): - def __init__(self) -> None: - super().__init__( - orientation=Gtk.Orientation.VERTICAL, - valign=Gtk.Align.END, - vexpand=True, - spacing=5, - ) - - self.button_hbox = ConnectBox() - self.ok = self.button_hbox.get_ok_button() - self.ok.set_label(preconnect.update_mods) - - self.connect_last = Gtk.Button( - label=preconnect.connect_last, - halign=Gtk.Align.END, - tooltip_text=preconnect.connect_last_tooltip, - ) - self.button_hbox.add(self.connect_last) - - self.raise_window = Gtk.CheckButton( - # TODO: strings - label="Foreground DZGUI while downloading", - halign=Gtk.Align.END, - hexpand=True, - valign=Gtk.Align.END, - visible=False, - has_tooltip=True, - sensitive=False, - tooltip_text="Foreground the DZGUI window after mod downloads are queued", - active=True, - ) - self.add(self.button_hbox) - self.add(self.raise_window) - - def get_back_button(self) -> Gtk.Button: - return self.button_hbox.get_back_button() - - def get_ok_button(self) -> Gtk.Button: - return self.ok From 8d1d5bde80a4756aeb5d84a3f3d5c4ae44780736 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 16 Aug 2026 12:43:19 +0900 Subject: [PATCH 040/139] feat: add SpinnerButton --- dzgui/views/components/buttons.py | 36 ++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/dzgui/views/components/buttons.py b/dzgui/views/components/buttons.py index 8071ed7..6697c17 100644 --- a/dzgui/views/components/buttons.py +++ b/dzgui/views/components/buttons.py @@ -64,7 +64,6 @@ class IconButton(Gtk.Button): self.icon = Icon(icon, margin_start=margin_start, margin_end=margin_end) self.set_image(self.icon) self.set_image_position(position) - # self.set_image_position(Gtk.PositionType.RIGHT) self.set_focus_on_click(False) def swap_icon(self, icon: str) -> None: @@ -267,3 +266,38 @@ class LoggerAlertsButton(IconTextButton): self.set_halign(Gtk.Align.END) self.set_hexpand(True) self.set_tooltip_text(alert_button_tooltip) + + +class SpinnerButton(Gtk.Button): + def __init__(self, label: str) -> None: + super().__init__() + + self.text = label + self.label = Gtk.Label(label=label, halign=Gtk.Align.CENTER) + + self.spinner = Gtk.Spinner() + self.spinner.set_halign(Gtk.Align.END) + self.spinner.set_sensitive(False) + + grid = Gtk.Grid(column_spacing=10) + for el in self.label, self.spinner: + grid.add(el) + + self.add(grid) + + self.connect("clicked", self._on_button_clicked) + self.connect("map", self._on_map) + + def _on_map(self, a) -> None: + self.spinner.set_visible(False) + + def _on_button_clicked(self, button: Self) -> None: + self.start_spinner() + + def stop_spinner(self) -> None: + self.spinner.set_visible(False) + self.spinner.stop() + + def start_spinner(self) -> None: + self.spinner.set_visible(True) + self.spinner.start() From dfd6850d6ddcecde20d65b0353925b2770548de4 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 16 Aug 2026 12:44:51 +0900 Subject: [PATCH 041/139] change: move ErrorPopover --- dzgui/views/components/misc.py | 20 +++++++++++++++++++- dzgui/views/pages/offline.py | 24 ++++++------------------ 2 files changed, 25 insertions(+), 19 deletions(-) diff --git a/dzgui/views/components/misc.py b/dzgui/views/components/misc.py index 7b9fc0f..fa8e29c 100644 --- a/dzgui/views/components/misc.py +++ b/dzgui/views/components/misc.py @@ -1,5 +1,8 @@ +from dzgui.const.constants import ERROR, FLATPAK_RUN_CMD, FLATPAK_SANDBOX, STEAM_CMD from dzgui.strings import options -from dzgui.const.constants import FLATPAK_RUN_CMD, FLATPAK_SANDBOX, STEAM_CMD +from dzgui.views.components.box import HBox +from dzgui.views.components.buttons import Icon + import gi @@ -33,3 +36,18 @@ class ClientCombo(Gtk.ComboBox): self.pack_start(renderer_text, True) self.add_attribute(renderer_text, "text", 0) self.set_active(0) + +class ErrorPopover(Gtk.Popover): + def __init__(self, relative_to: Gtk.Widget, position: Gtk.PositionType) -> None: + super().__init__(relative_to=relative_to, position=position) + + self.hbox = HBox() + self.label = Gtk.Label(label="", margin_start=10, margin_end=10) + error_icon = Icon(ERROR, margin_start=10) + self.hbox.extend([error_icon, self.label]) + self.add(self.hbox) + self.show_all() + self.popdown() + + def set_label(self, label: str) -> None: + self.label.set_label(label) diff --git a/dzgui/views/pages/offline.py b/dzgui/views/pages/offline.py index f5baa9d..dec7060 100644 --- a/dzgui/views/pages/offline.py +++ b/dzgui/views/pages/offline.py @@ -9,7 +9,6 @@ from dzgui.const.constants import ( APPNAME_DAYZ, APPNAME_DAYZ_EXP_HUMAN, EDIT_DELETE, - ERROR, FOLDER, WARNING, ) @@ -21,6 +20,7 @@ from dzgui.views.components.buttons import Icon, IconTextButton from dzgui.views.components.eventbox import InfoEventBox from dzgui.views.components.frame import HeadingFrame from dzgui.views.components.scrollable import NoOverlayScrolledWindow +from dzgui.views.components.misc import ErrorPopover from dzgui.views.trees.tree_mods import OfflineModTreeView @@ -57,17 +57,9 @@ class PageHeading(Gtk.Box): css.add_class(self, "page-heading") -class ErrorPopover(Gtk.Popover): - def __init__(self) -> None: - super().__init__(position=Gtk.PositionType.RIGHT) - - self.hbox = HBox() - self.label = Gtk.Label(label="", margin_start=10, margin_end=10) - error_icon = Icon(ERROR, margin_start=10) - self.hbox.extend([error_icon, self.label]) - self.add(self.hbox) - self.show_all() - self.popdown() +class OfflineErrorPopover(ErrorPopover): + def __init__(self, relative_to: Gtk.Widget, position: Gtk.PositionType) -> None: + super().__init__(relative_to=relative_to, position=position) def set_label(self, error: FolderError, msg: str) -> None: match error: @@ -115,13 +107,10 @@ class FolderHBox(HBox): [self.eb, self.button, self.spinner, self.scrolled_label, self.unset_button] ) - self.pop = ErrorPopover() - self.pop.set_relative_to(self.button) + self.pop = OfflineErrorPopover(self.button, Gtk.PositionType.RIGHT) self.pop.connect("unmap", lambda _: self.grab_focus()) - self.sidepop = ErrorPopover() - self.sidepop.set_position(Gtk.PositionType.BOTTOM) - self.sidepop.set_relative_to(self.scrolled_label) + self.sidepop = OfflineErrorPopover(self.scrolled_label, Gtk.PositionType.BOTTOM) self.sidepop.connect("unmap", lambda _: self.grab_focus()) self.connect("map", self._on_map) @@ -467,7 +456,6 @@ class OfflineLoader(Gtk.Box): ] ) - # TODO: share ConnectBox class with preconnect dialog? self.button_box = HBox(spacing=5) self.button_box.set_halign(Gtk.Align.END) self.button_box.set_margin_top(15) From 69cba3460d9381cf0d4585ac49c8c54db361cd40 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 16 Aug 2026 12:45:26 +0900 Subject: [PATCH 042/139] feat: simplify API udpate signals --- dzgui/controllers/emitter.py | 4 ++++ dzgui/controllers/mc.py | 4 ++-- dzgui/managers/config.py | 11 ++++++----- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/dzgui/controllers/emitter.py b/dzgui/controllers/emitter.py index ba7ef10..28016ca 100644 --- a/dzgui/controllers/emitter.py +++ b/dzgui/controllers/emitter.py @@ -145,6 +145,10 @@ class Emitter(GObject.GObject): def api_change_failed(self) -> None: pass + @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=()) + def api_change_successful(self) -> None: + pass + @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=()) def already_saved_server(self) -> None: pass diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 2a611e1..b8b69fc 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -301,8 +301,8 @@ class Controller(GObject.GObject): dialog = ExceptionDialog(self, str(e)) dialog.run() - def update_api_key(self, key: Preferences, text: str) -> None: - self.config_man.update_api_key(key, text) + def update_steam_api_key(self, text: str) -> None: + self.config_man.update_steam_api_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 2e81f28..ac559b2 100644 --- a/dzgui/managers/config.py +++ b/dzgui/managers/config.py @@ -98,12 +98,13 @@ class ConfigManager: logger.critical(e) raise e - @call_on_thread(dialogs.checking_api) - def update_api_key(self, key: Preferences, text: str) -> None: - if key is Preferences.STEAM: - res = test_steam_api(text) + @call_on_thread(dialogs.checking_api, show_dialog=False) + def update_steam_api_key(self, text: str) -> None: + res = test_steam_api(text) if res is True: - self.update_config(key, text) + self.update_config(Preferences.STEAM, text) + func = StoredFunc(lambda: self.emitter.emit("api_change_successful")) + self.thread_man.set_cleanup_func(func) else: self.thread_man.set_cleanup_func( StoredFunc(lambda: self.emitter.emit("api_change_failed")) From cc485289e3f0d7602c7c6fe46db03bc67f286d82 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 16 Aug 2026 12:46:16 +0900 Subject: [PATCH 043/139] feat: abstract field creation into SubmitField --- dzgui/strings/options.py | 9 +- dzgui/views/pages/options.py | 342 ++++++++++++++++++++--------------- 2 files changed, 201 insertions(+), 150 deletions(-) diff --git a/dzgui/strings/options.py b/dzgui/strings/options.py index ec28776..fd2aaaf 100644 --- a/dzgui/strings/options.py +++ b/dzgui/strings/options.py @@ -1,12 +1,19 @@ 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)" +steam_placeholder = "Enter your Steam API key" +name_placeholder = "Identifies you to other players" + server_combo = "Server browser" saved_combo = "Saved servers" recent_combo = "Recent" lan_combo = "LAN" + +fullscreen_eventbox = "This option is not available on Steam Deck." +save_button = "Save" + +api_failed = "API validation failed" diff --git a/dzgui/views/pages/options.py b/dzgui/views/pages/options.py index ff97e2e..a0748fc 100644 --- a/dzgui/views/pages/options.py +++ b/dzgui/views/pages/options.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import TYPE_CHECKING +from typing import Callable, TYPE_CHECKING from dzgui.api import pefile as PeFile @@ -17,15 +17,15 @@ from dzgui.const.constants import ( ) from dzgui.const.endpoints import STEAM_API_SETUP from dzgui.const.enum import Preferences, ServerTab -from dzgui.strings import errors, options +from dzgui.strings import options from dzgui.util import strings, css, open_links from dzgui.views.components.box import ShortHBox, VBox from dzgui.views.components.eventbox import InfoEventBox from dzgui.views.components.labels import LeftLabel -from dzgui.views.components.buttons import WebButton +from dzgui.views.components.buttons import SpinnerButton, WebButton from dzgui.views.components.frame import HeadingFrame -from dzgui.views.components.misc import ClientCombo +from dzgui.views.components.misc import ClientCombo, ErrorPopover from dzgui.views.dialogs.generic import ExceptionDialog from dzgui.views.mixins.scrollable_mixin import ScrollableMixin @@ -40,6 +40,182 @@ if TYPE_CHECKING: from dzgui.controllers.emitter import Emitter +class SubmitField(Gtk.Box): + def __init__( + self, + controller: "Controller", + placeholder: str, + context: Preferences, + slow: bool = False, + private: bool = True, + ) -> None: + super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) + + self.callback: Callable + self.controller = controller + self.context = context + + self.old_text: str + self.placeholder = placeholder + + self.entry = Gtk.Entry(placeholder_text=placeholder, hexpand=True) + + # TODO: audit for all applicable callbacks + if private: + self.entry.set_icon_from_icon_name( + Gtk.EntryIconPosition.SECONDARY, VIEW_REVEAL + ) + self.entry.set_icon_activatable(Gtk.EntryIconPosition.SECONDARY, True) + self.entry.connect("icon-release", self._on_icon_release) + self.entry.set_visibility(False) + + if slow: + self.button = SpinnerButton(label=options.save_button) + else: + self.button = Gtk.Button(label=options.save_button) + + self.button.connect("clicked", self._on_save_clicked) + self.entry.connect("insert-text", self._on_text_typed) + self.entry.connect("activate", self._on_field_activated) + self.entry.get_property("buffer").connect("deleted-text", self._on_text_deleted) + + self.add(self.entry) + self.add(self.button) + + def set_text(self, text: str) -> None: + self.old_text = text + self.entry.set_text(text) + if len(text) == 0: + self.button.set_sensitive(False) + + def _is_valid_text(self, text: str) -> bool: + if text.isspace(): + return False + if len(text) == 0: + return False + + if text == self.old_text: + 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.button.set_sensitive(state) + + def _on_text_typed( + self, + entry: Gtk.Entry, + text: str, + length: int, + pos: int, + ) -> None: + + buffer = entry.get_property("buffer") + text = buffer.get_text() + text + state = self._is_valid_text(text) + self.button.set_sensitive(state) + + def _on_icon_release( + self, + widget: Gtk.Entry, + icon_pos: Gtk.EntryIconPosition, + event: Gdk.Event, + ) -> None: + visible = widget.get_visibility() + if visible: + icon, state = VIEW_REVEAL, False + else: + icon, state = VIEW_CONCEAL, True + widget.set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, icon) + widget.set_visibility(state) + + def _on_field_activated(self, entry: Gtk.Entry) -> None: + text = entry.get_text() + if not self._is_valid_text(text): + return + self.save_option() + + def set_callback(self, callback: Callable) -> None: + self.callback = callback + + def save_option(self) -> None: + self.callback() + self.button.set_sensitive(False) + + def _on_save_clicked(self, button: Gtk.Button) -> None: + self.save_option() + + +class SteamSubmitField(SubmitField): + def __init__(self, controller: "Controller") -> None: + super().__init__( + controller, + options.steam_placeholder, + Preferences.STEAM, + slow=True, + private=True, + ) + + self.pop = ErrorPopover( + position=Gtk.PositionType.BOTTOM, relative_to=self.entry + ) + self.pop.set_label(options.api_failed) + self.pop.show_all() + self.pop.popdown() + + emitter = controller.get_emitter() + emitter.connect("api_change_failed", self._on_api_failure) + emitter.connect("api_change_successful", self._on_api_success) + + self.set_callback(self.save_setting) + + def save_setting(self) -> None: + self.entry.set_sensitive(False) + text = "".join(self.entry.get_text().split()) + self.controller.update_steam_api_key(text) + + def _on_api_success(self, emitter: "Emitter") -> None: + self.old_text = self.entry.get_text() + self.button.stop_spinner() + self.entry.set_sensitive(True) + + def _on_api_failure(self, emitter: "Emitter") -> None: + self.pop.popup() + self.button.stop_spinner() + self.entry.set_sensitive(True) + + def block_text_entry(self) -> None: + self.entry.set_position(-1) + self.entry.set_can_focus(False) + + def unblock_text_entry(self) -> None: + self.entry.set_can_focus(True) + + +class NameSubmitField(SubmitField): + def __init__(self, controller: "Controller") -> None: + super().__init__( + controller, + options.name_placeholder, + Preferences.NAME, + private=False, + ) + self.entry.set_width_chars(30) # type: ignore + self.set_callback(self.save_player_name) + + def save_player_name(self) -> None: + value = self.entry.get_text().strip() + self.old_text = value + self.controller.update_config(self.context, value) + + class ToggleField(Gtk.Box): def __init__( self, @@ -80,29 +256,23 @@ class Options(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore self.controller = controller self.controller.register_widget("options", self) - emitter = controller.get_emitter() - emitter.connect("api_change_failed", self._on_api_change_failed) self.DEFAULT_WIDTH = 1 self.DEFAULT_HEIGHT = 1 self.steam_entry: Gtk.Entry + self.pop: Gtk.Popover self.steam = WebButton(label=strings.options.steam_web) self.steam.connect("clicked", self._on_link_button_clicked, STEAM_API_SETUP) - self.steam_box = self._make_submit_field( - strings.options.enter_steam, Preferences.STEAM, True - ) + self.steam_box = SteamSubmitField(controller) api_rows = [ [LeftLabel(strings.options.steam_placeholder), self.steam_box, self.steam], ] - self.player_box = self._make_submit_field( - strings.options.name_placeholder, Preferences.NAME - ) + self.player_box = NameSubmitField(controller) 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) # type: ignore self.fullscreen_toggle = ToggleField( @@ -112,12 +282,6 @@ class Options(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore Preferences.WINDOW, ) - # TODO: strings - eb = InfoEventBox( - "This option is not available on Steam Deck.", self.controller - ) - self.fullscreen_toggle.add(eb) - self.client_combo = ClientCombo() self.client_combo.connect("changed", self._on_client_changed) @@ -210,11 +374,13 @@ class Options(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore return str(model[ind][0]) def block_text_entry(self) -> None: - self.steam_entry.set_position(-1) - self.steam_entry.set_can_focus(False) + self.steam_box.block_text_entry() + # self.steam_entry.set_position(-1) + # self.steam_entry.set_can_focus(False) def unblock_text_entry(self) -> None: - self.steam_entry.set_can_focus(True) + self.steam_box.unblock_text_entry() + # self.steam_entry.set_can_focus(True) def _on_developers_clicked(self, button: Gtk.Button) -> None: self.controller.show_developers_page() @@ -222,46 +388,6 @@ class Options(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore def _on_link_button_clicked(self, button: Gtk.Button, url: str) -> None: open_links.open_link_by_url(url) - def _make_submit_field( - self, - placeholder: str, - context: Preferences, - private: bool = False, - ) -> Gtk.Box: - - entry = Gtk.Entry(placeholder_text=placeholder, hexpand=True) - button = Gtk.Button(label="Save") - - button.connect("clicked", self._on_save_clicked, entry, context) - entry.connect("insert-text", self._on_text_typed, context, button) - entry.connect("activate", self._on_field_activated, context, button) - entry.get_property("buffer").connect( - "deleted-text", self._on_text_deleted, context, button - ) - - if private: - entry.set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, VIEW_REVEAL) - entry.set_icon_activatable(Gtk.EntryIconPosition.SECONDARY, True) - entry.connect("icon-release", self._on_icon_release) - entry.set_visibility(False) - - if context == Preferences.STEAM: - self.steam_entry = entry - - box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) - box.add(entry) - box.add(button) - - return box - - def _on_field_activated( - self, entry: Gtk.Entry, context: Preferences, button: Gtk.Button - ) -> None: - text = entry.get_text() - if not self._is_valid_text(text, context): - return - self._on_save_clicked(button, entry, context) - def _make_grid(self, rows: list) -> Gtk.Grid: grid = Gtk.Grid( orientation=Gtk.Orientation.VERTICAL, @@ -281,35 +407,6 @@ class Options(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore row += 1 return grid - def _on_save_clicked( - self, button: Gtk.Button, entry: Gtk.Entry, enum: Preferences - ) -> None: - old_text = self.controller.query_config(enum) - self.old_text = old_text - self.old_entry = entry - - button.set_sensitive(False) - match enum: - case Preferences.NAME: - value = entry.get_text().strip() - self.controller.update_config(enum, value) - case Preferences.STEAM: - text = "".join(entry.get_text().split()) - self.controller.update_api_key(enum, text) - - def _on_api_change_failed(self, emitter: "Emitter") -> None: - self.old_entry.set_text(self.old_text) - # TODO: use popover - dialog = ExceptionDialog(self.controller, errors.api_validation_error) - dialog.run() - - def restore_api_text(self, text: str, entry: Gtk.Entry) -> None: - entry.set_text(text) - - def revert(self, mode: Preferences) -> None: - if mode == Preferences.STEAM: - self.steam_entry.set_text(self.old_steam) - def _on_start_tab_changed(self, combo: Gtk.ComboBoxText) -> None: _iter = combo.get_active_iter() if _iter is None: @@ -325,49 +422,6 @@ class Options(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore real_cmd = combo.get_model()[_iter][1] self.controller.update_config(Preferences.CLIENT, real_cmd) - def _is_valid_text(self, text: str, context: Preferences) -> bool: - if text.isspace(): - return False - if len(text) == 0: - return False - - match context: - case Preferences.NAME: - old = self.old_name - case Preferences.STEAM: - old = self.old_steam - if text == old: - return False - return True - - def _on_text_deleted( - self, - buffer: Gtk.EntryBuffer, - position: int, - chars: int, - context: Preferences, - button: Gtk.Button, - ) -> None: - - text = buffer.get_text() - state = self._is_valid_text(text, context) - button.set_sensitive(state) - - def _on_text_typed( - self, - entry: Gtk.Entry, - text: str, - length: int, - pos: int, - context: Preferences, - button: Gtk.Button, - ) -> None: - - buffer = entry.get_property("buffer") - text = buffer.get_text() + text - state = self._is_valid_text(text, context) - button.set_sensitive(state) - def populate_settings(self) -> None: prefs = self.controller.get_prefs() # NOTE: re-check in case file was removed by user between runs @@ -385,32 +439,22 @@ class Options(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore steam_path = Path(default_steam_path) - self.old_steam = steam - self.old_name = name - - self.steam_entry.set_text(steam) - p = self.player_box.get_children()[0] - if hasattr(p, "set_text"): - p.set_text(name) + self.steam_box.set_text(steam) + self.player_box.set_text(name) fs = config["fullscreen"] miles = config["use_miles"] if prefs.is_steam_deck: self.fullscreen_toggle.set_sensitive(False) + eb = InfoEventBox(options.fullscreen_eventbox, self.controller) + self.fullscreen_toggle.add(eb) + fs = True self.fullscreen_toggle.set_suboption_active(fs) self.distance_toggle.set_suboption_active(miles) - # NOTE: disable buttons if no text is set - for field in ( - [name, self.player_box], - [steam, self.steam_box], - ): - if field[0] == "": - field[1].get_children()[1].set_sensitive(False) - dayz_version = PeFile.get_pretty_version(steam_path, APPID_DAYZ) if dayz_version is None: dayz_version = strings.null From 4917c0ce2ed08dd6cb86d8084d88a05fd89cd12b Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 16 Aug 2026 12:56:49 +0900 Subject: [PATCH 044/139] chore: clear typehinting errors --- dzgui/views/components/buttons.py | 2 +- dzgui/views/pages/offline.py | 6 +++--- dzgui/views/pages/options.py | 7 +++++-- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/dzgui/views/components/buttons.py b/dzgui/views/components/buttons.py index 6697c17..45df27d 100644 --- a/dzgui/views/components/buttons.py +++ b/dzgui/views/components/buttons.py @@ -288,7 +288,7 @@ class SpinnerButton(Gtk.Button): self.connect("clicked", self._on_button_clicked) self.connect("map", self._on_map) - def _on_map(self, a) -> None: + def _on_map(self, button: Self) -> None: self.spinner.set_visible(False) def _on_button_clicked(self, button: Self) -> None: diff --git a/dzgui/views/pages/offline.py b/dzgui/views/pages/offline.py index dec7060..b0200b0 100644 --- a/dzgui/views/pages/offline.py +++ b/dzgui/views/pages/offline.py @@ -61,7 +61,7 @@ class OfflineErrorPopover(ErrorPopover): def __init__(self, relative_to: Gtk.Widget, position: Gtk.PositionType) -> None: super().__init__(relative_to=relative_to, position=position) - def set_label(self, error: FolderError, msg: str) -> None: + def set_error_label(self, error: FolderError, msg: str) -> None: match error: case FolderError.NO_VALID_MODS: prefix = offline.no_mods @@ -159,13 +159,13 @@ class FolderHBox(HBox): def present_error(self, error: FolderError, msg: str) -> None: if error == FolderError.FOLDER_CHANGED: - self.sidepop.set_label(error, msg) + self.sidepop.set_error_label(error, msg) self.sidepop.popup() return self.folder = "" self.label.set_text("") self.unset_button.hide() - self.pop.set_label(error, msg) + self.pop.set_error_label(error, msg) self.pop.popup() diff --git a/dzgui/views/pages/options.py b/dzgui/views/pages/options.py index a0748fc..0066a6a 100644 --- a/dzgui/views/pages/options.py +++ b/dzgui/views/pages/options.py @@ -69,6 +69,7 @@ class SubmitField(Gtk.Box): self.entry.connect("icon-release", self._on_icon_release) self.entry.set_visibility(False) + self.button: SpinnerButton | Gtk.Button if slow: self.button = SpinnerButton(label=options.save_button) else: @@ -183,12 +184,14 @@ class SteamSubmitField(SubmitField): def _on_api_success(self, emitter: "Emitter") -> None: self.old_text = self.entry.get_text() - self.button.stop_spinner() + if isinstance(self.button, SpinnerButton): + self.button.stop_spinner() self.entry.set_sensitive(True) def _on_api_failure(self, emitter: "Emitter") -> None: self.pop.popup() - self.button.stop_spinner() + if isinstance(self.button, SpinnerButton): + self.button.stop_spinner() self.entry.set_sensitive(True) def block_text_entry(self) -> None: From 87f167bd538cb6fec0b5ef7e458f52aaa404360e Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 16 Aug 2026 12:58:30 +0900 Subject: [PATCH 045/139] chore: drop duplicate definition --- dzgui/views/pages/options.py | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/dzgui/views/pages/options.py b/dzgui/views/pages/options.py index 0066a6a..277c13e 100644 --- a/dzgui/views/pages/options.py +++ b/dzgui/views/pages/options.py @@ -485,19 +485,5 @@ class Options(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore self, toggle.get_children()[0], "_on_radio_toggled", state ) - def _on_icon_release( - self, - widget: Gtk.Entry, - icon_pos: Gtk.EntryIconPosition, - event: Gdk.Event, - ) -> None: - visible = widget.get_visibility() - if visible: - icon, state = VIEW_REVEAL, False - else: - icon, state = VIEW_CONCEAL, True - widget.set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, icon) - widget.set_visibility(state) - def grab_content_area(self) -> None: self.grab_focus() From 9b24928f74980ccc54ba87d6f7f855b89f6da1fd Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 16 Aug 2026 13:38:39 +0900 Subject: [PATCH 046/139] fix: set selection mode on FlowBox --- dzgui/views/components/filter_panel.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/dzgui/views/components/filter_panel.py b/dzgui/views/components/filter_panel.py index b93f06f..35a6ea4 100644 --- a/dzgui/views/components/filter_panel.py +++ b/dzgui/views/components/filter_panel.py @@ -29,7 +29,12 @@ if TYPE_CHECKING: class ButtonGrid(Gtk.Grid): - def __init__(self, controller: "Controller", defaults: dict[str, str], tooltips: dict[str, str]) -> None: + def __init__( + self, + controller: "Controller", + defaults: dict[str, str], + tooltips: dict[str, str], + ) -> None: super().__init__( halign=Gtk.Align.CENTER, column_spacing=5, column_homogeneous=True ) @@ -39,7 +44,7 @@ class ButtonGrid(Gtk.Grid): self.checks: list[Gtk.CheckButton] = [] flowbox = Gtk.FlowBox( - halign=Gtk.Align.CENTER, min_children_per_line=3, max_children_per_line=3 + halign=Gtk.Align.CENTER, min_children_per_line=3, max_children_per_line=3, selection_mode=Gtk.SelectionMode.NONE ) for check in defaults.keys(): From ce1cad6dcf496a6925f3217a290d748c30601b83 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 16 Aug 2026 13:38:52 +0900 Subject: [PATCH 047/139] chore: reword filter tooltips --- dzgui/managers/filter.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/dzgui/managers/filter.py b/dzgui/managers/filter.py index 923dd79..febb861 100644 --- a/dzgui/managers/filter.py +++ b/dzgui/managers/filter.py @@ -33,16 +33,16 @@ class FilterManager: self.tooltips = { strings.filter_1pp: "First-person perspective", strings.filter_day: "In-game time between 0700 and 1659", - strings.filter_empty: "Servers contain no players", + strings.filter_empty: "Servers with no players", strings.filter_3pp: "Third-person perspective", strings.filter_night: "In-game time between 1700 and 0659", - strings.filter_full: "Servers have no open slots", + strings.filter_full: "Servers with no open slots", strings.filter_official: "Bohemia official servers", strings.filter_nonascii: "Server names using non-standard, complex glyphs", - strings.filter_lowpop: "Current population is under 30%", + strings.filter_lowpop: "Current population is under 30% of total", strings.filter_unofficial: "Third-party servers", - strings.filter_duplicate: "Duplicate of existing servers (usually spoofed)", - strings.filter_modded: "Server has one or more mods", + strings.filter_duplicate: "Duplicates of existing servers (usually spoofed)", + strings.filter_modded: "Servers with one or more mods", } self.active_keyword = "" From a72d692c68d922428a98a3881054cd0bbda50c83 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Fri, 14 Aug 2026 08:20:38 +0900 Subject: [PATCH 048/139] fix: time regex --- dzgui/model/proxy_model.py | 4 ++-- tests/test_time.py | 26 ++++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) create mode 100644 tests/test_time.py diff --git a/dzgui/model/proxy_model.py b/dzgui/model/proxy_model.py index c07a711..997a59f 100644 --- a/dzgui/model/proxy_model.py +++ b/dzgui/model/proxy_model.py @@ -231,10 +231,10 @@ class ProxyModelManager: final.append(row) rows = final case strings.filter_day: - reg = r"([0][0-9]|[1][0-6])" + reg = r"([0][7-9]|[1][0-6])" rows = [row for row in rows if not re.match(reg, row[3])] case strings.filter_night: - reg = r"([0][0-4]|[1][8]|[2][0-3])" + reg = r"([0][0-6]|[1][7-9]|[2][0-3])" rows = [row for row in rows if not re.match(reg, row[3])] case strings.filter_nonascii: rows = [row for row in rows if row[0].isascii()] diff --git a/tests/test_time.py b/tests/test_time.py new file mode 100644 index 0000000..f67906d --- /dev/null +++ b/tests/test_time.py @@ -0,0 +1,26 @@ +import pytest +import re + +day = r"([0][7-9]|[1][0-6])" +night = r"([0][0-6]|[1][7-9]|[2][0-3])" + +pytestmark = pytest.mark.FOO + +def iterate(h: str, r: str) -> None: + for m in range(60): + time = f"{h:02}:{m:02}" + assert re.match(r, time) is not None + + +def test_day() -> None: + for h in range(17): + if h < 7: + continue + iterate(h, day) + + +def test_night() -> None: + for h in range(24): + if 6 < h < 17: + continue + iterate(h, night) From 1753a40b942f6b35eefd19839e75a3035298ffd6 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Fri, 14 Aug 2026 08:37:13 +0900 Subject: [PATCH 049/139] chore: drop unused test mark --- tests/test_time.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/test_time.py b/tests/test_time.py index f67906d..65257b3 100644 --- a/tests/test_time.py +++ b/tests/test_time.py @@ -1,11 +1,8 @@ -import pytest import re day = r"([0][7-9]|[1][0-6])" night = r"([0][0-6]|[1][7-9]|[2][0-3])" -pytestmark = pytest.mark.FOO - def iterate(h: str, r: str) -> None: for m in range(60): time = f"{h:02}:{m:02}" From dd30fa33d138c8eb573408016d26865240450a15 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 16 Aug 2026 09:59:04 +0900 Subject: [PATCH 050/139] chore: drop unused vars and classes --- dzgui/controllers/mc.py | 1 - dzgui/views/components/connect_box.py | 68 --------------------------- 2 files changed, 69 deletions(-) delete mode 100644 dzgui/views/components/connect_box.py diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index b2c1a08..2a611e1 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -88,7 +88,6 @@ class Controller(GObject.GObject): # NOTE: suppress requests until entire UI is loaded self.loaded = False - self.pending_jobs = 1 self.exit_event = threading.Event() self.cancel_event = threading.Event() diff --git a/dzgui/views/components/connect_box.py b/dzgui/views/components/connect_box.py deleted file mode 100644 index 3f1200b..0000000 --- a/dzgui/views/components/connect_box.py +++ /dev/null @@ -1,68 +0,0 @@ -from dzgui.strings import preconnect -import gi - -gi.require_version("Gtk", "3.0") -from gi.repository import Gdk, Gtk # noqa E402 - - -class ConnectBox(Gtk.Box): - def __init__(self) -> None: - super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=5) - - self.back = Gtk.Button( - label=preconnect.back, halign=Gtk.Align.END, hexpand=True - ) - - # TODO: dynamically update this - self.ok = Gtk.Button(label="Launch", halign=Gtk.Align.END) - - for button in self.back, self.ok: - self.add(button) - - def get_back_button(self) -> Gtk.Button: - return self.back - - def get_ok_button(self) -> Gtk.Button: - return self.ok - - -class PreconnectBox(Gtk.Box): - def __init__(self) -> None: - super().__init__( - orientation=Gtk.Orientation.VERTICAL, - valign=Gtk.Align.END, - vexpand=True, - spacing=5, - ) - - self.button_hbox = ConnectBox() - self.ok = self.button_hbox.get_ok_button() - self.ok.set_label(preconnect.update_mods) - - self.connect_last = Gtk.Button( - label=preconnect.connect_last, - halign=Gtk.Align.END, - tooltip_text=preconnect.connect_last_tooltip, - ) - self.button_hbox.add(self.connect_last) - - self.raise_window = Gtk.CheckButton( - # TODO: strings - label="Foreground DZGUI while downloading", - halign=Gtk.Align.END, - hexpand=True, - valign=Gtk.Align.END, - visible=False, - has_tooltip=True, - sensitive=False, - tooltip_text="Foreground the DZGUI window after mod downloads are queued", - active=True, - ) - self.add(self.button_hbox) - self.add(self.raise_window) - - def get_back_button(self) -> Gtk.Button: - return self.button_hbox.get_back_button() - - def get_ok_button(self) -> Gtk.Button: - return self.ok From 557e2219934ff898042550e7f1f10c7ef4da0e89 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 16 Aug 2026 12:43:19 +0900 Subject: [PATCH 051/139] feat: add SpinnerButton --- dzgui/views/components/buttons.py | 36 ++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/dzgui/views/components/buttons.py b/dzgui/views/components/buttons.py index 8071ed7..6697c17 100644 --- a/dzgui/views/components/buttons.py +++ b/dzgui/views/components/buttons.py @@ -64,7 +64,6 @@ class IconButton(Gtk.Button): self.icon = Icon(icon, margin_start=margin_start, margin_end=margin_end) self.set_image(self.icon) self.set_image_position(position) - # self.set_image_position(Gtk.PositionType.RIGHT) self.set_focus_on_click(False) def swap_icon(self, icon: str) -> None: @@ -267,3 +266,38 @@ class LoggerAlertsButton(IconTextButton): self.set_halign(Gtk.Align.END) self.set_hexpand(True) self.set_tooltip_text(alert_button_tooltip) + + +class SpinnerButton(Gtk.Button): + def __init__(self, label: str) -> None: + super().__init__() + + self.text = label + self.label = Gtk.Label(label=label, halign=Gtk.Align.CENTER) + + self.spinner = Gtk.Spinner() + self.spinner.set_halign(Gtk.Align.END) + self.spinner.set_sensitive(False) + + grid = Gtk.Grid(column_spacing=10) + for el in self.label, self.spinner: + grid.add(el) + + self.add(grid) + + self.connect("clicked", self._on_button_clicked) + self.connect("map", self._on_map) + + def _on_map(self, a) -> None: + self.spinner.set_visible(False) + + def _on_button_clicked(self, button: Self) -> None: + self.start_spinner() + + def stop_spinner(self) -> None: + self.spinner.set_visible(False) + self.spinner.stop() + + def start_spinner(self) -> None: + self.spinner.set_visible(True) + self.spinner.start() From f5c71200a88c6fd5da7f6e9caff222ea506aec90 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 16 Aug 2026 12:44:51 +0900 Subject: [PATCH 052/139] change: move ErrorPopover --- dzgui/views/components/misc.py | 20 +++++++++++++++++++- dzgui/views/pages/offline.py | 24 ++++++------------------ 2 files changed, 25 insertions(+), 19 deletions(-) diff --git a/dzgui/views/components/misc.py b/dzgui/views/components/misc.py index 7b9fc0f..fa8e29c 100644 --- a/dzgui/views/components/misc.py +++ b/dzgui/views/components/misc.py @@ -1,5 +1,8 @@ +from dzgui.const.constants import ERROR, FLATPAK_RUN_CMD, FLATPAK_SANDBOX, STEAM_CMD from dzgui.strings import options -from dzgui.const.constants import FLATPAK_RUN_CMD, FLATPAK_SANDBOX, STEAM_CMD +from dzgui.views.components.box import HBox +from dzgui.views.components.buttons import Icon + import gi @@ -33,3 +36,18 @@ class ClientCombo(Gtk.ComboBox): self.pack_start(renderer_text, True) self.add_attribute(renderer_text, "text", 0) self.set_active(0) + +class ErrorPopover(Gtk.Popover): + def __init__(self, relative_to: Gtk.Widget, position: Gtk.PositionType) -> None: + super().__init__(relative_to=relative_to, position=position) + + self.hbox = HBox() + self.label = Gtk.Label(label="", margin_start=10, margin_end=10) + error_icon = Icon(ERROR, margin_start=10) + self.hbox.extend([error_icon, self.label]) + self.add(self.hbox) + self.show_all() + self.popdown() + + def set_label(self, label: str) -> None: + self.label.set_label(label) diff --git a/dzgui/views/pages/offline.py b/dzgui/views/pages/offline.py index f5baa9d..dec7060 100644 --- a/dzgui/views/pages/offline.py +++ b/dzgui/views/pages/offline.py @@ -9,7 +9,6 @@ from dzgui.const.constants import ( APPNAME_DAYZ, APPNAME_DAYZ_EXP_HUMAN, EDIT_DELETE, - ERROR, FOLDER, WARNING, ) @@ -21,6 +20,7 @@ from dzgui.views.components.buttons import Icon, IconTextButton from dzgui.views.components.eventbox import InfoEventBox from dzgui.views.components.frame import HeadingFrame from dzgui.views.components.scrollable import NoOverlayScrolledWindow +from dzgui.views.components.misc import ErrorPopover from dzgui.views.trees.tree_mods import OfflineModTreeView @@ -57,17 +57,9 @@ class PageHeading(Gtk.Box): css.add_class(self, "page-heading") -class ErrorPopover(Gtk.Popover): - def __init__(self) -> None: - super().__init__(position=Gtk.PositionType.RIGHT) - - self.hbox = HBox() - self.label = Gtk.Label(label="", margin_start=10, margin_end=10) - error_icon = Icon(ERROR, margin_start=10) - self.hbox.extend([error_icon, self.label]) - self.add(self.hbox) - self.show_all() - self.popdown() +class OfflineErrorPopover(ErrorPopover): + def __init__(self, relative_to: Gtk.Widget, position: Gtk.PositionType) -> None: + super().__init__(relative_to=relative_to, position=position) def set_label(self, error: FolderError, msg: str) -> None: match error: @@ -115,13 +107,10 @@ class FolderHBox(HBox): [self.eb, self.button, self.spinner, self.scrolled_label, self.unset_button] ) - self.pop = ErrorPopover() - self.pop.set_relative_to(self.button) + self.pop = OfflineErrorPopover(self.button, Gtk.PositionType.RIGHT) self.pop.connect("unmap", lambda _: self.grab_focus()) - self.sidepop = ErrorPopover() - self.sidepop.set_position(Gtk.PositionType.BOTTOM) - self.sidepop.set_relative_to(self.scrolled_label) + self.sidepop = OfflineErrorPopover(self.scrolled_label, Gtk.PositionType.BOTTOM) self.sidepop.connect("unmap", lambda _: self.grab_focus()) self.connect("map", self._on_map) @@ -467,7 +456,6 @@ class OfflineLoader(Gtk.Box): ] ) - # TODO: share ConnectBox class with preconnect dialog? self.button_box = HBox(spacing=5) self.button_box.set_halign(Gtk.Align.END) self.button_box.set_margin_top(15) From e06775c3c86787743ecea948d624617253198727 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 16 Aug 2026 12:45:26 +0900 Subject: [PATCH 053/139] feat: simplify API udpate signals --- dzgui/controllers/emitter.py | 4 ++++ dzgui/controllers/mc.py | 4 ++-- dzgui/managers/config.py | 11 ++++++----- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/dzgui/controllers/emitter.py b/dzgui/controllers/emitter.py index ba7ef10..28016ca 100644 --- a/dzgui/controllers/emitter.py +++ b/dzgui/controllers/emitter.py @@ -145,6 +145,10 @@ class Emitter(GObject.GObject): def api_change_failed(self) -> None: pass + @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=()) + def api_change_successful(self) -> None: + pass + @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=()) def already_saved_server(self) -> None: pass diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 2a611e1..b8b69fc 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -301,8 +301,8 @@ class Controller(GObject.GObject): dialog = ExceptionDialog(self, str(e)) dialog.run() - def update_api_key(self, key: Preferences, text: str) -> None: - self.config_man.update_api_key(key, text) + def update_steam_api_key(self, text: str) -> None: + self.config_man.update_steam_api_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 2e81f28..ac559b2 100644 --- a/dzgui/managers/config.py +++ b/dzgui/managers/config.py @@ -98,12 +98,13 @@ class ConfigManager: logger.critical(e) raise e - @call_on_thread(dialogs.checking_api) - def update_api_key(self, key: Preferences, text: str) -> None: - if key is Preferences.STEAM: - res = test_steam_api(text) + @call_on_thread(dialogs.checking_api, show_dialog=False) + def update_steam_api_key(self, text: str) -> None: + res = test_steam_api(text) if res is True: - self.update_config(key, text) + self.update_config(Preferences.STEAM, text) + func = StoredFunc(lambda: self.emitter.emit("api_change_successful")) + self.thread_man.set_cleanup_func(func) else: self.thread_man.set_cleanup_func( StoredFunc(lambda: self.emitter.emit("api_change_failed")) From 510719e3ec98124a0ffa71f708de94b4fe061501 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 16 Aug 2026 12:46:16 +0900 Subject: [PATCH 054/139] feat: abstract field creation into SubmitField --- dzgui/strings/options.py | 9 +- dzgui/views/pages/options.py | 342 ++++++++++++++++++++--------------- 2 files changed, 201 insertions(+), 150 deletions(-) diff --git a/dzgui/strings/options.py b/dzgui/strings/options.py index ec28776..fd2aaaf 100644 --- a/dzgui/strings/options.py +++ b/dzgui/strings/options.py @@ -1,12 +1,19 @@ 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)" +steam_placeholder = "Enter your Steam API key" +name_placeholder = "Identifies you to other players" + server_combo = "Server browser" saved_combo = "Saved servers" recent_combo = "Recent" lan_combo = "LAN" + +fullscreen_eventbox = "This option is not available on Steam Deck." +save_button = "Save" + +api_failed = "API validation failed" diff --git a/dzgui/views/pages/options.py b/dzgui/views/pages/options.py index ff97e2e..a0748fc 100644 --- a/dzgui/views/pages/options.py +++ b/dzgui/views/pages/options.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import TYPE_CHECKING +from typing import Callable, TYPE_CHECKING from dzgui.api import pefile as PeFile @@ -17,15 +17,15 @@ from dzgui.const.constants import ( ) from dzgui.const.endpoints import STEAM_API_SETUP from dzgui.const.enum import Preferences, ServerTab -from dzgui.strings import errors, options +from dzgui.strings import options from dzgui.util import strings, css, open_links from dzgui.views.components.box import ShortHBox, VBox from dzgui.views.components.eventbox import InfoEventBox from dzgui.views.components.labels import LeftLabel -from dzgui.views.components.buttons import WebButton +from dzgui.views.components.buttons import SpinnerButton, WebButton from dzgui.views.components.frame import HeadingFrame -from dzgui.views.components.misc import ClientCombo +from dzgui.views.components.misc import ClientCombo, ErrorPopover from dzgui.views.dialogs.generic import ExceptionDialog from dzgui.views.mixins.scrollable_mixin import ScrollableMixin @@ -40,6 +40,182 @@ if TYPE_CHECKING: from dzgui.controllers.emitter import Emitter +class SubmitField(Gtk.Box): + def __init__( + self, + controller: "Controller", + placeholder: str, + context: Preferences, + slow: bool = False, + private: bool = True, + ) -> None: + super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) + + self.callback: Callable + self.controller = controller + self.context = context + + self.old_text: str + self.placeholder = placeholder + + self.entry = Gtk.Entry(placeholder_text=placeholder, hexpand=True) + + # TODO: audit for all applicable callbacks + if private: + self.entry.set_icon_from_icon_name( + Gtk.EntryIconPosition.SECONDARY, VIEW_REVEAL + ) + self.entry.set_icon_activatable(Gtk.EntryIconPosition.SECONDARY, True) + self.entry.connect("icon-release", self._on_icon_release) + self.entry.set_visibility(False) + + if slow: + self.button = SpinnerButton(label=options.save_button) + else: + self.button = Gtk.Button(label=options.save_button) + + self.button.connect("clicked", self._on_save_clicked) + self.entry.connect("insert-text", self._on_text_typed) + self.entry.connect("activate", self._on_field_activated) + self.entry.get_property("buffer").connect("deleted-text", self._on_text_deleted) + + self.add(self.entry) + self.add(self.button) + + def set_text(self, text: str) -> None: + self.old_text = text + self.entry.set_text(text) + if len(text) == 0: + self.button.set_sensitive(False) + + def _is_valid_text(self, text: str) -> bool: + if text.isspace(): + return False + if len(text) == 0: + return False + + if text == self.old_text: + 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.button.set_sensitive(state) + + def _on_text_typed( + self, + entry: Gtk.Entry, + text: str, + length: int, + pos: int, + ) -> None: + + buffer = entry.get_property("buffer") + text = buffer.get_text() + text + state = self._is_valid_text(text) + self.button.set_sensitive(state) + + def _on_icon_release( + self, + widget: Gtk.Entry, + icon_pos: Gtk.EntryIconPosition, + event: Gdk.Event, + ) -> None: + visible = widget.get_visibility() + if visible: + icon, state = VIEW_REVEAL, False + else: + icon, state = VIEW_CONCEAL, True + widget.set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, icon) + widget.set_visibility(state) + + def _on_field_activated(self, entry: Gtk.Entry) -> None: + text = entry.get_text() + if not self._is_valid_text(text): + return + self.save_option() + + def set_callback(self, callback: Callable) -> None: + self.callback = callback + + def save_option(self) -> None: + self.callback() + self.button.set_sensitive(False) + + def _on_save_clicked(self, button: Gtk.Button) -> None: + self.save_option() + + +class SteamSubmitField(SubmitField): + def __init__(self, controller: "Controller") -> None: + super().__init__( + controller, + options.steam_placeholder, + Preferences.STEAM, + slow=True, + private=True, + ) + + self.pop = ErrorPopover( + position=Gtk.PositionType.BOTTOM, relative_to=self.entry + ) + self.pop.set_label(options.api_failed) + self.pop.show_all() + self.pop.popdown() + + emitter = controller.get_emitter() + emitter.connect("api_change_failed", self._on_api_failure) + emitter.connect("api_change_successful", self._on_api_success) + + self.set_callback(self.save_setting) + + def save_setting(self) -> None: + self.entry.set_sensitive(False) + text = "".join(self.entry.get_text().split()) + self.controller.update_steam_api_key(text) + + def _on_api_success(self, emitter: "Emitter") -> None: + self.old_text = self.entry.get_text() + self.button.stop_spinner() + self.entry.set_sensitive(True) + + def _on_api_failure(self, emitter: "Emitter") -> None: + self.pop.popup() + self.button.stop_spinner() + self.entry.set_sensitive(True) + + def block_text_entry(self) -> None: + self.entry.set_position(-1) + self.entry.set_can_focus(False) + + def unblock_text_entry(self) -> None: + self.entry.set_can_focus(True) + + +class NameSubmitField(SubmitField): + def __init__(self, controller: "Controller") -> None: + super().__init__( + controller, + options.name_placeholder, + Preferences.NAME, + private=False, + ) + self.entry.set_width_chars(30) # type: ignore + self.set_callback(self.save_player_name) + + def save_player_name(self) -> None: + value = self.entry.get_text().strip() + self.old_text = value + self.controller.update_config(self.context, value) + + class ToggleField(Gtk.Box): def __init__( self, @@ -80,29 +256,23 @@ class Options(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore self.controller = controller self.controller.register_widget("options", self) - emitter = controller.get_emitter() - emitter.connect("api_change_failed", self._on_api_change_failed) self.DEFAULT_WIDTH = 1 self.DEFAULT_HEIGHT = 1 self.steam_entry: Gtk.Entry + self.pop: Gtk.Popover self.steam = WebButton(label=strings.options.steam_web) self.steam.connect("clicked", self._on_link_button_clicked, STEAM_API_SETUP) - self.steam_box = self._make_submit_field( - strings.options.enter_steam, Preferences.STEAM, True - ) + self.steam_box = SteamSubmitField(controller) api_rows = [ [LeftLabel(strings.options.steam_placeholder), self.steam_box, self.steam], ] - self.player_box = self._make_submit_field( - strings.options.name_placeholder, Preferences.NAME - ) + self.player_box = NameSubmitField(controller) 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) # type: ignore self.fullscreen_toggle = ToggleField( @@ -112,12 +282,6 @@ class Options(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore Preferences.WINDOW, ) - # TODO: strings - eb = InfoEventBox( - "This option is not available on Steam Deck.", self.controller - ) - self.fullscreen_toggle.add(eb) - self.client_combo = ClientCombo() self.client_combo.connect("changed", self._on_client_changed) @@ -210,11 +374,13 @@ class Options(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore return str(model[ind][0]) def block_text_entry(self) -> None: - self.steam_entry.set_position(-1) - self.steam_entry.set_can_focus(False) + self.steam_box.block_text_entry() + # self.steam_entry.set_position(-1) + # self.steam_entry.set_can_focus(False) def unblock_text_entry(self) -> None: - self.steam_entry.set_can_focus(True) + self.steam_box.unblock_text_entry() + # self.steam_entry.set_can_focus(True) def _on_developers_clicked(self, button: Gtk.Button) -> None: self.controller.show_developers_page() @@ -222,46 +388,6 @@ class Options(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore def _on_link_button_clicked(self, button: Gtk.Button, url: str) -> None: open_links.open_link_by_url(url) - def _make_submit_field( - self, - placeholder: str, - context: Preferences, - private: bool = False, - ) -> Gtk.Box: - - entry = Gtk.Entry(placeholder_text=placeholder, hexpand=True) - button = Gtk.Button(label="Save") - - button.connect("clicked", self._on_save_clicked, entry, context) - entry.connect("insert-text", self._on_text_typed, context, button) - entry.connect("activate", self._on_field_activated, context, button) - entry.get_property("buffer").connect( - "deleted-text", self._on_text_deleted, context, button - ) - - if private: - entry.set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, VIEW_REVEAL) - entry.set_icon_activatable(Gtk.EntryIconPosition.SECONDARY, True) - entry.connect("icon-release", self._on_icon_release) - entry.set_visibility(False) - - if context == Preferences.STEAM: - self.steam_entry = entry - - box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) - box.add(entry) - box.add(button) - - return box - - def _on_field_activated( - self, entry: Gtk.Entry, context: Preferences, button: Gtk.Button - ) -> None: - text = entry.get_text() - if not self._is_valid_text(text, context): - return - self._on_save_clicked(button, entry, context) - def _make_grid(self, rows: list) -> Gtk.Grid: grid = Gtk.Grid( orientation=Gtk.Orientation.VERTICAL, @@ -281,35 +407,6 @@ class Options(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore row += 1 return grid - def _on_save_clicked( - self, button: Gtk.Button, entry: Gtk.Entry, enum: Preferences - ) -> None: - old_text = self.controller.query_config(enum) - self.old_text = old_text - self.old_entry = entry - - button.set_sensitive(False) - match enum: - case Preferences.NAME: - value = entry.get_text().strip() - self.controller.update_config(enum, value) - case Preferences.STEAM: - text = "".join(entry.get_text().split()) - self.controller.update_api_key(enum, text) - - def _on_api_change_failed(self, emitter: "Emitter") -> None: - self.old_entry.set_text(self.old_text) - # TODO: use popover - dialog = ExceptionDialog(self.controller, errors.api_validation_error) - dialog.run() - - def restore_api_text(self, text: str, entry: Gtk.Entry) -> None: - entry.set_text(text) - - def revert(self, mode: Preferences) -> None: - if mode == Preferences.STEAM: - self.steam_entry.set_text(self.old_steam) - def _on_start_tab_changed(self, combo: Gtk.ComboBoxText) -> None: _iter = combo.get_active_iter() if _iter is None: @@ -325,49 +422,6 @@ class Options(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore real_cmd = combo.get_model()[_iter][1] self.controller.update_config(Preferences.CLIENT, real_cmd) - def _is_valid_text(self, text: str, context: Preferences) -> bool: - if text.isspace(): - return False - if len(text) == 0: - return False - - match context: - case Preferences.NAME: - old = self.old_name - case Preferences.STEAM: - old = self.old_steam - if text == old: - return False - return True - - def _on_text_deleted( - self, - buffer: Gtk.EntryBuffer, - position: int, - chars: int, - context: Preferences, - button: Gtk.Button, - ) -> None: - - text = buffer.get_text() - state = self._is_valid_text(text, context) - button.set_sensitive(state) - - def _on_text_typed( - self, - entry: Gtk.Entry, - text: str, - length: int, - pos: int, - context: Preferences, - button: Gtk.Button, - ) -> None: - - buffer = entry.get_property("buffer") - text = buffer.get_text() + text - state = self._is_valid_text(text, context) - button.set_sensitive(state) - def populate_settings(self) -> None: prefs = self.controller.get_prefs() # NOTE: re-check in case file was removed by user between runs @@ -385,32 +439,22 @@ class Options(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore steam_path = Path(default_steam_path) - self.old_steam = steam - self.old_name = name - - self.steam_entry.set_text(steam) - p = self.player_box.get_children()[0] - if hasattr(p, "set_text"): - p.set_text(name) + self.steam_box.set_text(steam) + self.player_box.set_text(name) fs = config["fullscreen"] miles = config["use_miles"] if prefs.is_steam_deck: self.fullscreen_toggle.set_sensitive(False) + eb = InfoEventBox(options.fullscreen_eventbox, self.controller) + self.fullscreen_toggle.add(eb) + fs = True self.fullscreen_toggle.set_suboption_active(fs) self.distance_toggle.set_suboption_active(miles) - # NOTE: disable buttons if no text is set - for field in ( - [name, self.player_box], - [steam, self.steam_box], - ): - if field[0] == "": - field[1].get_children()[1].set_sensitive(False) - dayz_version = PeFile.get_pretty_version(steam_path, APPID_DAYZ) if dayz_version is None: dayz_version = strings.null From 1a1145c79cd176cb500dea4b819f73c3d25fe9ef Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 16 Aug 2026 12:56:49 +0900 Subject: [PATCH 055/139] chore: clear typehinting errors --- dzgui/views/components/buttons.py | 2 +- dzgui/views/pages/offline.py | 6 +++--- dzgui/views/pages/options.py | 7 +++++-- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/dzgui/views/components/buttons.py b/dzgui/views/components/buttons.py index 6697c17..45df27d 100644 --- a/dzgui/views/components/buttons.py +++ b/dzgui/views/components/buttons.py @@ -288,7 +288,7 @@ class SpinnerButton(Gtk.Button): self.connect("clicked", self._on_button_clicked) self.connect("map", self._on_map) - def _on_map(self, a) -> None: + def _on_map(self, button: Self) -> None: self.spinner.set_visible(False) def _on_button_clicked(self, button: Self) -> None: diff --git a/dzgui/views/pages/offline.py b/dzgui/views/pages/offline.py index dec7060..b0200b0 100644 --- a/dzgui/views/pages/offline.py +++ b/dzgui/views/pages/offline.py @@ -61,7 +61,7 @@ class OfflineErrorPopover(ErrorPopover): def __init__(self, relative_to: Gtk.Widget, position: Gtk.PositionType) -> None: super().__init__(relative_to=relative_to, position=position) - def set_label(self, error: FolderError, msg: str) -> None: + def set_error_label(self, error: FolderError, msg: str) -> None: match error: case FolderError.NO_VALID_MODS: prefix = offline.no_mods @@ -159,13 +159,13 @@ class FolderHBox(HBox): def present_error(self, error: FolderError, msg: str) -> None: if error == FolderError.FOLDER_CHANGED: - self.sidepop.set_label(error, msg) + self.sidepop.set_error_label(error, msg) self.sidepop.popup() return self.folder = "" self.label.set_text("") self.unset_button.hide() - self.pop.set_label(error, msg) + self.pop.set_error_label(error, msg) self.pop.popup() diff --git a/dzgui/views/pages/options.py b/dzgui/views/pages/options.py index a0748fc..0066a6a 100644 --- a/dzgui/views/pages/options.py +++ b/dzgui/views/pages/options.py @@ -69,6 +69,7 @@ class SubmitField(Gtk.Box): self.entry.connect("icon-release", self._on_icon_release) self.entry.set_visibility(False) + self.button: SpinnerButton | Gtk.Button if slow: self.button = SpinnerButton(label=options.save_button) else: @@ -183,12 +184,14 @@ class SteamSubmitField(SubmitField): def _on_api_success(self, emitter: "Emitter") -> None: self.old_text = self.entry.get_text() - self.button.stop_spinner() + if isinstance(self.button, SpinnerButton): + self.button.stop_spinner() self.entry.set_sensitive(True) def _on_api_failure(self, emitter: "Emitter") -> None: self.pop.popup() - self.button.stop_spinner() + if isinstance(self.button, SpinnerButton): + self.button.stop_spinner() self.entry.set_sensitive(True) def block_text_entry(self) -> None: From 6c810b28a921da05663944308862229348a380ca Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 16 Aug 2026 12:58:30 +0900 Subject: [PATCH 056/139] chore: drop duplicate definition --- dzgui/views/pages/options.py | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/dzgui/views/pages/options.py b/dzgui/views/pages/options.py index 0066a6a..277c13e 100644 --- a/dzgui/views/pages/options.py +++ b/dzgui/views/pages/options.py @@ -485,19 +485,5 @@ class Options(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore self, toggle.get_children()[0], "_on_radio_toggled", state ) - def _on_icon_release( - self, - widget: Gtk.Entry, - icon_pos: Gtk.EntryIconPosition, - event: Gdk.Event, - ) -> None: - visible = widget.get_visibility() - if visible: - icon, state = VIEW_REVEAL, False - else: - icon, state = VIEW_CONCEAL, True - widget.set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, icon) - widget.set_visibility(state) - def grab_content_area(self) -> None: self.grab_focus() From fd7a549dbc3e1750d7f001b736df00ba01004c93 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 16 Aug 2026 14:06:54 +0900 Subject: [PATCH 057/139] change: point to tip of releases page --- docs/source/dzgui7.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/dzgui7.rst b/docs/source/dzgui7.rst index 5793579..b6a0e30 100644 --- a/docs/source/dzgui7.rst +++ b/docs/source/dzgui7.rst @@ -8,7 +8,7 @@ runtime environment are built-in. Turnkey installer ------------------------ -- Visit the project's `Releases page `_. +- Visit the project's `Releases page `_. - Extract the DZGUI tarball from the top of the **Assets** list. - From a terminal, run the command ``./dzgui`` or double click on the extracted file in a file explorer. From f5d595eccde744819fe646366a03fbc75f956dbe Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 16 Aug 2026 14:07:21 +0900 Subject: [PATCH 058/139] chore: drop BM step from setup --- docs/source/setup.rst | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/docs/source/setup.rst b/docs/source/setup.rst index b7204d8..ec1fa07 100644 --- a/docs/source/setup.rst +++ b/docs/source/setup.rst @@ -4,7 +4,7 @@ Setup DayZ license ----------------- -Prepare a Steam account with a DayZ license (i.e., own the game). From Steam's right-click menu for the game options, under ``Compatibility``, enable a Proton version ≥ ``6.8`` (or use Proton +Prepare a Steam account with a DayZ license (i.e., own the game). From Steam's right-click menu for the game options, under ``Compatibility``, enable a Proton version ≥ ``6.8`` (or use Proton Experimental for the latest version). As of this writing, any recent version of Proton should work, and it is encouraged to use the most recent one. @@ -33,20 +33,10 @@ Once configured, you can insert this key in the app when launching it for the fi Battlemetrics API key ^^^^^^^^^^^^^^^^^^^^^^ -**This step is optional, but recommended**. Using this key lets you also connect to and query servers by their shorthand ID instead of by IP. +.. warning:: -Register for an API key at `BattleMetrics `_ (free). - -From the ``Personal Access Tokens area``, select ``New Token``. - -Give the token any name in the field at the top. - -Leave **all options unchecked** and scroll to the bottom. Select ``Create Token``. - -Once configured, you can insert this key in the app when launching it for the first time (optional), or later on when using the connect/query by ID methods in the app. - -.. tip:: - Each server has a unique ID. This is the string of numbers at the end of the URL. For example, in the URL ``https://www.battlemetrics.com/servers/dayz/8039514``, the ID is ``8039514``. + This feature is no longer supported in DZGUI. Battlemetrics has switched to a paid service. + Battlemetrics queries are expected to fail in DZGUI 6. In DZGUI 7, this feature has been removed entirely. Next steps -------------- From 70ccbf29f3ddd6b4a621a8a6ff6aef8f1e464da0 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 16 Aug 2026 14:07:52 +0900 Subject: [PATCH 059/139] chore: mention inbuilt support in DZGUI 7 --- docs/source/steam.rst | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/source/steam.rst b/docs/source/steam.rst index c709725..687c75d 100644 --- a/docs/source/steam.rst +++ b/docs/source/steam.rst @@ -1,6 +1,10 @@ Steam integration ==================== +.. note:: + + In :doc:`DZGUI 7 `, Steam shortcut creation and cover art setup are handled automatically during setup. You can skip this step unless you are using DZGUI 6. + DZGUI can be added to Steam as a "non-Steam game" in order to facilitate integration with Steam Deck or desktop environments. First, launch Steam in the **Large** (default) view. @@ -74,7 +78,7 @@ The final result will create box art looking like the above. :scale: 100% :alt: Adding a non-Steam game -Next, right-click the DZGUI entry in your Library and select **Properties** to open the properties dialog. Next to the **Shortcut** field, you will see a small square box which represents the game’s +Next, right-click the DZGUI entry in your Library and select **Properties** to open the properties dialog. Next to the **Shortcut** field, you will see a small square box which represents the game’s icon. Click this to open a file explorer, navigate to ``$HOME/.local/share/dzgui``, and select ``icon.png``. This will add a small icon to the list view. Finally, after you launch DZGUI for the first time, you should quit the application and return to the Library view. Select the **Recent Games** dropdown on the right-hand side. From fe8bf611e16144e4269deafe2a63582d41612dd0 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 16 Aug 2026 14:14:36 +0900 Subject: [PATCH 060/139] chore: expand knowledge base --- docs/source/kb.rst | 44 +++++++++++++++++++++++++++++++++++++------- 1 file changed, 37 insertions(+), 7 deletions(-) diff --git a/docs/source/kb.rst b/docs/source/kb.rst index 638f189..afaa68f 100644 --- a/docs/source/kb.rst +++ b/docs/source/kb.rst @@ -3,17 +3,22 @@ Knowledge Base .. _DZG-001: -DZG-001: Periodically getting dropped from servers, or servers time out in DZGUI +DZG-001: Timeouts occur while trying to query a specific server ------------------------------------------------------------------------------------ -DayZ opens a large number of connections while querying/connected to servers. +The leading cause of specific servers periodically timing out is local network configuration. + +Many third-party DayZ servers use a server rental/hosting provider with DDoS protection. +This can cause responses from servers to originate from a server other than the one originally queried. + +Consumer-grade routers are likely to drop this traffic as invalid due to how they handle NAT (network address translation). +By contrast, wireless routers and enterprise-grade routers may be less likely to have this issue. + +If you find that a specific server is unresponsive for you when it shouldn't be, add a port forwarding rule to your router's settings for +the server's query port. In addition, packets sent from server responses are expected to be a standard size (see warning below). +Deviation from this may cause your router to discard incoming responses from the server. -If your network does not have enough headroom or has settings departing from defaults, this may lead to getting dropped from servers, -unresponsiveness, or a timeout. - -If you are on Wi-Fi, try switching to a wired connection and see if the problem resolves itself. Consumer Wi-Fi routers -tend to have less headroom than their wired counterparts. .. important:: Ensure that MTU (maximum tranmission unit) on your network is set to the standard size of 1,500 bytes. @@ -80,3 +85,28 @@ There is some misconception that a Steam Web API key could be used to gain infor A Steam Web API key is the most strict way of getting authentic, reliable, and consistent server information in a zero-trust model. You are responsible for the creation, storage, management, and revocation of your Web API key. + +DZG-008: Periodically getting dropped from servers while connected +------------------------------------------------------------------- +In some cases, DayZ opens a large number of connections while connected to servers. + +If your network does not have enough headroom or has settings departing from defaults, this may lead to getting dropped from servers, +unresponsiveness, or a timeout. + +If you are on Wi-Fi, try switching to a wired connection and see if the problem resolves itself. Consumer Wi-Fi routers +tend to have less headroom for simultaneous connections than their wired counterparts. + +DZG-009: Floating dialogs appear maximized on tiling window managers +------------------------------------------------------------------- + +The main DZGUI window and its child dialogs are expected to be rendered as floating by your window manager. +DZGUI sends window manager hints to this effect, but tiling window managers designed to bisect the screen into quadrants (e.g., i3 window manager) +may try to always launch applications in fullscreen. + +To resolve this, set specific exclusions or window hints in your WM's configuration file. +For example, for i3, add the following to your `XDG_CONFIG_HOME/i3/config` file (defaults to `$HOME/.config/i3/config`): + +.. code:: console + + for_window [instance="DZGUI"] floating enable, move position center + for_window [instance="DZGUI - Dialog"] floating enable, move position center From a97c17f66b8339c20df6b9bd272bc6983b3c2488 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 16 Aug 2026 14:18:39 +0900 Subject: [PATCH 061/139] docs: update contributing --- CONTRIBUTING.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e5ba222..77d627a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -67,3 +67,7 @@ It is recommended to follow integrates well with tooling and helps the project review your code. Please see DEVELOPERS.md for further details. + +## License applicability to contributions + +Code contributed to this repository becomes subject to the terms of the GPL license defined in the LICENSE file at the project root. From afca9e5e6a1b25a2a84f378446e1ce9047e81442 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 16 Aug 2026 14:06:54 +0900 Subject: [PATCH 062/139] change: point to tip of releases page --- docs/source/dzgui7.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/dzgui7.rst b/docs/source/dzgui7.rst index 5793579..b6a0e30 100644 --- a/docs/source/dzgui7.rst +++ b/docs/source/dzgui7.rst @@ -8,7 +8,7 @@ runtime environment are built-in. Turnkey installer ------------------------ -- Visit the project's `Releases page `_. +- Visit the project's `Releases page `_. - Extract the DZGUI tarball from the top of the **Assets** list. - From a terminal, run the command ``./dzgui`` or double click on the extracted file in a file explorer. From 080b99cda8cd551c463b2f36b9ae4e8b549ef90f Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 16 Aug 2026 14:07:21 +0900 Subject: [PATCH 063/139] chore: drop BM step from setup --- docs/source/setup.rst | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/docs/source/setup.rst b/docs/source/setup.rst index b7204d8..ec1fa07 100644 --- a/docs/source/setup.rst +++ b/docs/source/setup.rst @@ -4,7 +4,7 @@ Setup DayZ license ----------------- -Prepare a Steam account with a DayZ license (i.e., own the game). From Steam's right-click menu for the game options, under ``Compatibility``, enable a Proton version ≥ ``6.8`` (or use Proton +Prepare a Steam account with a DayZ license (i.e., own the game). From Steam's right-click menu for the game options, under ``Compatibility``, enable a Proton version ≥ ``6.8`` (or use Proton Experimental for the latest version). As of this writing, any recent version of Proton should work, and it is encouraged to use the most recent one. @@ -33,20 +33,10 @@ Once configured, you can insert this key in the app when launching it for the fi Battlemetrics API key ^^^^^^^^^^^^^^^^^^^^^^ -**This step is optional, but recommended**. Using this key lets you also connect to and query servers by their shorthand ID instead of by IP. +.. warning:: -Register for an API key at `BattleMetrics `_ (free). - -From the ``Personal Access Tokens area``, select ``New Token``. - -Give the token any name in the field at the top. - -Leave **all options unchecked** and scroll to the bottom. Select ``Create Token``. - -Once configured, you can insert this key in the app when launching it for the first time (optional), or later on when using the connect/query by ID methods in the app. - -.. tip:: - Each server has a unique ID. This is the string of numbers at the end of the URL. For example, in the URL ``https://www.battlemetrics.com/servers/dayz/8039514``, the ID is ``8039514``. + This feature is no longer supported in DZGUI. Battlemetrics has switched to a paid service. + Battlemetrics queries are expected to fail in DZGUI 6. In DZGUI 7, this feature has been removed entirely. Next steps -------------- From a421d4c70b785f91bef13da7b5176acd547fe9e8 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 16 Aug 2026 14:07:52 +0900 Subject: [PATCH 064/139] chore: mention inbuilt support in DZGUI 7 --- docs/source/steam.rst | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/source/steam.rst b/docs/source/steam.rst index c709725..687c75d 100644 --- a/docs/source/steam.rst +++ b/docs/source/steam.rst @@ -1,6 +1,10 @@ Steam integration ==================== +.. note:: + + In :doc:`DZGUI 7 `, Steam shortcut creation and cover art setup are handled automatically during setup. You can skip this step unless you are using DZGUI 6. + DZGUI can be added to Steam as a "non-Steam game" in order to facilitate integration with Steam Deck or desktop environments. First, launch Steam in the **Large** (default) view. @@ -74,7 +78,7 @@ The final result will create box art looking like the above. :scale: 100% :alt: Adding a non-Steam game -Next, right-click the DZGUI entry in your Library and select **Properties** to open the properties dialog. Next to the **Shortcut** field, you will see a small square box which represents the game’s +Next, right-click the DZGUI entry in your Library and select **Properties** to open the properties dialog. Next to the **Shortcut** field, you will see a small square box which represents the game’s icon. Click this to open a file explorer, navigate to ``$HOME/.local/share/dzgui``, and select ``icon.png``. This will add a small icon to the list view. Finally, after you launch DZGUI for the first time, you should quit the application and return to the Library view. Select the **Recent Games** dropdown on the right-hand side. From ee33ce6662f873839edbc1ed92c3280af9c95488 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 16 Aug 2026 14:14:36 +0900 Subject: [PATCH 065/139] chore: expand knowledge base --- docs/source/kb.rst | 44 +++++++++++++++++++++++++++++++++++++------- 1 file changed, 37 insertions(+), 7 deletions(-) diff --git a/docs/source/kb.rst b/docs/source/kb.rst index 638f189..afaa68f 100644 --- a/docs/source/kb.rst +++ b/docs/source/kb.rst @@ -3,17 +3,22 @@ Knowledge Base .. _DZG-001: -DZG-001: Periodically getting dropped from servers, or servers time out in DZGUI +DZG-001: Timeouts occur while trying to query a specific server ------------------------------------------------------------------------------------ -DayZ opens a large number of connections while querying/connected to servers. +The leading cause of specific servers periodically timing out is local network configuration. + +Many third-party DayZ servers use a server rental/hosting provider with DDoS protection. +This can cause responses from servers to originate from a server other than the one originally queried. + +Consumer-grade routers are likely to drop this traffic as invalid due to how they handle NAT (network address translation). +By contrast, wireless routers and enterprise-grade routers may be less likely to have this issue. + +If you find that a specific server is unresponsive for you when it shouldn't be, add a port forwarding rule to your router's settings for +the server's query port. In addition, packets sent from server responses are expected to be a standard size (see warning below). +Deviation from this may cause your router to discard incoming responses from the server. -If your network does not have enough headroom or has settings departing from defaults, this may lead to getting dropped from servers, -unresponsiveness, or a timeout. - -If you are on Wi-Fi, try switching to a wired connection and see if the problem resolves itself. Consumer Wi-Fi routers -tend to have less headroom than their wired counterparts. .. important:: Ensure that MTU (maximum tranmission unit) on your network is set to the standard size of 1,500 bytes. @@ -80,3 +85,28 @@ There is some misconception that a Steam Web API key could be used to gain infor A Steam Web API key is the most strict way of getting authentic, reliable, and consistent server information in a zero-trust model. You are responsible for the creation, storage, management, and revocation of your Web API key. + +DZG-008: Periodically getting dropped from servers while connected +------------------------------------------------------------------- +In some cases, DayZ opens a large number of connections while connected to servers. + +If your network does not have enough headroom or has settings departing from defaults, this may lead to getting dropped from servers, +unresponsiveness, or a timeout. + +If you are on Wi-Fi, try switching to a wired connection and see if the problem resolves itself. Consumer Wi-Fi routers +tend to have less headroom for simultaneous connections than their wired counterparts. + +DZG-009: Floating dialogs appear maximized on tiling window managers +------------------------------------------------------------------- + +The main DZGUI window and its child dialogs are expected to be rendered as floating by your window manager. +DZGUI sends window manager hints to this effect, but tiling window managers designed to bisect the screen into quadrants (e.g., i3 window manager) +may try to always launch applications in fullscreen. + +To resolve this, set specific exclusions or window hints in your WM's configuration file. +For example, for i3, add the following to your `XDG_CONFIG_HOME/i3/config` file (defaults to `$HOME/.config/i3/config`): + +.. code:: console + + for_window [instance="DZGUI"] floating enable, move position center + for_window [instance="DZGUI - Dialog"] floating enable, move position center From f7e0b44379ef03194359d83097c3544c713e597a Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 16 Aug 2026 14:18:39 +0900 Subject: [PATCH 066/139] docs: update contributing --- CONTRIBUTING.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e5ba222..77d627a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -67,3 +67,7 @@ It is recommended to follow integrates well with tooling and helps the project review your code. Please see DEVELOPERS.md for further details. + +## License applicability to contributions + +Code contributed to this repository becomes subject to the terms of the GPL license defined in the LICENSE file at the project root. From 88ad8cf7c23570f09b9fc28701e1d26883e97731 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Mon, 17 Aug 2026 17:29:38 +0900 Subject: [PATCH 067/139] feat: expand all changelog nodes --- dzgui/views/pages/changelog.py | 32 +++++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/dzgui/views/pages/changelog.py b/dzgui/views/pages/changelog.py index 4d36637..9548b11 100644 --- a/dzgui/views/pages/changelog.py +++ b/dzgui/views/pages/changelog.py @@ -1,5 +1,6 @@ import logging import re +import textwrap from typing import TYPE_CHECKING from importlib import resources @@ -7,6 +8,7 @@ from importlib import resources 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.components.box import HBox, VBox from dzgui.views.mixins.help_menu_mixin import HelpMenuMixin from dzgui.views.mixins.scrollable_mixin import ScrollableMixin @@ -31,20 +33,40 @@ class Changelog(HelpMenuMixin, ScrollableMixin, Gtk.ScrolledWindow): # type: ig logger.critical(e) changelog = missing_changelog - # TODO: should long text be wrapped? + # FIXME: wrap long text self.controller = controller self.box = Gtk.Box( orientation=Gtk.Orientation.VERTICAL, spacing=5, margin_top=10 ) self.add(self.box) + expand_all = Gtk.Button( + label="Expand all", halign=Gtk.Align.START, margin_start=20 + ) + expand_all.connect("clicked", self._on_expand_all_clicked) + self.box.add(expand_all) + + self.expanded = False + self.expanders: list[Gtk.Expander] = [] self.connect("key-press-event", self._on_keypress) self.connect("key-press-event", self._on_esc_keypress) changes = self._parse(changelog) self._generate_nodes(changes) + self.show_all() + def _on_expand_all_clicked(self, button: Gtk.Button) -> None: + self.expanded = not self.expanded + for expander in self.expanders: + # NOTE: simply setting set_expanded() does not trigger activate() signal, + # so margins are not applied + if expander.get_expanded() == self.expanded: + continue + expander.activate() + label = "Collapse all" if self.expanded else "Expand all" + button.set_label(label) + def grab_content_area(self) -> None: self.grab_focus() @@ -65,9 +87,7 @@ class Changelog(HelpMenuMixin, ScrollableMixin, Gtk.ScrolledWindow): # type: ig text = "\n".join(changes) formatted = format_pango(text) - container = Gtk.Box( - valign=Gtk.Align.START, halign=Gtk.Align.START - ) + container = Gtk.Box(valign=Gtk.Align.START, halign=Gtk.Align.START) label = Gtk.Label() label.set_markup(formatted) container.add(label) @@ -81,6 +101,7 @@ class Changelog(HelpMenuMixin, ScrollableMixin, Gtk.ScrolledWindow): # type: ig expander.add(container) expander.connect("activate", self._on_expand, container) self.box.add(expander) + self.expanders.append(expander) def _on_expand(self, expander: Gtk.Box, container: Gtk.Box) -> None: """ @@ -94,6 +115,7 @@ class Changelog(HelpMenuMixin, ScrollableMixin, Gtk.ScrolledWindow): # type: ig container.set_margin_bottom(15) def _parse(self, changelog: str) -> list[tuple[str, list[str]]]: + release = "" releases: list[tuple[str, list[str]]] = [] release_notes: list[str] = [] @@ -107,5 +129,5 @@ class Changelog(HelpMenuMixin, ScrollableMixin, Gtk.ScrolledWindow): # type: ig release = "" release = line continue - release_notes.append(line.rstrip()) + release_notes.append(textwrap.fill(line.rstrip(), width=120)) return releases From eeffdb57f36f7aa6eea51e1cc5501957363b7b7c Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Mon, 17 Aug 2026 17:30:10 +0900 Subject: [PATCH 068/139] chore: remove unused imports --- dzgui/views/pages/changelog.py | 1 - 1 file changed, 1 deletion(-) diff --git a/dzgui/views/pages/changelog.py b/dzgui/views/pages/changelog.py index 9548b11..35d6e48 100644 --- a/dzgui/views/pages/changelog.py +++ b/dzgui/views/pages/changelog.py @@ -8,7 +8,6 @@ from importlib import resources 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.components.box import HBox, VBox from dzgui.views.mixins.help_menu_mixin import HelpMenuMixin from dzgui.views.mixins.scrollable_mixin import ScrollableMixin From 456c6e62e8b9be7f6d5aa356b24186b4b7e4333b Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Mon, 17 Aug 2026 17:59:13 +0900 Subject: [PATCH 069/139] chore: add test for changelog markdown headers --- tests/test_changelog.py | 40 +++++++++++++++++++++++++++------------- 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/tests/test_changelog.py b/tests/test_changelog.py index 3eae186..acc6019 100644 --- a/tests/test_changelog.py +++ b/tests/test_changelog.py @@ -1,20 +1,34 @@ +import re import pytest -from importlib import resources -from dzgui.const.constants import APP_NAME_LOWER, CHANGELOG_PATH +from pathlib import Path @pytest.fixture -def changelog(): - path = resources.files(APP_NAME_LOWER).joinpath(CHANGELOG_PATH) - return path +def changelog(request) -> None: + root = request.config.rootpath + changelog = Path(root).joinpath("CHANGELOG.md").read_text() + return changelog -def test_headings(changelog): - with open(changelog, "r") as f: - lines = f.readlines() - for line in lines: - if line.startswith("#"): - pass - # TODO: use regex - pass +def count_hash(line: str) -> int: + cnt = 0 + for c in line: + if c == "#": + cnt += 1 + return cnt + + +@pytest.mark.FOO +def test_changelog_prefix(changelog) -> None: + r = r".*(\[.*\]).*" + lines = changelog.splitlines() + sort = sorted(lines) + match = [line for line in sort if line.startswith("#")] + for m in match: + if "Changelog" in m: + assert count_hash(m) == 1 + elif re.match(r, m) is not None: + assert count_hash(m) == 2 + else: + assert count_hash(m) == 3 From 79bbc25714e5fe72b3936d5f68d1b6951a6b40d6 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Mon, 17 Aug 2026 17:59:13 +0900 Subject: [PATCH 070/139] chore: add test for changelog markdown headers --- tests/test_changelog.py | 40 +++++++++++++++++++++++++++------------- 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/tests/test_changelog.py b/tests/test_changelog.py index 3eae186..acc6019 100644 --- a/tests/test_changelog.py +++ b/tests/test_changelog.py @@ -1,20 +1,34 @@ +import re import pytest -from importlib import resources -from dzgui.const.constants import APP_NAME_LOWER, CHANGELOG_PATH +from pathlib import Path @pytest.fixture -def changelog(): - path = resources.files(APP_NAME_LOWER).joinpath(CHANGELOG_PATH) - return path +def changelog(request) -> None: + root = request.config.rootpath + changelog = Path(root).joinpath("CHANGELOG.md").read_text() + return changelog -def test_headings(changelog): - with open(changelog, "r") as f: - lines = f.readlines() - for line in lines: - if line.startswith("#"): - pass - # TODO: use regex - pass +def count_hash(line: str) -> int: + cnt = 0 + for c in line: + if c == "#": + cnt += 1 + return cnt + + +@pytest.mark.FOO +def test_changelog_prefix(changelog) -> None: + r = r".*(\[.*\]).*" + lines = changelog.splitlines() + sort = sorted(lines) + match = [line for line in sort if line.startswith("#")] + for m in match: + if "Changelog" in m: + assert count_hash(m) == 1 + elif re.match(r, m) is not None: + assert count_hash(m) == 2 + else: + assert count_hash(m) == 3 From 5536180fecdd5341c09bf781ef90bf645616d8f7 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Mon, 17 Aug 2026 18:03:27 +0900 Subject: [PATCH 071/139] chore: drop placeholder mark --- tests/test_changelog.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_changelog.py b/tests/test_changelog.py index acc6019..e897571 100644 --- a/tests/test_changelog.py +++ b/tests/test_changelog.py @@ -19,7 +19,6 @@ def count_hash(line: str) -> int: return cnt -@pytest.mark.FOO def test_changelog_prefix(changelog) -> None: r = r".*(\[.*\]).*" lines = changelog.splitlines() From d0ccc3bf4cacd073d507154ccf57f6c15d83dda3 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:22:14 +0900 Subject: [PATCH 072/139] feat: add notebook to ExceptionDialog --- dzgui/views/dialogs/generic.py | 54 ++++++++++++++++++++++++++++++---- 1 file changed, 49 insertions(+), 5 deletions(-) diff --git a/dzgui/views/dialogs/generic.py b/dzgui/views/dialogs/generic.py index c9b890c..19e6b44 100644 --- a/dzgui/views/dialogs/generic.py +++ b/dzgui/views/dialogs/generic.py @@ -196,17 +196,49 @@ 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 + wrap_mode=Gtk.WrapMode.WORD, + editable=False, + left_margin=10, + right_margin=10, + top_margin=15, ) textview.set_buffer(Gtk.TextBuffer(text=self.trace)) - box.pack_start(textview, EXPAND, FILL, 10) - scrollable.add(box) + box.pack_start(textview, EXPAND, FILL, 0) + + self.error_details = Gtk.ScrolledWindow( + overlay_scrolling=False, + max_content_height=150, + propagate_natural_height=False, + ) + details_box = Gtk.Box( + hexpand=True, + vexpand=True, + margin_right=5, + orientation=Gtk.Orientation.VERTICAL, + ) + + self.details_buffer = Gtk.TextBuffer() + details_textview = Gtk.TextView( + wrap_mode=Gtk.WrapMode.WORD_CHAR, + editable=False, + left_margin=10, + right_margin=10, + top_margin=15, + ) + + details_textview.set_buffer(self.details_buffer) + details_box.pack_start(details_textview, EXPAND, FILL, 10) + self.error_details.add(details_box) + + self.error_notebook = Gtk.Notebook(show_tabs=False, margin_bottom=15) + self.error_notebook.append_page(box, Gtk.Label(label="Error")) + self.error_notebook.append_page(self.error_details, Gtk.Label(label="Details")) + self.error_notebook.connect("switch-page", self._on_page_changed) + scrollable.add(self.error_notebook) content = self.get_content_area() content.set_spacing(0) - # TODO: padding around top of content area when traceback is long content.add(scrollable) copy_button = ClipboardButton(controller, self.get_trace) @@ -219,6 +251,18 @@ class ExceptionDialog(GenericDialog): self.ok.grab_focus() self.connect("response", self._on_response) + def _on_page_changed( + self, notebook: Gtk.Notebook, child: Gtk.Widget, index: int + ) -> None: + if child == self.error_details: + child.set_propagate_natural_height(True) + else: + self.error_details.set_propagate_natural_height(False) + + def set_secondary_text(self, text: str) -> None: + self.details_buffer.set_text(text) + self.error_notebook.set_show_tabs(True) + def get_trace(self) -> str: return self.trace From 067b77bf92d639c3b2f280f873983b8111c29103 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:26:42 +0900 Subject: [PATCH 073/139] fix: clear typehinting errors --- dzgui/views/dialogs/generic.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dzgui/views/dialogs/generic.py b/dzgui/views/dialogs/generic.py index 19e6b44..16d0e8e 100644 --- a/dzgui/views/dialogs/generic.py +++ b/dzgui/views/dialogs/generic.py @@ -252,10 +252,10 @@ class ExceptionDialog(GenericDialog): self.connect("response", self._on_response) def _on_page_changed( - self, notebook: Gtk.Notebook, child: Gtk.Widget, index: int + self, notebook: Gtk.Notebook, child: Gtk.Box | Gtk.ScrolledWindow, index: int ) -> None: if child == self.error_details: - child.set_propagate_natural_height(True) + self.error_details.set_propagate_natural_height(True) else: self.error_details.set_propagate_natural_height(False) From 1e8a423ec346a1d5c7e9dd167959458bb59292be Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:38:45 +0900 Subject: [PATCH 074/139] fix: add bottom padding --- dzgui/views/dialogs/generic.py | 1 + 1 file changed, 1 insertion(+) diff --git a/dzgui/views/dialogs/generic.py b/dzgui/views/dialogs/generic.py index 16d0e8e..f3174eb 100644 --- a/dzgui/views/dialogs/generic.py +++ b/dzgui/views/dialogs/generic.py @@ -202,6 +202,7 @@ class ExceptionDialog(GenericDialog): left_margin=10, right_margin=10, top_margin=15, + bottom_margin=10, ) textview.set_buffer(Gtk.TextBuffer(text=self.trace)) box.pack_start(textview, EXPAND, FILL, 0) From 8098c4b68c7833a9074ec02621b1eae32dfd9073 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:40:02 +0900 Subject: [PATCH 075/139] feat: internal knowledeat: add knowledge base text to specific exception dialogs --- dzgui/managers/connection.py | 6 +++++- dzgui/managers/mods.py | 3 ++- dzgui/model/servers.py | 3 ++- dzgui/strings/kb.py | 15 +++++++++++++++ 4 files changed, 24 insertions(+), 3 deletions(-) create mode 100644 dzgui/strings/kb.py diff --git a/dzgui/managers/connection.py b/dzgui/managers/connection.py index c96abc2..9baf109 100644 --- a/dzgui/managers/connection.py +++ b/dzgui/managers/connection.py @@ -45,6 +45,7 @@ from dzgui.strings.dialogs import ( waiting_for_mods, waiting_for_directories, ) +from dzgui.strings import kb from dzgui.strings.server_mods import checkmark, resync from dzgui.util.format import format_mib from dzgui.util.strings import dialog, server_timeout @@ -193,7 +194,9 @@ class ConnectionManager: is_last = self.is_last_server() # TODO: strings - invalid_mods = [(mod[0], mod[1]) for mod in remote_mods if mod[2] == "Invalid mod"] + invalid_mods = [ + (mod[0], mod[1]) for mod in remote_mods if mod[2] == "Invalid mod" + ] prereqs = Prerequisites( name=info.server_name, @@ -298,6 +301,7 @@ class ConnectionManager: def _server_timeout(self) -> None: dialog = ExceptionDialog(self.controller, server_timeout) + dialog.set_secondary_text(kb.DZG_006) dialog.run() def _connect_steam(self, menu_only: bool) -> None: diff --git a/dzgui/managers/mods.py b/dzgui/managers/mods.py index da31385..522c340 100644 --- a/dzgui/managers/mods.py +++ b/dzgui/managers/mods.py @@ -20,7 +20,7 @@ from dzgui.const.constants import ( 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.strings import dialogs +from dzgui.strings import dialogs, kb from dzgui.util.format import format_mods from dzgui.util.strings import server_timeout from dzgui.util.symlink import rebuild_symlinks @@ -215,6 +215,7 @@ class ModManager: def _server_timeout(self) -> None: dialog = ExceptionDialog(self.controller, server_timeout) + dialog.set_seconary_text(kb.DZG_006) dialog.run() def select_colorized(self) -> None: diff --git a/dzgui/model/servers.py b/dzgui/model/servers.py index bc5d616..cfb2c0a 100644 --- a/dzgui/model/servers.py +++ b/dzgui/model/servers.py @@ -14,7 +14,7 @@ from dzgui.const.constants import ( ) from dzgui.const.enum import FilterMode, Preferences, ServerTab from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager -from dzgui.strings import dialogs +from dzgui.strings import dialogs, kb from dzgui.util.strings import server_timeout, dialog from dzgui.views.dialogs.generic import ExceptionDialog @@ -420,6 +420,7 @@ class ServerModelManager: # customize statusbar and dialog accordingly if show_dialog: dialog = ExceptionDialog(self.controller, server_timeout) + dialog.set_secondary_text(kb.DZG_006) dialog.run() def _push_data(self, data: list[Any]) -> None: diff --git a/dzgui/strings/kb.py b/dzgui/strings/kb.py new file mode 100644 index 0000000..42f300b --- /dev/null +++ b/dzgui/strings/kb.py @@ -0,0 +1,15 @@ +DZG_006 = """The leading cause of specific servers periodically timing out is local network configuration. + +Many third-party DayZ servers use a server rental/hosting provider with DDoS protection. + +This can cause responses from servers to originate from a server other than the one originally queried. + +Consumer-grade routers are likely to drop this traffic as invalid due to how they handle NAT (network address translation). + +By contrast, wireless routers and enterprise-grade routers may be less likely to have this issue. + +If you find that a specific server is unresponsive for you when it shouldn't be, add a port forwarding rule to your router's settings for the server's query port. + +In addition, packets received from server responses are expected to be a standard size: MTU (maximum transmission unit) of 1500. + +Deviation from this may cause your router to discard incoming responses from the server.""" From 250da7a635e77a2bde2d8bd094d673208487c5b8 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:40:34 +0900 Subject: [PATCH 076/139] fix: typo --- 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 522c340..742873a 100644 --- a/dzgui/managers/mods.py +++ b/dzgui/managers/mods.py @@ -215,7 +215,7 @@ class ModManager: def _server_timeout(self) -> None: dialog = ExceptionDialog(self.controller, server_timeout) - dialog.set_seconary_text(kb.DZG_006) + dialog.set_secondary_text(kb.DZG_006) dialog.run() def select_colorized(self) -> None: From 1e073af27ab548ee8dd2e91e596d71873808dfdb Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:22:14 +0900 Subject: [PATCH 077/139] feat: add notebook to ExceptionDialog --- dzgui/views/dialogs/generic.py | 54 ++++++++++++++++++++++++++++++---- 1 file changed, 49 insertions(+), 5 deletions(-) diff --git a/dzgui/views/dialogs/generic.py b/dzgui/views/dialogs/generic.py index c9b890c..19e6b44 100644 --- a/dzgui/views/dialogs/generic.py +++ b/dzgui/views/dialogs/generic.py @@ -196,17 +196,49 @@ 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 + wrap_mode=Gtk.WrapMode.WORD, + editable=False, + left_margin=10, + right_margin=10, + top_margin=15, ) textview.set_buffer(Gtk.TextBuffer(text=self.trace)) - box.pack_start(textview, EXPAND, FILL, 10) - scrollable.add(box) + box.pack_start(textview, EXPAND, FILL, 0) + + self.error_details = Gtk.ScrolledWindow( + overlay_scrolling=False, + max_content_height=150, + propagate_natural_height=False, + ) + details_box = Gtk.Box( + hexpand=True, + vexpand=True, + margin_right=5, + orientation=Gtk.Orientation.VERTICAL, + ) + + self.details_buffer = Gtk.TextBuffer() + details_textview = Gtk.TextView( + wrap_mode=Gtk.WrapMode.WORD_CHAR, + editable=False, + left_margin=10, + right_margin=10, + top_margin=15, + ) + + details_textview.set_buffer(self.details_buffer) + details_box.pack_start(details_textview, EXPAND, FILL, 10) + self.error_details.add(details_box) + + self.error_notebook = Gtk.Notebook(show_tabs=False, margin_bottom=15) + self.error_notebook.append_page(box, Gtk.Label(label="Error")) + self.error_notebook.append_page(self.error_details, Gtk.Label(label="Details")) + self.error_notebook.connect("switch-page", self._on_page_changed) + scrollable.add(self.error_notebook) content = self.get_content_area() content.set_spacing(0) - # TODO: padding around top of content area when traceback is long content.add(scrollable) copy_button = ClipboardButton(controller, self.get_trace) @@ -219,6 +251,18 @@ class ExceptionDialog(GenericDialog): self.ok.grab_focus() self.connect("response", self._on_response) + def _on_page_changed( + self, notebook: Gtk.Notebook, child: Gtk.Widget, index: int + ) -> None: + if child == self.error_details: + child.set_propagate_natural_height(True) + else: + self.error_details.set_propagate_natural_height(False) + + def set_secondary_text(self, text: str) -> None: + self.details_buffer.set_text(text) + self.error_notebook.set_show_tabs(True) + def get_trace(self) -> str: return self.trace From 0ebc25b3b4b0669be63cf92acaf6e9f9153b550d Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:26:42 +0900 Subject: [PATCH 078/139] fix: clear typehinting errors --- dzgui/views/dialogs/generic.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dzgui/views/dialogs/generic.py b/dzgui/views/dialogs/generic.py index 19e6b44..16d0e8e 100644 --- a/dzgui/views/dialogs/generic.py +++ b/dzgui/views/dialogs/generic.py @@ -252,10 +252,10 @@ class ExceptionDialog(GenericDialog): self.connect("response", self._on_response) def _on_page_changed( - self, notebook: Gtk.Notebook, child: Gtk.Widget, index: int + self, notebook: Gtk.Notebook, child: Gtk.Box | Gtk.ScrolledWindow, index: int ) -> None: if child == self.error_details: - child.set_propagate_natural_height(True) + self.error_details.set_propagate_natural_height(True) else: self.error_details.set_propagate_natural_height(False) From 8087f3a707b120da95a797908e2e0e7974276ab5 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:38:45 +0900 Subject: [PATCH 079/139] fix: add bottom padding --- dzgui/views/dialogs/generic.py | 1 + 1 file changed, 1 insertion(+) diff --git a/dzgui/views/dialogs/generic.py b/dzgui/views/dialogs/generic.py index 16d0e8e..f3174eb 100644 --- a/dzgui/views/dialogs/generic.py +++ b/dzgui/views/dialogs/generic.py @@ -202,6 +202,7 @@ class ExceptionDialog(GenericDialog): left_margin=10, right_margin=10, top_margin=15, + bottom_margin=10, ) textview.set_buffer(Gtk.TextBuffer(text=self.trace)) box.pack_start(textview, EXPAND, FILL, 0) From 0bb8f3206f4048375da2a78e21189c20896eb90d Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:40:02 +0900 Subject: [PATCH 080/139] feat: internal knowledeat: add knowledge base text to specific exception dialogs --- dzgui/managers/connection.py | 6 +++++- dzgui/managers/mods.py | 3 ++- dzgui/model/servers.py | 3 ++- dzgui/strings/kb.py | 15 +++++++++++++++ 4 files changed, 24 insertions(+), 3 deletions(-) create mode 100644 dzgui/strings/kb.py diff --git a/dzgui/managers/connection.py b/dzgui/managers/connection.py index c96abc2..9baf109 100644 --- a/dzgui/managers/connection.py +++ b/dzgui/managers/connection.py @@ -45,6 +45,7 @@ from dzgui.strings.dialogs import ( waiting_for_mods, waiting_for_directories, ) +from dzgui.strings import kb from dzgui.strings.server_mods import checkmark, resync from dzgui.util.format import format_mib from dzgui.util.strings import dialog, server_timeout @@ -193,7 +194,9 @@ class ConnectionManager: is_last = self.is_last_server() # TODO: strings - invalid_mods = [(mod[0], mod[1]) for mod in remote_mods if mod[2] == "Invalid mod"] + invalid_mods = [ + (mod[0], mod[1]) for mod in remote_mods if mod[2] == "Invalid mod" + ] prereqs = Prerequisites( name=info.server_name, @@ -298,6 +301,7 @@ class ConnectionManager: def _server_timeout(self) -> None: dialog = ExceptionDialog(self.controller, server_timeout) + dialog.set_secondary_text(kb.DZG_006) dialog.run() def _connect_steam(self, menu_only: bool) -> None: diff --git a/dzgui/managers/mods.py b/dzgui/managers/mods.py index da31385..522c340 100644 --- a/dzgui/managers/mods.py +++ b/dzgui/managers/mods.py @@ -20,7 +20,7 @@ from dzgui.const.constants import ( 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.strings import dialogs +from dzgui.strings import dialogs, kb from dzgui.util.format import format_mods from dzgui.util.strings import server_timeout from dzgui.util.symlink import rebuild_symlinks @@ -215,6 +215,7 @@ class ModManager: def _server_timeout(self) -> None: dialog = ExceptionDialog(self.controller, server_timeout) + dialog.set_seconary_text(kb.DZG_006) dialog.run() def select_colorized(self) -> None: diff --git a/dzgui/model/servers.py b/dzgui/model/servers.py index bc5d616..cfb2c0a 100644 --- a/dzgui/model/servers.py +++ b/dzgui/model/servers.py @@ -14,7 +14,7 @@ from dzgui.const.constants import ( ) from dzgui.const.enum import FilterMode, Preferences, ServerTab from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager -from dzgui.strings import dialogs +from dzgui.strings import dialogs, kb from dzgui.util.strings import server_timeout, dialog from dzgui.views.dialogs.generic import ExceptionDialog @@ -420,6 +420,7 @@ class ServerModelManager: # customize statusbar and dialog accordingly if show_dialog: dialog = ExceptionDialog(self.controller, server_timeout) + dialog.set_secondary_text(kb.DZG_006) dialog.run() def _push_data(self, data: list[Any]) -> None: diff --git a/dzgui/strings/kb.py b/dzgui/strings/kb.py new file mode 100644 index 0000000..42f300b --- /dev/null +++ b/dzgui/strings/kb.py @@ -0,0 +1,15 @@ +DZG_006 = """The leading cause of specific servers periodically timing out is local network configuration. + +Many third-party DayZ servers use a server rental/hosting provider with DDoS protection. + +This can cause responses from servers to originate from a server other than the one originally queried. + +Consumer-grade routers are likely to drop this traffic as invalid due to how they handle NAT (network address translation). + +By contrast, wireless routers and enterprise-grade routers may be less likely to have this issue. + +If you find that a specific server is unresponsive for you when it shouldn't be, add a port forwarding rule to your router's settings for the server's query port. + +In addition, packets received from server responses are expected to be a standard size: MTU (maximum transmission unit) of 1500. + +Deviation from this may cause your router to discard incoming responses from the server.""" From b300b614a09ed7eccd06247b957fbdd96575bdc2 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:40:34 +0900 Subject: [PATCH 081/139] fix: typo --- 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 522c340..742873a 100644 --- a/dzgui/managers/mods.py +++ b/dzgui/managers/mods.py @@ -215,7 +215,7 @@ class ModManager: def _server_timeout(self) -> None: dialog = ExceptionDialog(self.controller, server_timeout) - dialog.set_seconary_text(kb.DZG_006) + dialog.set_secondary_text(kb.DZG_006) dialog.run() def select_colorized(self) -> None: From 7cc8076df1ae989a6328d951b511ecdd87f54bef Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:36:56 +0900 Subject: [PATCH 082/139] chore: backport DebugDialog --- dzgui/views/dialogs/generic.py | 60 ++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/dzgui/views/dialogs/generic.py b/dzgui/views/dialogs/generic.py index f3174eb..e6c98c4 100644 --- a/dzgui/views/dialogs/generic.py +++ b/dzgui/views/dialogs/generic.py @@ -169,6 +169,66 @@ class QuitDialog(GenericDialog): self.controller.save_res_and_quit() +class TextBufferDialog(GenericDialog): + def __init__( + self, controller: "Controller", heading: str, secondary: str, text: str + ): + super().__init__( + controller=controller, + text=heading, + mtype=Gtk.MessageType.INFO, + buttons=Gtk.ButtonsType.NONE, + secondary=secondary, + ) + self.text = text + copy_button = ClipboardButton(controller, self.get_text) + self.add_action_widget(copy_button, Gtk.ResponseType.NONE) + self.add_button("OK", Gtk.ResponseType.OK) + self.connect("response", self._on_response) + + def get_text(self) -> str: + return self.text + + def _on_response( + self, dialog: Self, response: Gtk.ResponseType + ) -> None | Literal[True]: + 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 + + +class DebugDialog(TextBufferDialog): + def __init__(self, controller: "Controller", debug: str): + super().__init__( + controller=controller, + heading="Debug", + secondary="Debug args", + text=debug, + ) + 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=debug)) + box.pack_start(textview, EXPAND, FILL, 10) + scrollable.add(box) + content = self.get_content_area() + content.set_spacing(0) + content.add(scrollable) + self.show_all() + + class ExceptionDialog(GenericDialog): """ Error dialog with rich traceback. From 5eb89a172d01573d9476cb7688f576b879eb7b52 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:46:02 +0900 Subject: [PATCH 083/139] feat: add DebugDialog to preconnect --- dzgui/api/steam.py | 16 ++++++++++++++++ dzgui/controllers/mc.py | 3 +++ dzgui/managers/connection.py | 8 ++++++++ dzgui/views/pages/preconnect.py | 12 ++++++++++++ 4 files changed, 39 insertions(+) diff --git a/dzgui/api/steam.py b/dzgui/api/steam.py index c5b0074..6139e9a 100644 --- a/dzgui/api/steam.py +++ b/dzgui/api/steam.py @@ -150,6 +150,22 @@ def get_remote_signatures(mods: list[str]) -> list[tuple[str, str, int, int]]: return hashes +def connect_debug(client: str, addr: str, appid: int, name: str, mods: list[str]) -> str: + concat = concat_mods(mods) + client_args = concat_bash_args(client) + params = [ + "-applaunch", + str(appid), + f"-connect={addr}", + "-nolauncher", + "-nosplash", + "-skipintro", + f"-name={name}", + f"-mod={concat}", + ] + client_args.extend(params) + return " ".join(client_args) + # 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(client: str, addr: str, appid: int, name: str, mods: list[str]) -> int: diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index b8b69fc..e6af0d6 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -526,6 +526,9 @@ class Controller(GObject.GObject): ind = self.config_man.get_start_tab() self.get_servers().notebook.set_current_page(ind) + def get_debug_args(self) -> str: + return self.connection_man.get_debug_args() + def update_and_load_to_menu(self) -> None: self.connection_man.update_and_connect(menu_only=True) diff --git a/dzgui/managers/connection.py b/dzgui/managers/connection.py index 9baf109..44cde10 100644 --- a/dzgui/managers/connection.py +++ b/dzgui/managers/connection.py @@ -13,6 +13,7 @@ from dzgui.api.shortcuts import Shortcuts from dzgui.api.steam import ( connect, + connect_debug, get_app_allows_downloads, get_app_name, get_needs_update, @@ -304,6 +305,13 @@ class ConnectionManager: dialog.set_secondary_text(kb.DZG_006) dialog.run() + def get_debug_args(self) -> str: + addr = f"{self.record.ip}:{self.record.gameport}" + playername = self.controller.query_config(Preferences.NAME) + return connect_debug( + self.client, addr, self.appid, playername, self.remote_mod_ids + ) + def _connect_steam(self, menu_only: bool) -> None: addr = f"{self.record.ip}:{self.record.gameport}" playername = self.controller.query_config(Preferences.NAME) diff --git a/dzgui/views/pages/preconnect.py b/dzgui/views/pages/preconnect.py index 2e07de0..b9f7bd9 100644 --- a/dzgui/views/pages/preconnect.py +++ b/dzgui/views/pages/preconnect.py @@ -11,6 +11,7 @@ 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 +from dzgui.views.dialogs.generic import DebugDialog from dzgui.views.components.frame import HeadingFrame from dzgui.views.trees.tree_server_mods import ServerModTreeView @@ -120,6 +121,12 @@ class PreConnectionAssistant(Gtk.Box): spacing=5, ) box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5) + # TODO: + if self.controller.get_prefs().is_debug: + # TODO: strings + debug = Gtk.Button(label="Debug", halign=Gtk.Align.START) + debug.connect("clicked", self._on_debug_clicked) + box.add(debug) for button in self.back, self.ok, self.connect_last: box.add(button) self.button_box.add(box) @@ -221,6 +228,11 @@ class PreConnectionAssistant(Gtk.Box): def _on_connect_last_clicked(self, button: Gtk.Button) -> None: self.controller.update_and_load_to_menu() + def _on_debug_clicked(self, button: Gtk.Button) -> None: + args = self.controller.get_debug_args() + d = DebugDialog(self.controller, args) + d.run() + def _on_ok_clicked(self, button: Gtk.Button) -> None: self.controller.update_and_connect() From 48b0660ebd874a8cc8bce8c33b8d30631c1fc4d8 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:00:28 +0900 Subject: [PATCH 084/139] chore: update strings --- dzgui/strings/dialogs.py | 3 +++ dzgui/strings/preconnect.py | 1 + 2 files changed, 4 insertions(+) diff --git a/dzgui/strings/dialogs.py b/dzgui/strings/dialogs.py index 460250d..3ab9a04 100644 --- a/dzgui/strings/dialogs.py +++ b/dzgui/strings/dialogs.py @@ -20,3 +20,6 @@ ok = "OK" mission_dialog = "Set a mission folder location" custom_mod_dialog = "Set location to a custom mods folder" + +debug_heading = "Debug" +debug_secondary = "Launch arguments" diff --git a/dzgui/strings/preconnect.py b/dzgui/strings/preconnect.py index 9e0f764..274c4b0 100644 --- a/dzgui/strings/preconnect.py +++ b/dzgui/strings/preconnect.py @@ -2,6 +2,7 @@ update_mods = "Update mods and connect" connect = "Connect" connect_last = "Load to menu screen" connect_last_tooltip = "Launch DayZ with these mods (if any) and\ngo to the menu screen without connecting." +debug = "Debug" back = "Back" cancel = "Cancel" warnings = "Warnings" From a2938c17f501ec78460625e54ed6d124ef8a8a07 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:00:53 +0900 Subject: [PATCH 085/139] chore: subclass ExceptionDialog from TextBufferDialog --- dzgui/views/dialogs/generic.py | 52 +++++++++++----------------------- 1 file changed, 16 insertions(+), 36 deletions(-) diff --git a/dzgui/views/dialogs/generic.py b/dzgui/views/dialogs/generic.py index e6c98c4..bf020f2 100644 --- a/dzgui/views/dialogs/generic.py +++ b/dzgui/views/dialogs/generic.py @@ -1,6 +1,7 @@ from typing import Literal, Self, TYPE_CHECKING from dzgui.const.constants import NO_EXPAND, NO_FILL, NO_PADDING, EXPAND, FILL +from dzgui.strings import dialogs from dzgui.util import strings from dzgui.views.components.buttons import ClipboardButton @@ -171,12 +172,17 @@ class QuitDialog(GenericDialog): class TextBufferDialog(GenericDialog): def __init__( - self, controller: "Controller", heading: str, secondary: str, text: str + self, + controller: "Controller", + mtype: Gtk.MessageType, + heading: str, + secondary: str, + text: str, ): super().__init__( controller=controller, text=heading, - mtype=Gtk.MessageType.INFO, + mtype=mtype, buttons=Gtk.ButtonsType.NONE, secondary=secondary, ) @@ -209,8 +215,9 @@ class DebugDialog(TextBufferDialog): def __init__(self, controller: "Controller", debug: str): super().__init__( controller=controller, - heading="Debug", - secondary="Debug args", + mtype=Gtk.MessageType.INFO, + heading=dialogs.debug_heading, + secondary=dialogs.debug_secondary, text=debug, ) scrollable = Gtk.ScrolledWindow( @@ -229,7 +236,7 @@ class DebugDialog(TextBufferDialog): self.show_all() -class ExceptionDialog(GenericDialog): +class ExceptionDialog(TextBufferDialog): """ Error dialog with rich traceback. Usage: @@ -244,13 +251,12 @@ class ExceptionDialog(GenericDialog): def __init__(self, controller: "Controller", trace: str): super().__init__( controller=controller, - text=strings.error_heading, - mtype=Gtk.MessageType.ERROR, - buttons=Gtk.ButtonsType.NONE, + heading=strings.error_heading, secondary=strings.something_wrong, + text=trace, + mtype=Gtk.MessageType.ERROR, ) - self.trace = trace # NOTE: box expands to end of content area scrollable = Gtk.ScrolledWindow( propagate_natural_height=True, max_content_height=500 @@ -264,7 +270,7 @@ class ExceptionDialog(GenericDialog): top_margin=15, bottom_margin=10, ) - textview.set_buffer(Gtk.TextBuffer(text=self.trace)) + textview.set_buffer(Gtk.TextBuffer(text=trace)) box.pack_start(textview, EXPAND, FILL, 0) self.error_details = Gtk.ScrolledWindow( @@ -302,15 +308,7 @@ class ExceptionDialog(GenericDialog): content.set_spacing(0) content.add(scrollable) - copy_button = ClipboardButton(controller, self.get_trace) - self.add_action_widget(copy_button, Gtk.ResponseType.NONE) - self.add_button("OK", Gtk.ResponseType.OK) - self.show_all() - 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 _on_page_changed( self, notebook: Gtk.Notebook, child: Gtk.Box | Gtk.ScrolledWindow, index: int @@ -323,21 +321,3 @@ class ExceptionDialog(GenericDialog): def set_secondary_text(self, text: str) -> None: self.details_buffer.set_text(text) self.error_notebook.set_show_tabs(True) - - def get_trace(self) -> str: - return self.trace - - def _on_response( - self, dialog: Self, response: Gtk.ResponseType - ) -> None | Literal[True]: - 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 From 22c9be36a6a97ea184d2a657293d83e2896df0c1 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:01:06 +0900 Subject: [PATCH 086/139] chore: add strings to preconnect page --- dzgui/views/pages/preconnect.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dzgui/views/pages/preconnect.py b/dzgui/views/pages/preconnect.py index b9f7bd9..4d6e6eb 100644 --- a/dzgui/views/pages/preconnect.py +++ b/dzgui/views/pages/preconnect.py @@ -121,10 +121,10 @@ class PreConnectionAssistant(Gtk.Box): spacing=5, ) box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5) - # TODO: + + # NOTE: -d flag if self.controller.get_prefs().is_debug: - # TODO: strings - debug = Gtk.Button(label="Debug", halign=Gtk.Align.START) + debug = Gtk.Button(label=preconnect.debug, halign=Gtk.Align.START) debug.connect("clicked", self._on_debug_clicked) box.add(debug) for button in self.back, self.ok, self.connect_last: From 3932a3aed4515e7a6cb117c41e863d39179c02af Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:01:40 +0900 Subject: [PATCH 087/139] chore: drop debug key from prefs enum --- dzgui/const/enum.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/dzgui/const/enum.py b/dzgui/const/enum.py index 28a4cdf..4e77740 100644 --- a/dzgui/const/enum.py +++ b/dzgui/const/enum.py @@ -76,9 +76,6 @@ class Preferences(EnumWithAttrs): INSTALL = { "key": "auto_install", } - DEBUG = { - "key": "debug", - } DEFAULT = { "key": "default_steam_path", } From 027e6c0781aaec4259cd30ff11a1698ec6b24173 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:02:59 +0900 Subject: [PATCH 088/139] chore: drop unused enums --- dzgui/const/enum.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/dzgui/const/enum.py b/dzgui/const/enum.py index 4e77740..05a9763 100644 --- a/dzgui/const/enum.py +++ b/dzgui/const/enum.py @@ -73,9 +73,6 @@ class Preferences(EnumWithAttrs): NAME = { "key": "name", } - INSTALL = { - "key": "auto_install", - } DEFAULT = { "key": "default_steam_path", } @@ -88,9 +85,6 @@ class Preferences(EnumWithAttrs): IP_LIST = { "key": "ip_list", } - BRANCH = { - "key": "branch", - } START_TAB = { "key": "start_tab", } From cca1006efbd5ac84a544fcdb3297a1654f18bd05 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:03:08 +0900 Subject: [PATCH 089/139] chore: drop unused method --- dzgui/controllers/mc.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index e6af0d6..d8ca3d6 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -114,9 +114,6 @@ class Controller(GObject.GObject): def query_config(self, key: Preferences) -> Any: return self.config_man.lookup(key) - def is_auto_install(self) -> bool: - return bool(self.query_config(Preferences.INSTALL)) - def suppress_signal( self, owner: Any, child: Any, func_name: str, state: bool ) -> None: From 0f59d92bc302dbdca4434b387558fa897693109a Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:33:05 +0900 Subject: [PATCH 090/139] chore: drop unused method --- dzgui/managers/mods.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/dzgui/managers/mods.py b/dzgui/managers/mods.py index 742873a..8689072 100644 --- a/dzgui/managers/mods.py +++ b/dzgui/managers/mods.py @@ -213,11 +213,6 @@ class ModManager: 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.set_secondary_text(kb.DZG_006) - dialog.run() - def select_colorized(self) -> None: model = self.treeview.get_model() if model is None: From 9f8b760c79616231c54dcf2585e4c04d9c26eb1b Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:33:42 +0900 Subject: [PATCH 091/139] fix: defer propagation until size allocation --- dzgui/views/dialogs/generic.py | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/dzgui/views/dialogs/generic.py b/dzgui/views/dialogs/generic.py index bf020f2..2257873 100644 --- a/dzgui/views/dialogs/generic.py +++ b/dzgui/views/dialogs/generic.py @@ -221,19 +221,19 @@ class DebugDialog(TextBufferDialog): text=debug, ) scrollable = Gtk.ScrolledWindow( - propagate_natural_height=True, max_content_height=500 + propagate_natural_height=False, + min_content_height=200, + max_content_height=200, + margin_bottom=10, ) - 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.CHAR, editable=False, left_margin=10, right_margin=10 ) textview.set_buffer(Gtk.TextBuffer(text=debug)) - box.pack_start(textview, EXPAND, FILL, 10) - scrollable.add(box) + scrollable.add(textview) content = self.get_content_area() content.set_spacing(0) content.add(scrollable) - self.show_all() class ExceptionDialog(TextBufferDialog): @@ -308,15 +308,13 @@ class ExceptionDialog(TextBufferDialog): content.set_spacing(0) content.add(scrollable) - self.show_all() - def _on_page_changed( self, notebook: Gtk.Notebook, child: Gtk.Box | Gtk.ScrolledWindow, index: int ) -> None: if child == self.error_details: - self.error_details.set_propagate_natural_height(True) + GLib.idle_add(self.error_details.set_propagate_natural_height, True) else: - self.error_details.set_propagate_natural_height(False) + GLib.idle_add(self.error_details.set_propagate_natural_height, False) def set_secondary_text(self, text: str) -> None: self.details_buffer.set_text(text) From 1a3154712f57cd313bf06716375988be7b51fe48 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:36:35 +0900 Subject: [PATCH 092/139] fix: draw dialog outside of init --- dzgui/controllers/mc.py | 2 ++ dzgui/managers/config.py | 1 + dzgui/managers/connection.py | 1 + dzgui/managers/mods.py | 4 +--- dzgui/managers/update.py | 1 + dzgui/model/servers.py | 2 ++ dzgui/views/pages/options.py | 1 + dzgui/views/pages/preconnect.py | 1 + 8 files changed, 10 insertions(+), 3 deletions(-) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index d8ca3d6..9effd3c 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -263,6 +263,7 @@ class Controller(GObject.GObject): self.open_page(NotebookPage.LOG) except Exception as e: dialog = ExceptionDialog(self, str(e)) + dialog.show_all() dialog.run() def select_colorized(self) -> None: @@ -296,6 +297,7 @@ class Controller(GObject.GObject): write_diagnostic(self.prefs.paths.config, file) except Exception as e: dialog = ExceptionDialog(self, str(e)) + dialog.show_all() dialog.run() def update_steam_api_key(self, text: str) -> None: diff --git a/dzgui/managers/config.py b/dzgui/managers/config.py index ac559b2..9128579 100644 --- a/dzgui/managers/config.py +++ b/dzgui/managers/config.py @@ -178,6 +178,7 @@ class ConfigManager: logger.critical(e) trace = traceback.format_exc() dialog = ExceptionDialog(self.controller, trace) + dialog.show_all() dialog.run() raise e diff --git a/dzgui/managers/connection.py b/dzgui/managers/connection.py index 44cde10..fc5b65a 100644 --- a/dzgui/managers/connection.py +++ b/dzgui/managers/connection.py @@ -303,6 +303,7 @@ class ConnectionManager: def _server_timeout(self) -> None: dialog = ExceptionDialog(self.controller, server_timeout) dialog.set_secondary_text(kb.DZG_006) + dialog.show_all() dialog.run() def get_debug_args(self) -> str: diff --git a/dzgui/managers/mods.py b/dzgui/managers/mods.py index 8689072..c04d231 100644 --- a/dzgui/managers/mods.py +++ b/dzgui/managers/mods.py @@ -20,11 +20,9 @@ from dzgui.const.constants import ( 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.strings import dialogs, kb +from dzgui.strings import dialogs from dzgui.util.format import format_mods -from dzgui.util.strings import server_timeout from dzgui.util.symlink import rebuild_symlinks -from dzgui.views.dialogs.generic import ExceptionDialog import dzgui.api.pefile as PeFile diff --git a/dzgui/managers/update.py b/dzgui/managers/update.py index 3c0f928..ff64fb4 100644 --- a/dzgui/managers/update.py +++ b/dzgui/managers/update.py @@ -64,4 +64,5 @@ class UpdateManager: def _on_update_failure(self, msg: str) -> None: dialog = ExceptionDialog(self.controller, msg) + dialog.show_all() dialog.run() diff --git a/dzgui/model/servers.py b/dzgui/model/servers.py index cfb2c0a..e2c9430 100644 --- a/dzgui/model/servers.py +++ b/dzgui/model/servers.py @@ -405,6 +405,7 @@ class ServerModelManager: if show_dialog: dialog = ExceptionDialog(self.controller, dialogs.load_error_lan) + dialog.show_all() dialog.run() def _cleanup_on_failure(self, show_dialog: bool = True) -> None: @@ -421,6 +422,7 @@ class ServerModelManager: if show_dialog: dialog = ExceptionDialog(self.controller, server_timeout) dialog.set_secondary_text(kb.DZG_006) + dialog.show_all() dialog.run() def _push_data(self, data: list[Any]) -> None: diff --git a/dzgui/views/pages/options.py b/dzgui/views/pages/options.py index 277c13e..93341ec 100644 --- a/dzgui/views/pages/options.py +++ b/dzgui/views/pages/options.py @@ -430,6 +430,7 @@ class Options(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore # NOTE: re-check in case file was removed by user between runs if prefs.paths.config.is_file() is False: dialog = ExceptionDialog(self.controller, strings.config_not_found) + dialog.show_all() dialog.run() raise OSError(f"Config file '{prefs.paths.config}' not found") diff --git a/dzgui/views/pages/preconnect.py b/dzgui/views/pages/preconnect.py index 4d6e6eb..93b0fb6 100644 --- a/dzgui/views/pages/preconnect.py +++ b/dzgui/views/pages/preconnect.py @@ -231,6 +231,7 @@ class PreConnectionAssistant(Gtk.Box): def _on_debug_clicked(self, button: Gtk.Button) -> None: args = self.controller.get_debug_args() d = DebugDialog(self.controller, args) + d.show_all() d.run() def _on_ok_clicked(self, button: Gtk.Button) -> None: From 6ded9a3eec1b3424ca2d90b96bfd8ce2b2e836f1 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:59:00 +0900 Subject: [PATCH 093/139] chore: disambiguate buffer setter method --- dzgui/managers/connection.py | 2 +- dzgui/model/servers.py | 2 +- dzgui/views/dialogs/generic.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/dzgui/managers/connection.py b/dzgui/managers/connection.py index fc5b65a..7d2047e 100644 --- a/dzgui/managers/connection.py +++ b/dzgui/managers/connection.py @@ -302,7 +302,7 @@ class ConnectionManager: def _server_timeout(self) -> None: dialog = ExceptionDialog(self.controller, server_timeout) - dialog.set_secondary_text(kb.DZG_006) + dialog.set_details_buffer(kb.DZG_006) dialog.show_all() dialog.run() diff --git a/dzgui/model/servers.py b/dzgui/model/servers.py index e2c9430..a429ffb 100644 --- a/dzgui/model/servers.py +++ b/dzgui/model/servers.py @@ -421,7 +421,7 @@ class ServerModelManager: # customize statusbar and dialog accordingly if show_dialog: dialog = ExceptionDialog(self.controller, server_timeout) - dialog.set_secondary_text(kb.DZG_006) + dialog.set_details_buffer(kb.DZG_006) dialog.show_all() dialog.run() diff --git a/dzgui/views/dialogs/generic.py b/dzgui/views/dialogs/generic.py index 2257873..4e871ff 100644 --- a/dzgui/views/dialogs/generic.py +++ b/dzgui/views/dialogs/generic.py @@ -316,6 +316,6 @@ class ExceptionDialog(TextBufferDialog): else: GLib.idle_add(self.error_details.set_propagate_natural_height, False) - def set_secondary_text(self, text: str) -> None: + def set_details_buffer(self, text: str) -> None: self.details_buffer.set_text(text) self.error_notebook.set_show_tabs(True) From 5b83a26f6a7959bb27137b1e56bbef35e8b9c7aa Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:44:55 +0900 Subject: [PATCH 094/139] chore: reword remarks on MTU --- docs/source/kb.rst | 4 ++-- dzgui/strings/kb.py | 4 +--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/docs/source/kb.rst b/docs/source/kb.rst index afaa68f..7ab8bb2 100644 --- a/docs/source/kb.rst +++ b/docs/source/kb.rst @@ -16,8 +16,8 @@ By contrast, wireless routers and enterprise-grade routers may be less likely to If you find that a specific server is unresponsive for you when it shouldn't be, add a port forwarding rule to your router's settings for the server's query port. -In addition, packets sent from server responses are expected to be a standard size (see warning below). -Deviation from this may cause your router to discard incoming responses from the server. +In addition, a uniform packet size is expected. If your router's MTU setting differs from the default of 1,500, +it could lead to incoming responses being discarded (see warning below). .. important:: diff --git a/dzgui/strings/kb.py b/dzgui/strings/kb.py index 42f300b..68ad2e5 100644 --- a/dzgui/strings/kb.py +++ b/dzgui/strings/kb.py @@ -10,6 +10,4 @@ By contrast, wireless routers and enterprise-grade routers may be less likely to If you find that a specific server is unresponsive for you when it shouldn't be, add a port forwarding rule to your router's settings for the server's query port. -In addition, packets received from server responses are expected to be a standard size: MTU (maximum transmission unit) of 1500. - -Deviation from this may cause your router to discard incoming responses from the server.""" +In addition, a uniform packet size is expected. If your router's MTU (maximum transmission unit) setting differs from the default of 1500, it could lead to incoming responses being discarded.""" From 2b394553b31927a610716f19ba777ff7ecbd0124 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 20 Aug 2026 07:55:43 +0900 Subject: [PATCH 095/139] chore: normalize docstring --- dzgui/managers/config.py | 1 - dzgui/views/dialogs/generic.py | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/dzgui/managers/config.py b/dzgui/managers/config.py index 9128579..576f809 100644 --- a/dzgui/managers/config.py +++ b/dzgui/managers/config.py @@ -180,7 +180,6 @@ class ConfigManager: dialog = ExceptionDialog(self.controller, trace) dialog.show_all() dialog.run() - raise e def save_res_and_quit(self, tv: "ServerTreeView", window: "OuterWindow") -> None: columns = tv.get_columns() diff --git a/dzgui/views/dialogs/generic.py b/dzgui/views/dialogs/generic.py index 4e871ff..b504955 100644 --- a/dzgui/views/dialogs/generic.py +++ b/dzgui/views/dialogs/generic.py @@ -245,6 +245,7 @@ class ExceptionDialog(TextBufferDialog): except Exception: trace = traceback.format_exc() dialog = ExceptionDialog(Controller, trace) + dialog.show_all() dialog.run() """ From c7bd6f635c3ed6455dd52c04c9df663c6b0a55d9 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 20 Aug 2026 10:13:37 +0900 Subject: [PATCH 096/139] feat: uninstall wizard WIP --- dzgui/api/shortcuts.py | 7 + dzgui/main.py | 24 ++-- dzgui/strings/uninstall.py | 38 ++++++ dzgui/views/dialogs/uninstall.py | 226 +++++++++++++++++++++++++++++++ dzgui/views/dialogs/wizard.py | 17 ++- 5 files changed, 293 insertions(+), 19 deletions(-) create mode 100644 dzgui/strings/uninstall.py create mode 100644 dzgui/views/dialogs/uninstall.py diff --git a/dzgui/api/shortcuts.py b/dzgui/api/shortcuts.py index 154645e..50c1d33 100644 --- a/dzgui/api/shortcuts.py +++ b/dzgui/api/shortcuts.py @@ -160,6 +160,13 @@ class Shortcuts: NEW_ENTRY["tags"] = {} return NEW_ENTRY + def delete_shortcut(self, start_path: Path) -> None: + for s in self.shortcuts["shortcuts"]: + if self.shortcuts[s]["StartDir"] == str(start_path): + del self.shortcuts[s] + break + self.save_shortcuts() + def save_shortcuts(self) -> None: try: backup = self.shortcuts_path.with_suffix(".vdf.bak") diff --git a/dzgui/main.py b/dzgui/main.py index 731f58e..ff59ba8 100644 --- a/dzgui/main.py +++ b/dzgui/main.py @@ -8,30 +8,26 @@ from dzgui.init.prefix import get_version from dzgui.util.map_count import set_map_count from dzgui.util.strings import flags +from dzgui.views.dialogs.uninstall import UninstallWizard +from dzgui.config.xdg import get_xdg_paths + parser = argparse.ArgumentParser(description=flags.description) -parser.add_argument("-d", "--debug", action="store_true", help=flags.debug) -parser.add_argument("-m", "--map", action="store_true", help=flags.map_count) -parser.add_argument("-u", "--uninstall", action="store_true", help=flags.uninstall) -parser.add_argument("-v", "--version", action="store_true", help=flags.version) +group = parser.add_mutually_exclusive_group() +group.add_argument("-d", "--debug", action="store_true", help=flags.debug) +group.add_argument("-m", "--map", action="store_true", help=flags.map_count) +group.add_argument("-u", "--uninstall", action="store_true", help=flags.uninstall) +group.add_argument("-v", "--version", action="store_true", help=flags.version) args = parser.parse_args() -def uninstall() -> None: - # TODO: uninstall data files (-u) - # -u removes state, log, freedesktop - # XDG_SHARE_HOME/dzgui - # XDG_STATE_HOME/dzgui - # XDG_DATA_HOME/dzgui - pass - - def main() -> None: # TODO: isolate single flags if args.version is True: print(get_version()) sys.exit(0) if args.uninstall is True: - uninstall() + paths = get_xdg_paths() + UninstallWizard(False, paths) sys.exit(0) if args.debug is True: warnings.filterwarnings("default", category=DeprecationWarning) diff --git a/dzgui/strings/uninstall.py b/dzgui/strings/uninstall.py new file mode 100644 index 0000000..676392f --- /dev/null +++ b/dzgui/strings/uninstall.py @@ -0,0 +1,38 @@ +pg1_title = "Choose uninstall targets" +pg1_heading = "Choose uninstall targets" +pg1_blurb = "Here you can choose which DZGUI configuration/state files to remove. " + +pg2_title = "Complete" +pg2_heading = "Uninstall complete" +pg2_blurb = "Finished removing the selected files. See the system log for errors." + +config_label = "Configuration files" +config_details = ( + "User preferences and API key. " + "Keep this file if you want to import it again later." +) +desktop_label = "Desktop shortcut" +desktop_details = "Shortcut set on the desktop, if applicable." + +start_menu_label = "Start menu shortcut" +start_menu_details = ( + "Shortcut set under the Games category of the start menu, if applicable." +) +state_label = "Long-term state files" +state_details = ( + "UI size, filter preferences, column size, server notes, and mod signatures." +) +steam_shortcut = "Steam shortcut" +steam_details = "Shortcut to DZGUI installed within Steam, if applicable. Requires Steam to be restarted to reflect the change." + + +path_remove_prefix = "The following path will be removed:" +not_installed = "It looks like DZGUI was not previously set up; nothing to uninstall." +standalone_uninstall = ( + "It looks like you are running the standalone version of DZGUI. " + "To finalize the uninstall process, you can remove the 'dzgui' file." +) +system_uninstall = ( + "It looks like DZGUI was installed via your system package manager or directly from source code.\n" + "To finalize the uninstall process, follow the standard removal procedure for that context." +) diff --git a/dzgui/views/dialogs/uninstall.py b/dzgui/views/dialogs/uninstall.py new file mode 100644 index 0000000..afda8eb --- /dev/null +++ b/dzgui/views/dialogs/uninstall.py @@ -0,0 +1,226 @@ +import os +import subprocess + +from pathlib import Path +from typing import Self, TYPE_CHECKING + +from dzgui.api.shortcuts import Shortcuts +from dzgui.const.constants import APP_NAME +from dzgui.strings import uninstall +from dzgui.util._json import read_json +from dzgui.util.css import load_css +from dzgui.views.components.box import HBox +from dzgui.views.dialogs.wizard import ScrolledWizardPage, CheckboxWithLabel + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk, GLib, Pango # noqa E402 + + +class Assistant(Gtk.Assistant): + def __init__(self, is_deck: bool, paths: dict[str, str]): + super().__init__() + if is_deck: + # NOTE: deemed to be "safe" dimensions that exclude taskbar size + self.set_default_size(1085, 670) + else: + self.set_default_size(1500, 900) + + self.set_forward_page_func(self._advance_page) + + pyapp = os.getenv("PYAPP") + + self.page1 = UninstallPage(paths, pyapp) + self.page2 = CompletionPage(pyapp) + + self.append_page(self.page1) + self.set_page_type(self.page1, Gtk.AssistantPageType.INTRO) + self.set_page_title(self.page1, uninstall.pg1_title) + self.set_page_complete(self.page1, True) + + self.append_page(self.page2) + self.set_page_title(self.page2, uninstall.pg2_title) + self.set_page_type(self.page2, Gtk.AssistantPageType.SUMMARY) + + self.connect("cancel", self.destroy_and_quit) + self.connect("close", self.destroy_and_quit) + self.show_all() + load_css() + + def _advance_page(self, index: int) -> int: + cur_page = self.get_nth_page(index) + if cur_page == self.page1: + self.page1.uninstall() + return index + 1 + + def destroy_and_quit(self, widget: Self) -> None: + self.destroy() + Gtk.main_quit() + + +class Window(Gtk.Window): + def __init__(self, is_deck: bool, paths: dict[str, str]) -> None: + super().__init__(title=APP_NAME, icon_name=APP_NAME) + self.assistant = Assistant(is_deck, paths) + + +class CompletionPage(ScrolledWizardPage): + def __init__(self, pyapp: str | None): + super().__init__(heading=uninstall.pg2_heading, description=uninstall.pg2_blurb) + + text = ( + uninstall.standalone_uninstall + if pyapp is not None + else uninstall.system_uninstall + ) + + label = Gtk.Label( + label=text, + wrap_mode=Pango.WrapMode.WORD, + margin=10, + max_width_chars=80, + justify=Gtk.Justification.CENTER, + ) + frame = Gtk.Frame() + frame.add(label) + self.add_start(frame) + + +class FileLabel(HBox): + def __init__(self, path: Path) -> None: + super().__init__(spacing=10) + + label = Gtk.Label(label=uninstall.path_remove_prefix, halign=Gtk.Align.START) + textview = Gtk.TextView( + editable=False, + halign=Gtk.Align.START, + left_margin=10, + right_margin=10, + ) + textview.set_buffer(Gtk.TextBuffer(text=str(path))) + self.extend([label, textview]) + + +class CheckboxWithPath(CheckboxWithLabel): + def __init__( + self, label: str, details: str, path: Path, active: bool = True + ) -> None: + super().__init__(text=label, blurb_text=details) + + self.conf_path = path + self.set_active(active) + fl = FileLabel(path) + self.indent_below(fl) + + def get_conf_path(self) -> Path: + return self.conf_path + + +class UninstallPage(ScrolledWizardPage): + def __init__(self, paths: dict[str, str], pyapp: str | None): + super().__init__(heading=uninstall.pg1_heading, description=uninstall.pg1_blurb) + + self.pyapp = pyapp + + config = Path(paths["XDG_CONFIG_HOME"]) + state = Path(paths["XDG_STATE_HOME"]) + self.share = Path(paths["XDG_DATA_HOME"]) + shortcut = self.share.parent.joinpath("applications/dzgui.desktop") + desktop = config.parent.parent.joinpath("Desktop/dzgui.desktop") + self.steam_path = self.parse_steam_path(config, self.share) + + self.boxes: list[CheckboxWithPath] = [] + self.checks_area = Gtk.Box( + orientation=Gtk.Orientation.VERTICAL, spacing=20, margin_top=20 + ) + self.config_box = CheckboxWithPath( + uninstall.config_label, uninstall.config_details, config, active=False + ) + self.desktop_box = CheckboxWithPath( + uninstall.desktop_label, uninstall.desktop_details, desktop + ) + self.start_menu_box = CheckboxWithPath( + uninstall.start_menu_label, uninstall.start_menu_details, shortcut + ) + self.share_box = CheckboxWithPath( + uninstall.desktop_label, uninstall.desktop_details, desktop + ) + self.state_box = CheckboxWithPath( + uninstall.state_label, uninstall.state_details, state + ) + self.steam_box = CheckboxWithLabel( + uninstall.steam_shortcut, uninstall.steam_details + ) + + for box in ( + self.config_box, + self.state_box, + self.desktop_box, + self.start_menu_box, + ): + self.checks_area.add(box) + self.boxes.append(box) + + self.checks_area.add(self.steam_box) + + self.add_start(self.checks_area) + self.show_all() + + def parse_steam_path(self, config: Path, steam: Path) -> Path | None: + try: + file = config.joinpath("config.json") + conf = read_json(file) + steam = conf["default_steam_path"] + self.steam_path = Path(steam) + return Path(steam) + except Exception as e: + print(e) + return None + + def wipe_conf_file(self, box: CheckboxWithPath) -> None: + if box.get_active(): + path = box.get_conf_path() + try: + # TODO: + # path.unlink() + print(f"Deleted '{path}'") + except Exception as e: + print(e) + + def wipe_steam_shortcut(self) -> None: + # TODO: + pass + # try: + # shortcuts = Shortcuts(self.steam_path) + # shortcuts.delete_shortcut(self.share) + # except Exception as e: + # print(e) + + def wipe_pyapp(self) -> None: + if self.pyapp is None: + return + res = subprocess.run([self.pyapp, "self", "remove"]) + print(res) + + def uninstall(self) -> None: + for box in self.boxes: + self.wipe_conf_file(box) + if self.steam_box.get_active(): + self.wipe_steam_shortcut() + # TODO: + # self.wipe_pyapp() + pass + + +class UninstallWizard(Gtk.Application): + def __init__(self, is_deck: bool, paths: dict[str, str]) -> None: + super().__init__() + config = Path(paths["XDG_CONFIG_HOME"]) + # TODO: tests + if not config.is_dir() or not config.joinpath("config.json").is_file(): + print(uninstall.not_installed) + return + GLib.set_prgname(APP_NAME) + self.win = Window(is_deck, paths) + Gtk.main() diff --git a/dzgui/views/dialogs/wizard.py b/dzgui/views/dialogs/wizard.py index 3a57570..d0f0aa0 100644 --- a/dzgui/views/dialogs/wizard.py +++ b/dzgui/views/dialogs/wizard.py @@ -99,6 +99,8 @@ class ScrolledWizardPage(Gtk.ScrolledWindow): self.box.pack_start(image, expand=False, fill=True, padding=0) self.add(self.box) + self.box.pack_start(self.heading, expand=False, fill=True, padding=0) + self.box.pack_start(self.description, expand=False, fill=True, padding=0) def get_page_type(self) -> Gtk.AssistantPageType: return self.page_type @@ -123,12 +125,9 @@ class EnumeratedWizardPage(ScrolledWizardPage): def __init__(self, enum: PageNum, heading: str, description: str) -> None: super().__init__(heading=heading, description=description) - self.enum = enum self.prog = Progress() self.box.pack_end(self.prog, expand=False, fill=False, padding=0) - self.box.pack_start(self.heading, expand=False, fill=True, padding=0) - self.box.pack_start(self.description, expand=False, fill=True, padding=0) def get_enum(self) -> PageNum: return self.enum @@ -541,7 +540,9 @@ class Assistant(Gtk.Assistant): return self.set_page_complete(page, True) - def _add_page(self, page: EnumeratedWizardPage, ptype: Gtk.AssistantPageType) -> None: + def _add_page( + self, page: EnumeratedWizardPage, ptype: Gtk.AssistantPageType + ) -> None: self.append_page(page) self.set_page_type(page, ptype) self.set_page_title(page, page.get_title()) @@ -567,15 +568,21 @@ class CheckboxWithLabel(Gtk.Box): def __init__(self, text: str, blurb_text: str) -> None: super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=5) + self.indent = 20 self.button = Gtk.CheckButton(label=text) self.button.set_active(True) - label = Gtk.Label(label="", halign=Gtk.Align.START, margin_start=20) + label = Gtk.Label(label="", halign=Gtk.Align.START, margin_start=self.indent) + wrapped = textwrap.fill(blurb_text, width=100) label.set_markup(f"- {wrapped}") for el in self.button, label: self.add(el) + def indent_below(self, widget: Gtk.Widget) -> None: + widget.set_margin_start(self.indent) + self.add(widget) + def get_checkbox(self) -> Gtk.CheckButton: return self.button From ca1958b0a640600cb120c2eae19e0555d0321827 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 20 Aug 2026 10:14:12 +0900 Subject: [PATCH 097/139] chore: drop unused import --- dzgui/views/dialogs/uninstall.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dzgui/views/dialogs/uninstall.py b/dzgui/views/dialogs/uninstall.py index afda8eb..5973d29 100644 --- a/dzgui/views/dialogs/uninstall.py +++ b/dzgui/views/dialogs/uninstall.py @@ -2,7 +2,7 @@ import os import subprocess from pathlib import Path -from typing import Self, TYPE_CHECKING +from typing import Self from dzgui.api.shortcuts import Shortcuts from dzgui.const.constants import APP_NAME From 0f4d611345a362da340c44cba39a2183570df9c6 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 20 Aug 2026 10:20:26 +0900 Subject: [PATCH 098/139] change: center align content area --- dzgui/views/dialogs/uninstall.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/dzgui/views/dialogs/uninstall.py b/dzgui/views/dialogs/uninstall.py index 5973d29..a46513e 100644 --- a/dzgui/views/dialogs/uninstall.py +++ b/dzgui/views/dialogs/uninstall.py @@ -132,7 +132,11 @@ class UninstallPage(ScrolledWizardPage): self.boxes: list[CheckboxWithPath] = [] self.checks_area = Gtk.Box( - orientation=Gtk.Orientation.VERTICAL, spacing=20, margin_top=20 + orientation=Gtk.Orientation.VERTICAL, + halign=Gtk.Align.CENTER, + spacing=20, + margin_top=20, + margin_bottom=20, ) self.config_box = CheckboxWithPath( uninstall.config_label, uninstall.config_details, config, active=False From c34fa2fe0363cd93525ea766434e661c86064412 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 20 Aug 2026 10:20:34 +0900 Subject: [PATCH 099/139] chore: update strings --- dzgui/strings/uninstall.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dzgui/strings/uninstall.py b/dzgui/strings/uninstall.py index 676392f..a609899 100644 --- a/dzgui/strings/uninstall.py +++ b/dzgui/strings/uninstall.py @@ -2,9 +2,9 @@ pg1_title = "Choose uninstall targets" pg1_heading = "Choose uninstall targets" pg1_blurb = "Here you can choose which DZGUI configuration/state files to remove. " -pg2_title = "Complete" +pg2_title = "Uninstall complete" pg2_heading = "Uninstall complete" -pg2_blurb = "Finished removing the selected files. See the system log for errors." +pg2_blurb = "Finished removing the selected files. See the console for any errors." config_label = "Configuration files" config_details = ( From c03aa8f1663ab4cb59e11426920683c88bc827ad Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 20 Aug 2026 13:18:08 +0900 Subject: [PATCH 100/139] feat: delete shortcut --- dzgui/api/shortcuts.py | 7 ++++--- dzgui/views/dialogs/uninstall.py | 29 ++++++++++++++++------------- 2 files changed, 20 insertions(+), 16 deletions(-) diff --git a/dzgui/api/shortcuts.py b/dzgui/api/shortcuts.py index 50c1d33..594d1a1 100644 --- a/dzgui/api/shortcuts.py +++ b/dzgui/api/shortcuts.py @@ -161,9 +161,10 @@ class Shortcuts: return NEW_ENTRY def delete_shortcut(self, start_path: Path) -> None: - for s in self.shortcuts["shortcuts"]: - if self.shortcuts[s]["StartDir"] == str(start_path): - del self.shortcuts[s] + shortcuts = self.shortcuts["shortcuts"] + for s in shortcuts: + if shortcuts[s]["StartDir"] == str(start_path): + del shortcuts[s] break self.save_shortcuts() diff --git a/dzgui/views/dialogs/uninstall.py b/dzgui/views/dialogs/uninstall.py index a46513e..1e50129 100644 --- a/dzgui/views/dialogs/uninstall.py +++ b/dzgui/views/dialogs/uninstall.py @@ -1,4 +1,5 @@ import os +import shutil import subprocess from pathlib import Path @@ -9,6 +10,7 @@ from dzgui.const.constants import APP_NAME from dzgui.strings import uninstall from dzgui.util._json import read_json from dzgui.util.css import load_css +from dzgui.util.format import format_exception from dzgui.views.components.box import HBox from dzgui.views.dialogs.wizard import ScrolledWizardPage, CheckboxWithLabel @@ -179,27 +181,29 @@ class UninstallPage(ScrolledWizardPage): self.steam_path = Path(steam) return Path(steam) except Exception as e: - print(e) + print(format_exception(e)) return None def wipe_conf_file(self, box: CheckboxWithPath) -> None: if box.get_active(): path = box.get_conf_path() try: - # TODO: - # path.unlink() + if path.is_dir(): + shutil.rmtree(path) + else: + path.unlink() print(f"Deleted '{path}'") except Exception as e: - print(e) + print(format_exception(e)) def wipe_steam_shortcut(self) -> None: - # TODO: - pass - # try: - # shortcuts = Shortcuts(self.steam_path) - # shortcuts.delete_shortcut(self.share) - # except Exception as e: - # print(e) + if self.steam_path is None: + return + try: + shortcuts = Shortcuts(self.steam_path) + shortcuts.delete_shortcut(self.share) + except Exception as e: + print(format_exception(e)) def wipe_pyapp(self) -> None: if self.pyapp is None: @@ -212,8 +216,7 @@ class UninstallPage(ScrolledWizardPage): self.wipe_conf_file(box) if self.steam_box.get_active(): self.wipe_steam_shortcut() - # TODO: - # self.wipe_pyapp() + self.wipe_pyapp() pass From 7dfdb4209a40e6ac91283846b0ab0c666189cfca Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 20 Aug 2026 13:29:47 +0900 Subject: [PATCH 101/139] fix: trigger on dayz launcher process --- dzgui/api/steam.py | 5 +++-- dzgui/const/constants.py | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/dzgui/api/steam.py b/dzgui/api/steam.py index c5b0074..4ac97bc 100644 --- a/dzgui/api/steam.py +++ b/dzgui/api/steam.py @@ -16,6 +16,7 @@ from dzgui.const.constants import ( APPID_DAYZ_EXP, APP_NAME, DAYZ_BINARY, + DAYZ_LAUNCHER, DEBIAN_STEAM_PATH, DEFAULT_STEAM_PATH, FLATPAK_STEAM_PATH, @@ -382,14 +383,14 @@ def get_client_allows_downloads(path: Path) -> bool: def is_dayz_running() -> bool: """Subprocesses spawned from Steam will not show up in regular process tree""" procs = [] - substring = DAYZ_BINARY + substring = [DAYZ_BINARY, DAYZ_LAUNCHER] for proc in psutil.process_iter(): try: procs.append(proc.cmdline()) except Exception as e: logger.warning(e) continue - return any(substring in item for sublist in procs for item in sublist) + return any(s in item for sublist in procs for item in sublist for s in substring) def get_app_path(folders_path: Path, appid: int) -> Path: diff --git a/dzgui/const/constants.py b/dzgui/const/constants.py index 5be3d0b..d75fc31 100644 --- a/dzgui/const/constants.py +++ b/dzgui/const/constants.py @@ -12,6 +12,7 @@ APPNAME_DAYZ = "DayZ" APPNAME_DAYZ_EXP = "DayZ Exp" APPNAME_DAYZ_EXP_HUMAN = "DayZ Experimental" DAYZ_BINARY = "DayZ_x64.exe" +DAYZ_LAUNCHER = "DayZLauncher.exe" LIBRARYFOLDERS_PATH = "steamapps/libraryfolders.vdf" WORKSHOP_PATH = "steamapps/workshop/content/" + str(APPID_DAYZ) From 203cf0da7fc9cba869c8a9966b5541a0c90617fc Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 20 Aug 2026 13:53:06 +0900 Subject: [PATCH 102/139] chore: abstract strings --- dzgui/strings/changelog.py | 2 ++ dzgui/views/pages/changelog.py | 6 +++--- 2 files changed, 5 insertions(+), 3 deletions(-) create mode 100644 dzgui/strings/changelog.py diff --git a/dzgui/strings/changelog.py b/dzgui/strings/changelog.py new file mode 100644 index 0000000..cc637ea --- /dev/null +++ b/dzgui/strings/changelog.py @@ -0,0 +1,2 @@ +collapse_all = "Collapse all" +expand_all = "Expand all" diff --git a/dzgui/views/pages/changelog.py b/dzgui/views/pages/changelog.py index 35d6e48..108912c 100644 --- a/dzgui/views/pages/changelog.py +++ b/dzgui/views/pages/changelog.py @@ -6,6 +6,7 @@ from typing import TYPE_CHECKING from importlib import resources from dzgui.const.constants import APP_NAME, APP_NAME_LOWER, CHANGELOG_PATH +from dzgui.strings import changelog as clog from dzgui.util.strings import missing_changelog from dzgui.util.format import format_pango from dzgui.views.mixins.help_menu_mixin import HelpMenuMixin @@ -32,7 +33,6 @@ class Changelog(HelpMenuMixin, ScrollableMixin, Gtk.ScrolledWindow): # type: ig logger.critical(e) changelog = missing_changelog - # FIXME: wrap long text self.controller = controller self.box = Gtk.Box( orientation=Gtk.Orientation.VERTICAL, spacing=5, margin_top=10 @@ -40,7 +40,7 @@ class Changelog(HelpMenuMixin, ScrollableMixin, Gtk.ScrolledWindow): # type: ig self.add(self.box) expand_all = Gtk.Button( - label="Expand all", halign=Gtk.Align.START, margin_start=20 + label=clog.expand_all, halign=Gtk.Align.START, margin_start=20 ) expand_all.connect("clicked", self._on_expand_all_clicked) self.box.add(expand_all) @@ -63,7 +63,7 @@ class Changelog(HelpMenuMixin, ScrollableMixin, Gtk.ScrolledWindow): # type: ig if expander.get_expanded() == self.expanded: continue expander.activate() - label = "Collapse all" if self.expanded else "Expand all" + label = clog.collapse_all if self.expanded else clog.expand_all button.set_label(label) def grab_content_area(self) -> None: From 05fab5774eba64d033d3240d2eff828e31accea2 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Mon, 17 Aug 2026 17:59:13 +0900 Subject: [PATCH 103/139] chore: add test for changelog markdown headers --- tests/test_changelog.py | 40 +++++++++++++++++++++++++++------------- 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/tests/test_changelog.py b/tests/test_changelog.py index 3eae186..acc6019 100644 --- a/tests/test_changelog.py +++ b/tests/test_changelog.py @@ -1,20 +1,34 @@ +import re import pytest -from importlib import resources -from dzgui.const.constants import APP_NAME_LOWER, CHANGELOG_PATH +from pathlib import Path @pytest.fixture -def changelog(): - path = resources.files(APP_NAME_LOWER).joinpath(CHANGELOG_PATH) - return path +def changelog(request) -> None: + root = request.config.rootpath + changelog = Path(root).joinpath("CHANGELOG.md").read_text() + return changelog -def test_headings(changelog): - with open(changelog, "r") as f: - lines = f.readlines() - for line in lines: - if line.startswith("#"): - pass - # TODO: use regex - pass +def count_hash(line: str) -> int: + cnt = 0 + for c in line: + if c == "#": + cnt += 1 + return cnt + + +@pytest.mark.FOO +def test_changelog_prefix(changelog) -> None: + r = r".*(\[.*\]).*" + lines = changelog.splitlines() + sort = sorted(lines) + match = [line for line in sort if line.startswith("#")] + for m in match: + if "Changelog" in m: + assert count_hash(m) == 1 + elif re.match(r, m) is not None: + assert count_hash(m) == 2 + else: + assert count_hash(m) == 3 From b5105ca9f9c00e6f61bb5382262100e80bc25f20 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Mon, 17 Aug 2026 18:03:27 +0900 Subject: [PATCH 104/139] chore: drop placeholder mark --- tests/test_changelog.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_changelog.py b/tests/test_changelog.py index acc6019..e897571 100644 --- a/tests/test_changelog.py +++ b/tests/test_changelog.py @@ -19,7 +19,6 @@ def count_hash(line: str) -> int: return cnt -@pytest.mark.FOO def test_changelog_prefix(changelog) -> None: r = r".*(\[.*\]).*" lines = changelog.splitlines() From ddc18e5705708afdf0d42bd688136f727edb7228 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:22:14 +0900 Subject: [PATCH 105/139] feat: add notebook to ExceptionDialog --- dzgui/views/dialogs/generic.py | 54 ++++++++++++++++++++++++++++++---- 1 file changed, 49 insertions(+), 5 deletions(-) diff --git a/dzgui/views/dialogs/generic.py b/dzgui/views/dialogs/generic.py index c9b890c..19e6b44 100644 --- a/dzgui/views/dialogs/generic.py +++ b/dzgui/views/dialogs/generic.py @@ -196,17 +196,49 @@ 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 + wrap_mode=Gtk.WrapMode.WORD, + editable=False, + left_margin=10, + right_margin=10, + top_margin=15, ) textview.set_buffer(Gtk.TextBuffer(text=self.trace)) - box.pack_start(textview, EXPAND, FILL, 10) - scrollable.add(box) + box.pack_start(textview, EXPAND, FILL, 0) + + self.error_details = Gtk.ScrolledWindow( + overlay_scrolling=False, + max_content_height=150, + propagate_natural_height=False, + ) + details_box = Gtk.Box( + hexpand=True, + vexpand=True, + margin_right=5, + orientation=Gtk.Orientation.VERTICAL, + ) + + self.details_buffer = Gtk.TextBuffer() + details_textview = Gtk.TextView( + wrap_mode=Gtk.WrapMode.WORD_CHAR, + editable=False, + left_margin=10, + right_margin=10, + top_margin=15, + ) + + details_textview.set_buffer(self.details_buffer) + details_box.pack_start(details_textview, EXPAND, FILL, 10) + self.error_details.add(details_box) + + self.error_notebook = Gtk.Notebook(show_tabs=False, margin_bottom=15) + self.error_notebook.append_page(box, Gtk.Label(label="Error")) + self.error_notebook.append_page(self.error_details, Gtk.Label(label="Details")) + self.error_notebook.connect("switch-page", self._on_page_changed) + scrollable.add(self.error_notebook) content = self.get_content_area() content.set_spacing(0) - # TODO: padding around top of content area when traceback is long content.add(scrollable) copy_button = ClipboardButton(controller, self.get_trace) @@ -219,6 +251,18 @@ class ExceptionDialog(GenericDialog): self.ok.grab_focus() self.connect("response", self._on_response) + def _on_page_changed( + self, notebook: Gtk.Notebook, child: Gtk.Widget, index: int + ) -> None: + if child == self.error_details: + child.set_propagate_natural_height(True) + else: + self.error_details.set_propagate_natural_height(False) + + def set_secondary_text(self, text: str) -> None: + self.details_buffer.set_text(text) + self.error_notebook.set_show_tabs(True) + def get_trace(self) -> str: return self.trace From 97ddbcdf0a12a0f6d59dd13ed8170f973ec8b942 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:26:42 +0900 Subject: [PATCH 106/139] fix: clear typehinting errors --- dzgui/views/dialogs/generic.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dzgui/views/dialogs/generic.py b/dzgui/views/dialogs/generic.py index 19e6b44..16d0e8e 100644 --- a/dzgui/views/dialogs/generic.py +++ b/dzgui/views/dialogs/generic.py @@ -252,10 +252,10 @@ class ExceptionDialog(GenericDialog): self.connect("response", self._on_response) def _on_page_changed( - self, notebook: Gtk.Notebook, child: Gtk.Widget, index: int + self, notebook: Gtk.Notebook, child: Gtk.Box | Gtk.ScrolledWindow, index: int ) -> None: if child == self.error_details: - child.set_propagate_natural_height(True) + self.error_details.set_propagate_natural_height(True) else: self.error_details.set_propagate_natural_height(False) From c64631cdcab2fa475d4d660e7a0b45c2ffa69ba7 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:38:45 +0900 Subject: [PATCH 107/139] fix: add bottom padding --- dzgui/views/dialogs/generic.py | 1 + 1 file changed, 1 insertion(+) diff --git a/dzgui/views/dialogs/generic.py b/dzgui/views/dialogs/generic.py index 16d0e8e..f3174eb 100644 --- a/dzgui/views/dialogs/generic.py +++ b/dzgui/views/dialogs/generic.py @@ -202,6 +202,7 @@ class ExceptionDialog(GenericDialog): left_margin=10, right_margin=10, top_margin=15, + bottom_margin=10, ) textview.set_buffer(Gtk.TextBuffer(text=self.trace)) box.pack_start(textview, EXPAND, FILL, 0) From 0dfcc5a38711bc1c8e0e129eaab44120be0ceb4a Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:40:02 +0900 Subject: [PATCH 108/139] feat: internal knowledeat: add knowledge base text to specific exception dialogs --- dzgui/managers/connection.py | 6 +++++- dzgui/managers/mods.py | 3 ++- dzgui/model/servers.py | 3 ++- dzgui/strings/kb.py | 15 +++++++++++++++ 4 files changed, 24 insertions(+), 3 deletions(-) create mode 100644 dzgui/strings/kb.py diff --git a/dzgui/managers/connection.py b/dzgui/managers/connection.py index c96abc2..9baf109 100644 --- a/dzgui/managers/connection.py +++ b/dzgui/managers/connection.py @@ -45,6 +45,7 @@ from dzgui.strings.dialogs import ( waiting_for_mods, waiting_for_directories, ) +from dzgui.strings import kb from dzgui.strings.server_mods import checkmark, resync from dzgui.util.format import format_mib from dzgui.util.strings import dialog, server_timeout @@ -193,7 +194,9 @@ class ConnectionManager: is_last = self.is_last_server() # TODO: strings - invalid_mods = [(mod[0], mod[1]) for mod in remote_mods if mod[2] == "Invalid mod"] + invalid_mods = [ + (mod[0], mod[1]) for mod in remote_mods if mod[2] == "Invalid mod" + ] prereqs = Prerequisites( name=info.server_name, @@ -298,6 +301,7 @@ class ConnectionManager: def _server_timeout(self) -> None: dialog = ExceptionDialog(self.controller, server_timeout) + dialog.set_secondary_text(kb.DZG_006) dialog.run() def _connect_steam(self, menu_only: bool) -> None: diff --git a/dzgui/managers/mods.py b/dzgui/managers/mods.py index da31385..522c340 100644 --- a/dzgui/managers/mods.py +++ b/dzgui/managers/mods.py @@ -20,7 +20,7 @@ from dzgui.const.constants import ( 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.strings import dialogs +from dzgui.strings import dialogs, kb from dzgui.util.format import format_mods from dzgui.util.strings import server_timeout from dzgui.util.symlink import rebuild_symlinks @@ -215,6 +215,7 @@ class ModManager: def _server_timeout(self) -> None: dialog = ExceptionDialog(self.controller, server_timeout) + dialog.set_seconary_text(kb.DZG_006) dialog.run() def select_colorized(self) -> None: diff --git a/dzgui/model/servers.py b/dzgui/model/servers.py index bc5d616..cfb2c0a 100644 --- a/dzgui/model/servers.py +++ b/dzgui/model/servers.py @@ -14,7 +14,7 @@ from dzgui.const.constants import ( ) from dzgui.const.enum import FilterMode, Preferences, ServerTab from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager -from dzgui.strings import dialogs +from dzgui.strings import dialogs, kb from dzgui.util.strings import server_timeout, dialog from dzgui.views.dialogs.generic import ExceptionDialog @@ -420,6 +420,7 @@ class ServerModelManager: # customize statusbar and dialog accordingly if show_dialog: dialog = ExceptionDialog(self.controller, server_timeout) + dialog.set_secondary_text(kb.DZG_006) dialog.run() def _push_data(self, data: list[Any]) -> None: diff --git a/dzgui/strings/kb.py b/dzgui/strings/kb.py new file mode 100644 index 0000000..42f300b --- /dev/null +++ b/dzgui/strings/kb.py @@ -0,0 +1,15 @@ +DZG_006 = """The leading cause of specific servers periodically timing out is local network configuration. + +Many third-party DayZ servers use a server rental/hosting provider with DDoS protection. + +This can cause responses from servers to originate from a server other than the one originally queried. + +Consumer-grade routers are likely to drop this traffic as invalid due to how they handle NAT (network address translation). + +By contrast, wireless routers and enterprise-grade routers may be less likely to have this issue. + +If you find that a specific server is unresponsive for you when it shouldn't be, add a port forwarding rule to your router's settings for the server's query port. + +In addition, packets received from server responses are expected to be a standard size: MTU (maximum transmission unit) of 1500. + +Deviation from this may cause your router to discard incoming responses from the server.""" From de4e74daa15db55d06cce44c4f09d8719acc264f Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:40:34 +0900 Subject: [PATCH 109/139] fix: typo --- 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 522c340..742873a 100644 --- a/dzgui/managers/mods.py +++ b/dzgui/managers/mods.py @@ -215,7 +215,7 @@ class ModManager: def _server_timeout(self) -> None: dialog = ExceptionDialog(self.controller, server_timeout) - dialog.set_seconary_text(kb.DZG_006) + dialog.set_secondary_text(kb.DZG_006) dialog.run() def select_colorized(self) -> None: From b153ab875ef4a3d7b8148347c7a057f56a423d6e Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:36:56 +0900 Subject: [PATCH 110/139] chore: backport DebugDialog --- dzgui/views/dialogs/generic.py | 60 ++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/dzgui/views/dialogs/generic.py b/dzgui/views/dialogs/generic.py index f3174eb..e6c98c4 100644 --- a/dzgui/views/dialogs/generic.py +++ b/dzgui/views/dialogs/generic.py @@ -169,6 +169,66 @@ class QuitDialog(GenericDialog): self.controller.save_res_and_quit() +class TextBufferDialog(GenericDialog): + def __init__( + self, controller: "Controller", heading: str, secondary: str, text: str + ): + super().__init__( + controller=controller, + text=heading, + mtype=Gtk.MessageType.INFO, + buttons=Gtk.ButtonsType.NONE, + secondary=secondary, + ) + self.text = text + copy_button = ClipboardButton(controller, self.get_text) + self.add_action_widget(copy_button, Gtk.ResponseType.NONE) + self.add_button("OK", Gtk.ResponseType.OK) + self.connect("response", self._on_response) + + def get_text(self) -> str: + return self.text + + def _on_response( + self, dialog: Self, response: Gtk.ResponseType + ) -> None | Literal[True]: + 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 + + +class DebugDialog(TextBufferDialog): + def __init__(self, controller: "Controller", debug: str): + super().__init__( + controller=controller, + heading="Debug", + secondary="Debug args", + text=debug, + ) + 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=debug)) + box.pack_start(textview, EXPAND, FILL, 10) + scrollable.add(box) + content = self.get_content_area() + content.set_spacing(0) + content.add(scrollable) + self.show_all() + + class ExceptionDialog(GenericDialog): """ Error dialog with rich traceback. From b1f189bff8e59286922069aaa0f012a2bbd2fede Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:22:14 +0900 Subject: [PATCH 111/139] feat: add notebook to ExceptionDialog --- dzgui/views/dialogs/generic.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dzgui/views/dialogs/generic.py b/dzgui/views/dialogs/generic.py index e6c98c4..3a78f5a 100644 --- a/dzgui/views/dialogs/generic.py +++ b/dzgui/views/dialogs/generic.py @@ -316,9 +316,9 @@ class ExceptionDialog(GenericDialog): self, notebook: Gtk.Notebook, child: Gtk.Box | Gtk.ScrolledWindow, index: int ) -> None: if child == self.error_details: - self.error_details.set_propagate_natural_height(True) + GLib.idle_add(self.error_details.set_propagate_natural_height, True) else: - self.error_details.set_propagate_natural_height(False) + GLib.idle_add(self.error_details.set_propagate_natural_height, False) def set_secondary_text(self, text: str) -> None: self.details_buffer.set_text(text) From 4eaedd0cddd901c311ed121a6f73b30e088f64ef Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:46:02 +0900 Subject: [PATCH 112/139] feat: add DebugDialog to preconnect --- dzgui/api/steam.py | 16 ++++++++++++++++ dzgui/controllers/mc.py | 3 +++ dzgui/managers/connection.py | 8 ++++++++ dzgui/views/pages/preconnect.py | 12 ++++++++++++ 4 files changed, 39 insertions(+) diff --git a/dzgui/api/steam.py b/dzgui/api/steam.py index c5b0074..6139e9a 100644 --- a/dzgui/api/steam.py +++ b/dzgui/api/steam.py @@ -150,6 +150,22 @@ def get_remote_signatures(mods: list[str]) -> list[tuple[str, str, int, int]]: return hashes +def connect_debug(client: str, addr: str, appid: int, name: str, mods: list[str]) -> str: + concat = concat_mods(mods) + client_args = concat_bash_args(client) + params = [ + "-applaunch", + str(appid), + f"-connect={addr}", + "-nolauncher", + "-nosplash", + "-skipintro", + f"-name={name}", + f"-mod={concat}", + ] + client_args.extend(params) + return " ".join(client_args) + # 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(client: str, addr: str, appid: int, name: str, mods: list[str]) -> int: diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index b8b69fc..e6af0d6 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -526,6 +526,9 @@ class Controller(GObject.GObject): ind = self.config_man.get_start_tab() self.get_servers().notebook.set_current_page(ind) + def get_debug_args(self) -> str: + return self.connection_man.get_debug_args() + def update_and_load_to_menu(self) -> None: self.connection_man.update_and_connect(menu_only=True) diff --git a/dzgui/managers/connection.py b/dzgui/managers/connection.py index 9baf109..44cde10 100644 --- a/dzgui/managers/connection.py +++ b/dzgui/managers/connection.py @@ -13,6 +13,7 @@ from dzgui.api.shortcuts import Shortcuts from dzgui.api.steam import ( connect, + connect_debug, get_app_allows_downloads, get_app_name, get_needs_update, @@ -304,6 +305,13 @@ class ConnectionManager: dialog.set_secondary_text(kb.DZG_006) dialog.run() + def get_debug_args(self) -> str: + addr = f"{self.record.ip}:{self.record.gameport}" + playername = self.controller.query_config(Preferences.NAME) + return connect_debug( + self.client, addr, self.appid, playername, self.remote_mod_ids + ) + def _connect_steam(self, menu_only: bool) -> None: addr = f"{self.record.ip}:{self.record.gameport}" playername = self.controller.query_config(Preferences.NAME) diff --git a/dzgui/views/pages/preconnect.py b/dzgui/views/pages/preconnect.py index 2e07de0..b9f7bd9 100644 --- a/dzgui/views/pages/preconnect.py +++ b/dzgui/views/pages/preconnect.py @@ -11,6 +11,7 @@ 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 +from dzgui.views.dialogs.generic import DebugDialog from dzgui.views.components.frame import HeadingFrame from dzgui.views.trees.tree_server_mods import ServerModTreeView @@ -120,6 +121,12 @@ class PreConnectionAssistant(Gtk.Box): spacing=5, ) box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5) + # TODO: + if self.controller.get_prefs().is_debug: + # TODO: strings + debug = Gtk.Button(label="Debug", halign=Gtk.Align.START) + debug.connect("clicked", self._on_debug_clicked) + box.add(debug) for button in self.back, self.ok, self.connect_last: box.add(button) self.button_box.add(box) @@ -221,6 +228,11 @@ class PreConnectionAssistant(Gtk.Box): def _on_connect_last_clicked(self, button: Gtk.Button) -> None: self.controller.update_and_load_to_menu() + def _on_debug_clicked(self, button: Gtk.Button) -> None: + args = self.controller.get_debug_args() + d = DebugDialog(self.controller, args) + d.run() + def _on_ok_clicked(self, button: Gtk.Button) -> None: self.controller.update_and_connect() From 1332026c8cc326ec9d8d5f432dd4a57b1598edf3 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:00:28 +0900 Subject: [PATCH 113/139] chore: update strings --- dzgui/strings/dialogs.py | 3 +++ dzgui/strings/preconnect.py | 1 + 2 files changed, 4 insertions(+) diff --git a/dzgui/strings/dialogs.py b/dzgui/strings/dialogs.py index 460250d..3ab9a04 100644 --- a/dzgui/strings/dialogs.py +++ b/dzgui/strings/dialogs.py @@ -20,3 +20,6 @@ ok = "OK" mission_dialog = "Set a mission folder location" custom_mod_dialog = "Set location to a custom mods folder" + +debug_heading = "Debug" +debug_secondary = "Launch arguments" diff --git a/dzgui/strings/preconnect.py b/dzgui/strings/preconnect.py index 9e0f764..274c4b0 100644 --- a/dzgui/strings/preconnect.py +++ b/dzgui/strings/preconnect.py @@ -2,6 +2,7 @@ update_mods = "Update mods and connect" connect = "Connect" connect_last = "Load to menu screen" connect_last_tooltip = "Launch DayZ with these mods (if any) and\ngo to the menu screen without connecting." +debug = "Debug" back = "Back" cancel = "Cancel" warnings = "Warnings" From cf850c8f34c01652ddf67af8670a34a8b8446380 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:00:53 +0900 Subject: [PATCH 114/139] chore: subclass ExceptionDialog from TextBufferDialog --- dzgui/views/dialogs/generic.py | 52 +++++++++++----------------------- 1 file changed, 16 insertions(+), 36 deletions(-) diff --git a/dzgui/views/dialogs/generic.py b/dzgui/views/dialogs/generic.py index 3a78f5a..df71bc6 100644 --- a/dzgui/views/dialogs/generic.py +++ b/dzgui/views/dialogs/generic.py @@ -1,6 +1,7 @@ from typing import Literal, Self, TYPE_CHECKING from dzgui.const.constants import NO_EXPAND, NO_FILL, NO_PADDING, EXPAND, FILL +from dzgui.strings import dialogs from dzgui.util import strings from dzgui.views.components.buttons import ClipboardButton @@ -171,12 +172,17 @@ class QuitDialog(GenericDialog): class TextBufferDialog(GenericDialog): def __init__( - self, controller: "Controller", heading: str, secondary: str, text: str + self, + controller: "Controller", + mtype: Gtk.MessageType, + heading: str, + secondary: str, + text: str, ): super().__init__( controller=controller, text=heading, - mtype=Gtk.MessageType.INFO, + mtype=mtype, buttons=Gtk.ButtonsType.NONE, secondary=secondary, ) @@ -209,8 +215,9 @@ class DebugDialog(TextBufferDialog): def __init__(self, controller: "Controller", debug: str): super().__init__( controller=controller, - heading="Debug", - secondary="Debug args", + mtype=Gtk.MessageType.INFO, + heading=dialogs.debug_heading, + secondary=dialogs.debug_secondary, text=debug, ) scrollable = Gtk.ScrolledWindow( @@ -229,7 +236,7 @@ class DebugDialog(TextBufferDialog): self.show_all() -class ExceptionDialog(GenericDialog): +class ExceptionDialog(TextBufferDialog): """ Error dialog with rich traceback. Usage: @@ -244,13 +251,12 @@ class ExceptionDialog(GenericDialog): def __init__(self, controller: "Controller", trace: str): super().__init__( controller=controller, - text=strings.error_heading, - mtype=Gtk.MessageType.ERROR, - buttons=Gtk.ButtonsType.NONE, + heading=strings.error_heading, secondary=strings.something_wrong, + text=trace, + mtype=Gtk.MessageType.ERROR, ) - self.trace = trace # NOTE: box expands to end of content area scrollable = Gtk.ScrolledWindow( propagate_natural_height=True, max_content_height=500 @@ -264,7 +270,7 @@ class ExceptionDialog(GenericDialog): top_margin=15, bottom_margin=10, ) - textview.set_buffer(Gtk.TextBuffer(text=self.trace)) + textview.set_buffer(Gtk.TextBuffer(text=trace)) box.pack_start(textview, EXPAND, FILL, 0) self.error_details = Gtk.ScrolledWindow( @@ -302,15 +308,7 @@ class ExceptionDialog(GenericDialog): content.set_spacing(0) content.add(scrollable) - copy_button = ClipboardButton(controller, self.get_trace) - self.add_action_widget(copy_button, Gtk.ResponseType.NONE) - self.add_button("OK", Gtk.ResponseType.OK) - self.show_all() - 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 _on_page_changed( self, notebook: Gtk.Notebook, child: Gtk.Box | Gtk.ScrolledWindow, index: int @@ -323,21 +321,3 @@ class ExceptionDialog(GenericDialog): def set_secondary_text(self, text: str) -> None: self.details_buffer.set_text(text) self.error_notebook.set_show_tabs(True) - - def get_trace(self) -> str: - return self.trace - - def _on_response( - self, dialog: Self, response: Gtk.ResponseType - ) -> None | Literal[True]: - 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 From 37c3937aee406b32f5b48921fb6fa55f8fbe2afa Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:01:06 +0900 Subject: [PATCH 115/139] chore: add strings to preconnect page --- dzgui/views/pages/preconnect.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dzgui/views/pages/preconnect.py b/dzgui/views/pages/preconnect.py index b9f7bd9..4d6e6eb 100644 --- a/dzgui/views/pages/preconnect.py +++ b/dzgui/views/pages/preconnect.py @@ -121,10 +121,10 @@ class PreConnectionAssistant(Gtk.Box): spacing=5, ) box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5) - # TODO: + + # NOTE: -d flag if self.controller.get_prefs().is_debug: - # TODO: strings - debug = Gtk.Button(label="Debug", halign=Gtk.Align.START) + debug = Gtk.Button(label=preconnect.debug, halign=Gtk.Align.START) debug.connect("clicked", self._on_debug_clicked) box.add(debug) for button in self.back, self.ok, self.connect_last: From cb9ffb5b996cf355b6b49742f844116053a1790a Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:01:40 +0900 Subject: [PATCH 116/139] chore: drop debug key from prefs enum --- dzgui/const/enum.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/dzgui/const/enum.py b/dzgui/const/enum.py index 28a4cdf..4e77740 100644 --- a/dzgui/const/enum.py +++ b/dzgui/const/enum.py @@ -76,9 +76,6 @@ class Preferences(EnumWithAttrs): INSTALL = { "key": "auto_install", } - DEBUG = { - "key": "debug", - } DEFAULT = { "key": "default_steam_path", } From 9ec3fa09e3aad09dadc1c3f19b8bf179161cf70e Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:02:59 +0900 Subject: [PATCH 117/139] chore: drop unused enums --- dzgui/const/enum.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/dzgui/const/enum.py b/dzgui/const/enum.py index 4e77740..05a9763 100644 --- a/dzgui/const/enum.py +++ b/dzgui/const/enum.py @@ -73,9 +73,6 @@ class Preferences(EnumWithAttrs): NAME = { "key": "name", } - INSTALL = { - "key": "auto_install", - } DEFAULT = { "key": "default_steam_path", } @@ -88,9 +85,6 @@ class Preferences(EnumWithAttrs): IP_LIST = { "key": "ip_list", } - BRANCH = { - "key": "branch", - } START_TAB = { "key": "start_tab", } From 52b18f710f21955090a8480c8e837ef202c3f3c8 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:03:08 +0900 Subject: [PATCH 118/139] chore: drop unused method --- dzgui/controllers/mc.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index e6af0d6..d8ca3d6 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -114,9 +114,6 @@ class Controller(GObject.GObject): def query_config(self, key: Preferences) -> Any: return self.config_man.lookup(key) - def is_auto_install(self) -> bool: - return bool(self.query_config(Preferences.INSTALL)) - def suppress_signal( self, owner: Any, child: Any, func_name: str, state: bool ) -> None: From bd022ea1d8063084c893159a79e6ac5d3204bb59 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:33:05 +0900 Subject: [PATCH 119/139] chore: drop unused method --- dzgui/managers/mods.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/dzgui/managers/mods.py b/dzgui/managers/mods.py index 742873a..8689072 100644 --- a/dzgui/managers/mods.py +++ b/dzgui/managers/mods.py @@ -213,11 +213,6 @@ class ModManager: 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.set_secondary_text(kb.DZG_006) - dialog.run() - def select_colorized(self) -> None: model = self.treeview.get_model() if model is None: From ec004aad5dc7415a978a1018862191ac45c9ce4d Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:33:42 +0900 Subject: [PATCH 120/139] fix: defer propagation until size allocation --- dzgui/views/dialogs/generic.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/dzgui/views/dialogs/generic.py b/dzgui/views/dialogs/generic.py index df71bc6..2257873 100644 --- a/dzgui/views/dialogs/generic.py +++ b/dzgui/views/dialogs/generic.py @@ -221,19 +221,19 @@ class DebugDialog(TextBufferDialog): text=debug, ) scrollable = Gtk.ScrolledWindow( - propagate_natural_height=True, max_content_height=500 + propagate_natural_height=False, + min_content_height=200, + max_content_height=200, + margin_bottom=10, ) - 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.CHAR, editable=False, left_margin=10, right_margin=10 ) textview.set_buffer(Gtk.TextBuffer(text=debug)) - box.pack_start(textview, EXPAND, FILL, 10) - scrollable.add(box) + scrollable.add(textview) content = self.get_content_area() content.set_spacing(0) content.add(scrollable) - self.show_all() class ExceptionDialog(TextBufferDialog): @@ -308,8 +308,6 @@ class ExceptionDialog(TextBufferDialog): content.set_spacing(0) content.add(scrollable) - self.show_all() - def _on_page_changed( self, notebook: Gtk.Notebook, child: Gtk.Box | Gtk.ScrolledWindow, index: int ) -> None: From e7ff025605daad6c761fb9e841680ea45c968931 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:36:35 +0900 Subject: [PATCH 121/139] fix: draw dialog outside of init --- dzgui/controllers/mc.py | 2 ++ dzgui/managers/config.py | 1 + dzgui/managers/connection.py | 1 + dzgui/managers/mods.py | 4 +--- dzgui/managers/update.py | 1 + dzgui/model/servers.py | 2 ++ dzgui/views/pages/options.py | 1 + dzgui/views/pages/preconnect.py | 1 + 8 files changed, 10 insertions(+), 3 deletions(-) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index d8ca3d6..9effd3c 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -263,6 +263,7 @@ class Controller(GObject.GObject): self.open_page(NotebookPage.LOG) except Exception as e: dialog = ExceptionDialog(self, str(e)) + dialog.show_all() dialog.run() def select_colorized(self) -> None: @@ -296,6 +297,7 @@ class Controller(GObject.GObject): write_diagnostic(self.prefs.paths.config, file) except Exception as e: dialog = ExceptionDialog(self, str(e)) + dialog.show_all() dialog.run() def update_steam_api_key(self, text: str) -> None: diff --git a/dzgui/managers/config.py b/dzgui/managers/config.py index ac559b2..9128579 100644 --- a/dzgui/managers/config.py +++ b/dzgui/managers/config.py @@ -178,6 +178,7 @@ class ConfigManager: logger.critical(e) trace = traceback.format_exc() dialog = ExceptionDialog(self.controller, trace) + dialog.show_all() dialog.run() raise e diff --git a/dzgui/managers/connection.py b/dzgui/managers/connection.py index 44cde10..fc5b65a 100644 --- a/dzgui/managers/connection.py +++ b/dzgui/managers/connection.py @@ -303,6 +303,7 @@ class ConnectionManager: def _server_timeout(self) -> None: dialog = ExceptionDialog(self.controller, server_timeout) dialog.set_secondary_text(kb.DZG_006) + dialog.show_all() dialog.run() def get_debug_args(self) -> str: diff --git a/dzgui/managers/mods.py b/dzgui/managers/mods.py index 8689072..c04d231 100644 --- a/dzgui/managers/mods.py +++ b/dzgui/managers/mods.py @@ -20,11 +20,9 @@ from dzgui.const.constants import ( 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.strings import dialogs, kb +from dzgui.strings import dialogs from dzgui.util.format import format_mods -from dzgui.util.strings import server_timeout from dzgui.util.symlink import rebuild_symlinks -from dzgui.views.dialogs.generic import ExceptionDialog import dzgui.api.pefile as PeFile diff --git a/dzgui/managers/update.py b/dzgui/managers/update.py index 3c0f928..ff64fb4 100644 --- a/dzgui/managers/update.py +++ b/dzgui/managers/update.py @@ -64,4 +64,5 @@ class UpdateManager: def _on_update_failure(self, msg: str) -> None: dialog = ExceptionDialog(self.controller, msg) + dialog.show_all() dialog.run() diff --git a/dzgui/model/servers.py b/dzgui/model/servers.py index cfb2c0a..e2c9430 100644 --- a/dzgui/model/servers.py +++ b/dzgui/model/servers.py @@ -405,6 +405,7 @@ class ServerModelManager: if show_dialog: dialog = ExceptionDialog(self.controller, dialogs.load_error_lan) + dialog.show_all() dialog.run() def _cleanup_on_failure(self, show_dialog: bool = True) -> None: @@ -421,6 +422,7 @@ class ServerModelManager: if show_dialog: dialog = ExceptionDialog(self.controller, server_timeout) dialog.set_secondary_text(kb.DZG_006) + dialog.show_all() dialog.run() def _push_data(self, data: list[Any]) -> None: diff --git a/dzgui/views/pages/options.py b/dzgui/views/pages/options.py index 277c13e..93341ec 100644 --- a/dzgui/views/pages/options.py +++ b/dzgui/views/pages/options.py @@ -430,6 +430,7 @@ class Options(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore # NOTE: re-check in case file was removed by user between runs if prefs.paths.config.is_file() is False: dialog = ExceptionDialog(self.controller, strings.config_not_found) + dialog.show_all() dialog.run() raise OSError(f"Config file '{prefs.paths.config}' not found") diff --git a/dzgui/views/pages/preconnect.py b/dzgui/views/pages/preconnect.py index 4d6e6eb..93b0fb6 100644 --- a/dzgui/views/pages/preconnect.py +++ b/dzgui/views/pages/preconnect.py @@ -231,6 +231,7 @@ class PreConnectionAssistant(Gtk.Box): def _on_debug_clicked(self, button: Gtk.Button) -> None: args = self.controller.get_debug_args() d = DebugDialog(self.controller, args) + d.show_all() d.run() def _on_ok_clicked(self, button: Gtk.Button) -> None: From 17b1a757450f48f5a28d8c556897de55ddd6cc5b Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:59:00 +0900 Subject: [PATCH 122/139] chore: disambiguate buffer setter method --- dzgui/managers/connection.py | 2 +- dzgui/model/servers.py | 2 +- dzgui/views/dialogs/generic.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/dzgui/managers/connection.py b/dzgui/managers/connection.py index fc5b65a..7d2047e 100644 --- a/dzgui/managers/connection.py +++ b/dzgui/managers/connection.py @@ -302,7 +302,7 @@ class ConnectionManager: def _server_timeout(self) -> None: dialog = ExceptionDialog(self.controller, server_timeout) - dialog.set_secondary_text(kb.DZG_006) + dialog.set_details_buffer(kb.DZG_006) dialog.show_all() dialog.run() diff --git a/dzgui/model/servers.py b/dzgui/model/servers.py index e2c9430..a429ffb 100644 --- a/dzgui/model/servers.py +++ b/dzgui/model/servers.py @@ -421,7 +421,7 @@ class ServerModelManager: # customize statusbar and dialog accordingly if show_dialog: dialog = ExceptionDialog(self.controller, server_timeout) - dialog.set_secondary_text(kb.DZG_006) + dialog.set_details_buffer(kb.DZG_006) dialog.show_all() dialog.run() diff --git a/dzgui/views/dialogs/generic.py b/dzgui/views/dialogs/generic.py index 2257873..4e871ff 100644 --- a/dzgui/views/dialogs/generic.py +++ b/dzgui/views/dialogs/generic.py @@ -316,6 +316,6 @@ class ExceptionDialog(TextBufferDialog): else: GLib.idle_add(self.error_details.set_propagate_natural_height, False) - def set_secondary_text(self, text: str) -> None: + def set_details_buffer(self, text: str) -> None: self.details_buffer.set_text(text) self.error_notebook.set_show_tabs(True) From e9d064820ea5d89de781eea95a433f12045c719b Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:44:55 +0900 Subject: [PATCH 123/139] chore: reword remarks on MTU --- docs/source/kb.rst | 4 ++-- dzgui/strings/kb.py | 4 +--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/docs/source/kb.rst b/docs/source/kb.rst index afaa68f..7ab8bb2 100644 --- a/docs/source/kb.rst +++ b/docs/source/kb.rst @@ -16,8 +16,8 @@ By contrast, wireless routers and enterprise-grade routers may be less likely to If you find that a specific server is unresponsive for you when it shouldn't be, add a port forwarding rule to your router's settings for the server's query port. -In addition, packets sent from server responses are expected to be a standard size (see warning below). -Deviation from this may cause your router to discard incoming responses from the server. +In addition, a uniform packet size is expected. If your router's MTU setting differs from the default of 1,500, +it could lead to incoming responses being discarded (see warning below). .. important:: diff --git a/dzgui/strings/kb.py b/dzgui/strings/kb.py index 42f300b..68ad2e5 100644 --- a/dzgui/strings/kb.py +++ b/dzgui/strings/kb.py @@ -10,6 +10,4 @@ By contrast, wireless routers and enterprise-grade routers may be less likely to If you find that a specific server is unresponsive for you when it shouldn't be, add a port forwarding rule to your router's settings for the server's query port. -In addition, packets received from server responses are expected to be a standard size: MTU (maximum transmission unit) of 1500. - -Deviation from this may cause your router to discard incoming responses from the server.""" +In addition, a uniform packet size is expected. If your router's MTU (maximum transmission unit) setting differs from the default of 1500, it could lead to incoming responses being discarded.""" From 0d5e190b75f924af44273e4952baf6c5675950f1 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 20 Aug 2026 07:55:43 +0900 Subject: [PATCH 124/139] chore: normalize docstring --- dzgui/managers/config.py | 1 - dzgui/views/dialogs/generic.py | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/dzgui/managers/config.py b/dzgui/managers/config.py index 9128579..576f809 100644 --- a/dzgui/managers/config.py +++ b/dzgui/managers/config.py @@ -180,7 +180,6 @@ class ConfigManager: dialog = ExceptionDialog(self.controller, trace) dialog.show_all() dialog.run() - raise e def save_res_and_quit(self, tv: "ServerTreeView", window: "OuterWindow") -> None: columns = tv.get_columns() diff --git a/dzgui/views/dialogs/generic.py b/dzgui/views/dialogs/generic.py index 4e871ff..b504955 100644 --- a/dzgui/views/dialogs/generic.py +++ b/dzgui/views/dialogs/generic.py @@ -245,6 +245,7 @@ class ExceptionDialog(TextBufferDialog): except Exception: trace = traceback.format_exc() dialog = ExceptionDialog(Controller, trace) + dialog.show_all() dialog.run() """ From 93d870b5bf0a58aae4dab2a2a4fb3a57e914eb7c Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 20 Aug 2026 10:13:37 +0900 Subject: [PATCH 125/139] feat: uninstall wizard WIP --- dzgui/api/shortcuts.py | 7 + dzgui/main.py | 24 ++-- dzgui/strings/uninstall.py | 38 ++++++ dzgui/views/dialogs/uninstall.py | 226 +++++++++++++++++++++++++++++++ dzgui/views/dialogs/wizard.py | 17 ++- 5 files changed, 293 insertions(+), 19 deletions(-) create mode 100644 dzgui/strings/uninstall.py create mode 100644 dzgui/views/dialogs/uninstall.py diff --git a/dzgui/api/shortcuts.py b/dzgui/api/shortcuts.py index 154645e..50c1d33 100644 --- a/dzgui/api/shortcuts.py +++ b/dzgui/api/shortcuts.py @@ -160,6 +160,13 @@ class Shortcuts: NEW_ENTRY["tags"] = {} return NEW_ENTRY + def delete_shortcut(self, start_path: Path) -> None: + for s in self.shortcuts["shortcuts"]: + if self.shortcuts[s]["StartDir"] == str(start_path): + del self.shortcuts[s] + break + self.save_shortcuts() + def save_shortcuts(self) -> None: try: backup = self.shortcuts_path.with_suffix(".vdf.bak") diff --git a/dzgui/main.py b/dzgui/main.py index 731f58e..ff59ba8 100644 --- a/dzgui/main.py +++ b/dzgui/main.py @@ -8,30 +8,26 @@ from dzgui.init.prefix import get_version from dzgui.util.map_count import set_map_count from dzgui.util.strings import flags +from dzgui.views.dialogs.uninstall import UninstallWizard +from dzgui.config.xdg import get_xdg_paths + parser = argparse.ArgumentParser(description=flags.description) -parser.add_argument("-d", "--debug", action="store_true", help=flags.debug) -parser.add_argument("-m", "--map", action="store_true", help=flags.map_count) -parser.add_argument("-u", "--uninstall", action="store_true", help=flags.uninstall) -parser.add_argument("-v", "--version", action="store_true", help=flags.version) +group = parser.add_mutually_exclusive_group() +group.add_argument("-d", "--debug", action="store_true", help=flags.debug) +group.add_argument("-m", "--map", action="store_true", help=flags.map_count) +group.add_argument("-u", "--uninstall", action="store_true", help=flags.uninstall) +group.add_argument("-v", "--version", action="store_true", help=flags.version) args = parser.parse_args() -def uninstall() -> None: - # TODO: uninstall data files (-u) - # -u removes state, log, freedesktop - # XDG_SHARE_HOME/dzgui - # XDG_STATE_HOME/dzgui - # XDG_DATA_HOME/dzgui - pass - - def main() -> None: # TODO: isolate single flags if args.version is True: print(get_version()) sys.exit(0) if args.uninstall is True: - uninstall() + paths = get_xdg_paths() + UninstallWizard(False, paths) sys.exit(0) if args.debug is True: warnings.filterwarnings("default", category=DeprecationWarning) diff --git a/dzgui/strings/uninstall.py b/dzgui/strings/uninstall.py new file mode 100644 index 0000000..676392f --- /dev/null +++ b/dzgui/strings/uninstall.py @@ -0,0 +1,38 @@ +pg1_title = "Choose uninstall targets" +pg1_heading = "Choose uninstall targets" +pg1_blurb = "Here you can choose which DZGUI configuration/state files to remove. " + +pg2_title = "Complete" +pg2_heading = "Uninstall complete" +pg2_blurb = "Finished removing the selected files. See the system log for errors." + +config_label = "Configuration files" +config_details = ( + "User preferences and API key. " + "Keep this file if you want to import it again later." +) +desktop_label = "Desktop shortcut" +desktop_details = "Shortcut set on the desktop, if applicable." + +start_menu_label = "Start menu shortcut" +start_menu_details = ( + "Shortcut set under the Games category of the start menu, if applicable." +) +state_label = "Long-term state files" +state_details = ( + "UI size, filter preferences, column size, server notes, and mod signatures." +) +steam_shortcut = "Steam shortcut" +steam_details = "Shortcut to DZGUI installed within Steam, if applicable. Requires Steam to be restarted to reflect the change." + + +path_remove_prefix = "The following path will be removed:" +not_installed = "It looks like DZGUI was not previously set up; nothing to uninstall." +standalone_uninstall = ( + "It looks like you are running the standalone version of DZGUI. " + "To finalize the uninstall process, you can remove the 'dzgui' file." +) +system_uninstall = ( + "It looks like DZGUI was installed via your system package manager or directly from source code.\n" + "To finalize the uninstall process, follow the standard removal procedure for that context." +) diff --git a/dzgui/views/dialogs/uninstall.py b/dzgui/views/dialogs/uninstall.py new file mode 100644 index 0000000..afda8eb --- /dev/null +++ b/dzgui/views/dialogs/uninstall.py @@ -0,0 +1,226 @@ +import os +import subprocess + +from pathlib import Path +from typing import Self, TYPE_CHECKING + +from dzgui.api.shortcuts import Shortcuts +from dzgui.const.constants import APP_NAME +from dzgui.strings import uninstall +from dzgui.util._json import read_json +from dzgui.util.css import load_css +from dzgui.views.components.box import HBox +from dzgui.views.dialogs.wizard import ScrolledWizardPage, CheckboxWithLabel + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk, GLib, Pango # noqa E402 + + +class Assistant(Gtk.Assistant): + def __init__(self, is_deck: bool, paths: dict[str, str]): + super().__init__() + if is_deck: + # NOTE: deemed to be "safe" dimensions that exclude taskbar size + self.set_default_size(1085, 670) + else: + self.set_default_size(1500, 900) + + self.set_forward_page_func(self._advance_page) + + pyapp = os.getenv("PYAPP") + + self.page1 = UninstallPage(paths, pyapp) + self.page2 = CompletionPage(pyapp) + + self.append_page(self.page1) + self.set_page_type(self.page1, Gtk.AssistantPageType.INTRO) + self.set_page_title(self.page1, uninstall.pg1_title) + self.set_page_complete(self.page1, True) + + self.append_page(self.page2) + self.set_page_title(self.page2, uninstall.pg2_title) + self.set_page_type(self.page2, Gtk.AssistantPageType.SUMMARY) + + self.connect("cancel", self.destroy_and_quit) + self.connect("close", self.destroy_and_quit) + self.show_all() + load_css() + + def _advance_page(self, index: int) -> int: + cur_page = self.get_nth_page(index) + if cur_page == self.page1: + self.page1.uninstall() + return index + 1 + + def destroy_and_quit(self, widget: Self) -> None: + self.destroy() + Gtk.main_quit() + + +class Window(Gtk.Window): + def __init__(self, is_deck: bool, paths: dict[str, str]) -> None: + super().__init__(title=APP_NAME, icon_name=APP_NAME) + self.assistant = Assistant(is_deck, paths) + + +class CompletionPage(ScrolledWizardPage): + def __init__(self, pyapp: str | None): + super().__init__(heading=uninstall.pg2_heading, description=uninstall.pg2_blurb) + + text = ( + uninstall.standalone_uninstall + if pyapp is not None + else uninstall.system_uninstall + ) + + label = Gtk.Label( + label=text, + wrap_mode=Pango.WrapMode.WORD, + margin=10, + max_width_chars=80, + justify=Gtk.Justification.CENTER, + ) + frame = Gtk.Frame() + frame.add(label) + self.add_start(frame) + + +class FileLabel(HBox): + def __init__(self, path: Path) -> None: + super().__init__(spacing=10) + + label = Gtk.Label(label=uninstall.path_remove_prefix, halign=Gtk.Align.START) + textview = Gtk.TextView( + editable=False, + halign=Gtk.Align.START, + left_margin=10, + right_margin=10, + ) + textview.set_buffer(Gtk.TextBuffer(text=str(path))) + self.extend([label, textview]) + + +class CheckboxWithPath(CheckboxWithLabel): + def __init__( + self, label: str, details: str, path: Path, active: bool = True + ) -> None: + super().__init__(text=label, blurb_text=details) + + self.conf_path = path + self.set_active(active) + fl = FileLabel(path) + self.indent_below(fl) + + def get_conf_path(self) -> Path: + return self.conf_path + + +class UninstallPage(ScrolledWizardPage): + def __init__(self, paths: dict[str, str], pyapp: str | None): + super().__init__(heading=uninstall.pg1_heading, description=uninstall.pg1_blurb) + + self.pyapp = pyapp + + config = Path(paths["XDG_CONFIG_HOME"]) + state = Path(paths["XDG_STATE_HOME"]) + self.share = Path(paths["XDG_DATA_HOME"]) + shortcut = self.share.parent.joinpath("applications/dzgui.desktop") + desktop = config.parent.parent.joinpath("Desktop/dzgui.desktop") + self.steam_path = self.parse_steam_path(config, self.share) + + self.boxes: list[CheckboxWithPath] = [] + self.checks_area = Gtk.Box( + orientation=Gtk.Orientation.VERTICAL, spacing=20, margin_top=20 + ) + self.config_box = CheckboxWithPath( + uninstall.config_label, uninstall.config_details, config, active=False + ) + self.desktop_box = CheckboxWithPath( + uninstall.desktop_label, uninstall.desktop_details, desktop + ) + self.start_menu_box = CheckboxWithPath( + uninstall.start_menu_label, uninstall.start_menu_details, shortcut + ) + self.share_box = CheckboxWithPath( + uninstall.desktop_label, uninstall.desktop_details, desktop + ) + self.state_box = CheckboxWithPath( + uninstall.state_label, uninstall.state_details, state + ) + self.steam_box = CheckboxWithLabel( + uninstall.steam_shortcut, uninstall.steam_details + ) + + for box in ( + self.config_box, + self.state_box, + self.desktop_box, + self.start_menu_box, + ): + self.checks_area.add(box) + self.boxes.append(box) + + self.checks_area.add(self.steam_box) + + self.add_start(self.checks_area) + self.show_all() + + def parse_steam_path(self, config: Path, steam: Path) -> Path | None: + try: + file = config.joinpath("config.json") + conf = read_json(file) + steam = conf["default_steam_path"] + self.steam_path = Path(steam) + return Path(steam) + except Exception as e: + print(e) + return None + + def wipe_conf_file(self, box: CheckboxWithPath) -> None: + if box.get_active(): + path = box.get_conf_path() + try: + # TODO: + # path.unlink() + print(f"Deleted '{path}'") + except Exception as e: + print(e) + + def wipe_steam_shortcut(self) -> None: + # TODO: + pass + # try: + # shortcuts = Shortcuts(self.steam_path) + # shortcuts.delete_shortcut(self.share) + # except Exception as e: + # print(e) + + def wipe_pyapp(self) -> None: + if self.pyapp is None: + return + res = subprocess.run([self.pyapp, "self", "remove"]) + print(res) + + def uninstall(self) -> None: + for box in self.boxes: + self.wipe_conf_file(box) + if self.steam_box.get_active(): + self.wipe_steam_shortcut() + # TODO: + # self.wipe_pyapp() + pass + + +class UninstallWizard(Gtk.Application): + def __init__(self, is_deck: bool, paths: dict[str, str]) -> None: + super().__init__() + config = Path(paths["XDG_CONFIG_HOME"]) + # TODO: tests + if not config.is_dir() or not config.joinpath("config.json").is_file(): + print(uninstall.not_installed) + return + GLib.set_prgname(APP_NAME) + self.win = Window(is_deck, paths) + Gtk.main() diff --git a/dzgui/views/dialogs/wizard.py b/dzgui/views/dialogs/wizard.py index 3a57570..d0f0aa0 100644 --- a/dzgui/views/dialogs/wizard.py +++ b/dzgui/views/dialogs/wizard.py @@ -99,6 +99,8 @@ class ScrolledWizardPage(Gtk.ScrolledWindow): self.box.pack_start(image, expand=False, fill=True, padding=0) self.add(self.box) + self.box.pack_start(self.heading, expand=False, fill=True, padding=0) + self.box.pack_start(self.description, expand=False, fill=True, padding=0) def get_page_type(self) -> Gtk.AssistantPageType: return self.page_type @@ -123,12 +125,9 @@ class EnumeratedWizardPage(ScrolledWizardPage): def __init__(self, enum: PageNum, heading: str, description: str) -> None: super().__init__(heading=heading, description=description) - self.enum = enum self.prog = Progress() self.box.pack_end(self.prog, expand=False, fill=False, padding=0) - self.box.pack_start(self.heading, expand=False, fill=True, padding=0) - self.box.pack_start(self.description, expand=False, fill=True, padding=0) def get_enum(self) -> PageNum: return self.enum @@ -541,7 +540,9 @@ class Assistant(Gtk.Assistant): return self.set_page_complete(page, True) - def _add_page(self, page: EnumeratedWizardPage, ptype: Gtk.AssistantPageType) -> None: + def _add_page( + self, page: EnumeratedWizardPage, ptype: Gtk.AssistantPageType + ) -> None: self.append_page(page) self.set_page_type(page, ptype) self.set_page_title(page, page.get_title()) @@ -567,15 +568,21 @@ class CheckboxWithLabel(Gtk.Box): def __init__(self, text: str, blurb_text: str) -> None: super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=5) + self.indent = 20 self.button = Gtk.CheckButton(label=text) self.button.set_active(True) - label = Gtk.Label(label="", halign=Gtk.Align.START, margin_start=20) + label = Gtk.Label(label="", halign=Gtk.Align.START, margin_start=self.indent) + wrapped = textwrap.fill(blurb_text, width=100) label.set_markup(f"- {wrapped}") for el in self.button, label: self.add(el) + def indent_below(self, widget: Gtk.Widget) -> None: + widget.set_margin_start(self.indent) + self.add(widget) + def get_checkbox(self) -> Gtk.CheckButton: return self.button From aeffce2579810a3728e0df56bd0fffb5c5de4525 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 20 Aug 2026 10:14:12 +0900 Subject: [PATCH 126/139] chore: drop unused import --- dzgui/views/dialogs/uninstall.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dzgui/views/dialogs/uninstall.py b/dzgui/views/dialogs/uninstall.py index afda8eb..5973d29 100644 --- a/dzgui/views/dialogs/uninstall.py +++ b/dzgui/views/dialogs/uninstall.py @@ -2,7 +2,7 @@ import os import subprocess from pathlib import Path -from typing import Self, TYPE_CHECKING +from typing import Self from dzgui.api.shortcuts import Shortcuts from dzgui.const.constants import APP_NAME From 71d760dc569f11a5fe22d8159778863c068a2b2c Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 20 Aug 2026 10:20:26 +0900 Subject: [PATCH 127/139] change: center align content area --- dzgui/views/dialogs/uninstall.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/dzgui/views/dialogs/uninstall.py b/dzgui/views/dialogs/uninstall.py index 5973d29..a46513e 100644 --- a/dzgui/views/dialogs/uninstall.py +++ b/dzgui/views/dialogs/uninstall.py @@ -132,7 +132,11 @@ class UninstallPage(ScrolledWizardPage): self.boxes: list[CheckboxWithPath] = [] self.checks_area = Gtk.Box( - orientation=Gtk.Orientation.VERTICAL, spacing=20, margin_top=20 + orientation=Gtk.Orientation.VERTICAL, + halign=Gtk.Align.CENTER, + spacing=20, + margin_top=20, + margin_bottom=20, ) self.config_box = CheckboxWithPath( uninstall.config_label, uninstall.config_details, config, active=False From 96b91fd87cdf297b09040040feaeab46dfc5efba Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 20 Aug 2026 10:20:34 +0900 Subject: [PATCH 128/139] chore: update strings --- dzgui/strings/uninstall.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dzgui/strings/uninstall.py b/dzgui/strings/uninstall.py index 676392f..a609899 100644 --- a/dzgui/strings/uninstall.py +++ b/dzgui/strings/uninstall.py @@ -2,9 +2,9 @@ pg1_title = "Choose uninstall targets" pg1_heading = "Choose uninstall targets" pg1_blurb = "Here you can choose which DZGUI configuration/state files to remove. " -pg2_title = "Complete" +pg2_title = "Uninstall complete" pg2_heading = "Uninstall complete" -pg2_blurb = "Finished removing the selected files. See the system log for errors." +pg2_blurb = "Finished removing the selected files. See the console for any errors." config_label = "Configuration files" config_details = ( From dfeafc3d28d1ec6a3f2cfabeed4680b118256497 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 20 Aug 2026 13:18:08 +0900 Subject: [PATCH 129/139] feat: delete shortcut --- dzgui/api/shortcuts.py | 7 ++++--- dzgui/views/dialogs/uninstall.py | 29 ++++++++++++++++------------- 2 files changed, 20 insertions(+), 16 deletions(-) diff --git a/dzgui/api/shortcuts.py b/dzgui/api/shortcuts.py index 50c1d33..594d1a1 100644 --- a/dzgui/api/shortcuts.py +++ b/dzgui/api/shortcuts.py @@ -161,9 +161,10 @@ class Shortcuts: return NEW_ENTRY def delete_shortcut(self, start_path: Path) -> None: - for s in self.shortcuts["shortcuts"]: - if self.shortcuts[s]["StartDir"] == str(start_path): - del self.shortcuts[s] + shortcuts = self.shortcuts["shortcuts"] + for s in shortcuts: + if shortcuts[s]["StartDir"] == str(start_path): + del shortcuts[s] break self.save_shortcuts() diff --git a/dzgui/views/dialogs/uninstall.py b/dzgui/views/dialogs/uninstall.py index a46513e..1e50129 100644 --- a/dzgui/views/dialogs/uninstall.py +++ b/dzgui/views/dialogs/uninstall.py @@ -1,4 +1,5 @@ import os +import shutil import subprocess from pathlib import Path @@ -9,6 +10,7 @@ from dzgui.const.constants import APP_NAME from dzgui.strings import uninstall from dzgui.util._json import read_json from dzgui.util.css import load_css +from dzgui.util.format import format_exception from dzgui.views.components.box import HBox from dzgui.views.dialogs.wizard import ScrolledWizardPage, CheckboxWithLabel @@ -179,27 +181,29 @@ class UninstallPage(ScrolledWizardPage): self.steam_path = Path(steam) return Path(steam) except Exception as e: - print(e) + print(format_exception(e)) return None def wipe_conf_file(self, box: CheckboxWithPath) -> None: if box.get_active(): path = box.get_conf_path() try: - # TODO: - # path.unlink() + if path.is_dir(): + shutil.rmtree(path) + else: + path.unlink() print(f"Deleted '{path}'") except Exception as e: - print(e) + print(format_exception(e)) def wipe_steam_shortcut(self) -> None: - # TODO: - pass - # try: - # shortcuts = Shortcuts(self.steam_path) - # shortcuts.delete_shortcut(self.share) - # except Exception as e: - # print(e) + if self.steam_path is None: + return + try: + shortcuts = Shortcuts(self.steam_path) + shortcuts.delete_shortcut(self.share) + except Exception as e: + print(format_exception(e)) def wipe_pyapp(self) -> None: if self.pyapp is None: @@ -212,8 +216,7 @@ class UninstallPage(ScrolledWizardPage): self.wipe_conf_file(box) if self.steam_box.get_active(): self.wipe_steam_shortcut() - # TODO: - # self.wipe_pyapp() + self.wipe_pyapp() pass From 5ff936f2661f2e175d4e7a6afef160932107c23e Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 20 Aug 2026 13:29:47 +0900 Subject: [PATCH 130/139] fix: trigger on dayz launcher process --- dzgui/api/steam.py | 5 +++-- dzgui/const/constants.py | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/dzgui/api/steam.py b/dzgui/api/steam.py index 6139e9a..668def6 100644 --- a/dzgui/api/steam.py +++ b/dzgui/api/steam.py @@ -16,6 +16,7 @@ from dzgui.const.constants import ( APPID_DAYZ_EXP, APP_NAME, DAYZ_BINARY, + DAYZ_LAUNCHER, DEBIAN_STEAM_PATH, DEFAULT_STEAM_PATH, FLATPAK_STEAM_PATH, @@ -398,14 +399,14 @@ def get_client_allows_downloads(path: Path) -> bool: def is_dayz_running() -> bool: """Subprocesses spawned from Steam will not show up in regular process tree""" procs = [] - substring = DAYZ_BINARY + substring = [DAYZ_BINARY, DAYZ_LAUNCHER] for proc in psutil.process_iter(): try: procs.append(proc.cmdline()) except Exception as e: logger.warning(e) continue - return any(substring in item for sublist in procs for item in sublist) + return any(s in item for sublist in procs for item in sublist for s in substring) def get_app_path(folders_path: Path, appid: int) -> Path: diff --git a/dzgui/const/constants.py b/dzgui/const/constants.py index 5be3d0b..d75fc31 100644 --- a/dzgui/const/constants.py +++ b/dzgui/const/constants.py @@ -12,6 +12,7 @@ APPNAME_DAYZ = "DayZ" APPNAME_DAYZ_EXP = "DayZ Exp" APPNAME_DAYZ_EXP_HUMAN = "DayZ Experimental" DAYZ_BINARY = "DayZ_x64.exe" +DAYZ_LAUNCHER = "DayZLauncher.exe" LIBRARYFOLDERS_PATH = "steamapps/libraryfolders.vdf" WORKSHOP_PATH = "steamapps/workshop/content/" + str(APPID_DAYZ) From c348a499b0736286452e8dc1cd75acdcea8e1fb5 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:14:42 +0900 Subject: [PATCH 131/139] chore: trigger checks From 0a310f0b7012f363e4dda7dfd5e2d67886617d40 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 20 Aug 2026 13:39:14 +0900 Subject: [PATCH 132/139] change: normalize context menu keybind behavior for j,k keys Normalized custom menu navigation keys to be in conformity with native keybindings for Gdk.KEY_uparrow and Gdk.KEY_downarrow. --- dzgui/views/mixins/context_mixin.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/dzgui/views/mixins/context_mixin.py b/dzgui/views/mixins/context_mixin.py index 19c0f42..358c65a 100644 --- a/dzgui/views/mixins/context_mixin.py +++ b/dzgui/views/mixins/context_mixin.py @@ -104,20 +104,11 @@ class ContextMixin(TreeView): return False menu = self.context_menu children = menu.get_children() - sel = menu.get_selected_item() - for i, child in enumerate(children): - if sel is child: - ind = i - break match event.keyval: case Gdk.KEY_j: - if ind + 1 > len(children) - 1: - return False menu.emit("move-current", Gtk.MenuDirectionType.NEXT) case Gdk.KEY_k: - if ind - 1 < 0: - return False menu.emit("move-current", Gtk.MenuDirectionType.PREV) case Gdk.KEY_g: menu.select_item(children[0]) From e2a6fa0c7ecd0298e68997f24cb04e7ab2c9ccd2 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 20 Aug 2026 13:39:14 +0900 Subject: [PATCH 133/139] change: normalize context menu keybind behavior for j,k keys Normalized custom menu navigation keys to be in conformity with native keybindings for Gdk.KEY_uparrow and Gdk.KEY_downarrow. From 493818b770e6b8bb4cc3f034ec4d86bf87ea0b24 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 20 Aug 2026 13:46:06 +0900 Subject: [PATCH 134/139] change: import regex directly from proxy model --- dzgui/model/proxy_model.py | 9 +++++---- tests/test_time.py | 8 ++++---- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/dzgui/model/proxy_model.py b/dzgui/model/proxy_model.py index 997a59f..dec8338 100644 --- a/dzgui/model/proxy_model.py +++ b/dzgui/model/proxy_model.py @@ -12,6 +12,9 @@ if TYPE_CHECKING: from dzgui.api.servers import Record from dzgui.model.servers import NewPlayerCount +DAY_REG = r"([0][7-9]|[1][0-6])" +NIGHT_REG = r"([0][0-6]|[1][7-9]|[2][0-3])" + class ProxyModelManager: """ @@ -231,11 +234,9 @@ class ProxyModelManager: final.append(row) rows = final case strings.filter_day: - reg = r"([0][7-9]|[1][0-6])" - rows = [row for row in rows if not re.match(reg, row[3])] + rows = [row for row in rows if not re.match(DAY_REG, row[3])] case strings.filter_night: - reg = r"([0][0-6]|[1][7-9]|[2][0-3])" - rows = [row for row in rows if not re.match(reg, row[3])] + rows = [row for row in rows if not re.match(NIGHT_REG, row[3])] case strings.filter_nonascii: rows = [row for row in rows if row[0].isascii()] case strings.filter_lowpop: diff --git a/tests/test_time.py b/tests/test_time.py index 65257b3..210068a 100644 --- a/tests/test_time.py +++ b/tests/test_time.py @@ -1,7 +1,7 @@ import re -day = r"([0][7-9]|[1][0-6])" -night = r"([0][0-6]|[1][7-9]|[2][0-3])" +from dzgui.model.proxy_model import DAY_REG, NIGHT_REG + def iterate(h: str, r: str) -> None: for m in range(60): @@ -13,11 +13,11 @@ def test_day() -> None: for h in range(17): if h < 7: continue - iterate(h, day) + iterate(h, DAY_REG) def test_night() -> None: for h in range(24): if 6 < h < 17: continue - iterate(h, night) + iterate(h, NIGHT_REG) From 76b3186ed744c14d9ac340e69758d4c07476ca6e Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 20 Aug 2026 13:46:06 +0900 Subject: [PATCH 135/139] change: import regex directly from proxy model From e32c7fcacb1379cf52c93c7dfcde9b1990839cb4 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:32:44 +0900 Subject: [PATCH 136/139] chore: prototype for unused imports checker --- scripts/unused_imports.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 scripts/unused_imports.py diff --git a/scripts/unused_imports.py b/scripts/unused_imports.py new file mode 100644 index 0000000..ad6d8c7 --- /dev/null +++ b/scripts/unused_imports.py @@ -0,0 +1,27 @@ +from pathlib import Path + +def iterate(search_str: str) -> None: + # TODO: exclude files from .gitignore + print() + print(f"Searching for '{search_str}'") + print() + files = Path(".").rglob("*") + ignore = [".git", ".mypy", "build/", "test.py"] + for file in files: + if file.is_dir(): + continue + if any(s in str(file) for s in ignore): + continue + try: + text = file.read_text() + if search_str in text: + c = text.count(search_str) + if 0 < c < 2: + print(file, c) + except Exception: + continue + + +imports = ["Pango", "GObject", "GLib", "Gdk", "Gtk"] +for i in imports: + iterate(i) From cc1948d22586ba2711e53b215e7adaba1c051d02 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:33:13 +0900 Subject: [PATCH 137/139] chore: drop old comment --- dzgui/main.py | 1 - 1 file changed, 1 deletion(-) diff --git a/dzgui/main.py b/dzgui/main.py index ff59ba8..6abe684 100644 --- a/dzgui/main.py +++ b/dzgui/main.py @@ -21,7 +21,6 @@ args = parser.parse_args() def main() -> None: - # TODO: isolate single flags if args.version is True: print(get_version()) sys.exit(0) From 79fd96e834e5eca24671ebb4922eea2a8ffe0442 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:33:28 +0900 Subject: [PATCH 138/139] docs: update changelog --- CHANGELOG.md | 15 ++++++++++++++- dzgui/data/CHANGELOG.md | 18 +++++++++++++++++- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ef0913c..d1b5ca0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ ## [7.0.0] Unreleased ### Added - Setup wizard +- Uninstall wizard +- Descriptive details panel to certain error dialogs - Changelog text wrapping and formatting - Changelog ships with source - Documentation ships with source @@ -24,6 +26,7 @@ - Set favorite server from tables - Detailed/copyable trace in critical error dialogs - Visual icons +- Unset favorite server button - Integrated server notebook - Propagate width changes to all tables - Remember tree position in menus @@ -45,11 +48,17 @@ - Generate Steam shortcuts and cover art - Warn user if background downloads are disabled - Dynamic copy button +- Update map count (-u flag) +- Filter button tooltips +- Expand/collapse changelog versions ### Changed - Conform to PEP 440 versioning for beta versions +- Enforce fullscreen mode on Steam Deck - Reduce padding on keys button - Boldface breadcrumbs +- Changed some dialog error messages from popups to popovers +- Changed how filter buttons are packed in sidebar - Bold labels inside frames - 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 @@ -62,16 +71,20 @@ - Reduce size of geolocation DB on disk (~100MB) - Enable LAN page Empty/Full filters on first run of app - Propagate subscribed mods to Steam client +- Make j/k keys in context menus follow same wraparound logic as down arrow/up arrow ### Dropped - Battlemetrics API support -- Debug mode +- Unused metadata from system log files - 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 +- In-game time field missing certain ranges of time at night +- Wrap long entries in changelog +- Certain server descriptions with special characters not rendering correctly - 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 85bd68d..d1b5ca0 100644 --- a/dzgui/data/CHANGELOG.md +++ b/dzgui/data/CHANGELOG.md @@ -3,6 +3,8 @@ ## [7.0.0] Unreleased ### Added - Setup wizard +- Uninstall wizard +- Descriptive details panel to certain error dialogs - Changelog text wrapping and formatting - Changelog ships with source - Documentation ships with source @@ -24,6 +26,7 @@ - Set favorite server from tables - Detailed/copyable trace in critical error dialogs - Visual icons +- Unset favorite server button - Integrated server notebook - Propagate width changes to all tables - Remember tree position in menus @@ -44,11 +47,18 @@ - Play offline (load mods directly) - Generate Steam shortcuts and cover art - Warn user if background downloads are disabled +- Dynamic copy button +- Update map count (-u flag) +- Filter button tooltips +- Expand/collapse changelog versions ### Changed - Conform to PEP 440 versioning for beta versions +- Enforce fullscreen mode on Steam Deck - Reduce padding on keys button - Boldface breadcrumbs +- Changed some dialog error messages from popups to popovers +- Changed how filter buttons are packed in sidebar - Bold labels inside frames - 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 @@ -61,18 +71,24 @@ - Reduce size of geolocation DB on disk (~100MB) - Enable LAN page Empty/Full filters on first run of app - Propagate subscribed mods to Steam client +- Make j/k keys in context menus follow same wraparound logic as down arrow/up arrow ### Dropped -- Debug mode +- Battlemetrics API support +- Unused metadata from system log files - 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 +- In-game time field missing certain ranges of time at night +- Wrap long entries in changelog +- Certain server descriptions with special characters not rendering correctly - Center server title text on server dialogs - Rare segfaults when changing maps (threading) - Moved dialogs out of threads +- History table not being in lock-step with state file ### Unreleased - Load offline mods From c29346e134b0b3471f32c62685feebbed1373fb4 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:33:56 +0900 Subject: [PATCH 139/139] chore: bump version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 5c6c791..a349a4e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ description = "DayZ server browser and mod manager for Linux" authors = [ {name = "aclist"} ] -version = "7.0.0b21" +version = "7.0.0b22" license = "GPL-3.0-or-later" license-files = ["LICENSE"] readme = "README.md"