From 9d1443cf650addb4267538b301ed1ec493787648 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 7 Jun 2026 20:15:30 +0900 Subject: [PATCH 01/71] feat: launch offline (WIP) --- dzgui/strings/offline.py | 1 + dzgui/views/components/connect_box.py | 68 ++++++++++++ dzgui/views/pages/offline.py | 152 ++++++++++++++++++++++++++ 3 files changed, 221 insertions(+) create mode 100644 dzgui/strings/offline.py create mode 100644 dzgui/views/components/connect_box.py create mode 100644 dzgui/views/pages/offline.py diff --git a/dzgui/strings/offline.py b/dzgui/strings/offline.py new file mode 100644 index 0000000..1b21f79 --- /dev/null +++ b/dzgui/strings/offline.py @@ -0,0 +1 @@ +heading = "Play offline" diff --git a/dzgui/views/components/connect_box.py b/dzgui/views/components/connect_box.py new file mode 100644 index 0000000..3f1200b --- /dev/null +++ b/dzgui/views/components/connect_box.py @@ -0,0 +1,68 @@ +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 diff --git a/dzgui/views/pages/offline.py b/dzgui/views/pages/offline.py new file mode 100644 index 0000000..3c9f312 --- /dev/null +++ b/dzgui/views/pages/offline.py @@ -0,0 +1,152 @@ +from typing import Self, TYPE_CHECKING + +from dzgui.strings import offline +from dzgui.util import css +from dzgui.views.components.frame import HeadingFrame +from dzgui.views.trees.tree_mods import ModTreeView + + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk, Gdk # noqa + +if TYPE_CHECKING: + from dzgui.controllers.mc import Controller + +# FIXME: grab focus of outer page +# TODO: if any custom mods, symlink them before connecting proceeding + + +class OfflineLoader(Gtk.Box): + def __init__(self, controller: "Controller"): + super().__init__( + orientation=Gtk.Orientation.VERTICAL, + margin_start=10, + margin_end=10, + ) + + # TODO: wrap entire page in scrollable + self.controller = controller + self.controller.register_widget("offline_loader", self) + + label = Gtk.Label(label=offline.heading) + label.set_halign(Gtk.Align.CENTER) + css.add_class(label, "page-heading") + self.add(label) + + # TODO: wrap trees in scrollable + # TODO: suppress trees if there are no mods + self.local_mods = ModTreeView(controller) + self.scr = Gtk.ScrolledWindow() + self.scr.add(self.local_mods) + self.scr.set_size_request(600, 400) + # TODO: strings + self.local_frame = HeadingFrame(self.scr, "Installed mods") + + # TODO: update selected count statusbar when changed + sel = self.local_mods.get_selection() + sel.connect("changed", self._on_selection_changed) + + # TODO: abstract into class + # TODO: folder label + # TODO: descriptive text here explaining how this area works + # TODO: use statusbar msg from format.format_mods + # TODO: strings + self.custom_button = Gtk.Button( + label="Set custom mod folder", halign=Gtk.Align.START + ) + self.custom_button.connect("clicked", self._on_custom_button_clicked) + self.custom_mods = Gtk.TreeView() + self.custom_mods_box = Gtk.Box( + orientation=Gtk.Orientation.VERTICAL, margin_start=10, margin_bottom=10 + ) + self.custom_mods_box.add(self.custom_button) + self.custom_mods_box.add(self.custom_mods) + + # TODO: strings + self.custom_frame = HeadingFrame(self.custom_mods_box, "Custom mods") + + self.mission_box = Gtk.Box( + orientation=Gtk.Orientation.HORIZONTAL, margin_start=10, margin_bottom=10 + ) + # TODO: strings + self.mission_button = Gtk.Button(label="Select mission folder") + self.mission_label = Gtk.Label() + + self.mission_box.add(self.mission_button) + self.mission_box.add(self.mission_label) + + # TODO: strings + self.mission_frame = HeadingFrame(self.mission_box, "Mission") + + self.radio_box = Gtk.Box( + orientation=Gtk.Orientation.HORIZONTAL, margin_start=10, margin_bottom=10 + ) + # TODO: strings + # TODO: disable if not available, use PeFile + self.dayz = Gtk.RadioButton.new_with_label(None, "DayZ") + self.dayz_exp = Gtk.RadioButton.new_with_label_from_widget( + self.dayz, "DayZ Experimental" + ) + self.radio_box.add(self.dayz) + self.radio_box.add(self.dayz_exp) + self.radio_frame = HeadingFrame(self.radio_box, "DayZ Version") + + self.scrollable = Gtk.ScrolledWindow( + vexpand=True, propagate_natural_height=True + ) + # TODO: use same button anchoring logic as preconnect dialog + self.box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) + self.box.add(self.local_frame) + self.box.add(self.custom_frame) + self.box.add(self.mission_frame) + self.box.add(self.radio_frame) + self.scrollable.add(self.box) + + self.add(self.scrollable) + + # TODO: use ModelFactory + self.custom_mods = Gtk.TreeView() + + # TODO: shared ConnectBox class + self.back = Gtk.Button() + self.ok = Gtk.Button() + self.back.connect("clicked", self._on_back_clicked) + self.ok.connect("clicked", self._on_ok_clicked) + self.connect("key-press-event", self._on_keypress) + + def _on_keypress(self, widget: Self, event: Gdk.EventKey) -> None: + if event.keyval == Gdk.KEY_Escape: + self.back.emit("clicked") + + def _on_selection_changed(self, sel) -> None: + model, rows = sel.get_selected_rows() + # TODO: update sub-statusbar with selected mods + print(len(rows)) + + def populate(self, mods: Gtk.TreeModel) -> None: + self.local_mods.set_model(mods) + # TODO: push existing model + # TODO: set sensitivity of radio buttons + pass + + def _on_custom_button_clicked(self, button: Gtk.Button) -> None: + # TODO: recycle this for both buttons + folder = self.controller.set_custom_folder() + if folder is not None: + # TODO: parse mods from here + # TODO: offline mod manager would be cleaner + print(folder) + + def _on_back_clicked(self, button: Gtk.Button) -> None: + from dzgui.const.enum import NotebookPage + + self.controller.open_page(NotebookPage.SERVERS) + + def _on_ok_clicked(self, button: Gtk.Button) -> None: + pass + """ + create symlinks if there are custom mods + cf. rebuild_symlinks() + """ From 0070fe4061ea176a12065c6ca56aa56019ad7995 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 7 Jun 2026 20:17:32 +0900 Subject: [PATCH 02/71] fix: suppress headers if no model is loaded --- dzgui/views/trees/tree_mods.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/dzgui/views/trees/tree_mods.py b/dzgui/views/trees/tree_mods.py index e8340d7..b39daf0 100644 --- a/dzgui/views/trees/tree_mods.py +++ b/dzgui/views/trees/tree_mods.py @@ -30,8 +30,6 @@ class ModTreeView(ModsMixin, ContextMixin, TreeView): # type: ignore emitter.connect("mods_highlighted", self._on_mods_highlighted) self.set_fixed_height_mode(True) - self.set_headers_visible(True) - self.set_model(None) for i, column_title in enumerate(strings.mod_cols): @@ -64,6 +62,7 @@ class ModTreeView(ModsMixin, ContextMixin, TreeView): # type: ignore def _on_mods_updated(self, emitter: "Emitter", msg: str, mods: int) -> None: if mods < 1: return + self.set_headers_visible(True) path = Gtk.TreePath.new_from_indices([0]) self.set_cursor(path) From 831bd9145db0a3fcc0c53f6307b6de34721d7dca Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 7 Jun 2026 20:17:58 +0900 Subject: [PATCH 03/71] feat: launch offline (WIP)) --- dzgui/api/steam.py | 18 +++++++++++++++++- dzgui/const/enum.py | 1 + dzgui/controllers/mc.py | 12 +++++++++++- dzgui/managers/connection.py | 2 +- dzgui/util/strings.py | 2 ++ dzgui/views/base.py | 5 +++++ dzgui/views/components/statusbar.py | 2 +- dzgui/views/dialogs/filepicker.py | 25 +++++++++++++++++++++++++ dzgui/views/pages/mods.py | 21 +++++++++++++++++++-- 9 files changed, 82 insertions(+), 6 deletions(-) diff --git a/dzgui/api/steam.py b/dzgui/api/steam.py index 0fa126d..b322f76 100644 --- a/dzgui/api/steam.py +++ b/dzgui/api/steam.py @@ -146,7 +146,7 @@ def connect(client: str, addr: str, appid: int, name: str, mods: list[str]) -> i return proc.returncode -def load_to_menu(client: str, addr: str, appid: int, name: str, mods: list[str]) -> int: +def load_to_menu(client: str, appid: int, name: str, mods: list[str]) -> int: """Loads to the menu screen with the selected mods; used for AFK/pre-joining""" concat = concat_mods(mods) client_args = concat_bash_args(client) @@ -162,6 +162,22 @@ def load_to_menu(client: str, addr: str, appid: int, name: str, mods: list[str]) proc = subprocess.run([*client_args, *params]) return proc.returncode +def launch_offline(client: str, addr: str, appid: int, name: str, mods: list[str], mission: str) -> int: + """Launch offline with specific mods/missions""" + concat = concat_mods(mods) + client_args = concat_bash_args(client) + params = [ + "-applaunch", + str(appid), + "-nolauncher", + "-nosplash", + "-skipintro", + f"-name={name}", + f"-mod={concat}", + f"-mission={mission}" + ] + proc = subprocess.run([*client_args, *params]) + return proc.returncode def find_user_id(path: Path) -> str | None: resolved_path = path / "config" / "loginusers.vdf" diff --git a/dzgui/const/enum.py b/dzgui/const/enum.py index aad7698..c14f6fe 100644 --- a/dzgui/const/enum.py +++ b/dzgui/const/enum.py @@ -103,6 +103,7 @@ class Preferences(EnumWithAttrs): class NotebookPage(EnumWithAttrs): + OFFLINE = {"crumbs": strings.crumbs.offline, "statusbar": True} CHANGELOG = {"crumbs": strings.crumbs.changelog, "statusbar": True} DEVELOPERS = {"crumbs": strings.crumbs.developers, "statusbar": False} HELP = {"crumbs": strings.crumbs._help, "statusbar": True} diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index e0c57c0..074e842 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -26,7 +26,7 @@ from dzgui.model.servers import ServerModelManager from dzgui.util.diag import write_diagnostic from dzgui.util.format import format_player_count from dzgui.util.open_links import open_user_workshop, open_workshop_page -from dzgui.views.dialogs.filepicker import FilePicker +from dzgui.views.dialogs.filepicker import FilePicker, FolderPicker from dzgui.views.dialogs.generic import ExceptionDialog import gi @@ -47,6 +47,7 @@ if TYPE_CHECKING: from dzgui.views.components.filter_panel import FilterPanel from dzgui.views.components.right_panel import RightPanel from dzgui.views.components.statusbar import Statusbar + from dzgui.views.pages.offline import OfflineLoader from dzgui.views.pages.options import Options from dzgui.views.pages.preconnect import PreConnectionAssistant from dzgui.views.pages.servers import ServerNotebook @@ -72,6 +73,7 @@ class AppNavigation: filters: "FilterPanel" preconnect: "PreConnectionAssistant" options: "Options" + offline_loader: "OfflineLoader" class Controller(GObject.GObject): @@ -288,6 +290,10 @@ class Controller(GObject.GObject): dialog = ExceptionDialog(self, str(e)) dialog.run() + def set_custom_folder(self) -> None: + picker = FolderPicker(self.mediator.window) + return picker.pick_folder() + def update_api_key(self, key: Preferences, text: str) -> None: self.config_man.update_api_key(key, text) @@ -533,3 +539,7 @@ class Controller(GObject.GObject): self.clear_cancel_event() return True return False + + def open_offline(self, mods) -> None: + self.open_page(NotebookPage.OFFLINE) + self.mediator.offline_loader.populate(mods) diff --git a/dzgui/managers/connection.py b/dzgui/managers/connection.py index 7570dfd..035ba46 100644 --- a/dzgui/managers/connection.py +++ b/dzgui/managers/connection.py @@ -267,7 +267,7 @@ class ConnectionManager: if menu_only: rc = load_to_menu(client, addr, self.appid, playername, self.remote_mod_ids) else: - rc = connect(client, addr, self.appid, playername, self.remote_mod_ids) + rc = connect(client, self.appid, playername, self.remote_mod_ids) if rc != 0: # TODO: log/pop the error func = StoredFunc(self.controller.update_status) diff --git a/dzgui/util/strings.py b/dzgui/util/strings.py index dad40fa..cbff3d7 100644 --- a/dzgui/util/strings.py +++ b/dzgui/util/strings.py @@ -273,6 +273,7 @@ class Crumbs: thanks: str developers: str default: str + offline: str @dataclass(slots=True, frozen=True) @@ -462,6 +463,7 @@ crumbs = Crumbs( thanks="Help > Special thanks", developers="Options > Developers", default="Servers > ", + offline="Mods > Play offline" ) no_mods = "No local mods found." diff --git a/dzgui/views/base.py b/dzgui/views/base.py index f9b7a00..c540d13 100644 --- a/dzgui/views/base.py +++ b/dzgui/views/base.py @@ -110,8 +110,13 @@ class Notebook(ScrollableMixin, Gtk.Notebook): # type: ignore self.connection = PreConnectionAssistant(MainController) + from dzgui.views.pages.offline import OfflineLoader + + self.offline = OfflineLoader(MainController) + self.pages = { self.help: NotebookPage.HELP, + self.offline: NotebookPage.OFFLINE, self.clog: NotebookPage.CHANGELOG, self.keys: NotebookPage.KEYS, self.settings: NotebookPage.OPTIONS, diff --git a/dzgui/views/components/statusbar.py b/dzgui/views/components/statusbar.py index a6c1519..f7bc8fa 100644 --- a/dzgui/views/components/statusbar.py +++ b/dzgui/views/components/statusbar.py @@ -1,5 +1,4 @@ from typing import Self, Union, TYPE_CHECKING -from warnings import deprecated from dzgui.const.enum import NotebookPage, ServerTab from dzgui.util.strings import esc_to_return, question_to_return @@ -70,6 +69,7 @@ class Statusbar(Gtk.Grid): NotebookPage.CHANGELOG, NotebookPage.LOG, NotebookPage.CONNECTION, + NotebookPage.OFFLINE, ): self.set_by_context(enum, esc_to_return) return diff --git a/dzgui/views/dialogs/filepicker.py b/dzgui/views/dialogs/filepicker.py index 0691bc8..d8d8316 100644 --- a/dzgui/views/dialogs/filepicker.py +++ b/dzgui/views/dialogs/filepicker.py @@ -33,3 +33,28 @@ class FilePicker(Gtk.FileChooserDialog): self.destroy() return Path(file) return None + + +class FolderPicker(Gtk.FileChooserDialog): + def __init__(self, parent: Gtk.Window) -> None: + super().__init__( + title=picker.title, + action=Gtk.FileChooserAction.SELECT_FOLDER, + parent=parent, + resizable=True, + ) + self.add_buttons("_Cancel", Gtk.ResponseType.CANCEL) + self.add_buttons("_OK", Gtk.ResponseType.OK) + self.set_default_response(Gtk.ResponseType.OK) + + def pick_folder(self) -> Path | None: + res = self.run() + if res in (Gtk.ResponseType.CANCEL, Gtk.ResponseType.DELETE_EVENT): + self.destroy() + return None + if res == Gtk.ResponseType.OK: + folder = self.get_current_folder() + if folder is not None: + self.destroy() + return Path(folder) + return None diff --git a/dzgui/views/pages/mods.py b/dzgui/views/pages/mods.py index f87d993..5d7c575 100644 --- a/dzgui/views/pages/mods.py +++ b/dzgui/views/pages/mods.py @@ -11,21 +11,38 @@ if TYPE_CHECKING: from dzgui.controllers.mc import Controller - class Mods(NoOverlayScrolledWindow): def __init__(self, controller: "Controller") -> None: super().__init__() + self.box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + self.controller = controller self.emitter = controller.get_emitter() self.tree = ModTreeView(controller) + self.tree.set_vexpand(True) - self.add(self.tree) + self.offline_button = Gtk.Button( + label="Play offline", + halign=Gtk.Align.START, + margin_top=10, + margin_bottom=10, + ) + self.offline_button.connect("clicked", self._on_offline_clicked) + + self.box.add(self.offline_button) + self.box.add(self.tree) + + self.add(self.box) self.controller.register_widget("modtreeview", self.tree) self.connect("map", self._on_map) self.connect("unmap", self._on_unmap) + def _on_offline_clicked(self, button: Gtk.Button) -> None: + self.controller.open_offline(self.tree.get_model()) + # TODO: add offline page + def _on_unmap(self, widget: Self) -> None: self.emitter.emit("mod_page_toggled", False) From 0c0f280d1556b791d3b02cc7c6b375e5c8d22bc1 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 7 Jun 2026 20:18:27 +0900 Subject: [PATCH 04/71] docs: update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 59243cc..188c9ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,7 @@ - Preboot progress dialog - Choose to jump into splash screen instead of server - Collapsible connection panel +- Play offline (load mods directly) ## Changed - Conform to PEP 440 versioning for beta versions From 4b9687a84de8c3b3045169f86d09048057304cc5 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 7 Jun 2026 20:22:32 +0900 Subject: [PATCH 05/71] chore: clear typehinting errors --- dzgui/controllers/mc.py | 5 +++-- dzgui/managers/connection.py | 4 ++-- dzgui/views/pages/offline.py | 4 ++-- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 074e842..e88e1ee 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -37,6 +37,7 @@ from gi.repository import Gtk, Gdk, GLib, GObject # noqa E402 logger = logging.getLogger(APP_NAME) if TYPE_CHECKING: + from pathlib import Path from dzgui.api.servers import Record from dzgui.const.enum import ServerTab from dzgui.managers.connection import Prerequisites @@ -290,7 +291,7 @@ class Controller(GObject.GObject): dialog = ExceptionDialog(self, str(e)) dialog.run() - def set_custom_folder(self) -> None: + def set_custom_folder(self) -> "Path" | None: picker = FolderPicker(self.mediator.window) return picker.pick_folder() @@ -540,6 +541,6 @@ class Controller(GObject.GObject): return True return False - def open_offline(self, mods) -> None: + def open_offline(self, mods: Gtk.TreeModel | None) -> None: self.open_page(NotebookPage.OFFLINE) self.mediator.offline_loader.populate(mods) diff --git a/dzgui/managers/connection.py b/dzgui/managers/connection.py index 035ba46..a1aa0a9 100644 --- a/dzgui/managers/connection.py +++ b/dzgui/managers/connection.py @@ -265,9 +265,9 @@ class ConnectionManager: playername = self.controller.query_config(Preferences.NAME) client = self.controller.query_config(Preferences.CLIENT) if menu_only: - rc = load_to_menu(client, addr, self.appid, playername, self.remote_mod_ids) + rc = load_to_menu(client, self.appid, playername, self.remote_mod_ids) else: - rc = connect(client, self.appid, playername, self.remote_mod_ids) + rc = connect(client, addr, self.appid, playername, self.remote_mod_ids) if rc != 0: # TODO: log/pop the error func = StoredFunc(self.controller.update_status) diff --git a/dzgui/views/pages/offline.py b/dzgui/views/pages/offline.py index 3c9f312..e3ffc7a 100644 --- a/dzgui/views/pages/offline.py +++ b/dzgui/views/pages/offline.py @@ -120,12 +120,12 @@ class OfflineLoader(Gtk.Box): if event.keyval == Gdk.KEY_Escape: self.back.emit("clicked") - def _on_selection_changed(self, sel) -> None: + def _on_selection_changed(self, sel: Gtk.TreeSelection) -> None: model, rows = sel.get_selected_rows() # TODO: update sub-statusbar with selected mods print(len(rows)) - def populate(self, mods: Gtk.TreeModel) -> None: + def populate(self, mods: Gtk.TreeModel | None) -> None: self.local_mods.set_model(mods) # TODO: push existing model # TODO: set sensitivity of radio buttons From b17b8c24ff4fef6f277039168935a5ff747d4b10 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Mon, 8 Jun 2026 19:27:40 +0900 Subject: [PATCH 06/71] fix: fill model inside thread --- dzgui/controllers/mc.py | 2 +- dzgui/managers/mods.py | 10 ++++++---- dzgui/views/base.py | 3 +-- dzgui/views/pages/mods.py | 10 +++++----- dzgui/views/trees/tree_mods.py | 1 + pyproject.toml | 2 +- 6 files changed, 15 insertions(+), 13 deletions(-) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index e88e1ee..3ae8ffc 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -291,7 +291,7 @@ class Controller(GObject.GObject): dialog = ExceptionDialog(self, str(e)) dialog.run() - def set_custom_folder(self) -> "Path" | None: + def set_custom_folder(self) -> Union["Path", None]: picker = FolderPicker(self.mediator.window) return picker.pick_folder() diff --git a/dzgui/managers/mods.py b/dzgui/managers/mods.py index fdb0c50..a19f7c0 100644 --- a/dzgui/managers/mods.py +++ b/dzgui/managers/mods.py @@ -61,12 +61,14 @@ class ModManager: self.thread_man.set_cleanup_func(func) return - func = StoredFunc(self._on_mods_loaded, mods) - self.thread_man.set_cleanup_func(func) - - def _on_mods_loaded(self, mods: list[list[Any]]) -> None: self.store = ModelFactory().make_mod_store() self.store.extend(mods) + func = StoredFunc(self._on_mods_loaded) + self.thread_man.set_cleanup_func(func) + + # def _on_mods_loaded(self, mods: list[list[Any]]) -> None: + + def _on_mods_loaded(self) -> None: self.treeview.set_model(self.store) msg = self.format_mod_statusbar() total_mods = len(self.store) diff --git a/dzgui/views/base.py b/dzgui/views/base.py index c540d13..d91c795 100644 --- a/dzgui/views/base.py +++ b/dzgui/views/base.py @@ -23,6 +23,7 @@ from dzgui.views.pages.help import Help from dzgui.views.pages.keys import Keybindings from dzgui.views.pages.log import Log from dzgui.views.pages.mods import Mods +from dzgui.views.pages.offline import OfflineLoader from dzgui.views.pages.options import Options from dzgui.views.pages.preconnect import PreConnectionAssistant from dzgui.views.pages.servers import ServerNotebook @@ -110,8 +111,6 @@ class Notebook(ScrollableMixin, Gtk.Notebook): # type: ignore self.connection = PreConnectionAssistant(MainController) - from dzgui.views.pages.offline import OfflineLoader - self.offline = OfflineLoader(MainController) self.pages = { diff --git a/dzgui/views/pages/mods.py b/dzgui/views/pages/mods.py index 5d7c575..4218b3f 100644 --- a/dzgui/views/pages/mods.py +++ b/dzgui/views/pages/mods.py @@ -15,13 +15,13 @@ class Mods(NoOverlayScrolledWindow): def __init__(self, controller: "Controller") -> None: super().__init__() - self.box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) - - self.controller = controller - self.emitter = controller.get_emitter() self.tree = ModTreeView(controller) self.tree.set_vexpand(True) + self.controller = controller + self.controller.register_widget("modtreeview", self.tree) + self.emitter = controller.get_emitter() + self.offline_button = Gtk.Button( label="Play offline", halign=Gtk.Align.START, @@ -30,11 +30,11 @@ class Mods(NoOverlayScrolledWindow): ) self.offline_button.connect("clicked", self._on_offline_clicked) + self.box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) self.box.add(self.offline_button) self.box.add(self.tree) self.add(self.box) - self.controller.register_widget("modtreeview", self.tree) self.connect("map", self._on_map) self.connect("unmap", self._on_unmap) diff --git a/dzgui/views/trees/tree_mods.py b/dzgui/views/trees/tree_mods.py index b39daf0..1d74f84 100644 --- a/dzgui/views/trees/tree_mods.py +++ b/dzgui/views/trees/tree_mods.py @@ -4,6 +4,7 @@ from typing import Any, TYPE_CHECKING from dzgui.const.constants import APP_NAME, HEX_RED from dzgui.const.enum import ContextMenuGroup from dzgui.util import strings, localize +from dzgui.views.mixins.cursor_mixin import CursorMixin from dzgui.views.mixins.context_mixin import ContextMixin from dzgui.views.mixins.mods_mixin import ModsMixin from dzgui.views.trees.tree_base import TreeView diff --git a/pyproject.toml b/pyproject.toml index 8a5e8dc..17af853 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.0b10" +version = "7.0.0b12" license = "GPL-3.0-or-later" license-files = ["LICENSE"] readme = "README.md" From e6026e84f9e56233a998ac78fb7b5ffedbd5e0ad Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Tue, 9 Jun 2026 02:37:50 +0900 Subject: [PATCH 07/71] feat: launch offline (WIP) --- dzgui/controllers/mc.py | 37 ++-- dzgui/managers/contextmenu.py | 14 +- dzgui/managers/mods.py | 12 +- dzgui/model/model_factory.py | 3 + dzgui/strings/offline.py | 10 + dzgui/util/strings.py | 2 +- dzgui/views/base.py | 1 + dzgui/views/components/frame.py | 23 ++- dzgui/views/mixins/context_mixin.py | 2 + dzgui/views/pages/mods.py | 14 +- dzgui/views/pages/offline.py | 285 ++++++++++++++++++---------- dzgui/views/pages/options.py | 12 +- dzgui/views/pages/preconnect.py | 12 +- dzgui/views/trees/tree_base.py | 15 +- dzgui/views/trees/tree_mods.py | 18 +- 15 files changed, 312 insertions(+), 148 deletions(-) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 3ae8ffc..82e774d 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -54,7 +54,7 @@ if TYPE_CHECKING: from dzgui.views.pages.servers import ServerNotebook from dzgui.views.trees.tree_log import LogTreeView from dzgui.views.trees.tree_menu import MenuTreeView - from dzgui.views.trees.tree_mods import ModTreeView + from dzgui.views.trees.tree_mods import ModTreeView, OfflineModTreeView from dzgui.views.trees.tree_servers import ServerTreeView @@ -221,19 +221,29 @@ class Controller(GObject.GObject): open_user_workshop(uid, client) def load_mods(self) -> None: - self.mod_man = ModManager(self) + self.mediator.modtreeview.load_mods() def uncolorize_mods(self) -> None: - self.mod_man.uncolorize_mods() + mod_man = self.mediator.modtreeview.get_mod_man() + mod_man.uncolorize_mods() def highlight_stale(self) -> None: - self.mod_man.highlight_stale() + mod_man = self.mediator.modtreeview.get_mod_man() + mod_man.highlight_stale() def toggle_mod_selection(self, state: bool) -> None: - self.mod_man.toggle_mod_selection(state) + mod_man = self.mediator.modtreeview.get_mod_man() + mod_man.toggle_mod_selection(state) - def delete_mods(self) -> None: - self.mod_man.delete_mods() + def delete_mods( + self, treeview: Union["ModTreeView", "OfflineModTreeView"] = None + ) -> None: + if treeview is None: + view = self.mediator.modtreeview + else: + view = treeview + mod_man = view.get_mod_man() + mod_man.delete_mods() def get_mod_store(self) -> Gtk.TreeModel | None: return self.mediator.modtreeview.get_model() @@ -260,7 +270,8 @@ class Controller(GObject.GObject): dialog.run() def select_colorized(self) -> None: - self.mod_man.select_colorized() + mod_man = self.mediator.modtreeview.get_mod_man() + mod_man.select_colorized() def get_all_tree_filters(self) -> dict[str, dict[str, bool]]: servers = self.get_servers() @@ -293,7 +304,9 @@ class Controller(GObject.GObject): def set_custom_folder(self) -> Union["Path", None]: picker = FolderPicker(self.mediator.window) - return picker.pick_folder() + folder = picker.pick_folder() + picker.destroy() + return folder def update_api_key(self, key: Preferences, text: str) -> None: self.config_man.update_api_key(key, text) @@ -541,6 +554,8 @@ class Controller(GObject.GObject): return True return False - def open_offline(self, mods: Gtk.TreeModel | None) -> None: + def open_offline(self) -> None: self.open_page(NotebookPage.OFFLINE) - self.mediator.offline_loader.populate(mods) + mod_man = self.mediator.modtreeview.get_mod_man() + store = mod_man.store + self.mediator.offline_loader.populate(store) diff --git a/dzgui/managers/contextmenu.py b/dzgui/managers/contextmenu.py index 33aea9a..7701b1c 100644 --- a/dzgui/managers/contextmenu.py +++ b/dzgui/managers/contextmenu.py @@ -8,7 +8,7 @@ from dzgui.util.clip import copy_clipboard from dzgui.util.open_links import open_workshop_page from dzgui.views.dialogs.note import NoteDialog -from dzgui.views.trees.tree_mods import ModTreeView +from dzgui.views.trees.tree_mods import ModTreeView, OfflineModTreeView from dzgui.views.trees.tree_log import LogTreeView from dzgui.views.trees.tree_servers import ServerTreeView from dzgui.views.trees.tree_server_mods import ServerModTreeView @@ -27,7 +27,13 @@ if TYPE_CHECKING: class ContextMenuManager: def __init__( self, - treeview: LogTreeView | ModTreeView | ServerTreeView | ServerModTreeView, + treeview: ( + LogTreeView + | ModTreeView + | ServerTreeView + | ServerModTreeView + | OfflineModTreeView + ), controller: "Controller", ) -> None: self.controller = controller @@ -72,10 +78,10 @@ class ContextMenuManager: case ContextMenu.SHOW_MODS: self.controller.get_modlist(record) - if isinstance(self.treeview, ModTreeView): + if isinstance(self.treeview, (ModTreeView, OfflineModTreeView)): match action: case ContextMenu.DELETE_MOD: - self.controller.delete_mods() + self.controller.delete_mods(self.treeview) case ContextMenu.OPEN_WORKSHOP: self.open_mod_page() diff --git a/dzgui/managers/mods.py b/dzgui/managers/mods.py index a19f7c0..cc50181 100644 --- a/dzgui/managers/mods.py +++ b/dzgui/managers/mods.py @@ -2,7 +2,7 @@ import logging import shutil from pathlib import Path -from typing import Any, TYPE_CHECKING +from typing import TYPE_CHECKING from dzgui.api.mods import ( get_delimited_mods, @@ -40,20 +40,20 @@ class ModManager: this manager is instantiated each time the Mods page is opened """ - def __init__(self, controller: "Controller") -> None: + def __init__(self, tree: Gtk.TreeView, controller: "Controller") -> None: + self.treeview = tree self.controller = controller + self.emitter = controller.get_emitter() self.prefs = controller.get_prefs() self.path = controller.query_config(Preferences.DEFAULT) - self.treeview = self.controller.get_modtreeview() self.store: FastInsertListStore self.thread_man = ThreadingManager(controller) - self._get_mods() @call_on_thread(dialogs.fetching_mods) - def _get_mods(self) -> None: + def load_mods(self) -> None: mods = get_delimited_mods(self.path) if len(mods) < 1: msg = self.format_mod_statusbar() @@ -66,8 +66,6 @@ class ModManager: func = StoredFunc(self._on_mods_loaded) self.thread_man.set_cleanup_func(func) - # def _on_mods_loaded(self, mods: list[list[Any]]) -> None: - def _on_mods_loaded(self) -> None: self.treeview.set_model(self.store) msg = self.format_mod_statusbar() diff --git a/dzgui/model/model_factory.py b/dzgui/model/model_factory.py index ed1ccdc..ad255cf 100644 --- a/dzgui/model/model_factory.py +++ b/dzgui/model/model_factory.py @@ -165,3 +165,6 @@ class ModelFactory: def make_server_store(self) -> FastInsertListStore: return self.new_model_from_class(ServerCols) + + def convert_model_to_list(self, model: "FastInsertListStore") -> list: + return [[el for el in row] for row in model] diff --git a/dzgui/strings/offline.py b/dzgui/strings/offline.py index 1b21f79..631b979 100644 --- a/dzgui/strings/offline.py +++ b/dzgui/strings/offline.py @@ -1 +1,11 @@ heading = "Play offline" + +mission_button = "Select mission folder" +mission_frame = "Mission" + +version = "DayZ Version" + +custom_frame = "Custom mods" +custom_button = "Set custom mod folder" + +local_frame = "Installed mods" diff --git a/dzgui/util/strings.py b/dzgui/util/strings.py index cbff3d7..3d1a0e4 100644 --- a/dzgui/util/strings.py +++ b/dzgui/util/strings.py @@ -64,7 +64,7 @@ connect = "Connect" # Columns server_mod_cols = ["Mod", "ID", "Installed"] -mod_cols = ["Mod", "Symlink", "Dir", "Size (MiB)", "Color"] +mod_cols = ["Mod", "Symlink", "ID", "Size (MiB)", "Color"] log_cols = ["Timestamp", "Flag", "Traceback", "Message"] browser_cols = [ "Name", diff --git a/dzgui/views/base.py b/dzgui/views/base.py index d91c795..7f9ae36 100644 --- a/dzgui/views/base.py +++ b/dzgui/views/base.py @@ -23,6 +23,7 @@ from dzgui.views.pages.help import Help from dzgui.views.pages.keys import Keybindings from dzgui.views.pages.log import Log from dzgui.views.pages.mods import Mods + from dzgui.views.pages.offline import OfflineLoader from dzgui.views.pages.options import Options from dzgui.views.pages.preconnect import PreConnectionAssistant diff --git a/dzgui/views/components/frame.py b/dzgui/views/components/frame.py index c782d5f..a7193ad 100644 --- a/dzgui/views/components/frame.py +++ b/dzgui/views/components/frame.py @@ -6,16 +6,23 @@ gi.require_version("Gtk", "3.0") from gi.repository import Gtk # noqa +# TODO: subclass Gtk.Frame directly class HeadingFrame(Gtk.Box): - def __init__(self, widget: Gtk.Widget, heading: str) -> None: + def __init__(self, heading: str = "") -> None: super().__init__(orientation=Gtk.Orientation.VERTICAL) - label = Gtk.Label(label=heading) - label.set_halign(Gtk.Align.START) - css.add_class(label, "settings-subheading") + self.label = Gtk.Label(label=heading) + self.label.set_halign(Gtk.Align.START) + css.add_class(self.label, "settings-subheading") - frame = Gtk.Frame(hexpand=True) - frame.add(widget) - frame.set_label_widget(label) + self.frame = Gtk.Frame(hexpand=True) + self.frame.set_label_widget(self.label) - self.add(frame) + self.add(self.frame) + + @classmethod + def new_with_widget_and_label(cls, widget: Gtk.Widget, label: str) -> None: + n = cls() + n.frame.add(widget) + n.label.set_label(label) + return n diff --git a/dzgui/views/mixins/context_mixin.py b/dzgui/views/mixins/context_mixin.py index f8c4b83..78728de 100644 --- a/dzgui/views/mixins/context_mixin.py +++ b/dzgui/views/mixins/context_mixin.py @@ -44,6 +44,8 @@ class ContextMixin(TreeView): self.context_menu.connect("key-press-event", self._on_key) for row in group.value: + if row is None: + return item = self._process_dynamic_row(row) self.context_menu.append(item) diff --git a/dzgui/views/pages/mods.py b/dzgui/views/pages/mods.py index 4218b3f..ee4946b 100644 --- a/dzgui/views/pages/mods.py +++ b/dzgui/views/pages/mods.py @@ -11,12 +11,14 @@ if TYPE_CHECKING: from dzgui.controllers.mc import Controller -class Mods(NoOverlayScrolledWindow): +class Mods(Gtk.Box): def __init__(self, controller: "Controller") -> None: - super().__init__() + super().__init__(orientation=Gtk.Orientation.VERTICAL) + self.box = NoOverlayScrolledWindow() self.tree = ModTreeView(controller) self.tree.set_vexpand(True) + self.box.add(self.tree) self.controller = controller self.controller.register_widget("modtreeview", self.tree) @@ -30,18 +32,14 @@ class Mods(NoOverlayScrolledWindow): ) self.offline_button.connect("clicked", self._on_offline_clicked) - self.box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) - self.box.add(self.offline_button) - self.box.add(self.tree) - + self.add(self.offline_button) self.add(self.box) self.connect("map", self._on_map) self.connect("unmap", self._on_unmap) def _on_offline_clicked(self, button: Gtk.Button) -> None: - self.controller.open_offline(self.tree.get_model()) - # TODO: add offline page + self.controller.open_offline() def _on_unmap(self, widget: Self) -> None: self.emitter.emit("mod_page_toggled", False) diff --git a/dzgui/views/pages/offline.py b/dzgui/views/pages/offline.py index e3ffc7a..07b230e 100644 --- a/dzgui/views/pages/offline.py +++ b/dzgui/views/pages/offline.py @@ -1,9 +1,14 @@ -from typing import Self, TYPE_CHECKING +from pathlib import Path +from typing import Self, Sequence, TYPE_CHECKING -from dzgui.strings import offline from dzgui.util import css +import dzgui.api.pefile as PeFile +from dzgui.const.constants import APPID_DAYZ_EXP, APPNAME_DAYZ, APPNAME_DAYZ_EXP_HUMAN +from dzgui.const.enum import NotebookPage, Preferences +from dzgui.strings import offline +from dzgui.views.components.scrollable import NoOverlayScrolledWindow from dzgui.views.components.frame import HeadingFrame -from dzgui.views.trees.tree_mods import ModTreeView +from dzgui.views.trees.tree_mods import OfflineModTreeView import gi @@ -13,9 +18,144 @@ from gi.repository import Gtk, Gdk # noqa if TYPE_CHECKING: from dzgui.controllers.mc import Controller + from dzgui.model.model_factory import FastInsertListStore -# FIXME: grab focus of outer page -# TODO: if any custom mods, symlink them before connecting proceeding + +class GenericBox(Gtk.Box): + def __init__(self, orientation: Gtk.Orientation) -> None: + super().__init__(orientation=orientation) + + def extend(self, els: Sequence[Gtk.Widget]) -> None: + for el in els: + self.add(el) + + +class HBox(GenericBox): + def __init__(self) -> None: + super().__init__(orientation=Gtk.Orientation.HORIZONTAL) + + +class VBox(GenericBox): + def __init__(self) -> None: + super().__init__(orientation=Gtk.Orientation.VERTICAL) + + +class PageHeading(Gtk.Label): + def __init__(self, label: str) -> None: + super().__init__(label=label, halign=Gtk.Align.CENTER) + + css.add_class(self, "page-heading") + + +class FolderHBox(HBox): + def __init__(self, btn_label: str) -> None: + super().__init__() + + self.set_margin_start(10) + self.set_margin_bottom(10) + self.set_spacing(5) + + # TODO: use IconButton with folder-symbolic + self.button = Gtk.Button(label=btn_label, halign=Gtk.Align.START) + self.label = Gtk.Label() + self.extend([self.button, self.label]) + + def get_button(self) -> Gtk.Button: + return self.button + + def set_label(self, label: str) -> None: + self.label.set_label(label) + + +class ModFrame(HeadingFrame): + def __init__(self, controller: "Controller", label: str) -> None: + super().__init__(heading=label) + + self.vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + + self.tree = OfflineModTreeView(controller) + self.tree.set_model(None) + + self.scrolled = NoOverlayScrolledWindow() + self.scrolled.set_size_request(600, 400) + self.scrolled.add(self.tree) + self.status = Gtk.Label( + halign=Gtk.Align.START, margin_start=5, margin_top=3, margin_bottom=3 + ) + + self.vbox.pack_end(self.status, expand=False, fill=False, padding=0) + self.vbox.pack_end(self.scrolled, expand=False, fill=False, padding=0) + self.frame.add(self.vbox) + + sel = self.tree.get_selection() + sel.connect("changed", self._on_selection_changed) + + def collapse_tree(self) -> None: + self.scrolled.hide() + + def get_tree(self) -> OfflineModTreeView: + return self.tree + + def pack_start(self, widget: Gtk.Widget) -> None: + self.vbox.pack_start(widget, expand=False, fill=False, padding=5) + + def set_model(self, model: "FastInsertListStore") -> None: + self.tree.set_model(model) + self.tree.mod_man.store = model + self.set_cursor() + + def _on_selection_changed(self, sel: Gtk.TreeSelection) -> None: + model, rows = sel.get_selected_rows() + status = f"Mods selected: {len(rows)}" + self.status.set_label(status) + + def set_cursor(self) -> None: + path = Gtk.TreePath.new_from_indices([0]) + self.tree.set_cursor(path) + + +class CustomModFrame(ModFrame): + def __init__(self, controller: "Controller", heading: str) -> None: + super().__init__(controller, heading) + + self.controller = controller + self.custom_hbox = FolderHBox(offline.custom_button) + self.custom_hbox.get_button().connect("clicked", self._on_custom_button_clicked) + + self.pack_start(self.custom_hbox) + + def _on_custom_button_clicked(self, button: Gtk.Button) -> None: + # TODO: recycle for mission folder + # TODO: propagate results back to parent + folder = self.controller.set_custom_folder() + if folder is not None: + # TODO: CustomModManager + self.custom_hbox.set_label(str(folder)) + + +class RadioFrame(HeadingFrame): + def __init__(self, controller: "Controller") -> None: + super().__init__(heading=offline.version) + + self.controller = controller + + self.dayz = Gtk.RadioButton.new_with_label(None, APPNAME_DAYZ) + self.dayz_exp = Gtk.RadioButton.new_with_label_from_widget( + self.dayz, APPNAME_DAYZ_EXP_HUMAN + ) + + self.radio_box = HBox() + self.radio_box.extend([self.dayz, self.dayz_exp]) + self.radio_box.set_margin_start(10) + self.radio_box.set_margin_bottom(5) + + self.frame.add(self.radio_box) + + default_steam_path = self.controller.query_config(Preferences.DEFAULT) + steam_path = Path(default_steam_path) + dayz_exp = PeFile.get_pretty_version(steam_path, APPID_DAYZ_EXP) + if dayz_exp is None: + self.dayz_exp.set_sensitive(False) class OfflineLoader(Gtk.Box): @@ -26,127 +166,80 @@ class OfflineLoader(Gtk.Box): margin_end=10, ) - # TODO: wrap entire page in scrollable + # TODO: wrap entire page in scrollable, see preconnect dialog + # TODO: spacing between inner and outer scrollbars self.controller = controller self.controller.register_widget("offline_loader", self) - label = Gtk.Label(label=offline.heading) - label.set_halign(Gtk.Align.CENTER) - css.add_class(label, "page-heading") - self.add(label) + self.add(PageHeading(offline.heading)) - # TODO: wrap trees in scrollable - # TODO: suppress trees if there are no mods - self.local_mods = ModTreeView(controller) - self.scr = Gtk.ScrolledWindow() - self.scr.add(self.local_mods) - self.scr.set_size_request(600, 400) - # TODO: strings - self.local_frame = HeadingFrame(self.scr, "Installed mods") - - # TODO: update selected count statusbar when changed - sel = self.local_mods.get_selection() - sel.connect("changed", self._on_selection_changed) - - # TODO: abstract into class - # TODO: folder label # TODO: descriptive text here explaining how this area works - # TODO: use statusbar msg from format.format_mods - # TODO: strings - self.custom_button = Gtk.Button( - label="Set custom mod folder", halign=Gtk.Align.START + self.local_frame = ModFrame(controller, offline.local_frame) + self.custom_frame = CustomModFrame(controller, offline.custom_frame) + + self.mission_hbox = FolderHBox(offline.mission_button) + self.mission_frame = HeadingFrame.new_with_widget_and_label( + self.mission_hbox, offline.mission_frame ) - self.custom_button.connect("clicked", self._on_custom_button_clicked) - self.custom_mods = Gtk.TreeView() - self.custom_mods_box = Gtk.Box( - orientation=Gtk.Orientation.VERTICAL, margin_start=10, margin_bottom=10 - ) - self.custom_mods_box.add(self.custom_button) - self.custom_mods_box.add(self.custom_mods) - # TODO: strings - self.custom_frame = HeadingFrame(self.custom_mods_box, "Custom mods") - - self.mission_box = Gtk.Box( - orientation=Gtk.Orientation.HORIZONTAL, margin_start=10, margin_bottom=10 - ) - # TODO: strings - self.mission_button = Gtk.Button(label="Select mission folder") - self.mission_label = Gtk.Label() - - self.mission_box.add(self.mission_button) - self.mission_box.add(self.mission_label) - - # TODO: strings - self.mission_frame = HeadingFrame(self.mission_box, "Mission") - - self.radio_box = Gtk.Box( - orientation=Gtk.Orientation.HORIZONTAL, margin_start=10, margin_bottom=10 - ) - # TODO: strings - # TODO: disable if not available, use PeFile - self.dayz = Gtk.RadioButton.new_with_label(None, "DayZ") - self.dayz_exp = Gtk.RadioButton.new_with_label_from_widget( - self.dayz, "DayZ Experimental" - ) - self.radio_box.add(self.dayz) - self.radio_box.add(self.dayz_exp) - self.radio_frame = HeadingFrame(self.radio_box, "DayZ Version") + self.radio_frame = RadioFrame(controller) + # HeadingFrame.new_with_widget_and_label(self.radio_box, offline.version) + # TODO: abstraction self.scrollable = Gtk.ScrolledWindow( vexpand=True, propagate_natural_height=True ) # TODO: use same button anchoring logic as preconnect dialog - self.box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) - self.box.add(self.local_frame) - self.box.add(self.custom_frame) - self.box.add(self.mission_frame) - self.box.add(self.radio_frame) - self.scrollable.add(self.box) + self.content_box = VBox() + self.content_box.set_spacing(5) + self.content_box.extend( + [ + self.local_frame, + self.custom_frame, + self.mission_frame, + self.radio_frame, + ] + ) + self.scrollable.add(self.content_box) self.add(self.scrollable) # TODO: use ModelFactory - self.custom_mods = Gtk.TreeView() + # TODO: suppress symlink column + self.custom_tree = OfflineModTreeView(controller) - # TODO: shared ConnectBox class + # TODO: share ConnectBox class with preconnect dialog + self.button_box = HBox() self.back = Gtk.Button() self.ok = Gtk.Button() self.back.connect("clicked", self._on_back_clicked) self.ok.connect("clicked", self._on_ok_clicked) self.connect("key-press-event", self._on_keypress) + self.button_box.extend([self.back, self.ok]) + def _on_keypress(self, widget: Self, event: Gdk.EventKey) -> None: + # TODO: consider dropping this if event.keyval == Gdk.KEY_Escape: self.back.emit("clicked") - def _on_selection_changed(self, sel: Gtk.TreeSelection) -> None: - model, rows = sel.get_selected_rows() - # TODO: update sub-statusbar with selected mods - print(len(rows)) - - def populate(self, mods: Gtk.TreeModel | None) -> None: - self.local_mods.set_model(mods) - # TODO: push existing model - # TODO: set sensitivity of radio buttons - pass - - def _on_custom_button_clicked(self, button: Gtk.Button) -> None: - # TODO: recycle this for both buttons - folder = self.controller.set_custom_folder() - if folder is not None: - # TODO: parse mods from here - # TODO: offline mod manager would be cleaner - print(folder) + def populate(self, store: "FastInsertListStore") -> None: + self.local_frame.set_model(store) + # TODO: toggle if empty model, show warning label + # TODO: suppress trees if there are no mods + self.custom_frame.collapse_tree() def _on_back_clicked(self, button: Gtk.Button) -> None: - from dzgui.const.enum import NotebookPage - + # FIXME: returns to server page and not parent mod page + # consider using a toggle button instead, and suppressing statusbar self.controller.open_page(NotebookPage.SERVERS) def _on_ok_clicked(self, button: Gtk.Button) -> None: + """ + - collect symlinks to selected mods + cf. rebuild_symlinks() + - create symlinks for custom mods + - collect appid + - collect mission folder + """ pass - """ - create symlinks if there are custom mods - cf. rebuild_symlinks() - """ diff --git a/dzgui/views/pages/options.py b/dzgui/views/pages/options.py index e38d6ea..29b5130 100644 --- a/dzgui/views/pages/options.py +++ b/dzgui/views/pages/options.py @@ -187,12 +187,14 @@ class Options(Gtk.Box): if prefs.is_debug: grid.attach(developers, 1, 0, self.DEFAULT_WIDTH, self.DEFAULT_HEIGHT) - for frame in [ - HeadingFrame(api_box, strings.options.api_keys), - HeadingFrame(prefs_grid, strings.options.prefs), - HeadingFrame(mods_grid, strings.options.mods), - HeadingFrame(version_grid, strings.options.version), + for pair in [ + (api_box, strings.options.api_keys), + (prefs_grid, strings.options.prefs), + (mods_grid, strings.options.mods), + (version_grid, strings.options.version), ]: + + frame = HeadingFrame.new_with_widget_and_label(*pair) grid.attach(frame, col, row, self.DEFAULT_WIDTH, self.DEFAULT_HEIGHT) row += 1 diff --git a/dzgui/views/pages/preconnect.py b/dzgui/views/pages/preconnect.py index 90dbcf6..59789a8 100644 --- a/dzgui/views/pages/preconnect.py +++ b/dzgui/views/pages/preconnect.py @@ -166,7 +166,9 @@ class PreConnectionAssistant(Gtk.Box): self.mods_placeholder = Placeholder("This server has no mods.") self.tree_box.add(self.mods_placeholder) - self.tree_frame = HeadingFrame(self.tree_box, preconnect.mods) + self.tree_frame = HeadingFrame.new_with_widget_and_label( + self.tree_box, preconnect.mods + ) # TODO: abstract into components self.warning_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) @@ -175,7 +177,9 @@ class PreConnectionAssistant(Gtk.Box): self.warning_placeholder = Placeholder("No warnings.") self.warning_box.add(self.warning_tree) self.warning_box.add(self.warning_placeholder) - self.warning_frame = HeadingFrame(self.warning_box, preconnect.warnings) + self.warning_frame = HeadingFrame.new_with_widget_and_label( + self.warning_box, preconnect.warnings + ) self.error_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) self.error_tree = MaskedTree(ERROR) @@ -183,7 +187,9 @@ class PreConnectionAssistant(Gtk.Box): self.error_placeholder = Placeholder("No errors.") self.error_box.add(self.error_tree) self.error_box.add(self.error_placeholder) - self.error_frame = HeadingFrame(self.error_box, preconnect.errors) + self.error_frame = HeadingFrame.new_with_widget_and_label( + self.error_box, preconnect.errors + ) self.box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) self.box.add(self.title) diff --git a/dzgui/views/trees/tree_base.py b/dzgui/views/trees/tree_base.py index ac0733a..fbb3a1f 100644 --- a/dzgui/views/trees/tree_base.py +++ b/dzgui/views/trees/tree_base.py @@ -4,7 +4,7 @@ from typing import Any, Optional, TYPE_CHECKING from warnings import deprecated from dzgui.const.constants import APP_NAME, SEPARATOR -from dzgui.util.keys import is_navkey +from dzgui.util.keys import is_ctrl_mask, is_navkey from dzgui.views.mixins.cursor_mixin import CursorMixin import gi @@ -85,10 +85,17 @@ class TreeView(CursorMixin, Gtk.TreeView): # type: ignore model, rows = sel.get_selected_rows() return [model[row] for row in rows] - def _on_keypress(self, treeview: Gtk.TreeView, event: Gdk.EventKey) -> None: + def _on_keypress(self, treeview: Gtk.TreeView, event: Gdk.EventKey) -> bool: + from dzgui.views.trees.tree_servers import ServerTreeView + + # NOTE: suppress popup search and delegate binding to server view (cf. request_keyword_focus) + if not isinstance(treeview, ServerTreeView): + if is_ctrl_mask(event) and event.keyval == Gdk.KEY_f: + return True + if is_navkey(event.keyval): if self.get_model() is None: - return + return True if self.sel_blocked is False: self.sel_blocked = True self.controller.suppress_signal( @@ -98,7 +105,7 @@ class TreeView(CursorMixin, Gtk.TreeView): # type: ignore True, ) self._vim_nav(event) - return + return False def _on_key_release(self, treeview: Gtk.TreeView, event: Gdk.EventKey) -> None: """ diff --git a/dzgui/views/trees/tree_mods.py b/dzgui/views/trees/tree_mods.py index 1d74f84..17ab196 100644 --- a/dzgui/views/trees/tree_mods.py +++ b/dzgui/views/trees/tree_mods.py @@ -3,8 +3,8 @@ from typing import Any, TYPE_CHECKING from dzgui.const.constants import APP_NAME, HEX_RED from dzgui.const.enum import ContextMenuGroup +from dzgui.managers.mods import ModManager from dzgui.util import strings, localize -from dzgui.views.mixins.cursor_mixin import CursorMixin from dzgui.views.mixins.context_mixin import ContextMixin from dzgui.views.mixins.mods_mixin import ModsMixin from dzgui.views.trees.tree_base import TreeView @@ -25,7 +25,10 @@ logger = logging.getLogger(APP_NAME) class ModTreeView(ModsMixin, ContextMixin, TreeView): # type: ignore def __init__(self, controller: "Controller") -> None: super().__init__(controller, menu=ContextMenuGroup.MOD) + self.controller = controller + self.mod_man = ModManager(self, controller) + emitter = self.controller.get_emitter() emitter.connect("mods_updated", self._on_mods_updated) emitter.connect("mods_highlighted", self._on_mods_highlighted) @@ -57,6 +60,12 @@ class ModTreeView(ModsMixin, ContextMixin, TreeView): # type: ignore self.connect("button-press-event", self.present_menu) self.connect("key-press-event", self.present_menu) + def get_mod_man(self) -> ModManager: + return self.mod_man + + def load_mods(self) -> None: + self.mod_man.load_mods() + def _on_mods_highlighted(self, emitter: "Emitter") -> None: self.get_selection().unselect_all() @@ -106,3 +115,10 @@ class ModTreeView(ModsMixin, ContextMixin, TreeView): # type: ignore val = model[it][3] formatted = localize.number(val) cell.set_property("text", formatted) + + def set_menu(self, context: ContextMenuGroup) -> None: + self.menu = context + +class OfflineModTreeView(ModTreeView): + def __init__(self, controller: "Controller") -> None: + super().__init__(controller) From e081d0ad16c2d39cae82c79d926d0857b607bc2e Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Tue, 9 Jun 2026 03:09:34 +0900 Subject: [PATCH 08/71] feat: collect appid on toggle --- dzgui/views/pages/offline.py | 74 ++++++++++++++++++++---------------- 1 file changed, 41 insertions(+), 33 deletions(-) diff --git a/dzgui/views/pages/offline.py b/dzgui/views/pages/offline.py index 07b230e..29c3097 100644 --- a/dzgui/views/pages/offline.py +++ b/dzgui/views/pages/offline.py @@ -3,7 +3,7 @@ from typing import Self, Sequence, TYPE_CHECKING from dzgui.util import css import dzgui.api.pefile as PeFile -from dzgui.const.constants import APPID_DAYZ_EXP, APPNAME_DAYZ, APPNAME_DAYZ_EXP_HUMAN +from dzgui.const.constants import APPID_DAYZ, APPID_DAYZ_EXP, APPNAME_DAYZ, APPNAME_DAYZ_EXP_HUMAN from dzgui.const.enum import NotebookPage, Preferences from dzgui.strings import offline from dzgui.views.components.scrollable import NoOverlayScrolledWindow @@ -22,8 +22,8 @@ if TYPE_CHECKING: class GenericBox(Gtk.Box): - def __init__(self, orientation: Gtk.Orientation) -> None: - super().__init__(orientation=orientation) + def __init__(self, orientation: Gtk.Orientation, spacing: int = 0) -> None: + super().__init__(orientation=orientation, spacing=spacing) def extend(self, els: Sequence[Gtk.Widget]) -> None: for el in els: @@ -31,13 +31,13 @@ class GenericBox(Gtk.Box): class HBox(GenericBox): - def __init__(self) -> None: - super().__init__(orientation=Gtk.Orientation.HORIZONTAL) + def __init__(self, spacing: int = 0) -> None: + super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=spacing) class VBox(GenericBox): - def __init__(self) -> None: - super().__init__(orientation=Gtk.Orientation.VERTICAL) + def __init__(self, spacing: int = 0) -> None: + super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=spacing) class PageHeading(Gtk.Label): @@ -49,11 +49,10 @@ class PageHeading(Gtk.Label): class FolderHBox(HBox): def __init__(self, btn_label: str) -> None: - super().__init__() + super().__init__(spacing=5) self.set_margin_start(10) self.set_margin_bottom(10) - self.set_spacing(5) # TODO: use IconButton with folder-symbolic self.button = Gtk.Button(label=btn_label, halign=Gtk.Align.START) @@ -119,6 +118,7 @@ class CustomModFrame(ModFrame): super().__init__(controller, heading) self.controller = controller + # TODO: descriptive text here explaining how this area works self.custom_hbox = FolderHBox(offline.custom_button) self.custom_hbox.get_button().connect("clicked", self._on_custom_button_clicked) @@ -139,6 +139,9 @@ class RadioFrame(HeadingFrame): self.controller = controller + self.id_map = {APPNAME_DAYZ: APPID_DAYZ, APPNAME_DAYZ_EXP_HUMAN: APPID_DAYZ_EXP} + self.appid: APPID_DAYZ + self.dayz = Gtk.RadioButton.new_with_label(None, APPNAME_DAYZ) self.dayz_exp = Gtk.RadioButton.new_with_label_from_widget( self.dayz, APPNAME_DAYZ_EXP_HUMAN @@ -154,9 +157,20 @@ class RadioFrame(HeadingFrame): default_steam_path = self.controller.query_config(Preferences.DEFAULT) steam_path = Path(default_steam_path) dayz_exp = PeFile.get_pretty_version(steam_path, APPID_DAYZ_EXP) + + self.dayz.connect("toggled", self._on_radio_toggled) if dayz_exp is None: self.dayz_exp.set_sensitive(False) + def _on_radio_toggled(self, radio: Gtk.RadioButton) -> None: + for el in self.dayz, self.dayz_exp: + if el.get_active(): + label = el.get_label() + self.appid = self.id_map[label] + + def get_appid(self) -> int: + return self.appid + class OfflineLoader(Gtk.Box): def __init__(self, controller: "Controller"): @@ -166,32 +180,31 @@ class OfflineLoader(Gtk.Box): margin_end=10, ) - # TODO: wrap entire page in scrollable, see preconnect dialog # TODO: spacing between inner and outer scrollbars self.controller = controller self.controller.register_widget("offline_loader", self) self.add(PageHeading(offline.heading)) - # TODO: descriptive text here explaining how this area works self.local_frame = ModFrame(controller, offline.local_frame) self.custom_frame = CustomModFrame(controller, offline.custom_frame) + # TODO: use ModelFactory + # TODO: suppress symlink column + self.custom_tree = OfflineModTreeView(controller) + self.mission_hbox = FolderHBox(offline.mission_button) self.mission_frame = HeadingFrame.new_with_widget_and_label( self.mission_hbox, offline.mission_frame ) self.radio_frame = RadioFrame(controller) - # HeadingFrame.new_with_widget_and_label(self.radio_box, offline.version) - # TODO: abstraction self.scrollable = Gtk.ScrolledWindow( vexpand=True, propagate_natural_height=True ) - # TODO: use same button anchoring logic as preconnect dialog - self.content_box = VBox() - self.content_box.set_spacing(5) + + self.content_box = VBox(spacing=5) self.content_box.extend( [ self.local_frame, @@ -201,25 +214,23 @@ class OfflineLoader(Gtk.Box): ] ) - self.scrollable.add(self.content_box) - self.add(self.scrollable) - - # TODO: use ModelFactory - # TODO: suppress symlink column - self.custom_tree = OfflineModTreeView(controller) - - # TODO: share ConnectBox class with preconnect dialog - self.button_box = HBox() - self.back = Gtk.Button() - self.ok = Gtk.Button() + # 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(5) + self.back = Gtk.Button(label="Back") + self.ok = Gtk.Button(label="Launch") self.back.connect("clicked", self._on_back_clicked) self.ok.connect("clicked", self._on_ok_clicked) self.connect("key-press-event", self._on_keypress) self.button_box.extend([self.back, self.ok]) + self.scrollable.add(self.content_box) + self.add(self.scrollable) + self.add(self.button_box) + def _on_keypress(self, widget: Self, event: Gdk.EventKey) -> None: - # TODO: consider dropping this if event.keyval == Gdk.KEY_Escape: self.back.emit("clicked") @@ -230,16 +241,13 @@ class OfflineLoader(Gtk.Box): self.custom_frame.collapse_tree() def _on_back_clicked(self, button: Gtk.Button) -> None: - # FIXME: returns to server page and not parent mod page - # consider using a toggle button instead, and suppressing statusbar - self.controller.open_page(NotebookPage.SERVERS) + self.controller.open_page(NotebookPage.MODS) def _on_ok_clicked(self, button: Gtk.Button) -> None: + appid = self.radio_frame.get_appid() """ - collect symlinks to selected mods cf. rebuild_symlinks() - create symlinks for custom mods - - collect appid - collect mission folder """ - pass From 3473f033501fac89f8d0000bedb8f2089d054206 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 10 Jun 2026 04:51:05 +0900 Subject: [PATCH 09/71] fix: pad inner scrollbar --- dzgui/views/pages/offline.py | 16 ++++++++++++++-- dzgui/views/pages/preconnect.py | 2 +- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/dzgui/views/pages/offline.py b/dzgui/views/pages/offline.py index 29c3097..88430b9 100644 --- a/dzgui/views/pages/offline.py +++ b/dzgui/views/pages/offline.py @@ -3,7 +3,12 @@ from typing import Self, Sequence, TYPE_CHECKING from dzgui.util import css import dzgui.api.pefile as PeFile -from dzgui.const.constants import APPID_DAYZ, APPID_DAYZ_EXP, APPNAME_DAYZ, APPNAME_DAYZ_EXP_HUMAN +from dzgui.const.constants import ( + APPID_DAYZ, + APPID_DAYZ_EXP, + APPNAME_DAYZ, + APPNAME_DAYZ_EXP_HUMAN, +) from dzgui.const.enum import NotebookPage, Preferences from dzgui.strings import offline from dzgui.views.components.scrollable import NoOverlayScrolledWindow @@ -76,6 +81,7 @@ class ModFrame(HeadingFrame): self.tree.set_model(None) self.scrolled = NoOverlayScrolledWindow() + self.scrolled.set_margin_end(10) self.scrolled.set_size_request(600, 400) self.scrolled.add(self.tree) self.status = Gtk.Label( @@ -105,12 +111,17 @@ class ModFrame(HeadingFrame): def _on_selection_changed(self, sel: Gtk.TreeSelection) -> None: model, rows = sel.get_selected_rows() - status = f"Mods selected: {len(rows)}" + if len(rows) == 0: + # TODO recycle (util.format) + status = "Ctrl-click to select multiple; Shift-click to select a range." + else: + status = f"Mods selected: {len(rows)}" self.status.set_label(status) def set_cursor(self) -> None: path = Gtk.TreePath.new_from_indices([0]) self.tree.set_cursor(path) + self.tree.get_selection().unselect_all() class CustomModFrame(ModFrame): @@ -154,6 +165,7 @@ class RadioFrame(HeadingFrame): self.frame.add(self.radio_box) + # TODO: abstract out of here default_steam_path = self.controller.query_config(Preferences.DEFAULT) steam_path = Path(default_steam_path) dayz_exp = PeFile.get_pretty_version(steam_path, APPID_DAYZ_EXP) diff --git a/dzgui/views/pages/preconnect.py b/dzgui/views/pages/preconnect.py index 59789a8..fdce9a4 100644 --- a/dzgui/views/pages/preconnect.py +++ b/dzgui/views/pages/preconnect.py @@ -154,7 +154,7 @@ class PreConnectionAssistant(Gtk.Box): self.progress_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=15) self.progress_box.add(self.mod_count) - self.scrolled = Gtk.ScrolledWindow() + self.scrolled = Gtk.ScrolledWindow(margin_end=10) self.scrolled.add(self.tree) self.scrolled.set_size_request(600, 400) From 2dadd70be3160c8b0122efee4a6d6d73e0500c9d Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 10 Jun 2026 05:05:22 +0900 Subject: [PATCH 10/71] fix: pack tree and statusbar in vbox --- dzgui/views/pages/offline.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/dzgui/views/pages/offline.py b/dzgui/views/pages/offline.py index 88430b9..828f1e4 100644 --- a/dzgui/views/pages/offline.py +++ b/dzgui/views/pages/offline.py @@ -84,19 +84,22 @@ class ModFrame(HeadingFrame): self.scrolled.set_margin_end(10) self.scrolled.set_size_request(600, 400) self.scrolled.add(self.tree) + self.status = Gtk.Label( halign=Gtk.Align.START, margin_start=5, margin_top=3, margin_bottom=3 ) - self.vbox.pack_end(self.status, expand=False, fill=False, padding=0) - self.vbox.pack_end(self.scrolled, expand=False, fill=False, padding=0) + self.tree_vbox = VBox() + self.tree_vbox.extend([self.scrolled, self.status]) + + self.vbox.add(self.tree_vbox) self.frame.add(self.vbox) sel = self.tree.get_selection() sel.connect("changed", self._on_selection_changed) def collapse_tree(self) -> None: - self.scrolled.hide() + self.tree_vbox.hide() def get_tree(self) -> OfflineModTreeView: return self.tree From 775f582ea66893b09c7885161342ae6cdaee0d7c Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 10 Jun 2026 05:27:39 +0900 Subject: [PATCH 11/71] fix: drop extraneous arg --- dzgui/api/steam.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dzgui/api/steam.py b/dzgui/api/steam.py index b322f76..b773a57 100644 --- a/dzgui/api/steam.py +++ b/dzgui/api/steam.py @@ -162,7 +162,7 @@ def load_to_menu(client: str, appid: int, name: str, mods: list[str]) -> int: proc = subprocess.run([*client_args, *params]) return proc.returncode -def launch_offline(client: str, addr: str, appid: int, name: str, mods: list[str], mission: str) -> int: +def launch_offline(client: str, appid: int, name: str, mods: list[str], mission: str) -> int: """Launch offline with specific mods/missions""" concat = concat_mods(mods) client_args = concat_bash_args(client) From bc1118a29f11b307c5194e407452ca25c4b07a4a Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 10 Jun 2026 05:27:57 +0900 Subject: [PATCH 12/71] feat: OfflineManager --- dzgui/managers/offline.py | 43 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 dzgui/managers/offline.py diff --git a/dzgui/managers/offline.py b/dzgui/managers/offline.py new file mode 100644 index 0000000..ff69471 --- /dev/null +++ b/dzgui/managers/offline.py @@ -0,0 +1,43 @@ +from pathlib import Path +from typing import Union + +from dzgui.api.steam import launch_offline +from dzgui.const.enum import Preferences +from dzgui.managers.threading import call_on_thread, ThreadingManager +from dzgui.strings import dialogs +from dzgui.util.symlinks import clone_symlinks + + +class OfflineManager: + def __init__( + self, + appid: int, + mission: Union[Path, None] = None, + local_mods: Union[list[str], None] = None, + custom_mods: Union[list[str], None] = None, + ) -> None: + super().__init__() + + self.thread_man = ThreadingManager() + + self.appid = appid + self.mission_folder = mission + self.local_mods = local_mods + self.custom_mods = custom_mods + + @call_on_thread(dialogs.waiting_for_launch) + def launch(self) -> None: + client = self.controller.query_config(Preferences.CLIENT) + name = self.controller.query_config(Preferences.NAME) + steam_path = self.controller.query_config(Preferences.DEFAULT) + + new_symlinks: list[str] = [] + + combined_mods: list[str] = [] + combined_mods.extend(self.local_mods) + combined_mods.extend(new_symlinks) + + # TODO: cf. rebuild_symlinks + + clone_symlinks(Path(steam_path)) + launch_offline(client, self.appid, name, combined_mods) From 46fba4eef16ae25fa98b63a8826a2b99fcfcdbc4 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 10 Jun 2026 05:45:08 +0900 Subject: [PATCH 13/71] chore: clear typehinting errors --- dzgui/controllers/mc.py | 3 +-- dzgui/managers/offline.py | 36 +++++++++++++++++------------ dzgui/model/model_factory.py | 4 ++-- dzgui/views/components/frame.py | 3 ++- dzgui/views/mixins/context_mixin.py | 2 +- dzgui/views/pages/offline.py | 10 ++++---- 6 files changed, 32 insertions(+), 26 deletions(-) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 82e774d..f5339b9 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -20,7 +20,6 @@ from dzgui.controllers.emitter import Emitter from dzgui.managers.config import ConfigManager from dzgui.managers.connection import ConnectionManager from dzgui.managers.contextmenu import ContextMenuManager -from dzgui.managers.mods import ModManager from dzgui.managers.notes import NoteManager from dzgui.model.servers import ServerModelManager from dzgui.util.diag import write_diagnostic @@ -236,7 +235,7 @@ class Controller(GObject.GObject): mod_man.toggle_mod_selection(state) def delete_mods( - self, treeview: Union["ModTreeView", "OfflineModTreeView"] = None + self, treeview: Union["ModTreeView", "OfflineModTreeView", None] = None ) -> None: if treeview is None: view = self.mediator.modtreeview diff --git a/dzgui/managers/offline.py b/dzgui/managers/offline.py index ff69471..817a016 100644 --- a/dzgui/managers/offline.py +++ b/dzgui/managers/offline.py @@ -1,43 +1,49 @@ from pathlib import Path -from typing import Union +from typing import TYPE_CHECKING, Union from dzgui.api.steam import launch_offline from dzgui.const.enum import Preferences from dzgui.managers.threading import call_on_thread, ThreadingManager from dzgui.strings import dialogs -from dzgui.util.symlinks import clone_symlinks +from dzgui.util.symlink import clone_symlinks + +if TYPE_CHECKING: + from dzgui.controllers.mc import Controller class OfflineManager: def __init__( self, + controller: "Controller", appid: int, - mission: Union[Path, None] = None, + mission: str = "", local_mods: Union[list[str], None] = None, custom_mods: Union[list[str], None] = None, ) -> None: super().__init__() - self.thread_man = ThreadingManager() + self.controller = controller + self.thread_man = ThreadingManager(controller) self.appid = appid self.mission_folder = mission self.local_mods = local_mods self.custom_mods = custom_mods - @call_on_thread(dialogs.waiting_for_launch) - def launch(self) -> None: - client = self.controller.query_config(Preferences.CLIENT) - name = self.controller.query_config(Preferences.NAME) - steam_path = self.controller.query_config(Preferences.DEFAULT) + @call_on_thread(dialogs.waiting_for_launch) + def launch(self) -> None: + client = self.controller.query_config(Preferences.CLIENT) + name = self.controller.query_config(Preferences.NAME) + steam_path = self.controller.query_config(Preferences.DEFAULT) - new_symlinks: list[str] = [] + new_symlinks: list[str] = [] - combined_mods: list[str] = [] + combined_mods: list[str] = [] + if self.local_mods is not None: combined_mods.extend(self.local_mods) + + if self.custom_mods is not None: + clone_symlinks(Path(steam_path)) combined_mods.extend(new_symlinks) - # TODO: cf. rebuild_symlinks - - clone_symlinks(Path(steam_path)) - launch_offline(client, self.appid, name, combined_mods) + launch_offline(client, self.appid, name, combined_mods, self.mission_folder) diff --git a/dzgui/model/model_factory.py b/dzgui/model/model_factory.py index ad255cf..daefeb3 100644 --- a/dzgui/model/model_factory.py +++ b/dzgui/model/model_factory.py @@ -166,5 +166,5 @@ class ModelFactory: def make_server_store(self) -> FastInsertListStore: return self.new_model_from_class(ServerCols) - def convert_model_to_list(self, model: "FastInsertListStore") -> list: - return [[el for el in row] for row in model] + # def convert_model_to_list(self, model: "FastInsertListStore") -> list: + # return [[el for el in row] for row in model] diff --git a/dzgui/views/components/frame.py b/dzgui/views/components/frame.py index a7193ad..dab2671 100644 --- a/dzgui/views/components/frame.py +++ b/dzgui/views/components/frame.py @@ -1,3 +1,4 @@ +from typing import Self from dzgui.util import css import gi @@ -21,7 +22,7 @@ class HeadingFrame(Gtk.Box): self.add(self.frame) @classmethod - def new_with_widget_and_label(cls, widget: Gtk.Widget, label: str) -> None: + def new_with_widget_and_label(cls, widget: Gtk.Widget, label: str) -> Self: n = cls() n.frame.add(widget) n.label.set_label(label) diff --git a/dzgui/views/mixins/context_mixin.py b/dzgui/views/mixins/context_mixin.py index 78728de..19c0f42 100644 --- a/dzgui/views/mixins/context_mixin.py +++ b/dzgui/views/mixins/context_mixin.py @@ -45,7 +45,7 @@ class ContextMixin(TreeView): for row in group.value: if row is None: - return + return False item = self._process_dynamic_row(row) self.context_menu.append(item) diff --git a/dzgui/views/pages/offline.py b/dzgui/views/pages/offline.py index 828f1e4..4ee6a2a 100644 --- a/dzgui/views/pages/offline.py +++ b/dzgui/views/pages/offline.py @@ -104,7 +104,7 @@ class ModFrame(HeadingFrame): def get_tree(self) -> OfflineModTreeView: return self.tree - def pack_start(self, widget: Gtk.Widget) -> None: + def pack(self, widget: Gtk.Widget) -> None: self.vbox.pack_start(widget, expand=False, fill=False, padding=5) def set_model(self, model: "FastInsertListStore") -> None: @@ -136,14 +136,13 @@ class CustomModFrame(ModFrame): self.custom_hbox = FolderHBox(offline.custom_button) self.custom_hbox.get_button().connect("clicked", self._on_custom_button_clicked) - self.pack_start(self.custom_hbox) + self.pack(self.custom_hbox) def _on_custom_button_clicked(self, button: Gtk.Button) -> None: # TODO: recycle for mission folder # TODO: propagate results back to parent folder = self.controller.set_custom_folder() if folder is not None: - # TODO: CustomModManager self.custom_hbox.set_label(str(folder)) @@ -154,7 +153,7 @@ class RadioFrame(HeadingFrame): self.controller = controller self.id_map = {APPNAME_DAYZ: APPID_DAYZ, APPNAME_DAYZ_EXP_HUMAN: APPID_DAYZ_EXP} - self.appid: APPID_DAYZ + self.appid = APPID_DAYZ self.dayz = Gtk.RadioButton.new_with_label(None, APPNAME_DAYZ) self.dayz_exp = Gtk.RadioButton.new_with_label_from_widget( @@ -259,7 +258,8 @@ class OfflineLoader(Gtk.Box): self.controller.open_page(NotebookPage.MODS) def _on_ok_clicked(self, button: Gtk.Button) -> None: - appid = self.radio_frame.get_appid() + # appid = self.radio_frame.get_appid() + # TODO; delegate to OfflineManager """ - collect symlinks to selected mods cf. rebuild_symlinks() From d4a99f82292f4c616d7ce86b7b14672314407152 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 10 Jun 2026 06:15:27 +0900 Subject: [PATCH 14/71] feat: prepare mods in manager (WIP) --- dzgui/api/mods.py | 17 +++++++++++++---- dzgui/managers/offline.py | 34 +++++++++++++++++++++++++++++----- dzgui/views/pages/offline.py | 20 ++++++++++++-------- 3 files changed, 54 insertions(+), 17 deletions(-) diff --git a/dzgui/api/mods.py b/dzgui/api/mods.py index 944df15..af9aa5a 100644 --- a/dzgui/api/mods.py +++ b/dzgui/api/mods.py @@ -87,15 +87,18 @@ def get_mod_size(path: Path) -> float: return size -def get_delimited_mods(steam_path: Path) -> list[Any]: - workshop_path = get_local_mod_path(steam_path) - mods = get_local_mods(workshop_path) +def get_custom_mods(path: Path) -> list[Any]: + mods = get_local_mods(path) + # TODO: error handling + return parse_mods(mods) + + +def parse_mods(mods: list[Path]) -> list[Any]: clean = [] for mod in mods: mod_dir = mod.name symlink = _hash(mod_dir) # FIXME: malformed .cpp files could break this - # mention that mods may be downloading meta = parse_meta(mod) if meta is None: continue @@ -106,6 +109,12 @@ def get_delimited_mods(steam_path: Path) -> list[Any]: return clean +def get_delimited_mods(steam_path: Path) -> list[Any]: + workshop_path = get_local_mod_path(steam_path) + mods = get_local_mods(workshop_path) + return parse_mods(mods) + + def get_missing_mods(local: list, remote: list) -> list: return [mod for mod in remote if mod not in local] diff --git a/dzgui/managers/offline.py b/dzgui/managers/offline.py index 817a016..13abf3b 100644 --- a/dzgui/managers/offline.py +++ b/dzgui/managers/offline.py @@ -1,9 +1,11 @@ from pathlib import Path -from typing import TYPE_CHECKING, Union +from typing import TYPE_CHECKING +from dzgui.api.mods import get_custom_mods from dzgui.api.steam import launch_offline from dzgui.const.enum import Preferences from dzgui.managers.threading import call_on_thread, ThreadingManager +from dzgui.model.model_factory import FastInsertListStore, ModelFactory from dzgui.strings import dialogs from dzgui.util.symlink import clone_symlinks @@ -15,21 +17,41 @@ class OfflineManager: def __init__( self, controller: "Controller", - appid: int, - mission: str = "", - local_mods: Union[list[str], None] = None, - custom_mods: Union[list[str], None] = None, ) -> None: super().__init__() self.controller = controller self.thread_man = ThreadingManager(controller) + self.appid: int + self.mission_folder: str + self.local_mods: list[str] | None + self.custom_mods: list[str] | None + + # TODO: strings + @call_on_thread("parsing") + def parse_custom_mods(self, folder: str) -> "FastInsertListStore": + mods = get_custom_mods(Path(folder)) + store = ModelFactory().make_mod_store() + store.extend(mods) + # TODO: manipulate tree columns to only show name and size + return store + + def setup( + self, + appid: int, + mission: str = "", + local_mods: list[str] | None = None, + custom_mods: list[str] | None = None, + ) -> None: + self.appid = appid self.mission_folder = mission self.local_mods = local_mods self.custom_mods = custom_mods + self.launch() + @call_on_thread(dialogs.waiting_for_launch) def launch(self) -> None: client = self.controller.query_config(Preferences.CLIENT) @@ -43,6 +65,8 @@ class OfflineManager: combined_mods.extend(self.local_mods) if self.custom_mods is not None: + # TODO: new function that creates symlinks in game path + # based on selected mods clone_symlinks(Path(steam_path)) combined_mods.extend(new_symlinks) diff --git a/dzgui/views/pages/offline.py b/dzgui/views/pages/offline.py index 4ee6a2a..7d663fc 100644 --- a/dzgui/views/pages/offline.py +++ b/dzgui/views/pages/offline.py @@ -10,6 +10,7 @@ from dzgui.const.constants import ( APPNAME_DAYZ_EXP_HUMAN, ) from dzgui.const.enum import NotebookPage, Preferences +from dzgui.managers.offline import OfflineManager from dzgui.strings import offline from dzgui.views.components.scrollable import NoOverlayScrolledWindow from dzgui.views.components.frame import HeadingFrame @@ -145,6 +146,11 @@ class CustomModFrame(ModFrame): if folder is not None: self.custom_hbox.set_label(str(folder)) + def get_mods(self) -> list[str]: + rows = self.tree.get_selection().get_selected_rows() + dirs = [str(col[0]) for col in rows] + return dirs + class RadioFrame(HeadingFrame): def __init__(self, controller: "Controller") -> None: @@ -197,13 +203,13 @@ class OfflineLoader(Gtk.Box): # TODO: spacing between inner and outer scrollbars self.controller = controller self.controller.register_widget("offline_loader", self) + self.offline_man = OfflineManager(controller) self.add(PageHeading(offline.heading)) self.local_frame = ModFrame(controller, offline.local_frame) self.custom_frame = CustomModFrame(controller, offline.custom_frame) - # TODO: use ModelFactory # TODO: suppress symlink column self.custom_tree = OfflineModTreeView(controller) @@ -259,10 +265,8 @@ class OfflineLoader(Gtk.Box): def _on_ok_clicked(self, button: Gtk.Button) -> None: # appid = self.radio_frame.get_appid() - # TODO; delegate to OfflineManager - """ - - collect symlinks to selected mods - cf. rebuild_symlinks() - - create symlinks for custom mods - - collect mission folder - """ + # mission = self.mission_frame.get_mission() + # local_mods = self.local_frame.get_mods() + # custom_mods = self.custom_frame.get_mods() + # self.offline_man.setup(appid, mission, local_mods, custom_mods) + pass From 6050a370f86cd1bd0ea285bd42d3d2f4d89a7c1e Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 10 Jun 2026 06:21:12 +0900 Subject: [PATCH 15/71] feat: folder icons --- dzgui/const/constants.py | 1 + dzgui/views/components/buttons.py | 13 +++++++++---- dzgui/views/pages/offline.py | 10 ++++++---- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/dzgui/const/constants.py b/dzgui/const/constants.py index 4f0bf2e..35b5656 100644 --- a/dzgui/const/constants.py +++ b/dzgui/const/constants.py @@ -35,6 +35,7 @@ HEX_ORANGE = "#FFAC1C" CARET_DOWN = "go-down-symbolic" CARET_UP = "go-up-symbolic" CLIPBOARD = "edit-copy-symbolic" +FOLDER = "folder-symbolic" ERROR = "dialog-error-symbolic" HELP_BUBBLE = "help-about-symbolic" INPUT_KEYBOARD = "input-keyboard-symbolic" diff --git a/dzgui/views/components/buttons.py b/dzgui/views/components/buttons.py index affcc5b..209de0b 100644 --- a/dzgui/views/components/buttons.py +++ b/dzgui/views/components/buttons.py @@ -48,8 +48,10 @@ class LargeIcon(Gtk.Image): class IconButton(Gtk.Button): - def __init__(self, icon: str, margin: int = 0) -> None: - super().__init__() + def __init__( + self, icon: str, margin: int = 0, halign: Gtk.Align = Gtk.Align.START + ) -> None: + super().__init__(halign=halign) self.icon = Icon(icon, l_margin=margin) self.set_image(self.icon) self.set_image_position(Gtk.PositionType.RIGHT) @@ -57,8 +59,11 @@ class IconButton(Gtk.Button): class IconTextButton(IconButton): - def __init__(self, icon: str, label: str) -> None: - super().__init__(icon, margin=5) + def __init__( + self, icon: str, label: str, halign: Gtk.Align = Gtk.Align.START + ) -> None: + super().__init__(icon, margin=5, halign=halign) + print(icon) self.set_label(label) diff --git a/dzgui/views/pages/offline.py b/dzgui/views/pages/offline.py index 7d663fc..843c2a8 100644 --- a/dzgui/views/pages/offline.py +++ b/dzgui/views/pages/offline.py @@ -8,12 +8,14 @@ from dzgui.const.constants import ( APPID_DAYZ_EXP, APPNAME_DAYZ, APPNAME_DAYZ_EXP_HUMAN, + FOLDER, ) from dzgui.const.enum import NotebookPage, Preferences from dzgui.managers.offline import OfflineManager from dzgui.strings import offline -from dzgui.views.components.scrollable import NoOverlayScrolledWindow +from dzgui.views.components.buttons import IconTextButton from dzgui.views.components.frame import HeadingFrame +from dzgui.views.components.scrollable import NoOverlayScrolledWindow from dzgui.views.trees.tree_mods import OfflineModTreeView @@ -60,8 +62,9 @@ class FolderHBox(HBox): self.set_margin_start(10) self.set_margin_bottom(10) - # TODO: use IconButton with folder-symbolic - self.button = Gtk.Button(label=btn_label, halign=Gtk.Align.START) + # TODO: alternate class for left-aligned icons + self.button = IconTextButton(FOLDER, btn_label) + self.button.set_image_position(Gtk.PositionType.LEFT) self.label = Gtk.Label() self.extend([self.button, self.label]) @@ -200,7 +203,6 @@ class OfflineLoader(Gtk.Box): margin_end=10, ) - # TODO: spacing between inner and outer scrollbars self.controller = controller self.controller.register_widget("offline_loader", self) self.offline_man = OfflineManager(controller) From 24668870148df81e95a0a073a59382cc7ea8a2c1 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 10 Jun 2026 06:45:51 +0900 Subject: [PATCH 16/71] chore: drop print statement --- dzgui/views/components/buttons.py | 1 - 1 file changed, 1 deletion(-) diff --git a/dzgui/views/components/buttons.py b/dzgui/views/components/buttons.py index 209de0b..899ff59 100644 --- a/dzgui/views/components/buttons.py +++ b/dzgui/views/components/buttons.py @@ -63,7 +63,6 @@ class IconTextButton(IconButton): self, icon: str, label: str, halign: Gtk.Align = Gtk.Align.START ) -> None: super().__init__(icon, margin=5, halign=halign) - print(icon) self.set_label(label) From 978edc5f5f873d00e8f28934144adea7b1600435 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 10 Jun 2026 08:26:50 +0900 Subject: [PATCH 17/71] chore: add format strings --- dzgui/strings/generic.py | 1 + dzgui/util/format.py | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) create mode 100644 dzgui/strings/generic.py diff --git a/dzgui/strings/generic.py b/dzgui/strings/generic.py new file mode 100644 index 0000000..1aff78a --- /dev/null +++ b/dzgui/strings/generic.py @@ -0,0 +1 @@ +selectable_tree = "Ctrl-click to select multiple; Shift-click to select a range." diff --git a/dzgui/util/format.py b/dzgui/util/format.py index d5420cb..e7b4d07 100644 --- a/dzgui/util/format.py +++ b/dzgui/util/format.py @@ -1,6 +1,7 @@ import re from dzgui.util.localize import number +from dzgui.strings.generic import selectable_tree from dzgui.util.strings import no_mods, no_servers, workshop import gi @@ -46,8 +47,7 @@ def format_mods(size: int, mods: int) -> str: return no_mods l_size = number(size) plural = pluralize("mods", mods) - # TODO: strings - suffix = "Ctrl-click to select multiple; Shift-click to select a range." + suffix = selectable_tree return f"Found {mods:n} {plural} taking up {l_size} MiB. {suffix}" From 4506a0cfda3560e619cdcedcc24015e7931796a1 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 10 Jun 2026 08:27:24 +0900 Subject: [PATCH 18/71] chore: revert IconButton alignment changes --- dzgui/managers/offline.py | 3 ++- dzgui/views/components/buttons.py | 12 ++++-------- dzgui/views/pages/offline.py | 17 ++++++++++++++--- 3 files changed, 20 insertions(+), 12 deletions(-) diff --git a/dzgui/managers/offline.py b/dzgui/managers/offline.py index 13abf3b..5090d23 100644 --- a/dzgui/managers/offline.py +++ b/dzgui/managers/offline.py @@ -29,12 +29,13 @@ class OfflineManager: self.custom_mods: list[str] | None # TODO: strings - @call_on_thread("parsing") + #@call_on_thread("parsing") def parse_custom_mods(self, folder: str) -> "FastInsertListStore": mods = get_custom_mods(Path(folder)) store = ModelFactory().make_mod_store() store.extend(mods) # TODO: manipulate tree columns to only show name and size + # TODO: return store from cleanup function return store def setup( diff --git a/dzgui/views/components/buttons.py b/dzgui/views/components/buttons.py index 899ff59..affcc5b 100644 --- a/dzgui/views/components/buttons.py +++ b/dzgui/views/components/buttons.py @@ -48,10 +48,8 @@ class LargeIcon(Gtk.Image): class IconButton(Gtk.Button): - def __init__( - self, icon: str, margin: int = 0, halign: Gtk.Align = Gtk.Align.START - ) -> None: - super().__init__(halign=halign) + def __init__(self, icon: str, margin: int = 0) -> None: + super().__init__() self.icon = Icon(icon, l_margin=margin) self.set_image(self.icon) self.set_image_position(Gtk.PositionType.RIGHT) @@ -59,10 +57,8 @@ class IconButton(Gtk.Button): class IconTextButton(IconButton): - def __init__( - self, icon: str, label: str, halign: Gtk.Align = Gtk.Align.START - ) -> None: - super().__init__(icon, margin=5, halign=halign) + def __init__(self, icon: str, label: str) -> None: + super().__init__(icon, margin=5) self.set_label(label) diff --git a/dzgui/views/pages/offline.py b/dzgui/views/pages/offline.py index 843c2a8..e3b433c 100644 --- a/dzgui/views/pages/offline.py +++ b/dzgui/views/pages/offline.py @@ -12,7 +12,7 @@ from dzgui.const.constants import ( ) from dzgui.const.enum import NotebookPage, Preferences from dzgui.managers.offline import OfflineManager -from dzgui.strings import offline +from dzgui.strings import generic, offline from dzgui.views.components.buttons import IconTextButton from dzgui.views.components.frame import HeadingFrame from dzgui.views.components.scrollable import NoOverlayScrolledWindow @@ -64,7 +64,9 @@ class FolderHBox(HBox): # TODO: alternate class for left-aligned icons self.button = IconTextButton(FOLDER, btn_label) + self.button.set_halign(Gtk.Align.START) self.button.set_image_position(Gtk.PositionType.LEFT) + self.label = Gtk.Label() self.extend([self.button, self.label]) @@ -119,8 +121,7 @@ class ModFrame(HeadingFrame): def _on_selection_changed(self, sel: Gtk.TreeSelection) -> None: model, rows = sel.get_selected_rows() if len(rows) == 0: - # TODO recycle (util.format) - status = "Ctrl-click to select multiple; Shift-click to select a range." + status = generic.selectable_tree else: status = f"Mods selected: {len(rows)}" self.status.set_label(status) @@ -148,6 +149,16 @@ class CustomModFrame(ModFrame): folder = self.controller.set_custom_folder() if folder is not None: self.custom_hbox.set_label(str(folder)) + # TODO: + # self.controller.get_custom_mods() + from dzgui.managers.offline import OfflineManager + + om = OfflineManager(self.controller) + store = om.parse_custom_mods(folder) + + # FIXME: button should appear before tree vbox + self.tree.set_model(store) + self.tree_vbox.show() def get_mods(self) -> list[str]: rows = self.tree.get_selection().get_selected_rows() From 7d8b3b402f05be5986d8155c2996a6669adc7465 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 10 Jun 2026 14:30:50 +0900 Subject: [PATCH 19/71] feat: use emitter --- dzgui/controllers/emitter.py | 5 +++- dzgui/managers/offline.py | 18 ++++++++---- dzgui/views/pages/offline.py | 56 +++++++++++++++++++++++++----------- 3 files changed, 56 insertions(+), 23 deletions(-) diff --git a/dzgui/controllers/emitter.py b/dzgui/controllers/emitter.py index d22d585..abce7b1 100644 --- a/dzgui/controllers/emitter.py +++ b/dzgui/controllers/emitter.py @@ -1,7 +1,6 @@ from typing import TYPE_CHECKING import gi - gi.require_version("Gtk", "3.0") from gi.repository.Gtk import ListStore # noqa E402 from gi.repository import Gdk, GObject # noqa E402 @@ -154,3 +153,7 @@ class Emitter(GObject.GObject): @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=()) def lan_page_initialized(self) -> None: pass + + @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object, str)) + def custom_mods_loaded(self, store: "FastInsertListStore", folder: str) -> None: + pass diff --git a/dzgui/managers/offline.py b/dzgui/managers/offline.py index 5090d23..072c7bd 100644 --- a/dzgui/managers/offline.py +++ b/dzgui/managers/offline.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING from dzgui.api.mods import get_custom_mods from dzgui.api.steam import launch_offline from dzgui.const.enum import Preferences -from dzgui.managers.threading import call_on_thread, ThreadingManager +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.util.symlink import clone_symlinks @@ -21,6 +21,7 @@ class OfflineManager: super().__init__() self.controller = controller + self.emitter = controller.get_emitter() self.thread_man = ThreadingManager(controller) self.appid: int @@ -29,14 +30,21 @@ class OfflineManager: self.custom_mods: list[str] | None # TODO: strings - #@call_on_thread("parsing") - def parse_custom_mods(self, folder: str) -> "FastInsertListStore": + @call_on_thread("parsing") + def parse_custom_mods(self, local_mods: list[str], folder: str) -> "FastInsertListStore": mods = get_custom_mods(Path(folder)) store = ModelFactory().make_mod_store() store.extend(mods) - # TODO: manipulate tree columns to only show name and size + + has_duplicates = False + for row in store: + if row[1] in local_mods: + row[-1]=True + has_duplicates = True + + func = self.emitter.emit("custom_mods_loaded", store, folder, has_duplicates) + self.thread_man.set_cleanup_func(func) # TODO: return store from cleanup function - return store def setup( self, diff --git a/dzgui/views/pages/offline.py b/dzgui/views/pages/offline.py index e3b433c..17f6770 100644 --- a/dzgui/views/pages/offline.py +++ b/dzgui/views/pages/offline.py @@ -19,6 +19,7 @@ from dzgui.views.components.scrollable import NoOverlayScrolledWindow from dzgui.views.trees.tree_mods import OfflineModTreeView + import gi gi.require_version("Gtk", "3.0") @@ -133,36 +134,45 @@ class ModFrame(HeadingFrame): class CustomModFrame(ModFrame): - def __init__(self, controller: "Controller", heading: str) -> None: + def __init__(self, parent: Gtk.Widget, controller: "Controller", heading: str) -> None: super().__init__(controller, heading) + self.parent = parent + self.controller = controller + self.emitter = controller.get_emitter() + # TODO: descriptive text here explaining how this area works self.custom_hbox = FolderHBox(offline.custom_button) self.custom_hbox.get_button().connect("clicked", self._on_custom_button_clicked) self.pack(self.custom_hbox) + self.emitter.connect("custom_mods_loaded", self._on_custom_mods_loaded) + + + def _on_custom_mods_loaded(self, store: "FastInsertListStore", folder: str, has_duplicates: bool) -> None: + if len(store) == 0: + # TODO: pop error dialog area + # block button access + pass + else: + self.custom_hbox.set_label(folder) + self.tree.set_model(store) + self.tree_vbox.show() + + if has_duplicates: + # TODO: pop relevant error + # duplicates should block button access + pass + + def _on_custom_button_clicked(self, button: Gtk.Button) -> None: - # TODO: recycle for mission folder - # TODO: propagate results back to parent - folder = self.controller.set_custom_folder() - if folder is not None: - self.custom_hbox.set_label(str(folder)) - # TODO: - # self.controller.get_custom_mods() - from dzgui.managers.offline import OfflineManager - - om = OfflineManager(self.controller) - store = om.parse_custom_mods(folder) - - # FIXME: button should appear before tree vbox - self.tree.set_model(store) - self.tree_vbox.show() + self.parent.parse_mods() def get_mods(self) -> list[str]: rows = self.tree.get_selection().get_selected_rows() - dirs = [str(col[0]) for col in rows] + dirs = [str(col[1]) for col in rows] return dirs @@ -263,6 +273,14 @@ class OfflineLoader(Gtk.Box): self.add(self.scrollable) self.add(self.button_box) + def parse_mods(self) -> None: + # TODO: + local_mods = self.local_frame.get_mods() + folder = self.controller.set_custom_folder() + if folder is not None: + # TODO: + self.offline_man.parse_custom_mods(local_mods, folder) + def _on_keypress(self, widget: Self, event: Gdk.EventKey) -> None: if event.keyval == Gdk.KEY_Escape: self.back.emit("clicked") @@ -270,6 +288,7 @@ class OfflineLoader(Gtk.Box): def populate(self, store: "FastInsertListStore") -> None: self.local_frame.set_model(store) # TODO: toggle if empty model, show warning label + # NOTE: there may be no local mods # TODO: suppress trees if there are no mods self.custom_frame.collapse_tree() @@ -277,6 +296,9 @@ class OfflineLoader(Gtk.Box): self.controller.open_page(NotebookPage.MODS) def _on_ok_clicked(self, button: Gtk.Button) -> None: + # TODO; block button access if no mods are selected + # TODO: consider hooking up to emitter and changing button state whenever mods are selected + # appid = self.radio_frame.get_appid() # mission = self.mission_frame.get_mission() # local_mods = self.local_frame.get_mods() From db14c5252c4269f0515d04465c459eca34c63c0f Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 10 Jun 2026 15:18:42 +0900 Subject: [PATCH 20/71] change: clearer startup error --- dzgui/init/dayz.py | 3 ++- dzgui/strings/errors.py | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/dzgui/init/dayz.py b/dzgui/init/dayz.py index 1dbbdbc..7cdf75c 100644 --- a/dzgui/init/dayz.py +++ b/dzgui/init/dayz.py @@ -6,6 +6,7 @@ from pathlib import Path from dzgui.config.query import lookup from dzgui.const.constants import APPID_DAYZ, APP_NAME, LIBRARYFOLDERS_PATH from dzgui.const.enum import Preferences +from dzgui.strings.errors import dayz_not_installed from dzgui.views.dialogs.early_alert import EarlyAlertDialog import dzgui.api.pefile as PeFile @@ -19,5 +20,5 @@ def is_dayz_installed(config: Path) -> None: PeFile.get_app_path(Path(path) / LIBRARYFOLDERS_PATH, APPID_DAYZ) except Exception as e: logger.critical(e) - EarlyAlertDialog(str(e)) + EarlyAlertDialog(dayz_not_found) sys.exit(1) diff --git a/dzgui/strings/errors.py b/dzgui/strings/errors.py index 32d8741..3ee54d6 100644 --- a/dzgui/strings/errors.py +++ b/dzgui/strings/errors.py @@ -3,3 +3,4 @@ api_validation_error = ( ) api_popover = "API key validation failed." +dayz_not_found = "Could not find DayZ and/or Steam: are they installed?" From ab439144ea0aea2c37ffdf3e34c245ae30a4906a Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 10 Jun 2026 15:21:04 +0900 Subject: [PATCH 21/71] fix: use StoredFunc --- dzgui/controllers/emitter.py | 1 + dzgui/init/dayz.py | 2 +- dzgui/managers/offline.py | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/dzgui/controllers/emitter.py b/dzgui/controllers/emitter.py index abce7b1..df1b686 100644 --- a/dzgui/controllers/emitter.py +++ b/dzgui/controllers/emitter.py @@ -7,6 +7,7 @@ from gi.repository import Gdk, GObject # noqa E402 if TYPE_CHECKING: from dzgui.const.enum import NotebookPage, ServerTab + from dzgui.model.model_factory import FastInsertListStore # TODO: if servers fail to load, may leave dangling widgets waiting for a signal diff --git a/dzgui/init/dayz.py b/dzgui/init/dayz.py index 7cdf75c..cc6f2c4 100644 --- a/dzgui/init/dayz.py +++ b/dzgui/init/dayz.py @@ -6,7 +6,7 @@ from pathlib import Path from dzgui.config.query import lookup from dzgui.const.constants import APPID_DAYZ, APP_NAME, LIBRARYFOLDERS_PATH from dzgui.const.enum import Preferences -from dzgui.strings.errors import dayz_not_installed +from dzgui.strings.errors import dayz_not_found from dzgui.views.dialogs.early_alert import EarlyAlertDialog import dzgui.api.pefile as PeFile diff --git a/dzgui/managers/offline.py b/dzgui/managers/offline.py index 072c7bd..4e14e5a 100644 --- a/dzgui/managers/offline.py +++ b/dzgui/managers/offline.py @@ -42,7 +42,7 @@ class OfflineManager: row[-1]=True has_duplicates = True - func = self.emitter.emit("custom_mods_loaded", store, folder, has_duplicates) + func = StoredFunc(self.emitter.emit, "custom_mods_loaded", store, folder, has_duplicates) self.thread_man.set_cleanup_func(func) # TODO: return store from cleanup function From 606df3423cfffe48814dd075a2cb2c1e7bf412bf Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 10 Jun 2026 15:59:44 +0900 Subject: [PATCH 22/71] change: delegate early checks to BootWindow --- dzgui/init/dayz.py | 3 +-- dzgui/main.py | 10 ---------- dzgui/managers/offline.py | 5 ++--- dzgui/strings/errors.py | 2 +- dzgui/strings/preboot.py | 1 + dzgui/views/dialogs/boot.py | 3 +++ dzgui/views/pages/offline.py | 2 +- 7 files changed, 9 insertions(+), 17 deletions(-) diff --git a/dzgui/init/dayz.py b/dzgui/init/dayz.py index cc6f2c4..032179e 100644 --- a/dzgui/init/dayz.py +++ b/dzgui/init/dayz.py @@ -20,5 +20,4 @@ def is_dayz_installed(config: Path) -> None: PeFile.get_app_path(Path(path) / LIBRARYFOLDERS_PATH, APPID_DAYZ) except Exception as e: logger.critical(e) - EarlyAlertDialog(dayz_not_found) - sys.exit(1) + raise e diff --git a/dzgui/main.py b/dzgui/main.py index 45edf9d..7a05637 100644 --- a/dzgui/main.py +++ b/dzgui/main.py @@ -11,7 +11,6 @@ from dzgui.const.enum import Preferences from dzgui.config.query import lookup from dzgui.config.userprefs import UserPrefs from dzgui.config.xdg import get_xdg_paths, parse_filepaths -from dzgui.init.dayz import is_dayz_installed from dzgui.init.flock import lock_acquire from dzgui.init.migrate import ( has_new_config, @@ -124,18 +123,9 @@ def main() -> None: if has_steam_client() is False: EarlyAlertDialog(init.requires_steam) - is_dayz_installed(XDG.config) - bootwin = BootWindow(XDG, version) local_coords, latest_release = bootwin.get_results() - # rebuild_symlinks(XDG.config) - # remove_stale_signatures(XDG.config, XDG.version) - - ## TODO: handle IP DB failure and use coords fallback - # local_coords = get_local_coords(XDG.ips) - # latest_release = check_updates(version) - use_miles = lookup(XDG.config, Preferences.DIST) prefs = UserPrefs( is_steam_deck=_is_steam_deck, diff --git a/dzgui/managers/offline.py b/dzgui/managers/offline.py index 4e14e5a..f014b5b 100644 --- a/dzgui/managers/offline.py +++ b/dzgui/managers/offline.py @@ -5,7 +5,7 @@ from dzgui.api.mods import get_custom_mods from dzgui.api.steam import launch_offline 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.model.model_factory import ModelFactory from dzgui.strings import dialogs from dzgui.util.symlink import clone_symlinks @@ -31,7 +31,7 @@ class OfflineManager: # TODO: strings @call_on_thread("parsing") - def parse_custom_mods(self, local_mods: list[str], folder: str) -> "FastInsertListStore": + def parse_custom_mods(self, local_mods: list[str], folder: str) -> None: mods = get_custom_mods(Path(folder)) store = ModelFactory().make_mod_store() store.extend(mods) @@ -44,7 +44,6 @@ class OfflineManager: func = StoredFunc(self.emitter.emit, "custom_mods_loaded", store, folder, has_duplicates) self.thread_man.set_cleanup_func(func) - # TODO: return store from cleanup function def setup( self, diff --git a/dzgui/strings/errors.py b/dzgui/strings/errors.py index 3ee54d6..005a93b 100644 --- a/dzgui/strings/errors.py +++ b/dzgui/strings/errors.py @@ -3,4 +3,4 @@ api_validation_error = ( ) api_popover = "API key validation failed." -dayz_not_found = "Could not find DayZ and/or Steam: are they installed?" +dayz_not_found = "Could not find a valid DayZ installation." diff --git a/dzgui/strings/preboot.py b/dzgui/strings/preboot.py index 01a7ad6..69fffe2 100644 --- a/dzgui/strings/preboot.py +++ b/dzgui/strings/preboot.py @@ -3,3 +3,4 @@ signatures = "Updating mod signatures" geo = "Checking geolocation records" coords = "Checking local coordinates" updates = "Checking for updates" +dayz = "Looking for DayZ" diff --git a/dzgui/views/dialogs/boot.py b/dzgui/views/dialogs/boot.py index 4dc5821..885aa57 100644 --- a/dzgui/views/dialogs/boot.py +++ b/dzgui/views/dialogs/boot.py @@ -10,6 +10,7 @@ from dzgui.api.mods import remove_stale_signatures as remove_stale from dzgui.config.ipdb import get_ipdb from dzgui.const.constants import HEX_GREEN, HEX_RED from dzgui.init.coords import get_local_coords +from dzgui.init.dayz import is_dayz_installed from dzgui.init.update import check_updates from dzgui.const.constants import EXPAND, FILL from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager @@ -119,6 +120,7 @@ class BootDialog(Gtk.Dialog): self.error_box.hide() steps = [ + (StoredFunc(is_dayz_installed, self.xdg.config), preboot.dayz, False), (StoredFunc(rebuild_symlinks, self.xdg.config), preboot.symlinks, False), ( StoredFunc(remove_stale, self.xdg.config, self.xdg.version), @@ -126,6 +128,7 @@ class BootDialog(Gtk.Dialog): False, ), (StoredFunc(get_ipdb, self.xdg.ips), preboot.geo, False), + # TODO: handle IP DB failure and use coords fallback (StoredFunc(get_local_coords, self.xdg.ips), preboot.coords, True), (StoredFunc(check_updates, self.version), preboot.updates, True), ] diff --git a/dzgui/views/pages/offline.py b/dzgui/views/pages/offline.py index 17f6770..8ab1633 100644 --- a/dzgui/views/pages/offline.py +++ b/dzgui/views/pages/offline.py @@ -231,7 +231,7 @@ class OfflineLoader(Gtk.Box): self.add(PageHeading(offline.heading)) self.local_frame = ModFrame(controller, offline.local_frame) - self.custom_frame = CustomModFrame(controller, offline.custom_frame) + self.custom_frame = CustomModFrame(self, controller, offline.custom_frame) # TODO: suppress symlink column self.custom_tree = OfflineModTreeView(controller) From 486da7749ac70ae10284ab2bf03009952be4f8a2 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 10 Jun 2026 18:37:31 +0900 Subject: [PATCH 23/71] fix: emitter arguments --- dzgui/controllers/emitter.py | 4 ++-- dzgui/managers/offline.py | 2 +- dzgui/views/pages/offline.py | 9 +++++++-- dzgui/views/trees/tree_mods.py | 3 +++ 4 files changed, 13 insertions(+), 5 deletions(-) diff --git a/dzgui/controllers/emitter.py b/dzgui/controllers/emitter.py index df1b686..2a040d5 100644 --- a/dzgui/controllers/emitter.py +++ b/dzgui/controllers/emitter.py @@ -155,6 +155,6 @@ class Emitter(GObject.GObject): def lan_page_initialized(self) -> None: pass - @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object, str)) - def custom_mods_loaded(self, store: "FastInsertListStore", folder: str) -> None: + @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object, str, bool)) + def custom_mods_loaded(self, store: "FastInsertListStore", folder: str, has_duplicates: bool) -> None: pass diff --git a/dzgui/managers/offline.py b/dzgui/managers/offline.py index f014b5b..5f22d74 100644 --- a/dzgui/managers/offline.py +++ b/dzgui/managers/offline.py @@ -42,7 +42,7 @@ class OfflineManager: row[-1]=True has_duplicates = True - func = StoredFunc(self.emitter.emit, "custom_mods_loaded", store, folder, has_duplicates) + func = StoredFunc(lambda: self.emitter.emit("custom_mods_loaded", store, folder, has_duplicates)) self.thread_man.set_cleanup_func(func) def setup( diff --git a/dzgui/views/pages/offline.py b/dzgui/views/pages/offline.py index 8ab1633..14d6f36 100644 --- a/dzgui/views/pages/offline.py +++ b/dzgui/views/pages/offline.py @@ -27,6 +27,7 @@ from gi.repository import Gtk, Gdk # noqa if TYPE_CHECKING: from dzgui.controllers.mc import Controller + from dzgui.controllers.emitter import Emitter from dzgui.model.model_factory import FastInsertListStore @@ -105,6 +106,10 @@ class ModFrame(HeadingFrame): sel = self.tree.get_selection() sel.connect("changed", self._on_selection_changed) + def get_mods(self) -> list[str]: + model = self.tree.get_model() + return [row[1] for row in model] + def collapse_tree(self) -> None: self.tree_vbox.hide() @@ -151,7 +156,7 @@ class CustomModFrame(ModFrame): self.emitter.connect("custom_mods_loaded", self._on_custom_mods_loaded) - def _on_custom_mods_loaded(self, store: "FastInsertListStore", folder: str, has_duplicates: bool) -> None: + def _on_custom_mods_loaded(self, emitter: "Emitter", store: "FastInsertListStore", folder: str, has_duplicates: bool) -> None: if len(store) == 0: # TODO: pop error dialog area # block button access @@ -274,7 +279,7 @@ class OfflineLoader(Gtk.Box): self.add(self.button_box) def parse_mods(self) -> None: - # TODO: + # TODO: check method on custom frame local_mods = self.local_frame.get_mods() folder = self.controller.set_custom_folder() if folder is not None: diff --git a/dzgui/views/trees/tree_mods.py b/dzgui/views/trees/tree_mods.py index 17ab196..8a6ff16 100644 --- a/dzgui/views/trees/tree_mods.py +++ b/dzgui/views/trees/tree_mods.py @@ -115,10 +115,13 @@ class ModTreeView(ModsMixin, ContextMixin, TreeView): # type: ignore val = model[it][3] formatted = localize.number(val) cell.set_property("text", formatted) + # NOTE: reapply color property after float conversion + self._format_color(column, cell, model, it, data) def set_menu(self, context: ContextMenuGroup) -> None: self.menu = context + class OfflineModTreeView(ModTreeView): def __init__(self, controller: "Controller") -> None: super().__init__(controller) From 45fd180dcb902ec9dcd69270cfd9bf705101a5af Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 10 Jun 2026 19:23:14 +0900 Subject: [PATCH 24/71] fix: accept emitter as arg --- dzgui/init/dayz.py | 3 --- dzgui/views/pages/offline.py | 18 +++++++++++++----- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/dzgui/init/dayz.py b/dzgui/init/dayz.py index 032179e..66f60d9 100644 --- a/dzgui/init/dayz.py +++ b/dzgui/init/dayz.py @@ -1,13 +1,10 @@ import logging -import sys from pathlib import Path from dzgui.config.query import lookup from dzgui.const.constants import APPID_DAYZ, APP_NAME, LIBRARYFOLDERS_PATH from dzgui.const.enum import Preferences -from dzgui.strings.errors import dayz_not_found -from dzgui.views.dialogs.early_alert import EarlyAlertDialog import dzgui.api.pefile as PeFile diff --git a/dzgui/views/pages/offline.py b/dzgui/views/pages/offline.py index 14d6f36..4e2524c 100644 --- a/dzgui/views/pages/offline.py +++ b/dzgui/views/pages/offline.py @@ -1,3 +1,4 @@ +from __future__ import annotations from pathlib import Path from typing import Self, Sequence, TYPE_CHECKING @@ -19,7 +20,6 @@ from dzgui.views.components.scrollable import NoOverlayScrolledWindow from dzgui.views.trees.tree_mods import OfflineModTreeView - import gi gi.require_version("Gtk", "3.0") @@ -108,6 +108,8 @@ class ModFrame(HeadingFrame): def get_mods(self) -> list[str]: model = self.tree.get_model() + if model is None: + return [] return [row[1] for row in model] def collapse_tree(self) -> None: @@ -139,7 +141,9 @@ class ModFrame(HeadingFrame): class CustomModFrame(ModFrame): - def __init__(self, parent: Gtk.Widget, controller: "Controller", heading: str) -> None: + def __init__( + self, parent: OfflineLoader, controller: "Controller", heading: str + ) -> None: super().__init__(controller, heading) self.parent = parent @@ -155,8 +159,13 @@ class CustomModFrame(ModFrame): self.emitter.connect("custom_mods_loaded", self._on_custom_mods_loaded) - - def _on_custom_mods_loaded(self, emitter: "Emitter", store: "FastInsertListStore", folder: str, has_duplicates: bool) -> None: + def _on_custom_mods_loaded( + self, + emitter: "Emitter", + store: "FastInsertListStore", + folder: str, + has_duplicates: bool, + ) -> None: if len(store) == 0: # TODO: pop error dialog area # block button access @@ -171,7 +180,6 @@ class CustomModFrame(ModFrame): # duplicates should block button access pass - def _on_custom_button_clicked(self, button: Gtk.Button) -> None: self.parent.parse_mods() From b35b6148bc004d1799f95197994502e9032e3592 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 11 Jun 2026 07:51:16 +0900 Subject: [PATCH 25/71] change: tweak expansion properties --- dzgui/views/pages/offline.py | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/dzgui/views/pages/offline.py b/dzgui/views/pages/offline.py index 4e2524c..65a4b6e 100644 --- a/dzgui/views/pages/offline.py +++ b/dzgui/views/pages/offline.py @@ -59,7 +59,7 @@ class PageHeading(Gtk.Label): class FolderHBox(HBox): def __init__(self, btn_label: str) -> None: - super().__init__(spacing=5) + super().__init__(spacing=10) self.set_margin_start(10) self.set_margin_bottom(10) @@ -87,10 +87,11 @@ class ModFrame(HeadingFrame): self.tree = OfflineModTreeView(controller) self.tree.set_model(None) + self.tree.set_valign(Gtk.Align.FILL) self.scrolled = NoOverlayScrolledWindow() - self.scrolled.set_margin_end(10) - self.scrolled.set_size_request(600, 400) + self.scrolled.set_margin_end(5) + self.scrolled.set_vexpand(True) self.scrolled.add(self.tree) self.status = Gtk.Label( @@ -100,8 +101,12 @@ class ModFrame(HeadingFrame): self.tree_vbox = VBox() self.tree_vbox.extend([self.scrolled, self.status]) - self.vbox.add(self.tree_vbox) + self.vbox.pack_end(self.tree_vbox, expand=True, fill=True, padding=3) + # self.vbox.add(self.tree_vbox) self.frame.add(self.vbox) + #self.none_label = Gtk.Label(label="No mods found", halign=Gtk.Align.START) + #self.vbox.pack_end(self.none_label, expand=True, fill=True, padding=3) + #self.none_label.hide() sel = self.tree.get_selection() sel.connect("changed", self._on_selection_changed) @@ -249,10 +254,13 @@ class OfflineLoader(Gtk.Box): # TODO: suppress symlink column self.custom_tree = OfflineModTreeView(controller) + # TODO: custom class self.mission_hbox = FolderHBox(offline.mission_button) self.mission_frame = HeadingFrame.new_with_widget_and_label( self.mission_hbox, offline.mission_frame ) + self.mission_button = self.mission_hbox.get_button() + self.mission_button.connect("clicked", self._on_mission_clicked) self.radio_frame = RadioFrame(controller) @@ -286,8 +294,12 @@ class OfflineLoader(Gtk.Box): self.add(self.scrollable) self.add(self.button_box) + def _on_mission_clicked(self, button: Gtk.Button) -> None: + pass + def parse_mods(self) -> None: # TODO: check method on custom frame + # TODO: delegate folderpicker to offline manager local_mods = self.local_frame.get_mods() folder = self.controller.set_custom_folder() if folder is not None: @@ -300,6 +312,8 @@ class OfflineLoader(Gtk.Box): def populate(self, store: "FastInsertListStore") -> None: self.local_frame.set_model(store) + if store is None: + self.local_frame.collapse_tree() # TODO: toggle if empty model, show warning label # NOTE: there may be no local mods # TODO: suppress trees if there are no mods From 8503d7e698c44d18e0f87ca0e2ed03abae1f9501 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 11 Jun 2026 08:02:27 +0900 Subject: [PATCH 26/71] feat: namespace mod hashes --- dzgui/api/mods.py | 8 ++++++-- dzgui/views/pages/offline.py | 9 +++++---- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/dzgui/api/mods.py b/dzgui/api/mods.py index af9aa5a..8819af0 100644 --- a/dzgui/api/mods.py +++ b/dzgui/api/mods.py @@ -119,10 +119,14 @@ def get_missing_mods(local: list, remote: list) -> list: return [mod for mod in remote if mod not in local] -def _hash(uid: str) -> str: +def _hash(uid: str, use_custom: bool = False) -> str: md5 = hashlib.md5() md5.update(uid.encode("ascii")) - return "@" + md5.hexdigest()[:8] + if use_custom: + prefix = "@C" + else: + prefix = "@" + return prefix + md5.hexdigest()[:8] def remove_stale_signatures(config: Path, versions: Path) -> None: diff --git a/dzgui/views/pages/offline.py b/dzgui/views/pages/offline.py index 65a4b6e..9359210 100644 --- a/dzgui/views/pages/offline.py +++ b/dzgui/views/pages/offline.py @@ -102,11 +102,11 @@ class ModFrame(HeadingFrame): self.tree_vbox.extend([self.scrolled, self.status]) self.vbox.pack_end(self.tree_vbox, expand=True, fill=True, padding=3) - # self.vbox.add(self.tree_vbox) self.frame.add(self.vbox) - #self.none_label = Gtk.Label(label="No mods found", halign=Gtk.Align.START) - #self.vbox.pack_end(self.none_label, expand=True, fill=True, padding=3) - #self.none_label.hide() + # self.vbox.add(self.tree_vbox) + # self.none_label = Gtk.Label(label="No mods found", halign=Gtk.Align.START) + # self.vbox.pack_end(self.none_label, expand=True, fill=True, padding=3) + # self.none_label.hide() sel = self.tree.get_selection() sel.connect("changed", self._on_selection_changed) @@ -330,5 +330,6 @@ class OfflineLoader(Gtk.Box): # mission = self.mission_frame.get_mission() # local_mods = self.local_frame.get_mods() # custom_mods = self.custom_frame.get_mods() + # cf. api.mods._hash(uid, use_custom=True) # self.offline_man.setup(appid, mission, local_mods, custom_mods) pass From 401c8625adbde30e1a9f4a7c45246be83c9854e0 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 11 Jun 2026 08:07:58 +0900 Subject: [PATCH 27/71] chore: add placeholder notes --- dzgui/api/mods.py | 5 +---- dzgui/views/pages/offline.py | 11 +++++++++++ 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/dzgui/api/mods.py b/dzgui/api/mods.py index 8819af0..86e4b10 100644 --- a/dzgui/api/mods.py +++ b/dzgui/api/mods.py @@ -122,10 +122,7 @@ def get_missing_mods(local: list, remote: list) -> list: def _hash(uid: str, use_custom: bool = False) -> str: md5 = hashlib.md5() md5.update(uid.encode("ascii")) - if use_custom: - prefix = "@C" - else: - prefix = "@" + prefix = "@C" if use_custom else "@" return prefix + md5.hexdigest()[:8] diff --git a/dzgui/views/pages/offline.py b/dzgui/views/pages/offline.py index 9359210..4764b8f 100644 --- a/dzgui/views/pages/offline.py +++ b/dzgui/views/pages/offline.py @@ -111,6 +111,13 @@ class ModFrame(HeadingFrame): sel = self.tree.get_selection() sel.connect("changed", self._on_selection_changed) + # TODO: warnings area + # valid warnings (one at a time): + # - name collision within custom mods + # - name collision b/w custom and local (permissible) + # - no mods + # - not a valid mission + def get_mods(self) -> list[str]: model = self.tree.get_model() if model is None: @@ -295,6 +302,10 @@ class OfflineLoader(Gtk.Box): self.add(self.button_box) def _on_mission_clicked(self, button: Gtk.Button) -> None: + # process path, cf. set_custom_folder() + # file = path / "init.c" + # if not file.exists(): + # pop warning pass def parse_mods(self) -> None: From 687b1a915e2a0c95331d134b11e771f09529d77f Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 11 Jun 2026 10:43:52 +0900 Subject: [PATCH 28/71] feat: capture escape signal in BootDialog --- dzgui/strings/dialogs.py | 4 ++++ dzgui/views/dialogs/boot.py | 15 +++++++++------ 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/dzgui/strings/dialogs.py b/dzgui/strings/dialogs.py index 4732557..73a2516 100644 --- a/dzgui/strings/dialogs.py +++ b/dzgui/strings/dialogs.py @@ -11,3 +11,7 @@ deleting_mods = "Deleting mods" scanning_mods = "Scanning mods" checking_api = "Checking API key" + +running = "Running" +failed = "FAILED" +ok = "OK" diff --git a/dzgui/views/dialogs/boot.py b/dzgui/views/dialogs/boot.py index 885aa57..13bf1c6 100644 --- a/dzgui/views/dialogs/boot.py +++ b/dzgui/views/dialogs/boot.py @@ -14,7 +14,7 @@ from dzgui.init.dayz import is_dayz_installed from dzgui.init.update import check_updates from dzgui.const.constants import EXPAND, FILL from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager -from dzgui.strings import preboot +from dzgui.strings import preboot, dialogs from dzgui.util.format import format_exception from dzgui.util.strings import dialog_header from dzgui.util.symlink import rebuild_symlinks @@ -59,6 +59,9 @@ class BootDialog(Gtk.Dialog): self.view = Gtk.TreeView(enable_search=False, headers_visible=False) self.view.set_model(self.store) + # TODO: capture sigint in early dialogs + self.connect("key-press-event", lambda _, __: self.exit_button.emit("clicked")) + for i, column_title in enumerate(["Task", "State"]): renderer = Gtk.CellRendererText() column = Gtk.TreeViewColumn(column_title, renderer, text=i) @@ -184,10 +187,10 @@ class BootDialog(Gtk.Dialog): self.thread_man.set_cleanup_func(callback) def update_task(self, task: str) -> None: - self.store.append((task, "Running", True, 0)) + self.store.append((task, dialogs.running, True, 0)) def update_status(self, state: Success) -> None: - d = {Success.OK: "OK", Success.FAIL: "FAILED"} + d = {Success.OK: dialogs.ok, Success.FAIL: dialogs.failed} msg = d[state] self.store[len(self.store) - 1][1] = msg self.iter_step() @@ -200,7 +203,7 @@ class BootDialog(Gtk.Dialog): it: Gtk.TreeIter, data: Any, ) -> None: - if model[it][1] == "Running": + if model[it][1] == dialogs.running: cell.set_property("visible", True) else: cell.set_property("visible", False) @@ -217,9 +220,9 @@ class BootDialog(Gtk.Dialog): state = model[it][1] if column.get_sort_column_id() != 1: return - if state == "OK": + if state == dialogs.ok: cell.set_property(prop, HEX_GREEN) - elif state == "FAILED": + elif state == dialogs.running: cell.set_property(prop, HEX_RED) else: cell.set_property(prop, None) From 0137bf4610135b3bb64b4493587ebbe87d0f82d1 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 11 Jun 2026 10:47:32 +0900 Subject: [PATCH 29/71] fix: process escape key --- dzgui/views/dialogs/boot.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/dzgui/views/dialogs/boot.py b/dzgui/views/dialogs/boot.py index 13bf1c6..591bea2 100644 --- a/dzgui/views/dialogs/boot.py +++ b/dzgui/views/dialogs/boot.py @@ -1,9 +1,8 @@ import sys -from typing import Literal, TYPE_CHECKING +from typing import Any, Literal, Self, TYPE_CHECKING # import time from enum import Enum -from typing import Any, Self # TODO: import dialog titles from dzgui.api.mods import remove_stale_signatures as remove_stale @@ -60,7 +59,7 @@ class BootDialog(Gtk.Dialog): self.view.set_model(self.store) # TODO: capture sigint in early dialogs - self.connect("key-press-event", lambda _, __: self.exit_button.emit("clicked")) + self.connect("key-press-event", self._on_keypress) for i, column_title in enumerate(["Task", "State"]): renderer = Gtk.CellRendererText() @@ -141,6 +140,10 @@ class BootDialog(Gtk.Dialog): GLib.timeout_add(100, self.pulse_spinner) + def _on_keypress(self, Self, event: Gdk.EventKey) -> None: + if event.keyval == Gdk.KEY_Escape: + self.exit_button.emit("clicked") + def pulse_spinner(self) -> Literal[True]: for row in self.store: if row[2]: From 820deb295539520faccd77efb9beca3ce5ebb44e Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 11 Jun 2026 10:52:49 +0900 Subject: [PATCH 30/71] fix: incorrect error state --- dzgui/views/dialogs/boot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dzgui/views/dialogs/boot.py b/dzgui/views/dialogs/boot.py index 591bea2..ab90d1a 100644 --- a/dzgui/views/dialogs/boot.py +++ b/dzgui/views/dialogs/boot.py @@ -225,7 +225,7 @@ class BootDialog(Gtk.Dialog): return if state == dialogs.ok: cell.set_property(prop, HEX_GREEN) - elif state == dialogs.running: + elif state == dialogs.failed: cell.set_property(prop, HEX_RED) else: cell.set_property(prop, None) From 9983e91526a1fe50c81f27940dfbf08eb4a7f596 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:01:17 +0900 Subject: [PATCH 31/71] feat: test mission integrity --- dzgui/api/mods.py | 54 ++++++++++++++++++++++++++--------------------- 1 file changed, 30 insertions(+), 24 deletions(-) diff --git a/dzgui/api/mods.py b/dzgui/api/mods.py index 86e4b10..b0a65e2 100644 --- a/dzgui/api/mods.py +++ b/dzgui/api/mods.py @@ -48,35 +48,42 @@ def get_local_mods(workshop_path: Path) -> list[Path]: mods = [file for file in workshop_path.iterdir() if file.is_dir()] return mods +def is_mission(path: Path) -> None: + # TODO: parse integrity of other files + file = path / "init.c" + return file.exists() def parse_meta(file: Path) -> ModMeta | None: mod = file / "meta.cpp" if mod.exists() is False: return None - with open(file / "meta.cpp", "r") as f: - st = f.read() - lex = shlex.shlex(st) - lex.whitespace += "=;" - v = [] - while True: - tok = lex.get_token() - if not tok: - break - if tok == "protocol" or tok == "publishedid": - ntok = lex.get_token() - elif tok == "timestamp": - # NOTE: some malformed .NET tick conversions result in numbers < 0 - ntok = lex.get_token() - if ntok == "-": - ntok += str(lex.get_token()) - elif tok == "name": - ntok = lex.get_token() + try: + with open(file / "meta.cpp", "r") as f: + st = f.read() + lex = shlex.shlex(st) + lex.whitespace += "=;" + v = [] + while True: + tok = lex.get_token() + if not tok: + break + if tok == "protocol" or tok == "publishedid": + ntok = lex.get_token() + elif tok == "timestamp": + # NOTE: some malformed .NET tick conversions result in numbers < 0 + ntok = lex.get_token() + if ntok == "-": + ntok += str(lex.get_token()) + elif tok == "name": + ntok = lex.get_token() + if ntok is not None: + ntok = ntok.split('"')[1] if ntok is not None: - ntok = ntok.split('"')[1] - if ntok is not None: - v.append(ntok) - meta = ModMeta(*v) - return meta + v.append(ntok) + return ModMeta(*v) + except Exception as e: + logger.critical(e) + return None def get_mod_size(path: Path) -> float: @@ -98,7 +105,6 @@ def parse_mods(mods: list[Path]) -> list[Any]: for mod in mods: mod_dir = mod.name symlink = _hash(mod_dir) - # FIXME: malformed .cpp files could break this meta = parse_meta(mod) if meta is None: continue From 56da5717fa1c596868882c7fa1f16c3302171bf9 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:01:32 +0900 Subject: [PATCH 32/71] feat: add emitter signal --- dzgui/controllers/emitter.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/dzgui/controllers/emitter.py b/dzgui/controllers/emitter.py index 2a040d5..1331160 100644 --- a/dzgui/controllers/emitter.py +++ b/dzgui/controllers/emitter.py @@ -158,3 +158,7 @@ class Emitter(GObject.GObject): @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object, str, bool)) def custom_mods_loaded(self, store: "FastInsertListStore", folder: str, has_duplicates: bool) -> None: pass + + @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(str, bool)) + def custom_mission_loaded(self, folder: str, is_valid: bool) -> None: + pass From 49eb3caf0a7476924b739e0aeda00bfb22ced146 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:02:05 +0900 Subject: [PATCH 33/71] fix: handle empty store --- 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 cc50181..35391f3 100644 --- a/dzgui/managers/mods.py +++ b/dzgui/managers/mods.py @@ -48,7 +48,7 @@ class ModManager: self.prefs = controller.get_prefs() self.path = controller.query_config(Preferences.DEFAULT) - self.store: FastInsertListStore + self.store: FastInsertListStore | None = None self.thread_man = ThreadingManager(controller) From 586f7e6c66e918204ce68d2c74965501b045d503 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:02:28 +0900 Subject: [PATCH 34/71] feat: parse mission and mods --- dzgui/managers/offline.py | 36 ++++++++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/dzgui/managers/offline.py b/dzgui/managers/offline.py index 5f22d74..801d979 100644 --- a/dzgui/managers/offline.py +++ b/dzgui/managers/offline.py @@ -1,13 +1,14 @@ from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Union -from dzgui.api.mods import get_custom_mods +from dzgui.api.mods import is_mission, get_custom_mods from dzgui.api.steam import launch_offline from dzgui.const.enum import Preferences from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager from dzgui.model.model_factory import ModelFactory from dzgui.strings import dialogs from dzgui.util.symlink import clone_symlinks +from dzgui.views.dialogs.filepicker import FolderPicker if TYPE_CHECKING: from dzgui.controllers.mc import Controller @@ -29,18 +30,36 @@ class OfflineManager: self.local_mods: list[str] | None self.custom_mods: list[str] | None - # TODO: strings - @call_on_thread("parsing") + + def get_mission(self) -> None: + folder = self.open_folderpicker() + if folder is None: + return + is_valid = is_mission(folder) + if is_valid: + self.mission_folder = folder + self.emitter.emit("custom_mission_loaded", folder, is_valid) + + def get_custom_mods(self, local_mods: list[str]) -> None: + folder = self.open_folderpicker() + if folder is None: + return + self.parse_custom_mods(local_mods, folder) + + @call_on_thread(dialogs.parsing_mods) def parse_custom_mods(self, local_mods: list[str], folder: str) -> None: mods = get_custom_mods(Path(folder)) store = ModelFactory().make_mod_store() store.extend(mods) has_duplicates = False + seen = set() for row in store: - if row[1] in local_mods: + mod_id = row[1] + if mod_id in seen: row[-1]=True has_duplicates = True + seen.add(mod_id) func = StoredFunc(lambda: self.emitter.emit("custom_mods_loaded", store, folder, has_duplicates)) self.thread_man.set_cleanup_func(func) @@ -54,7 +73,6 @@ class OfflineManager: ) -> None: self.appid = appid - self.mission_folder = mission self.local_mods = local_mods self.custom_mods = custom_mods @@ -79,3 +97,9 @@ class OfflineManager: combined_mods.extend(new_symlinks) launch_offline(client, self.appid, name, combined_mods, self.mission_folder) + + def open_folderpicker(self) -> Union["Path", None]: + picker = FolderPicker(self.controller.get_window()) + folder = picker.pick_folder() + picker.destroy() + return folder From f9be6dfae46bb1d12c190fa200ca252ce5c6383f Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:02:44 +0900 Subject: [PATCH 35/71] chore: drop unused method --- dzgui/controllers/mc.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index f5339b9..fc687b3 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -25,7 +25,7 @@ from dzgui.model.servers import ServerModelManager from dzgui.util.diag import write_diagnostic from dzgui.util.format import format_player_count from dzgui.util.open_links import open_user_workshop, open_workshop_page -from dzgui.views.dialogs.filepicker import FilePicker, FolderPicker +from dzgui.views.dialogs.filepicker import FilePicker from dzgui.views.dialogs.generic import ExceptionDialog import gi @@ -301,12 +301,6 @@ class Controller(GObject.GObject): dialog = ExceptionDialog(self, str(e)) dialog.run() - def set_custom_folder(self) -> Union["Path", None]: - picker = FolderPicker(self.mediator.window) - folder = picker.pick_folder() - picker.destroy() - return folder - def update_api_key(self, key: Preferences, text: str) -> None: self.config_man.update_api_key(key, text) From 3c9b5c9ed3e062271f599ceecfc0424fdfd13acc Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:03:22 +0900 Subject: [PATCH 36/71] chore: update strings --- dzgui/strings/dialogs.py | 1 + 1 file changed, 1 insertion(+) diff --git a/dzgui/strings/dialogs.py b/dzgui/strings/dialogs.py index 73a2516..fc13eb9 100644 --- a/dzgui/strings/dialogs.py +++ b/dzgui/strings/dialogs.py @@ -9,6 +9,7 @@ load_error_lan = "Failed to find any servers on your network.\nCheck the server fetching_mods = "Fetching mod metadata" deleting_mods = "Deleting mods" scanning_mods = "Scanning mods" +parsing_mods = "Parsing mods" checking_api = "Checking API key" From 8123893a90a432ac0b6ec51e87d9f92127ed8188 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:03:44 +0900 Subject: [PATCH 37/71] chore: clean up offline mod manager --- dzgui/views/pages/offline.py | 112 ++++++++++++++++++++++------------- 1 file changed, 72 insertions(+), 40 deletions(-) diff --git a/dzgui/views/pages/offline.py b/dzgui/views/pages/offline.py index 4764b8f..4abfdfb 100644 --- a/dzgui/views/pages/offline.py +++ b/dzgui/views/pages/offline.py @@ -62,7 +62,7 @@ class FolderHBox(HBox): super().__init__(spacing=10) self.set_margin_start(10) - self.set_margin_bottom(10) + self.set_margin_bottom(5) # TODO: alternate class for left-aligned icons self.button = IconTextButton(FOLDER, btn_label) @@ -76,13 +76,18 @@ class FolderHBox(HBox): return self.button def set_label(self, label: str) -> None: - self.label.set_label(label) + prefix = "Current folder: " + self.label.set_label(prefix + label) + + def hide_label(self) -> None: + self.label.hide() class ModFrame(HeadingFrame): - def __init__(self, controller: "Controller", label: str) -> None: + def __init__(self, parent: OfflineLoader, controller: "Controller", label: str) -> None: super().__init__(heading=label) + self.parent = parent self.vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) self.tree = OfflineModTreeView(controller) @@ -103,14 +108,31 @@ class ModFrame(HeadingFrame): self.vbox.pack_end(self.tree_vbox, expand=True, fill=True, padding=3) self.frame.add(self.vbox) - # self.vbox.add(self.tree_vbox) - # self.none_label = Gtk.Label(label="No mods found", halign=Gtk.Align.START) - # self.vbox.pack_end(self.none_label, expand=True, fill=True, padding=3) - # self.none_label.hide() sel = self.tree.get_selection() sel.connect("changed", self._on_selection_changed) + # TODO: inherit icons from warnings area in preconnect dialog + self.none_label = Gtk.Label(label="No mods found", halign=Gtk.Align.START, margin_start=10, margin_bottom=5) + self.vbox.pack_end(self.none_label, expand=True, fill=True, padding=3) + + def set_error(self, msg: str) -> None: + self.none_label.set_label(msg) + + def hide_errors(self) -> None: + self.none_label.hide() + + def show_errors(self) -> None: + self.none_label.show() + + def start_empty(self) -> None: + self.none_label.show() + self.collapse_tree() + + def hide_all(self) -> None: + self.none_label.hide() + self.collapse_tree() + # TODO: warnings area # valid warnings (one at a time): # - name collision within custom mods @@ -145,18 +167,24 @@ class ModFrame(HeadingFrame): else: status = f"Mods selected: {len(rows)}" self.status.set_label(status) + self.parent.check_button() def set_cursor(self) -> None: path = Gtk.TreePath.new_from_indices([0]) self.tree.set_cursor(path) self.tree.get_selection().unselect_all() +#class LocalModFrame(ModFrame): +# def __init__(self, controller: "Controller", label: str) -> None: +# super().__init__(controller=controller, label=label) + + class CustomModFrame(ModFrame): def __init__( self, parent: OfflineLoader, controller: "Controller", heading: str ) -> None: - super().__init__(controller, heading) + super().__init__(parent, controller, heading) self.parent = parent @@ -170,6 +198,8 @@ class CustomModFrame(ModFrame): self.pack(self.custom_hbox) self.emitter.connect("custom_mods_loaded", self._on_custom_mods_loaded) + # TODO: move to mission frame + self.emitter.connect("custom_mission_loaded", lambda _, __, ___: print(_, __, ___)) def _on_custom_mods_loaded( self, @@ -179,24 +209,29 @@ class CustomModFrame(ModFrame): has_duplicates: bool, ) -> None: if len(store) == 0: - # TODO: pop error dialog area - # block button access - pass + # TODO: abstract into single method + self.set_error("No valid mods found") + self.show_errors() + self.custom_hbox.hide_label() else: self.custom_hbox.set_label(folder) self.tree.set_model(store) self.tree_vbox.show() + # TODO: look for duplicates within own dirs if has_duplicates: - # TODO: pop relevant error - # duplicates should block button access + # block button access pass def _on_custom_button_clicked(self, button: Gtk.Button) -> None: - self.parent.parse_mods() + local_mods = self.get_mods() + # TODO: emitter calls back to this widget + self.parent.offline_man.get_custom_mods(local_mods) def get_mods(self) -> list[str]: - rows = self.tree.get_selection().get_selected_rows() + model, rows = self.tree.get_selection().get_selected_rows() + if model is None: + return dirs = [str(col[1]) for col in rows] return dirs @@ -255,19 +290,19 @@ class OfflineLoader(Gtk.Box): self.add(PageHeading(offline.heading)) - self.local_frame = ModFrame(controller, offline.local_frame) + self.local_frame = ModFrame(self, controller, offline.local_frame) self.custom_frame = CustomModFrame(self, controller, offline.custom_frame) # TODO: suppress symlink column self.custom_tree = OfflineModTreeView(controller) - # TODO: custom class self.mission_hbox = FolderHBox(offline.mission_button) + # TODO: custom class self.mission_frame = HeadingFrame.new_with_widget_and_label( self.mission_hbox, offline.mission_frame ) self.mission_button = self.mission_hbox.get_button() - self.mission_button.connect("clicked", self._on_mission_clicked) + self.mission_button.connect("clicked", self._on_mission_button_clicked) self.radio_frame = RadioFrame(controller) @@ -290,7 +325,7 @@ class OfflineLoader(Gtk.Box): self.button_box.set_halign(Gtk.Align.END) self.button_box.set_margin_top(5) self.back = Gtk.Button(label="Back") - self.ok = Gtk.Button(label="Launch") + self.ok = Gtk.Button(label="Launch", sensitive=False) self.back.connect("clicked", self._on_back_clicked) self.ok.connect("clicked", self._on_ok_clicked) self.connect("key-press-event", self._on_keypress) @@ -301,34 +336,31 @@ class OfflineLoader(Gtk.Box): self.add(self.scrollable) self.add(self.button_box) - def _on_mission_clicked(self, button: Gtk.Button) -> None: - # process path, cf. set_custom_folder() - # file = path / "init.c" - # if not file.exists(): - # pop warning - pass - - def parse_mods(self) -> None: - # TODO: check method on custom frame - # TODO: delegate folderpicker to offline manager + def check_button(self) -> None: local_mods = self.local_frame.get_mods() - folder = self.controller.set_custom_folder() - if folder is not None: - # TODO: - self.offline_man.parse_custom_mods(local_mods, folder) + custom_mods = self.custom_frame.get_mods() + if len(local_mods) == 0 and len(custom_mods) == 0: + self.ok.set_sensitive(False) + else: + self.ok.set_sensitive(True) + + # TODO: put in separate class + def _on_mission_button_clicked(self, button: Gtk.Button) -> None: + self.offline_man.get_mission() def _on_keypress(self, widget: Self, event: Gdk.EventKey) -> None: if event.keyval == Gdk.KEY_Escape: self.back.emit("clicked") - def populate(self, store: "FastInsertListStore") -> None: + def populate(self, store: Union["FastInsertListStore", None]) -> None: self.local_frame.set_model(store) if store is None: - self.local_frame.collapse_tree() - # TODO: toggle if empty model, show warning label - # NOTE: there may be no local mods - # TODO: suppress trees if there are no mods - self.custom_frame.collapse_tree() + self.local_frame.start_empty() + else: + self.local_frame.show_errors() + + # NOTE: suppress custom tree until explicitly loaded + self.custom_frame.hide_all() def _on_back_clicked(self, button: Gtk.Button) -> None: self.controller.open_page(NotebookPage.MODS) @@ -342,5 +374,5 @@ class OfflineLoader(Gtk.Box): # local_mods = self.local_frame.get_mods() # custom_mods = self.custom_frame.get_mods() # cf. api.mods._hash(uid, use_custom=True) - # self.offline_man.setup(appid, mission, local_mods, custom_mods) + # offline_man.setup(appid, mission, local_mods, custom_mods) pass From 65fb07c8dac5ab5b894ac6feceed009a0e4eab8f Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:10:37 +0900 Subject: [PATCH 38/71] chore: clear type annotation errors --- dzgui/api/mods.py | 2 +- dzgui/controllers/mc.py | 1 - dzgui/managers/mods.py | 4 ++++ dzgui/managers/offline.py | 5 +++-- dzgui/views/dialogs/boot.py | 2 +- dzgui/views/pages/offline.py | 18 ++++++------------ 6 files changed, 15 insertions(+), 17 deletions(-) diff --git a/dzgui/api/mods.py b/dzgui/api/mods.py index b0a65e2..70ddc5b 100644 --- a/dzgui/api/mods.py +++ b/dzgui/api/mods.py @@ -48,7 +48,7 @@ def get_local_mods(workshop_path: Path) -> list[Path]: mods = [file for file in workshop_path.iterdir() if file.is_dir()] return mods -def is_mission(path: Path) -> None: +def is_mission(path: Path) -> bool: # TODO: parse integrity of other files file = path / "init.c" return file.exists() diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index fc687b3..c7168ab 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -36,7 +36,6 @@ from gi.repository import Gtk, Gdk, GLib, GObject # noqa E402 logger = logging.getLogger(APP_NAME) if TYPE_CHECKING: - from pathlib import Path from dzgui.api.servers import Record from dzgui.const.enum import ServerTab from dzgui.managers.connection import Prerequisites diff --git a/dzgui/managers/mods.py b/dzgui/managers/mods.py index 35391f3..6b9974b 100644 --- a/dzgui/managers/mods.py +++ b/dzgui/managers/mods.py @@ -68,6 +68,8 @@ class ModManager: def _on_mods_loaded(self) -> None: self.treeview.set_model(self.store) + if self.store is None: + return msg = self.format_mod_statusbar() total_mods = len(self.store) self.emitter.emit("mods_updated", msg, total_mods) @@ -124,6 +126,8 @@ class ModManager: pass def _on_mods_deleted(self, iters: list[Gtk.TreeIter]) -> None: + if self.store is None: + return for _iter in iters: self.store.remove(_iter) remove_stale_signatures(self.prefs.paths.config, self.prefs.paths.version) diff --git a/dzgui/managers/offline.py b/dzgui/managers/offline.py index 801d979..4df14aa 100644 --- a/dzgui/managers/offline.py +++ b/dzgui/managers/offline.py @@ -37,8 +37,9 @@ class OfflineManager: return is_valid = is_mission(folder) if is_valid: - self.mission_folder = folder - self.emitter.emit("custom_mission_loaded", folder, is_valid) + # TODO: str + self.mission_folder = str(folder) + self.emitter.emit("custom_mission_loaded", str(folder), is_valid) def get_custom_mods(self, local_mods: list[str]) -> None: folder = self.open_folderpicker() diff --git a/dzgui/views/dialogs/boot.py b/dzgui/views/dialogs/boot.py index ab90d1a..8491530 100644 --- a/dzgui/views/dialogs/boot.py +++ b/dzgui/views/dialogs/boot.py @@ -140,7 +140,7 @@ class BootDialog(Gtk.Dialog): GLib.timeout_add(100, self.pulse_spinner) - def _on_keypress(self, Self, event: Gdk.EventKey) -> None: + def _on_keypress(self, widget: Self, event: Gdk.EventKey) -> None: if event.keyval == Gdk.KEY_Escape: self.exit_button.emit("clicked") diff --git a/dzgui/views/pages/offline.py b/dzgui/views/pages/offline.py index 4abfdfb..d7c7682 100644 --- a/dzgui/views/pages/offline.py +++ b/dzgui/views/pages/offline.py @@ -1,6 +1,6 @@ from __future__ import annotations from pathlib import Path -from typing import Self, Sequence, TYPE_CHECKING +from typing import Self, Sequence, TYPE_CHECKING, Union from dzgui.util import css import dzgui.api.pefile as PeFile @@ -76,6 +76,7 @@ class FolderHBox(HBox): return self.button def set_label(self, label: str) -> None: + # TODO: strings prefix = "Current folder: " self.label.set_label(prefix + label) @@ -155,7 +156,7 @@ class ModFrame(HeadingFrame): def pack(self, widget: Gtk.Widget) -> None: self.vbox.pack_start(widget, expand=False, fill=False, padding=5) - def set_model(self, model: "FastInsertListStore") -> None: + def set_model(self, model: Union["FastInsertListStore", None]) -> None: self.tree.set_model(model) self.tree.mod_man.store = model self.set_cursor() @@ -174,11 +175,6 @@ class ModFrame(HeadingFrame): self.tree.set_cursor(path) self.tree.get_selection().unselect_all() -#class LocalModFrame(ModFrame): -# def __init__(self, controller: "Controller", label: str) -> None: -# super().__init__(controller=controller, label=label) - - class CustomModFrame(ModFrame): def __init__( @@ -210,6 +206,7 @@ class CustomModFrame(ModFrame): ) -> None: if len(store) == 0: # TODO: abstract into single method + # TODO: strings self.set_error("No valid mods found") self.show_errors() self.custom_hbox.hide_label() @@ -293,7 +290,7 @@ class OfflineLoader(Gtk.Box): self.local_frame = ModFrame(self, controller, offline.local_frame) self.custom_frame = CustomModFrame(self, controller, offline.custom_frame) - # TODO: suppress symlink column + # TODO: dynamically calculate new symlink hashes self.custom_tree = OfflineModTreeView(controller) self.mission_hbox = FolderHBox(offline.mission_button) @@ -366,13 +363,10 @@ class OfflineLoader(Gtk.Box): self.controller.open_page(NotebookPage.MODS) def _on_ok_clicked(self, button: Gtk.Button) -> None: - # TODO; block button access if no mods are selected - # TODO: consider hooking up to emitter and changing button state whenever mods are selected - # appid = self.radio_frame.get_appid() # mission = self.mission_frame.get_mission() # local_mods = self.local_frame.get_mods() # custom_mods = self.custom_frame.get_mods() # cf. api.mods._hash(uid, use_custom=True) - # offline_man.setup(appid, mission, local_mods, custom_mods) + # self.offline_man.setup(appid, mission, local_mods, custom_mods) pass From 450d54d7fdfdbd7e682f99d49dc150bcd5195c30 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:37:27 +0900 Subject: [PATCH 39/71] fix: swap logic --- dzgui/views/pages/offline.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dzgui/views/pages/offline.py b/dzgui/views/pages/offline.py index d7c7682..a252989 100644 --- a/dzgui/views/pages/offline.py +++ b/dzgui/views/pages/offline.py @@ -354,7 +354,7 @@ class OfflineLoader(Gtk.Box): if store is None: self.local_frame.start_empty() else: - self.local_frame.show_errors() + self.local_frame.hide_errors() # NOTE: suppress custom tree until explicitly loaded self.custom_frame.hide_all() From 5a8d85ac92ad8ecea6b10c047e3bec6886dd1d6e Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 11 Jun 2026 15:11:38 +0900 Subject: [PATCH 40/71] fix: less brittle meta parsing --- dzgui/api/mods.py | 48 +++++++++++++----------------------- tests/fixtures/cpp/meta1.cpp | 5 ++++ tests/fixtures/cpp/meta2.cpp | 5 ++++ tests/fixtures/cpp/meta3.cpp | 5 ++++ tests/fixtures/cpp/meta4.cpp | 5 ++++ tests/fixtures/cpp/meta5.cpp | 5 ++++ tests/fixtures/cpp/meta6.cpp | 5 ++++ tests/fixtures/cpp/test.py | 3 +++ tests/test_modmeta.py | 17 +++++++++++++ 9 files changed, 67 insertions(+), 31 deletions(-) create mode 100644 tests/fixtures/cpp/meta1.cpp create mode 100644 tests/fixtures/cpp/meta2.cpp create mode 100644 tests/fixtures/cpp/meta3.cpp create mode 100644 tests/fixtures/cpp/meta4.cpp create mode 100644 tests/fixtures/cpp/meta5.cpp create mode 100644 tests/fixtures/cpp/meta6.cpp create mode 100644 tests/fixtures/cpp/test.py create mode 100644 tests/test_modmeta.py diff --git a/dzgui/api/mods.py b/dzgui/api/mods.py index 70ddc5b..c115d11 100644 --- a/dzgui/api/mods.py +++ b/dzgui/api/mods.py @@ -1,6 +1,6 @@ import hashlib import logging -import shlex +import re from concurrent.futures import wait from concurrent.futures import ThreadPoolExecutor @@ -53,39 +53,25 @@ def is_mission(path: Path) -> bool: file = path / "init.c" return file.exists() -def parse_meta(file: Path) -> ModMeta | None: - mod = file / "meta.cpp" - if mod.exists() is False: - return None +def tokenize(file: str) -> dict[Any] | None: + delimiter=r"\s*=\s*" + modmeta = {} try: - with open(file / "meta.cpp", "r") as f: - st = f.read() - lex = shlex.shlex(st) - lex.whitespace += "=;" - v = [] - while True: - tok = lex.get_token() - if not tok: - break - if tok == "protocol" or tok == "publishedid": - ntok = lex.get_token() - elif tok == "timestamp": - # NOTE: some malformed .NET tick conversions result in numbers < 0 - ntok = lex.get_token() - if ntok == "-": - ntok += str(lex.get_token()) - elif tok == "name": - ntok = lex.get_token() - if ntok is not None: - ntok = ntok.split('"')[1] - if ntok is not None: - v.append(ntok) - return ModMeta(*v) + with open(file, "r", encoding="utf-8") as file: + for line in file: + line = line.strip().rstrip(";") + if not line: + continue + els = re.split(delimiter, line, maxsplit=1) + if len(els) == 2: + key, value = els + print(value) + modmeta[key.strip()] = value.strip('"') + return modmeta except Exception as e: logger.critical(e) return None - def get_mod_size(path: Path) -> float: s = 0 for f in path.rglob("*"): @@ -105,12 +91,12 @@ def parse_mods(mods: list[Path]) -> list[Any]: for mod in mods: mod_dir = mod.name symlink = _hash(mod_dir) - meta = parse_meta(mod) + meta = tokenize(mod) if meta is None: continue size = get_mod_size(mod) # NOTE: final col is cell renderer highlight toggle - clean.append([meta.name, symlink, mod_dir, size, False]) + clean.append([meta["name"], symlink, mod_dir, size, False]) clean.sort(key=lambda row: str(row[0]).casefold()) return clean diff --git a/tests/fixtures/cpp/meta1.cpp b/tests/fixtures/cpp/meta1.cpp new file mode 100644 index 0000000..b43c25f --- /dev/null +++ b/tests/fixtures/cpp/meta1.cpp @@ -0,0 +1,5 @@ +protocol = 1; +publishedid = 1234567890; +name = "ModName"; +timestamp = 638278452000000000; +hash = "a1b2c3d4e5f6g7h8i9j0"; diff --git a/tests/fixtures/cpp/meta2.cpp b/tests/fixtures/cpp/meta2.cpp new file mode 100644 index 0000000..4308b4f --- /dev/null +++ b/tests/fixtures/cpp/meta2.cpp @@ -0,0 +1,5 @@ +protocol = 1 +publishedid = 1234567890 +name = "ModName" +timestamp = 638278452000000000 +hash = "a1b2c3d4e5f6g7h8i9j0" diff --git a/tests/fixtures/cpp/meta3.cpp b/tests/fixtures/cpp/meta3.cpp new file mode 100644 index 0000000..2b32c3d --- /dev/null +++ b/tests/fixtures/cpp/meta3.cpp @@ -0,0 +1,5 @@ +protocol=1 +publishedid=1234567890 +name="ModName" +timestamp=638278452000000000 +hash="a1b2c3d4e5f6g7h8i9j0" diff --git a/tests/fixtures/cpp/meta4.cpp b/tests/fixtures/cpp/meta4.cpp new file mode 100644 index 0000000..1a8d18a --- /dev/null +++ b/tests/fixtures/cpp/meta4.cpp @@ -0,0 +1,5 @@ +protocol=1 +publishedid=1234567890 +name=ModName +timestamp=638278452000000000 +hash=a1b2c3d4e5f6g7h8i9j0 diff --git a/tests/fixtures/cpp/meta5.cpp b/tests/fixtures/cpp/meta5.cpp new file mode 100644 index 0000000..bfd7976 --- /dev/null +++ b/tests/fixtures/cpp/meta5.cpp @@ -0,0 +1,5 @@ +protocol=1; +publishedid=1234567890; +name=ModName; +timestamp=638278452000000000; +hash=a1b2c3d4e5f6g7h8i9j0; diff --git a/tests/fixtures/cpp/meta6.cpp b/tests/fixtures/cpp/meta6.cpp new file mode 100644 index 0000000..4991854 --- /dev/null +++ b/tests/fixtures/cpp/meta6.cpp @@ -0,0 +1,5 @@ +protocol=1 +publishedid=1234567890; +name="ModName"; +timestamp=638278452000000000; +hash= a1b2c3d4e5f6g7h8i9j0; diff --git a/tests/fixtures/cpp/test.py b/tests/fixtures/cpp/test.py new file mode 100644 index 0000000..453b6d7 --- /dev/null +++ b/tests/fixtures/cpp/test.py @@ -0,0 +1,3 @@ +from dzgui.api.mods import tokenize + +print(tokenize("meta4.cpp")) diff --git a/tests/test_modmeta.py b/tests/test_modmeta.py new file mode 100644 index 0000000..f2f4ca4 --- /dev/null +++ b/tests/test_modmeta.py @@ -0,0 +1,17 @@ +import pytest +from dzgui.api.mods import tokenize +from tests.fixtures import fixture_path + +@pytest.mark.mods +@pytest.mark.parametrize("fixture", [ + fixture_path("cpp/meta1.cpp"), + fixture_path("cpp/meta2.cpp"), + fixture_path("cpp/meta3.cpp"), + fixture_path("cpp/meta4.cpp"), + fixture_path("cpp/meta5.cpp"), + fixture_path("cpp/meta6.cpp"), + ] +) +def test_modmeta(fixture: str) -> None: + meta = tokenize(fixture) + assert meta["name"] == "ModName" From 20c850a9777cda3c12cf5a56cd14509860e25d01 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 11 Jun 2026 15:12:13 +0900 Subject: [PATCH 41/71] chore: drop print statement --- dzgui/api/mods.py | 1 - 1 file changed, 1 deletion(-) diff --git a/dzgui/api/mods.py b/dzgui/api/mods.py index c115d11..3305171 100644 --- a/dzgui/api/mods.py +++ b/dzgui/api/mods.py @@ -65,7 +65,6 @@ def tokenize(file: str) -> dict[Any] | None: els = re.split(delimiter, line, maxsplit=1) if len(els) == 2: key, value = els - print(value) modmeta[key.strip()] = value.strip('"') return modmeta except Exception as e: From 98b550cec6db1f77fb7233e52816aea874a9c0b8 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 11 Jun 2026 15:13:09 +0900 Subject: [PATCH 42/71] chore: dedicated mission frame --- dzgui/views/pages/offline.py | 55 ++++++++++++++++++++++++------------ 1 file changed, 37 insertions(+), 18 deletions(-) diff --git a/dzgui/views/pages/offline.py b/dzgui/views/pages/offline.py index a252989..0564c6a 100644 --- a/dzgui/views/pages/offline.py +++ b/dzgui/views/pages/offline.py @@ -62,6 +62,7 @@ class FolderHBox(HBox): super().__init__(spacing=10) self.set_margin_start(10) + self.set_margin_end(10) self.set_margin_bottom(5) # TODO: alternate class for left-aligned icons @@ -69,8 +70,10 @@ class FolderHBox(HBox): self.button.set_halign(Gtk.Align.START) self.button.set_image_position(Gtk.PositionType.LEFT) + self.scrolled_label = Gtk.ScrolledWindow(propagate_natural_width=True, halign=Gtk.Align.START) self.label = Gtk.Label() - self.extend([self.button, self.label]) + self.scrolled_label.add(self.label) + self.extend([self.button, self.scrolled_label]) def get_button(self) -> Gtk.Button: return self.button @@ -194,8 +197,6 @@ class CustomModFrame(ModFrame): self.pack(self.custom_hbox) self.emitter.connect("custom_mods_loaded", self._on_custom_mods_loaded) - # TODO: move to mission frame - self.emitter.connect("custom_mission_loaded", lambda _, __, ___: print(_, __, ___)) def _on_custom_mods_loaded( self, @@ -232,6 +233,37 @@ class CustomModFrame(ModFrame): dirs = [str(col[1]) for col in rows] return dirs +class MissionFrame(HeadingFrame): + def __init__(self, parent: OfflineLoader, controller: "Controller") -> None: + super().__init__(heading=offline.mission_frame) + + self.parent = parent + self.controller = controller + self.emitter = controller.get_emitter() + + self.mission_hbox = FolderHBox(offline.mission_button) + self.mission_button = self.mission_hbox.get_button() + self.mission_button.connect("clicked", self._on_mission_button_clicked) + + self.warning = Gtk.Label(halign=Gtk.Align.START, margin_start=10, margin_bottom=5) + self.vbox = VBox() + self.vbox.extend([self.mission_hbox, self.warning]) + self.frame.add(self.vbox) + + self.emitter.connect("custom_mission_loaded", self._on_mission_loaded) + + def _on_mission_loaded(self, emitter: "Emitter", folder: str, is_valid: bool) -> None: + self.mission_hbox.set_label(folder) + if not is_valid: + # TODO: strings + self.warning.show() + self.warning.set_label("Not a valid mission") + else: + self.warning.hide() + + def _on_mission_button_clicked(self, button: Gtk.Button) -> None: + self.parent.offline_man.get_mission() + class RadioFrame(HeadingFrame): def __init__(self, controller: "Controller") -> None: @@ -290,17 +322,8 @@ class OfflineLoader(Gtk.Box): self.local_frame = ModFrame(self, controller, offline.local_frame) self.custom_frame = CustomModFrame(self, controller, offline.custom_frame) - # TODO: dynamically calculate new symlink hashes self.custom_tree = OfflineModTreeView(controller) - - self.mission_hbox = FolderHBox(offline.mission_button) - # TODO: custom class - self.mission_frame = HeadingFrame.new_with_widget_and_label( - self.mission_hbox, offline.mission_frame - ) - self.mission_button = self.mission_hbox.get_button() - self.mission_button.connect("clicked", self._on_mission_button_clicked) - + self.mission_frame = MissionFrame(self, controller) self.radio_frame = RadioFrame(controller) self.scrollable = Gtk.ScrolledWindow( @@ -341,10 +364,6 @@ class OfflineLoader(Gtk.Box): else: self.ok.set_sensitive(True) - # TODO: put in separate class - def _on_mission_button_clicked(self, button: Gtk.Button) -> None: - self.offline_man.get_mission() - def _on_keypress(self, widget: Self, event: Gdk.EventKey) -> None: if event.keyval == Gdk.KEY_Escape: self.back.emit("clicked") @@ -355,7 +374,6 @@ class OfflineLoader(Gtk.Box): self.local_frame.start_empty() else: self.local_frame.hide_errors() - # NOTE: suppress custom tree until explicitly loaded self.custom_frame.hide_all() @@ -368,5 +386,6 @@ class OfflineLoader(Gtk.Box): # local_mods = self.local_frame.get_mods() # custom_mods = self.custom_frame.get_mods() # cf. api.mods._hash(uid, use_custom=True) + # TODO: dynamically calculate new symlink hashes # self.offline_man.setup(appid, mission, local_mods, custom_mods) pass From 4f51d962e7dc70225fd3a177795e0279eacebde5 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 11 Jun 2026 15:15:43 +0900 Subject: [PATCH 43/71] fix: parse filepath correctly --- dzgui/api/mods.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dzgui/api/mods.py b/dzgui/api/mods.py index 3305171..b24271a 100644 --- a/dzgui/api/mods.py +++ b/dzgui/api/mods.py @@ -53,7 +53,8 @@ def is_mission(path: Path) -> bool: file = path / "init.c" return file.exists() -def tokenize(file: str) -> dict[Any] | None: +def tokenize(mod: Path) -> dict[Any] | None: + file = mod.joinpath("meta.cpp") delimiter=r"\s*=\s*" modmeta = {} try: From 241df72bf3efa615daedb1e0543c2e8171dd8b25 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 11 Jun 2026 15:31:17 +0900 Subject: [PATCH 44/71] fix: pass custom flag --- dzgui/api/mods.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dzgui/api/mods.py b/dzgui/api/mods.py index b24271a..c1ba417 100644 --- a/dzgui/api/mods.py +++ b/dzgui/api/mods.py @@ -83,14 +83,14 @@ def get_mod_size(path: Path) -> float: def get_custom_mods(path: Path) -> list[Any]: mods = get_local_mods(path) # TODO: error handling - return parse_mods(mods) + return parse_mods(mods, use_custom=True) -def parse_mods(mods: list[Path]) -> list[Any]: +def parse_mods(mods: list[Path], use_custom: bool = False) -> list[Any]: clean = [] for mod in mods: mod_dir = mod.name - symlink = _hash(mod_dir) + symlink = _hash(mod_dir, use_custom) meta = tokenize(mod) if meta is None: continue From d5a857fe3ab26329c18656b2b8623c347db3861d Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 11 Jun 2026 15:31:49 +0900 Subject: [PATCH 45/71] fix: parse tree iters correctly --- dzgui/views/pages/offline.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/dzgui/views/pages/offline.py b/dzgui/views/pages/offline.py index 0564c6a..5267bf2 100644 --- a/dzgui/views/pages/offline.py +++ b/dzgui/views/pages/offline.py @@ -145,10 +145,10 @@ class ModFrame(HeadingFrame): # - not a valid mission def get_mods(self) -> list[str]: - model = self.tree.get_model() + model, treeiters = self.tree.get_selection().get_selected_rows() if model is None: return [] - return [row[1] for row in model] + return [model[_iter][1] for _iter in treeiters] def collapse_tree(self) -> None: self.tree_vbox.hide() @@ -211,7 +211,9 @@ class CustomModFrame(ModFrame): self.set_error("No valid mods found") self.show_errors() self.custom_hbox.hide_label() + self.tree_vbox.hide() else: + self.hide_errors() self.custom_hbox.set_label(folder) self.tree.set_model(store) self.tree_vbox.show() @@ -226,13 +228,6 @@ class CustomModFrame(ModFrame): # TODO: emitter calls back to this widget self.parent.offline_man.get_custom_mods(local_mods) - def get_mods(self) -> list[str]: - model, rows = self.tree.get_selection().get_selected_rows() - if model is None: - return - dirs = [str(col[1]) for col in rows] - return dirs - class MissionFrame(HeadingFrame): def __init__(self, parent: OfflineLoader, controller: "Controller") -> None: super().__init__(heading=offline.mission_frame) From d4825390f7156f0fbbcd3f0b7baa3b1c8b73a43b Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 11 Jun 2026 15:39:52 +0900 Subject: [PATCH 46/71] chore: update strings --- dzgui/strings/offline.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/dzgui/strings/offline.py b/dzgui/strings/offline.py index 631b979..5cb5410 100644 --- a/dzgui/strings/offline.py +++ b/dzgui/strings/offline.py @@ -9,3 +9,6 @@ custom_frame = "Custom mods" custom_button = "Set custom mod folder" local_frame = "Installed mods" + +no_mods = "No valid mods found" +no_mission = "Not a valid mission" From 77df66979aa54d7e8cb74a75219e9124adc310e9 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 11 Jun 2026 15:40:09 +0900 Subject: [PATCH 47/71] chore: cleanup --- dzgui/managers/offline.py | 1 + dzgui/views/pages/offline.py | 55 +++++++++++++++++------------------- 2 files changed, 27 insertions(+), 29 deletions(-) diff --git a/dzgui/managers/offline.py b/dzgui/managers/offline.py index 4df14aa..952b56f 100644 --- a/dzgui/managers/offline.py +++ b/dzgui/managers/offline.py @@ -36,6 +36,7 @@ class OfflineManager: if folder is None: return is_valid = is_mission(folder) + # TODO: stop storing values in here if is_valid: # TODO: str self.mission_folder = str(folder) diff --git a/dzgui/views/pages/offline.py b/dzgui/views/pages/offline.py index 5267bf2..e3c448b 100644 --- a/dzgui/views/pages/offline.py +++ b/dzgui/views/pages/offline.py @@ -117,32 +117,26 @@ class ModFrame(HeadingFrame): sel.connect("changed", self._on_selection_changed) # TODO: inherit icons from warnings area in preconnect dialog - self.none_label = Gtk.Label(label="No mods found", halign=Gtk.Align.START, margin_start=10, margin_bottom=5) - self.vbox.pack_end(self.none_label, expand=True, fill=True, padding=3) + self.error_label = Gtk.Label(label="No mods found", halign=Gtk.Align.START, margin_start=10, margin_bottom=5) + self.vbox.pack_end(self.error_label, expand=True, fill=True, padding=3) def set_error(self, msg: str) -> None: - self.none_label.set_label(msg) + self.error_label.set_label(msg) + self.error_label.show() def hide_errors(self) -> None: - self.none_label.hide() - - def show_errors(self) -> None: - self.none_label.show() + self.error_label.hide() def start_empty(self) -> None: - self.none_label.show() + self.error_label.show() self.collapse_tree() def hide_all(self) -> None: - self.none_label.hide() + self.error_label.hide() self.collapse_tree() # TODO: warnings area - # valid warnings (one at a time): - # - name collision within custom mods - # - name collision b/w custom and local (permissible) - # - no mods - # - not a valid mission + # name collision within custom mods def get_mods(self) -> list[str]: model, treeiters = self.tree.get_selection().get_selected_rows() @@ -198,6 +192,18 @@ class CustomModFrame(ModFrame): self.emitter.connect("custom_mods_loaded", self._on_custom_mods_loaded) + def hide_tree(self) -> None: + # TODO: no mods message is different from collision message + self.set_error(offline.no_mods) + self.custom_hbox.hide_label() + self.tree_vbox.hide() + + def present_tree(self, folder: str, store: "FastInsertListStore") -> None: + self.custom_hbox.set_label(folder) + self.tree.set_model(store) + self.tree_vbox.show() + self.hide_errors() + def _on_custom_mods_loaded( self, emitter: "Emitter", @@ -206,26 +212,18 @@ class CustomModFrame(ModFrame): has_duplicates: bool, ) -> None: if len(store) == 0: - # TODO: abstract into single method - # TODO: strings - self.set_error("No valid mods found") - self.show_errors() - self.custom_hbox.hide_label() - self.tree_vbox.hide() + self.hide_tree() else: - self.hide_errors() - self.custom_hbox.set_label(folder) - self.tree.set_model(store) - self.tree_vbox.show() + self.present_tree() - # TODO: look for duplicates within own dirs if has_duplicates: - # block button access + # TODO: block button access + # TODO: update error message + # cf hide_tree pass def _on_custom_button_clicked(self, button: Gtk.Button) -> None: local_mods = self.get_mods() - # TODO: emitter calls back to this widget self.parent.offline_man.get_custom_mods(local_mods) class MissionFrame(HeadingFrame): @@ -250,9 +248,8 @@ class MissionFrame(HeadingFrame): def _on_mission_loaded(self, emitter: "Emitter", folder: str, is_valid: bool) -> None: self.mission_hbox.set_label(folder) if not is_valid: - # TODO: strings self.warning.show() - self.warning.set_label("Not a valid mission") + self.warning.set_label(offline.no_mission) else: self.warning.hide() From ad85bb73437d686fbdbbba424124c986fbabdae9 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 11 Jun 2026 16:17:02 +0900 Subject: [PATCH 48/71] drop collision check for custom mods Mod hashes are taken from the directory names, which cannot be the same at the filesystem level. Custom mod hashes are also unique from local mods because they are given a different namespace. --- dzgui/controllers/emitter.py | 2 +- dzgui/managers/offline.py | 11 +---------- dzgui/views/pages/offline.py | 11 +++-------- 3 files changed, 5 insertions(+), 19 deletions(-) diff --git a/dzgui/controllers/emitter.py b/dzgui/controllers/emitter.py index 1331160..17a59d0 100644 --- a/dzgui/controllers/emitter.py +++ b/dzgui/controllers/emitter.py @@ -156,7 +156,7 @@ class Emitter(GObject.GObject): pass @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object, str, bool)) - def custom_mods_loaded(self, store: "FastInsertListStore", folder: str, has_duplicates: bool) -> None: + def custom_mods_loaded(self, store: "FastInsertListStore", folder: str) -> None: pass @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(str, bool)) diff --git a/dzgui/managers/offline.py b/dzgui/managers/offline.py index 952b56f..e6584b9 100644 --- a/dzgui/managers/offline.py +++ b/dzgui/managers/offline.py @@ -54,16 +54,7 @@ class OfflineManager: store = ModelFactory().make_mod_store() store.extend(mods) - has_duplicates = False - seen = set() - for row in store: - mod_id = row[1] - if mod_id in seen: - row[-1]=True - has_duplicates = True - seen.add(mod_id) - - func = StoredFunc(lambda: self.emitter.emit("custom_mods_loaded", store, folder, has_duplicates)) + func = StoredFunc(lambda: self.emitter.emit("custom_mods_loaded", store, folder)) self.thread_man.set_cleanup_func(func) def setup( diff --git a/dzgui/views/pages/offline.py b/dzgui/views/pages/offline.py index e3c448b..05b48a7 100644 --- a/dzgui/views/pages/offline.py +++ b/dzgui/views/pages/offline.py @@ -117,6 +117,7 @@ class ModFrame(HeadingFrame): sel.connect("changed", self._on_selection_changed) # TODO: inherit icons from warnings area in preconnect dialog + # TODO: strings self.error_label = Gtk.Label(label="No mods found", halign=Gtk.Align.START, margin_start=10, margin_bottom=5) self.vbox.pack_end(self.error_label, expand=True, fill=True, padding=3) @@ -209,19 +210,12 @@ class CustomModFrame(ModFrame): emitter: "Emitter", store: "FastInsertListStore", folder: str, - has_duplicates: bool, ) -> None: if len(store) == 0: self.hide_tree() else: self.present_tree() - if has_duplicates: - # TODO: block button access - # TODO: update error message - # cf hide_tree - pass - def _on_custom_button_clicked(self, button: Gtk.Button) -> None: local_mods = self.get_mods() self.parent.offline_man.get_custom_mods(local_mods) @@ -373,11 +367,12 @@ class OfflineLoader(Gtk.Box): self.controller.open_page(NotebookPage.MODS) def _on_ok_clicked(self, button: Gtk.Button) -> None: + # TODO: grab all values in one pass # appid = self.radio_frame.get_appid() # mission = self.mission_frame.get_mission() # local_mods = self.local_frame.get_mods() # custom_mods = self.custom_frame.get_mods() # cf. api.mods._hash(uid, use_custom=True) - # TODO: dynamically calculate new symlink hashes + # TODO: set up all symlinks prior to launch, including custom ones # self.offline_man.setup(appid, mission, local_mods, custom_mods) pass From 88270382f192662f997de92c3382b9a9d710e943 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 11 Jun 2026 17:46:52 +0900 Subject: [PATCH 49/71] fix: signal arguments --- dzgui/controllers/emitter.py | 3 ++- dzgui/views/pages/offline.py | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/dzgui/controllers/emitter.py b/dzgui/controllers/emitter.py index 17a59d0..c43756c 100644 --- a/dzgui/controllers/emitter.py +++ b/dzgui/controllers/emitter.py @@ -1,6 +1,7 @@ from typing import TYPE_CHECKING import gi + gi.require_version("Gtk", "3.0") from gi.repository.Gtk import ListStore # noqa E402 from gi.repository import Gdk, GObject # noqa E402 @@ -155,7 +156,7 @@ class Emitter(GObject.GObject): def lan_page_initialized(self) -> None: pass - @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object, str, bool)) + @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object, str)) def custom_mods_loaded(self, store: "FastInsertListStore", folder: str) -> None: pass diff --git a/dzgui/views/pages/offline.py b/dzgui/views/pages/offline.py index 05b48a7..6af5ae3 100644 --- a/dzgui/views/pages/offline.py +++ b/dzgui/views/pages/offline.py @@ -199,7 +199,7 @@ class CustomModFrame(ModFrame): self.custom_hbox.hide_label() self.tree_vbox.hide() - def present_tree(self, folder: str, store: "FastInsertListStore") -> None: + def present_tree(self, store: "FastInsertListStore", folder: str) -> None: self.custom_hbox.set_label(folder) self.tree.set_model(store) self.tree_vbox.show() @@ -214,7 +214,7 @@ class CustomModFrame(ModFrame): if len(store) == 0: self.hide_tree() else: - self.present_tree() + self.present_tree(store, folder) def _on_custom_button_clicked(self, button: Gtk.Button) -> None: local_mods = self.get_mods() From 4313075b61993bbaffc312dab2b320de3606ec25 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 11 Jun 2026 18:02:58 +0900 Subject: [PATCH 50/71] feat: left aligned icon --- dzgui/views/components/buttons.py | 43 +++++++++++++++++++++++-------- dzgui/views/pages/offline.py | 26 ++++++++++++++----- 2 files changed, 52 insertions(+), 17 deletions(-) diff --git a/dzgui/views/components/buttons.py b/dzgui/views/components/buttons.py index affcc5b..63b1986 100644 --- a/dzgui/views/components/buttons.py +++ b/dzgui/views/components/buttons.py @@ -31,40 +31,61 @@ if TYPE_CHECKING: class Icon(Gtk.Image): - def __init__(self, name: str, l_margin: int = 0) -> None: + def __init__(self, name: str, margin_start: int = 0, margin_end: int = 0) -> None: super().__init__( icon_name=name, icon_size=Gtk.IconSize.BUTTON, - margin_start=l_margin, + margin_start=margin_start, + margin_end=margin_end, ypad=2, ) class LargeIcon(Gtk.Image): - def __init__(self, name: str, l_margin: int = 5) -> None: + def __init__(self, name: str, margin_start: int = 5, margin_end: int = 0) -> None: super().__init__( - icon_name=name, icon_size=Gtk.IconSize.LARGE_TOOLBAR, margin_start=l_margin + icon_name=name, + icon_size=Gtk.IconSize.LARGE_TOOLBAR, + margin_start=margin_start, + margin_end=margin_end, ) class IconButton(Gtk.Button): - def __init__(self, icon: str, margin: int = 0) -> None: + def __init__( + self, + icon: str, + position: Gtk.PositionType = Gtk.PositionType.RIGHT, + margin_start: int = 0, + margin_end: int = 0, + ) -> None: super().__init__() - self.icon = Icon(icon, l_margin=margin) + self.icon = Icon(icon, margin_start=margin_start, margin_end=margin_end) self.set_image(self.icon) - self.set_image_position(Gtk.PositionType.RIGHT) + self.set_image_position(position) + # self.set_image_position(Gtk.PositionType.RIGHT) self.set_focus_on_click(False) class IconTextButton(IconButton): - def __init__(self, icon: str, label: str) -> None: - super().__init__(icon, margin=5) + def __init__( + self, icon: str, label: str, position: Gtk.PositionType = Gtk.PositionType.RIGHT + ) -> None: + match position: + case Gtk.PositionType.RIGHT: + super().__init__(icon, position, margin_start=5) + case Gtk.PositionType.LEFT: + super().__init__(icon, position, margin_end=5) + case _: + raise ValueError("Unsupported position type") self.set_label(label) class LargeIconTextButton(IconButton): - def __init__(self, icon: str, label: str) -> None: - super().__init__(icon) + def __init__( + self, icon: str, label: str, position: Gtk.PositionType = Gtk.PositionType.RIGHT + ) -> None: + super().__init__(icon, position) self.set_label(label) self.set_image(LargeIcon(icon)) diff --git a/dzgui/views/pages/offline.py b/dzgui/views/pages/offline.py index 6af5ae3..99d6d81 100644 --- a/dzgui/views/pages/offline.py +++ b/dzgui/views/pages/offline.py @@ -66,11 +66,13 @@ class FolderHBox(HBox): self.set_margin_bottom(5) # TODO: alternate class for left-aligned icons - self.button = IconTextButton(FOLDER, btn_label) + self.button = IconTextButton(FOLDER, btn_label, Gtk.PositionType.LEFT) self.button.set_halign(Gtk.Align.START) self.button.set_image_position(Gtk.PositionType.LEFT) - self.scrolled_label = Gtk.ScrolledWindow(propagate_natural_width=True, halign=Gtk.Align.START) + self.scrolled_label = Gtk.ScrolledWindow( + propagate_natural_width=True, halign=Gtk.Align.START + ) self.label = Gtk.Label() self.scrolled_label.add(self.label) self.extend([self.button, self.scrolled_label]) @@ -88,7 +90,9 @@ class FolderHBox(HBox): class ModFrame(HeadingFrame): - def __init__(self, parent: OfflineLoader, controller: "Controller", label: str) -> None: + def __init__( + self, parent: OfflineLoader, controller: "Controller", label: str + ) -> None: super().__init__(heading=label) self.parent = parent @@ -118,7 +122,12 @@ class ModFrame(HeadingFrame): # TODO: inherit icons from warnings area in preconnect dialog # TODO: strings - self.error_label = Gtk.Label(label="No mods found", halign=Gtk.Align.START, margin_start=10, margin_bottom=5) + self.error_label = Gtk.Label( + label="No mods found", + halign=Gtk.Align.START, + margin_start=10, + margin_bottom=5, + ) self.vbox.pack_end(self.error_label, expand=True, fill=True, padding=3) def set_error(self, msg: str) -> None: @@ -220,6 +229,7 @@ class CustomModFrame(ModFrame): local_mods = self.get_mods() self.parent.offline_man.get_custom_mods(local_mods) + class MissionFrame(HeadingFrame): def __init__(self, parent: OfflineLoader, controller: "Controller") -> None: super().__init__(heading=offline.mission_frame) @@ -232,14 +242,18 @@ class MissionFrame(HeadingFrame): self.mission_button = self.mission_hbox.get_button() self.mission_button.connect("clicked", self._on_mission_button_clicked) - self.warning = Gtk.Label(halign=Gtk.Align.START, margin_start=10, margin_bottom=5) + self.warning = Gtk.Label( + halign=Gtk.Align.START, margin_start=10, margin_bottom=5 + ) self.vbox = VBox() self.vbox.extend([self.mission_hbox, self.warning]) self.frame.add(self.vbox) self.emitter.connect("custom_mission_loaded", self._on_mission_loaded) - def _on_mission_loaded(self, emitter: "Emitter", folder: str, is_valid: bool) -> None: + def _on_mission_loaded( + self, emitter: "Emitter", folder: str, is_valid: bool + ) -> None: self.mission_hbox.set_label(folder) if not is_valid: self.warning.show() From 1e6ff9c4f7e230a90a9fa002595eb59b9bc203e9 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 11 Jun 2026 19:19:36 +0900 Subject: [PATCH 51/71] fix: set filepicker title --- dzgui/managers/offline.py | 13 +++++++------ dzgui/strings/dialogs.py | 3 +++ dzgui/views/dialogs/filepicker.py | 4 ++-- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/dzgui/managers/offline.py b/dzgui/managers/offline.py index e6584b9..a870402 100644 --- a/dzgui/managers/offline.py +++ b/dzgui/managers/offline.py @@ -30,9 +30,8 @@ class OfflineManager: self.local_mods: list[str] | None self.custom_mods: list[str] | None - def get_mission(self) -> None: - folder = self.open_folderpicker() + folder = self.open_folderpicker(dialogs.mission_dialog) if folder is None: return is_valid = is_mission(folder) @@ -43,7 +42,7 @@ class OfflineManager: self.emitter.emit("custom_mission_loaded", str(folder), is_valid) def get_custom_mods(self, local_mods: list[str]) -> None: - folder = self.open_folderpicker() + folder = self.open_folderpicker(dialogs.custom_mod_dialog) if folder is None: return self.parse_custom_mods(local_mods, folder) @@ -54,7 +53,9 @@ class OfflineManager: store = ModelFactory().make_mod_store() store.extend(mods) - func = StoredFunc(lambda: self.emitter.emit("custom_mods_loaded", store, folder)) + func = StoredFunc( + lambda: self.emitter.emit("custom_mods_loaded", store, folder) + ) self.thread_man.set_cleanup_func(func) def setup( @@ -91,8 +92,8 @@ class OfflineManager: launch_offline(client, self.appid, name, combined_mods, self.mission_folder) - def open_folderpicker(self) -> Union["Path", None]: - picker = FolderPicker(self.controller.get_window()) + def open_folderpicker(self, title: str) -> Union["Path", None]: + picker = FolderPicker(self.controller.get_window(), title) folder = picker.pick_folder() picker.destroy() return folder diff --git a/dzgui/strings/dialogs.py b/dzgui/strings/dialogs.py index fc13eb9..5df7bb7 100644 --- a/dzgui/strings/dialogs.py +++ b/dzgui/strings/dialogs.py @@ -16,3 +16,6 @@ checking_api = "Checking API key" running = "Running" failed = "FAILED" ok = "OK" + +mission_dialog = "Set a mission folder location" +custom_mod_dialog = "Set location to a custom mods folder" diff --git a/dzgui/views/dialogs/filepicker.py b/dzgui/views/dialogs/filepicker.py index d8d8316..c8dfa93 100644 --- a/dzgui/views/dialogs/filepicker.py +++ b/dzgui/views/dialogs/filepicker.py @@ -36,9 +36,9 @@ class FilePicker(Gtk.FileChooserDialog): class FolderPicker(Gtk.FileChooserDialog): - def __init__(self, parent: Gtk.Window) -> None: + def __init__(self, parent: Gtk.Window, title: str) -> None: super().__init__( - title=picker.title, + title=title, action=Gtk.FileChooserAction.SELECT_FOLDER, parent=parent, resizable=True, From 96fb484b383028fb31b7d46d377bfadfd43b17f8 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 11 Jun 2026 19:20:02 +0900 Subject: [PATCH 52/71] feat: add context aware multi select --- dzgui/views/pages/offline.py | 1 + 1 file changed, 1 insertion(+) diff --git a/dzgui/views/pages/offline.py b/dzgui/views/pages/offline.py index 99d6d81..cd1e76d 100644 --- a/dzgui/views/pages/offline.py +++ b/dzgui/views/pages/offline.py @@ -100,6 +100,7 @@ class ModFrame(HeadingFrame): self.tree = OfflineModTreeView(controller) self.tree.set_model(None) + self.tree.get_selection().set_mode(Gtk.SelectionMode.MULTIPLE) self.tree.set_valign(Gtk.Align.FILL) self.scrolled = NoOverlayScrolledWindow() From c2ef7449e5d1d4d1714ce842adaeb5159b9d1bb4 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 11 Jun 2026 19:21:33 +0900 Subject: [PATCH 53/71] fix: override ctrl-click signal --- dzgui/views/trees/tree_mods.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/dzgui/views/trees/tree_mods.py b/dzgui/views/trees/tree_mods.py index 8a6ff16..2a3dd78 100644 --- a/dzgui/views/trees/tree_mods.py +++ b/dzgui/views/trees/tree_mods.py @@ -60,6 +60,21 @@ class ModTreeView(ModsMixin, ContextMixin, TreeView): # type: ignore self.connect("button-press-event", self.present_menu) self.connect("key-press-event", self.present_menu) + self.connect("button-press-event", self.on_row_click) + + def on_row_click(self, widget: Gtk.Widget, event: Gdk.EventButton) -> None: + if event.state is Gdk.ModifierType.CONTROL_MASK and event.button == 1: + path_info = widget.get_path_at_pos(int(event.x), int(event.y)) + if path_info is None: + return + path, column, cellx, celly = path_info + _iter = self.get_model().get_iter(path) + if self.get_selection().iter_is_selected(_iter): + self.get_selection().unselect_iter(_iter) + else: + self.get_selection().select_iter(_iter) + return True + def get_mod_man(self) -> ModManager: return self.mod_man From 6a6b4c8027d4792fc7d51a81a58b855216805361 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 11 Jun 2026 19:22:03 +0900 Subject: [PATCH 54/71] chore: drop extraneous setting --- dzgui/views/pages/offline.py | 1 - 1 file changed, 1 deletion(-) diff --git a/dzgui/views/pages/offline.py b/dzgui/views/pages/offline.py index cd1e76d..99d6d81 100644 --- a/dzgui/views/pages/offline.py +++ b/dzgui/views/pages/offline.py @@ -100,7 +100,6 @@ class ModFrame(HeadingFrame): self.tree = OfflineModTreeView(controller) self.tree.set_model(None) - self.tree.get_selection().set_mode(Gtk.SelectionMode.MULTIPLE) self.tree.set_valign(Gtk.Align.FILL) self.scrolled = NoOverlayScrolledWindow() From a8b7ea50083476faa0756f71b16ce154aa058a19 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 11 Jun 2026 19:25:08 +0900 Subject: [PATCH 55/71] chore: clear type annotation errors --- dzgui/api/mods.py | 6 +++--- dzgui/views/trees/tree_mods.py | 12 +++++++++--- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/dzgui/api/mods.py b/dzgui/api/mods.py index c1ba417..a32542e 100644 --- a/dzgui/api/mods.py +++ b/dzgui/api/mods.py @@ -53,13 +53,13 @@ def is_mission(path: Path) -> bool: file = path / "init.c" return file.exists() -def tokenize(mod: Path) -> dict[Any] | None: +def tokenize(mod: Path) -> dict[str, Any] | None: file = mod.joinpath("meta.cpp") delimiter=r"\s*=\s*" modmeta = {} try: - with open(file, "r", encoding="utf-8") as file: - for line in file: + with open(file, "r", encoding="utf-8") as f: + for line in f: line = line.strip().rstrip(";") if not line: continue diff --git a/dzgui/views/trees/tree_mods.py b/dzgui/views/trees/tree_mods.py index 2a3dd78..45ee6ef 100644 --- a/dzgui/views/trees/tree_mods.py +++ b/dzgui/views/trees/tree_mods.py @@ -62,18 +62,24 @@ class ModTreeView(ModsMixin, ContextMixin, TreeView): # type: ignore self.connect("button-press-event", self.on_row_click) - def on_row_click(self, widget: Gtk.Widget, event: Gdk.EventButton) -> None: + def on_row_click(self, widget: Gtk.TreeView, event: Gdk.EventButton) -> bool: if event.state is Gdk.ModifierType.CONTROL_MASK and event.button == 1: path_info = widget.get_path_at_pos(int(event.x), int(event.y)) if path_info is None: - return + return False path, column, cellx, celly = path_info - _iter = self.get_model().get_iter(path) + model = self.get_model() + if model is None: + return False + if path is None: + return False + _iter = model.get_iter(path) if self.get_selection().iter_is_selected(_iter): self.get_selection().unselect_iter(_iter) else: self.get_selection().select_iter(_iter) return True + return False def get_mod_man(self) -> ModManager: return self.mod_man From e1cb682e744fea4e1c9c4a7bd921edc45e0aa9c0 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 13 Jun 2026 01:50:34 +0900 Subject: [PATCH 56/71] feat: custom symlink creation in thread --- dzgui/api/steam.py | 12 +- dzgui/const/constants.py | 2 + dzgui/controllers/emitter.py | 11 +- dzgui/managers/offline.py | 90 ++++++----- dzgui/strings/offline.py | 6 + dzgui/util/symlink.py | 18 +++ dzgui/views/dialogs/filepicker.py | 11 +- dzgui/views/pages/offline.py | 253 ++++++++++++++++++++---------- 8 files changed, 273 insertions(+), 130 deletions(-) diff --git a/dzgui/api/steam.py b/dzgui/api/steam.py index b773a57..6cecbf5 100644 --- a/dzgui/api/steam.py +++ b/dzgui/api/steam.py @@ -162,9 +162,12 @@ def load_to_menu(client: str, appid: int, name: str, mods: list[str]) -> int: proc = subprocess.run([*client_args, *params]) return proc.returncode -def launch_offline(client: str, appid: int, name: str, mods: list[str], mission: str) -> int: + +def launch_offline( + client: str, appid: int, name: str, mods: list[str], mission: str +) -> int: """Launch offline with specific mods/missions""" - concat = concat_mods(mods) + symlinks = ";".join(mods) client_args = concat_bash_args(client) params = [ "-applaunch", @@ -173,12 +176,13 @@ def launch_offline(client: str, appid: int, name: str, mods: list[str], mission: "-nosplash", "-skipintro", f"-name={name}", - f"-mod={concat}", - f"-mission={mission}" + f"-mod={symlinks}", + f"-mission={mission}", ] proc = subprocess.run([*client_args, *params]) return proc.returncode + def find_user_id(path: Path) -> str | None: resolved_path = path / "config" / "loginusers.vdf" try: diff --git a/dzgui/const/constants.py b/dzgui/const/constants.py index 35b5656..78362f7 100644 --- a/dzgui/const/constants.py +++ b/dzgui/const/constants.py @@ -32,10 +32,12 @@ HEX_GREEN = "#32CD32" HEX_RED = "#FF0000" HEX_ORANGE = "#FFAC1C" +# FIXME: normalize icon names CARET_DOWN = "go-down-symbolic" CARET_UP = "go-up-symbolic" CLIPBOARD = "edit-copy-symbolic" FOLDER = "folder-symbolic" +EDIT_DELETE = "edit-delete-symbolic" ERROR = "dialog-error-symbolic" HELP_BUBBLE = "help-about-symbolic" INPUT_KEYBOARD = "input-keyboard-symbolic" diff --git a/dzgui/controllers/emitter.py b/dzgui/controllers/emitter.py index c43756c..d70c47d 100644 --- a/dzgui/controllers/emitter.py +++ b/dzgui/controllers/emitter.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Union import gi @@ -9,6 +9,7 @@ from gi.repository import Gdk, GObject # noqa E402 if TYPE_CHECKING: from dzgui.const.enum import NotebookPage, ServerTab from dzgui.model.model_factory import FastInsertListStore + from dzgui.views.pages.offline import FolderHBox # TODO: if servers fail to load, may leave dangling widgets waiting for a signal @@ -160,6 +161,14 @@ class Emitter(GObject.GObject): def custom_mods_loaded(self, store: "FastInsertListStore", folder: str) -> None: pass + @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object,)) + def custom_mods_unloaded(self, widget: "FolderHBox") -> None: + pass + @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(str, bool)) def custom_mission_loaded(self, folder: str, is_valid: bool) -> None: pass + + @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=()) + def invalid_custom_mods(self) -> None: + pass diff --git a/dzgui/managers/offline.py b/dzgui/managers/offline.py index a870402..891bbdd 100644 --- a/dzgui/managers/offline.py +++ b/dzgui/managers/offline.py @@ -1,17 +1,23 @@ +import logging from pathlib import Path -from typing import TYPE_CHECKING, Union +from typing import Callable, TYPE_CHECKING, Union +import dzgui.api.pefile as PeFile from dzgui.api.mods import is_mission, get_custom_mods from dzgui.api.steam import launch_offline +from dzgui.const.constants import APP_NAME from dzgui.const.enum import Preferences from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager from dzgui.model.model_factory import ModelFactory from dzgui.strings import dialogs -from dzgui.util.symlink import clone_symlinks +from dzgui.util.symlink import create_custom_symlinks from dzgui.views.dialogs.filepicker import FolderPicker if TYPE_CHECKING: from dzgui.controllers.mc import Controller + from dzgui.model.model_factory import FastInsertListStore + +logger = logging.getLogger(APP_NAME) class OfflineManager: @@ -35,65 +41,73 @@ class OfflineManager: if folder is None: return is_valid = is_mission(folder) - # TODO: stop storing values in here - if is_valid: - # TODO: str - self.mission_folder = str(folder) self.emitter.emit("custom_mission_loaded", str(folder), is_valid) - def get_custom_mods(self, local_mods: list[str]) -> None: + def find_custom_mods(self, callback: Callable) -> None: folder = self.open_folderpicker(dialogs.custom_mod_dialog) + window = self.controller.get_window() + window.set_sensitive(False) if folder is None: + window.set_sensitive(True) return - self.parse_custom_mods(local_mods, folder) + # NOTE: starts spinner in calling UI + callback() + self.parse_custom_mods(folder) - @call_on_thread(dialogs.parsing_mods) - def parse_custom_mods(self, local_mods: list[str], folder: str) -> None: + @call_on_thread(dialogs.parsing_mods, show_dialog=False) + def parse_custom_mods(self, folder: str) -> None: mods = get_custom_mods(Path(folder)) store = ModelFactory().make_mod_store() store.extend(mods) - - func = StoredFunc( - lambda: self.emitter.emit("custom_mods_loaded", store, folder) - ) + func = StoredFunc(self.post_mod_loading, store, folder) self.thread_man.set_cleanup_func(func) - def setup( - self, - appid: int, - mission: str = "", - local_mods: list[str] | None = None, - custom_mods: list[str] | None = None, - ) -> None: - - self.appid = appid - self.local_mods = local_mods - self.custom_mods = custom_mods - - self.launch() + def post_mod_loading(self, store: "FastInsertListStore", folder: str) -> None: + window = self.controller.get_window() + window.set_sensitive(True) + self.emitter.emit("custom_mods_loaded", store, folder) @call_on_thread(dialogs.waiting_for_launch) - def launch(self) -> None: + def launch( + self, + appid: int, + mission: str, + local_mods: list[str], + custom_folder: str, + custom_mods: list[str], + ) -> None: + + # NOTE: local_mods and custom_mods are lists of symlinks client = self.controller.query_config(Preferences.CLIENT) name = self.controller.query_config(Preferences.NAME) steam_path = self.controller.query_config(Preferences.DEFAULT) - new_symlinks: list[str] = [] - combined_mods: list[str] = [] - if self.local_mods is not None: - combined_mods.extend(self.local_mods) - if self.custom_mods is not None: - # TODO: new function that creates symlinks in game path - # based on selected mods - clone_symlinks(Path(steam_path)) + if len(local_mods) > 0: + combined_mods.extend(local_mods) + + if len(custom_mods) > 0: + new_symlinks = create_custom_symlinks( + Path(steam_path), Path(custom_folder), custom_mods + ) combined_mods.extend(new_symlinks) - - launch_offline(client, self.appid, name, combined_mods, self.mission_folder) + launch_offline(client, appid, name, combined_mods, mission) def open_folderpicker(self, title: str) -> Union["Path", None]: picker = FolderPicker(self.controller.get_window(), title) folder = picker.pick_folder() picker.destroy() return folder + + def has_dayz_exp(self) -> bool: + try: + default_steam_path = self.controller.query_config(Preferences.DEFAULT) + steam_path = Path(default_steam_path) + dayz_exp = PeFile.get_pretty_version(steam_path, APPID_DAYZ_EXP) + if dayz_exp is None: + return False + return True + except Exception as e: + logger.warning(e) + return False diff --git a/dzgui/strings/offline.py b/dzgui/strings/offline.py index 5cb5410..1c52d50 100644 --- a/dzgui/strings/offline.py +++ b/dzgui/strings/offline.py @@ -12,3 +12,9 @@ local_frame = "Installed mods" no_mods = "No valid mods found" no_mission = "Not a valid mission" + +custom_eventbox = "Set the root folder. Mods must be in atomic folders and have a meta.cpp file at a minimum." +mission_eventbox = "Select a single mission folder containing an init.c file." + +folder_prefix = "Current folder: " +unset_button = "Unset folder" diff --git a/dzgui/util/symlink.py b/dzgui/util/symlink.py index 50253b8..802597f 100644 --- a/dzgui/util/symlink.py +++ b/dzgui/util/symlink.py @@ -12,10 +12,13 @@ logger = logging.getLogger(APP_NAME) def rebuild_symlinks(config: Path) -> None: + # TODO: pass direct path as argument path = lookup(config, Preferences.DEFAULT) steam_path = Path(path) dayz_path = PeFile.get_nested_app_path(steam_path, APPID_DAYZ) # NOTE: unlink stale symlinks + # TODO: expunge ephemeral (@C) links (possibility of name collisions) + # if str(file)[:2] == "@C": file.unlink() for file in dayz_path.iterdir(): if file.is_symlink() and file.exists() is False: file.unlink() @@ -30,6 +33,21 @@ def rebuild_symlinks(config: Path) -> None: clone_symlinks(steam_path) +def create_custom_symlinks(steam_path: Path, custom_dir: Path, uids: list[str]) -> None: + dayz_path = PeFile.get_nested_app_path(steam_path, APPID_DAYZ) + links = [] + for uid in uids: + md5sum = _hash(uid, use_custom=True) + source = dayz_path.joinpath(md5sum) + target = custom_dir.joinpath(uid) + links.append(md5sum) + if source.exists() is False: + source.symlink_to(target) + clone_symlinks(steam_path) + return links + + + def clone_symlinks(steam_path: Path) -> None: """ Shares symlinks between builds. Used after any symlink operation diff --git a/dzgui/views/dialogs/filepicker.py b/dzgui/views/dialogs/filepicker.py index c8dfa93..a219f49 100644 --- a/dzgui/views/dialogs/filepicker.py +++ b/dzgui/views/dialogs/filepicker.py @@ -54,7 +54,12 @@ class FolderPicker(Gtk.FileChooserDialog): return None if res == Gtk.ResponseType.OK: folder = self.get_current_folder() - if folder is not None: - self.destroy() - return Path(folder) + if folder is None: + try: + folder = Path.from_uri(self.get_uri()) + except Exception: + # FIXME + return Path("/") + self.destroy() + return Path(folder) return None diff --git a/dzgui/views/pages/offline.py b/dzgui/views/pages/offline.py index 99d6d81..377c369 100644 --- a/dzgui/views/pages/offline.py +++ b/dzgui/views/pages/offline.py @@ -1,20 +1,22 @@ from __future__ import annotations -from pathlib import Path +from enum import Enum from typing import Self, Sequence, TYPE_CHECKING, Union from dzgui.util import css -import dzgui.api.pefile as PeFile from dzgui.const.constants import ( APPID_DAYZ, APPID_DAYZ_EXP, APPNAME_DAYZ, APPNAME_DAYZ_EXP_HUMAN, + EDIT_DELETE, + ERROR, FOLDER, ) from dzgui.const.enum import NotebookPage, Preferences from dzgui.managers.offline import OfflineManager from dzgui.strings import generic, offline -from dzgui.views.components.buttons import IconTextButton +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.trees.tree_mods import OfflineModTreeView @@ -23,7 +25,7 @@ from dzgui.views.trees.tree_mods import OfflineModTreeView import gi gi.require_version("Gtk", "3.0") -from gi.repository import Gtk, Gdk # noqa +from gi.repository import Gtk, Gdk, GObject # noqa if TYPE_CHECKING: from dzgui.controllers.mc import Controller @@ -31,6 +33,11 @@ if TYPE_CHECKING: from dzgui.model.model_factory import FastInsertListStore +class FolderError(Enum): + NO_VALID_MODS = 1 + NO_VALID_MISSION = 2 + + class GenericBox(Gtk.Box): def __init__(self, orientation: Gtk.Orientation, spacing: int = 0) -> None: super().__init__(orientation=orientation, spacing=spacing) @@ -57,15 +64,40 @@ class PageHeading(Gtk.Label): 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() + + def set_label(self, error: FolderError, msg: str) -> None: + match error: + case FolderError.NO_VALID_MODS: + prefix = offline.no_mods + case FolderError.NO_VALID_MISSION: + prefix = offline.no_mission + self.label.set_label(f"{prefix}: '{msg}'") + + class FolderHBox(HBox): - def __init__(self, btn_label: str) -> None: + def __init__(self, controller: "Controller", btn_label: str, eb_text: str) -> None: super().__init__(spacing=10) - self.set_margin_start(10) + self.set_margin_start(5) self.set_margin_end(10) self.set_margin_bottom(5) - # TODO: alternate class for left-aligned icons + self.folder = "" + self.controller = controller + self.emitter = controller.get_emitter() + + self.eb = InfoEventBox(eb_text, controller) self.button = IconTextButton(FOLDER, btn_label, Gtk.PositionType.LEFT) self.button.set_halign(Gtk.Align.START) self.button.set_image_position(Gtk.PositionType.LEFT) @@ -75,19 +107,69 @@ class FolderHBox(HBox): ) self.label = Gtk.Label() self.scrolled_label.add(self.label) - self.extend([self.button, self.scrolled_label]) + + self.unset_button = IconTextButton(EDIT_DELETE, offline.unset_button) + self.unset_button.connect("clicked", self._on_unset_clicked) + + self.spinner = Gtk.Spinner() + self.extend( + [self.eb, self.button, self.spinner, self.scrolled_label, self.unset_button] + ) + + self.pop = ErrorPopover() + self.pop.set_relative_to(self.button) + + self.connect("map", self._on_map) + self.connect("unmap", self._on_unmap) + + def start_spinner(self) -> None: + self.spinner.show() + self.spinner.start() + + def stop_spinner(self) -> None: + self.spinner.hide() + self.spinner.stop() + + def get_spinner(self) -> None: + return self.spinner + + def _on_unmap(self, widget: Self) -> None: + self.unset_button.hide() + self.label.set_label("") + self.scrolled_label.hide() + + def _on_map(self, widget: Self) -> None: + self.spinner.hide() + self.scrolled_label.show() + self.unset_button.hide() + + def _on_unset_clicked(self, button: Gtk.Button) -> None: + self.label.set_label("") + self.unset_button.hide() + # FIXME: rename signal + self.emitter.emit("custom_mods_unloaded", self) def get_button(self) -> Gtk.Button: return self.button - def set_label(self, label: str) -> None: - # TODO: strings - prefix = "Current folder: " - self.label.set_label(prefix + label) - def hide_label(self) -> None: self.label.hide() + def get_folder(self) -> str: + return self.folder + + def set_folder(self, folder: str) -> None: + prefix = offline.folder_prefix + self.folder = folder + self.label.set_markup(prefix + folder) + self.label.show() + self.unset_button.show() + + def present_error(self, error: FolderError, msg: str) -> None: + self.unset_button.hide() + self.pop.set_label(error, msg) + self.pop.popup() + class ModFrame(HeadingFrame): def __init__( @@ -120,39 +202,29 @@ class ModFrame(HeadingFrame): sel = self.tree.get_selection() sel.connect("changed", self._on_selection_changed) - # TODO: inherit icons from warnings area in preconnect dialog - # TODO: strings - self.error_label = Gtk.Label( - label="No mods found", - halign=Gtk.Align.START, - margin_start=10, - margin_bottom=5, - ) - self.vbox.pack_end(self.error_label, expand=True, fill=True, padding=3) + self.connect("unmap", self._on_unmap) - def set_error(self, msg: str) -> None: - self.error_label.set_label(msg) - self.error_label.show() + def _on_unmap(self, widget: Self) -> None: + self.tree.set_model(None) + self.tree_vbox.hide() - def hide_errors(self) -> None: - self.error_label.hide() - - def start_empty(self) -> None: - self.error_label.show() - self.collapse_tree() - - def hide_all(self) -> None: - self.error_label.hide() - self.collapse_tree() - - # TODO: warnings area - # name collision within custom mods + def start(self, store: "FastInsertListStore") -> None: + self.tree.set_model(store) + self.show_tree() def get_mods(self) -> list[str]: model, treeiters = self.tree.get_selection().get_selected_rows() if model is None: return [] - return [model[_iter][1] for _iter in treeiters] + # TODO: include mod paths for custom mods + if type(self) is CustomModFrame: + return [model[_iter][2] for _iter in treeiters] + else: + # NOTE: pre-existing, canonical symlinks to published mods + return [model[_iter][1] for _iter in treeiters] + + def show_tree(self) -> None: + self.tree_vbox.show() def collapse_tree(self) -> None: self.tree_vbox.hide() @@ -175,6 +247,8 @@ class ModFrame(HeadingFrame): else: status = f"Mods selected: {len(rows)}" self.status.set_label(status) + # TODO: cleaner delegation + # simply send int value or use emitter self.parent.check_button() def set_cursor(self) -> None: @@ -194,25 +268,39 @@ class CustomModFrame(ModFrame): self.controller = controller self.emitter = controller.get_emitter() - # TODO: descriptive text here explaining how this area works - self.custom_hbox = FolderHBox(offline.custom_button) + self.custom_hbox = FolderHBox( + controller, offline.custom_button, offline.custom_eventbox + ) self.custom_hbox.get_button().connect("clicked", self._on_custom_button_clicked) self.pack(self.custom_hbox) self.emitter.connect("custom_mods_loaded", self._on_custom_mods_loaded) + self.emitter.connect("custom_mods_unloaded", self._on_custom_mods_unloaded) + + self.connect("map", self._on_map) + + def _on_map(self, widget: Self) -> None: + self.hide_tree() + + def _on_custom_mods_unloaded(self, emitter: "Emitter", widget: FolderHBox) -> None: + # TODO: kludgy workaround for generic button emitting global signal + if widget == self.custom_hbox: + self.hide_tree() def hide_tree(self) -> None: - # TODO: no mods message is different from collision message - self.set_error(offline.no_mods) self.custom_hbox.hide_label() + self.tree.set_model(None) self.tree_vbox.hide() + def present_error(self, folder: str) -> None: + self.hide_tree() + self.custom_hbox.present_error(FolderError.NO_VALID_MODS, folder) + def present_tree(self, store: "FastInsertListStore", folder: str) -> None: - self.custom_hbox.set_label(folder) + self.custom_hbox.set_folder(folder) self.tree.set_model(store) self.tree_vbox.show() - self.hide_errors() def _on_custom_mods_loaded( self, @@ -220,14 +308,19 @@ class CustomModFrame(ModFrame): store: "FastInsertListStore", folder: str, ) -> None: + self.custom_hbox.stop_spinner() if len(store) == 0: - self.hide_tree() + self.present_error(folder) else: self.present_tree(store, folder) def _on_custom_button_clicked(self, button: Gtk.Button) -> None: - local_mods = self.get_mods() - self.parent.offline_man.get_custom_mods(local_mods) + callback = self.custom_hbox.start_spinner + # TODO: cleaner delegation + self.parent.offline_man.find_custom_mods(callback) + + def get_folder(self) -> str: + return self.custom_hbox.get_folder() class MissionFrame(HeadingFrame): @@ -238,35 +331,33 @@ class MissionFrame(HeadingFrame): self.controller = controller self.emitter = controller.get_emitter() - self.mission_hbox = FolderHBox(offline.mission_button) + self.mission_hbox = FolderHBox( + controller, offline.mission_button, offline.mission_eventbox + ) self.mission_button = self.mission_hbox.get_button() self.mission_button.connect("clicked", self._on_mission_button_clicked) - self.warning = Gtk.Label( - halign=Gtk.Align.START, margin_start=10, margin_bottom=5 - ) - self.vbox = VBox() - self.vbox.extend([self.mission_hbox, self.warning]) - self.frame.add(self.vbox) + self.frame.add(self.mission_hbox) self.emitter.connect("custom_mission_loaded", self._on_mission_loaded) def _on_mission_loaded( self, emitter: "Emitter", folder: str, is_valid: bool ) -> None: - self.mission_hbox.set_label(folder) - if not is_valid: - self.warning.show() - self.warning.set_label(offline.no_mission) + if is_valid: + self.mission_hbox.set_folder(folder) else: - self.warning.hide() + self.mission_hbox.present_error(FolderError.NO_VALID_MISSION, folder) def _on_mission_button_clicked(self, button: Gtk.Button) -> None: self.parent.offline_man.get_mission() + def get_mission(self) -> str: + return self.mission_hbox.get_folder() + class RadioFrame(HeadingFrame): - def __init__(self, controller: "Controller") -> None: + def __init__(self, parent: OfflineLoader, controller: "Controller") -> None: super().__init__(heading=offline.version) self.controller = controller @@ -286,13 +377,10 @@ class RadioFrame(HeadingFrame): self.frame.add(self.radio_box) - # TODO: abstract out of here - default_steam_path = self.controller.query_config(Preferences.DEFAULT) - steam_path = Path(default_steam_path) - dayz_exp = PeFile.get_pretty_version(steam_path, APPID_DAYZ_EXP) - + # TODO: cleaner delegation + has_dayz_exp = parent.offline_man.has_dayz_exp() self.dayz.connect("toggled", self._on_radio_toggled) - if dayz_exp is None: + if has_dayz_exp is False: self.dayz_exp.set_sensitive(False) def _on_radio_toggled(self, radio: Gtk.RadioButton) -> None: @@ -315,6 +403,7 @@ class OfflineLoader(Gtk.Box): self.controller = controller self.controller.register_widget("offline_loader", self) + self.emitter = controller.get_emitter() self.offline_man = OfflineManager(controller) self.add(PageHeading(offline.heading)) @@ -324,7 +413,7 @@ class OfflineLoader(Gtk.Box): self.custom_tree = OfflineModTreeView(controller) self.mission_frame = MissionFrame(self, controller) - self.radio_frame = RadioFrame(controller) + self.radio_frame = RadioFrame(self, controller) self.scrollable = Gtk.ScrolledWindow( vexpand=True, propagate_natural_height=True @@ -343,7 +432,7 @@ 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(5) + self.button_box.set_margin_top(15) self.back = Gtk.Button(label="Back") self.ok = Gtk.Button(label="Launch", sensitive=False) self.back.connect("clicked", self._on_back_clicked) @@ -365,28 +454,24 @@ class OfflineLoader(Gtk.Box): self.ok.set_sensitive(True) def _on_keypress(self, widget: Self, event: Gdk.EventKey) -> None: + # FIXME: widget is not always in focus if event.keyval == Gdk.KEY_Escape: self.back.emit("clicked") def populate(self, store: Union["FastInsertListStore", None]) -> None: - self.local_frame.set_model(store) - if store is None: - self.local_frame.start_empty() - else: - self.local_frame.hide_errors() # NOTE: suppress custom tree until explicitly loaded - self.custom_frame.hide_all() + if store is None: + return + self.local_frame.start(store) + # self.custom_frame.start(store) def _on_back_clicked(self, button: Gtk.Button) -> None: self.controller.open_page(NotebookPage.MODS) def _on_ok_clicked(self, button: Gtk.Button) -> None: - # TODO: grab all values in one pass - # appid = self.radio_frame.get_appid() - # mission = self.mission_frame.get_mission() - # local_mods = self.local_frame.get_mods() - # custom_mods = self.custom_frame.get_mods() - # cf. api.mods._hash(uid, use_custom=True) - # TODO: set up all symlinks prior to launch, including custom ones - # self.offline_man.setup(appid, mission, local_mods, custom_mods) - pass + appid = self.radio_frame.get_appid() + mission = self.mission_frame.get_mission() + local_mods = self.local_frame.get_mods() + custom_folder = self.custom_frame.get_folder() + custom_mods = self.custom_frame.get_mods() + self.offline_man.launch(appid, mission, local_mods, custom_folder, custom_mods) From f7a42e2cdbc67b8006d8d87f43020b9a0e8f1e55 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 13 Jun 2026 02:03:00 +0900 Subject: [PATCH 57/71] fix: clear typehinting errors --- dzgui/controllers/emitter.py | 2 +- dzgui/managers/offline.py | 2 +- dzgui/util/symlink.py | 5 +++-- dzgui/views/dialogs/filepicker.py | 7 +++++-- dzgui/views/pages/offline.py | 8 +------- 5 files changed, 11 insertions(+), 13 deletions(-) diff --git a/dzgui/controllers/emitter.py b/dzgui/controllers/emitter.py index d70c47d..c90aef7 100644 --- a/dzgui/controllers/emitter.py +++ b/dzgui/controllers/emitter.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING, Union +from typing import TYPE_CHECKING import gi diff --git a/dzgui/managers/offline.py b/dzgui/managers/offline.py index 891bbdd..99efca7 100644 --- a/dzgui/managers/offline.py +++ b/dzgui/managers/offline.py @@ -5,7 +5,7 @@ from typing import Callable, TYPE_CHECKING, Union import dzgui.api.pefile as PeFile from dzgui.api.mods import is_mission, get_custom_mods from dzgui.api.steam import launch_offline -from dzgui.const.constants import APP_NAME +from dzgui.const.constants import APP_NAME, APPID_DAYZ_EXP from dzgui.const.enum import Preferences from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager from dzgui.model.model_factory import ModelFactory diff --git a/dzgui/util/symlink.py b/dzgui/util/symlink.py index 802597f..50fd6fb 100644 --- a/dzgui/util/symlink.py +++ b/dzgui/util/symlink.py @@ -33,7 +33,9 @@ def rebuild_symlinks(config: Path) -> None: clone_symlinks(steam_path) -def create_custom_symlinks(steam_path: Path, custom_dir: Path, uids: list[str]) -> None: +def create_custom_symlinks( + steam_path: Path, custom_dir: Path, uids: list[str] +) -> list[str]: dayz_path = PeFile.get_nested_app_path(steam_path, APPID_DAYZ) links = [] for uid in uids: @@ -47,7 +49,6 @@ def create_custom_symlinks(steam_path: Path, custom_dir: Path, uids: list[str]) return links - def clone_symlinks(steam_path: Path) -> None: """ Shares symlinks between builds. Used after any symlink operation diff --git a/dzgui/views/dialogs/filepicker.py b/dzgui/views/dialogs/filepicker.py index a219f49..1f9e0b2 100644 --- a/dzgui/views/dialogs/filepicker.py +++ b/dzgui/views/dialogs/filepicker.py @@ -56,9 +56,12 @@ class FolderPicker(Gtk.FileChooserDialog): folder = self.get_current_folder() if folder is None: try: - folder = Path.from_uri(self.get_uri()) + uri = self.get_uri() + if uri is None: + return None + return Path.from_uri(uri) except Exception: - # FIXME + # FIXME: edge cases return Path("/") self.destroy() return Path(folder) diff --git a/dzgui/views/pages/offline.py b/dzgui/views/pages/offline.py index 377c369..9a3d3bc 100644 --- a/dzgui/views/pages/offline.py +++ b/dzgui/views/pages/offline.py @@ -12,7 +12,7 @@ from dzgui.const.constants import ( ERROR, FOLDER, ) -from dzgui.const.enum import NotebookPage, Preferences +from dzgui.const.enum import NotebookPage from dzgui.managers.offline import OfflineManager from dzgui.strings import generic, offline from dzgui.views.components.buttons import Icon, IconTextButton @@ -130,9 +130,6 @@ class FolderHBox(HBox): self.spinner.hide() self.spinner.stop() - def get_spinner(self) -> None: - return self.spinner - def _on_unmap(self, widget: Self) -> None: self.unset_button.hide() self.label.set_label("") @@ -216,7 +213,6 @@ class ModFrame(HeadingFrame): model, treeiters = self.tree.get_selection().get_selected_rows() if model is None: return [] - # TODO: include mod paths for custom mods if type(self) is CustomModFrame: return [model[_iter][2] for _iter in treeiters] else: @@ -459,11 +455,9 @@ class OfflineLoader(Gtk.Box): self.back.emit("clicked") def populate(self, store: Union["FastInsertListStore", None]) -> None: - # NOTE: suppress custom tree until explicitly loaded if store is None: return self.local_frame.start(store) - # self.custom_frame.start(store) def _on_back_clicked(self, button: Gtk.Button) -> None: self.controller.open_page(NotebookPage.MODS) From 85f6fa053dce2cdb41757e038c6e842ccbe2a6e8 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 13 Jun 2026 02:10:41 +0900 Subject: [PATCH 58/71] fix: disable context menu on custom mod tree --- dzgui/const/enum.py | 1 + dzgui/views/pages/offline.py | 3 ++- dzgui/views/trees/tree_mods.py | 6 ++++-- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/dzgui/const/enum.py b/dzgui/const/enum.py index c14f6fe..9e84cc5 100644 --- a/dzgui/const/enum.py +++ b/dzgui/const/enum.py @@ -185,6 +185,7 @@ class ContextMenuGroup(Enum): SERVER_MOD = (ContextMenu.OPEN_WORKSHOP,) MOD = (ContextMenu.OPEN_WORKSHOP, ContextMenu.DELETE_MOD) + MOD_OFFLINE = (None,) LOG = (ContextMenu.COPY_LOG_CLIPBOARD,) SERVER_BROWSER = ( ContextMenu.CONNECT, diff --git a/dzgui/views/pages/offline.py b/dzgui/views/pages/offline.py index 9a3d3bc..ad9642c 100644 --- a/dzgui/views/pages/offline.py +++ b/dzgui/views/pages/offline.py @@ -12,7 +12,7 @@ from dzgui.const.constants import ( ERROR, FOLDER, ) -from dzgui.const.enum import NotebookPage +from dzgui.const.enum import ContextMenuGroup, NotebookPage from dzgui.managers.offline import OfflineManager from dzgui.strings import generic, offline from dzgui.views.components.buttons import Icon, IconTextButton @@ -259,6 +259,7 @@ class CustomModFrame(ModFrame): ) -> None: super().__init__(parent, controller, heading) + self.tree.set_menu(ContextMenuGroup.MOD_OFFLINE) self.parent = parent self.controller = controller diff --git a/dzgui/views/trees/tree_mods.py b/dzgui/views/trees/tree_mods.py index 45ee6ef..64dd55c 100644 --- a/dzgui/views/trees/tree_mods.py +++ b/dzgui/views/trees/tree_mods.py @@ -23,8 +23,10 @@ logger = logging.getLogger(APP_NAME) class ModTreeView(ModsMixin, ContextMixin, TreeView): # type: ignore - def __init__(self, controller: "Controller") -> None: - super().__init__(controller, menu=ContextMenuGroup.MOD) + def __init__( + self, controller: "Controller", menu: ContextMenuGroup = ContextMenuGroup.MOD + ) -> None: + super().__init__(controller, menu=menu) self.controller = controller self.mod_man = ModManager(self, controller) From ad589ba8503c018307f4827755dffe40632c90e9 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 13 Jun 2026 03:06:19 +0900 Subject: [PATCH 59/71] fix: grab focus after popup --- dzgui/views/pages/offline.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/dzgui/views/pages/offline.py b/dzgui/views/pages/offline.py index ad9642c..120dc02 100644 --- a/dzgui/views/pages/offline.py +++ b/dzgui/views/pages/offline.py @@ -102,6 +102,8 @@ class FolderHBox(HBox): self.button.set_halign(Gtk.Align.START) self.button.set_image_position(Gtk.PositionType.LEFT) + self.set_can_focus(True) + self.scrolled_label = Gtk.ScrolledWindow( propagate_natural_width=True, halign=Gtk.Align.START ) @@ -166,6 +168,7 @@ class FolderHBox(HBox): self.unset_button.hide() self.pop.set_label(error, msg) self.pop.popup() + self.grab_focus() class ModFrame(HeadingFrame): From 55f82c2e91863e8c8bfdd94f06a3d4b0fefefc28 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 13 Jun 2026 03:06:41 +0900 Subject: [PATCH 60/71] chore: drop comment --- dzgui/views/base.py | 1 - 1 file changed, 1 deletion(-) diff --git a/dzgui/views/base.py b/dzgui/views/base.py index 7f9ae36..c6451b5 100644 --- a/dzgui/views/base.py +++ b/dzgui/views/base.py @@ -70,7 +70,6 @@ class OuterWindow(Gtk.Window): MainController.set_start_tab() self.grid.hide_widgets_on_init() - # TODO: POC, trigger page change here MainController.loaded = True MainController.populate_model(MainController.get_active_treeview()) From 513c445b241ddbfece19c71853aa4d3c50a863a3 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 13 Jun 2026 03:06:55 +0900 Subject: [PATCH 61/71] fix: restore statusbar when returning --- dzgui/views/components/statusbar.py | 1 + dzgui/views/pages/mods.py | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/dzgui/views/components/statusbar.py b/dzgui/views/components/statusbar.py index f7bc8fa..b301d09 100644 --- a/dzgui/views/components/statusbar.py +++ b/dzgui/views/components/statusbar.py @@ -99,6 +99,7 @@ class Statusbar(Gtk.Grid): self.spinner.stop() # FIXME: CalcDist is being called when table is not loaded if dist is None: + self.set_by_context(context, "") return else: pretty = self.append_distance(dist) diff --git a/dzgui/views/pages/mods.py b/dzgui/views/pages/mods.py index ee4946b..fdd1e7b 100644 --- a/dzgui/views/pages/mods.py +++ b/dzgui/views/pages/mods.py @@ -1,4 +1,5 @@ from typing import Self, TYPE_CHECKING +from dzgui.const.enum import NotebookPage from dzgui.views.components.scrollable import NoOverlayScrolledWindow from dzgui.views.trees.tree_mods import ModTreeView @@ -20,6 +21,7 @@ class Mods(Gtk.Box): self.tree.set_vexpand(True) self.box.add(self.tree) + self.statusbar_cache = "" self.controller = controller self.controller.register_widget("modtreeview", self.tree) self.emitter = controller.get_emitter() @@ -39,6 +41,7 @@ class Mods(Gtk.Box): self.connect("unmap", self._on_unmap) def _on_offline_clicked(self, button: Gtk.Button) -> None: + self.statusbar_cache = self.controller.get_statusbar().get_cache() self.controller.open_offline() def _on_unmap(self, widget: Self) -> None: @@ -46,6 +49,13 @@ class Mods(Gtk.Box): def _on_map(self, widget: Self) -> None: self.emitter.emit("mod_page_toggled", True) + # TODO: delegation + # NOTE: handles going back from offline mods page + # more generic cache restoration method + if len(self.statusbar_cache) > 0: + self.controller.get_statusbar().set_by_context( + NotebookPage.MODS, self.statusbar_cache + ) def grab_content_area(self) -> None: self.tree.grab_focus() From 8b8c6268abd13b509c3167e2d276b9919dc02fdd Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 13 Jun 2026 03:36:59 +0900 Subject: [PATCH 62/71] feat: expunge ephemeral symlinks --- dzgui/util/symlink.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/dzgui/util/symlink.py b/dzgui/util/symlink.py index 50fd6fb..7d21539 100644 --- a/dzgui/util/symlink.py +++ b/dzgui/util/symlink.py @@ -16,12 +16,13 @@ def rebuild_symlinks(config: Path) -> None: path = lookup(config, Preferences.DEFAULT) steam_path = Path(path) dayz_path = PeFile.get_nested_app_path(steam_path, APPID_DAYZ) - # NOTE: unlink stale symlinks - # TODO: expunge ephemeral (@C) links (possibility of name collisions) - # if str(file)[:2] == "@C": file.unlink() for file in dayz_path.iterdir(): + # NOTE: unlink stale symlinks if file.is_symlink() and file.exists() is False: file.unlink() + # NOTE: expunge ephemeral (custom) symlinks + if file.is_symlink() and str(file.stem)[:2] == "@C": + file.unlink() workshop = get_local_mod_path(steam_path) # NOTE: create symlinks for missing mods for mod_id in get_local_mod_ids(steam_path): From 966d93503292109d64c6d40f9730f573936def09 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 13 Jun 2026 03:52:20 +0900 Subject: [PATCH 63/71] fix: allow loading with only mission file --- dzgui/views/pages/offline.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/dzgui/views/pages/offline.py b/dzgui/views/pages/offline.py index 120dc02..85bcc75 100644 --- a/dzgui/views/pages/offline.py +++ b/dzgui/views/pages/offline.py @@ -143,6 +143,7 @@ class FolderHBox(HBox): self.unset_button.hide() def _on_unset_clicked(self, button: Gtk.Button) -> None: + self.folder = "" self.label.set_label("") self.unset_button.hide() # FIXME: rename signal @@ -348,6 +349,7 @@ class MissionFrame(HeadingFrame): self.mission_hbox.set_folder(folder) else: self.mission_hbox.present_error(FolderError.NO_VALID_MISSION, folder) + self.parent.check_button() def _on_mission_button_clicked(self, button: Gtk.Button) -> None: self.parent.offline_man.get_mission() @@ -438,6 +440,7 @@ class OfflineLoader(Gtk.Box): self.back.connect("clicked", self._on_back_clicked) self.ok.connect("clicked", self._on_ok_clicked) self.connect("key-press-event", self._on_keypress) + self.emitter.connect("custom_mods_unloaded", lambda _, __: self.check_button()) self.button_box.extend([self.back, self.ok]) @@ -448,7 +451,8 @@ class OfflineLoader(Gtk.Box): def check_button(self) -> None: local_mods = self.local_frame.get_mods() custom_mods = self.custom_frame.get_mods() - if len(local_mods) == 0 and len(custom_mods) == 0: + mission = self.mission_frame.get_mission() + if len(local_mods) == 0 and len(custom_mods) == 0 and len(mission) == 0: self.ok.set_sensitive(False) else: self.ok.set_sensitive(True) From 4b16edbbeea9046d08668f26b1e358f67add39f9 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 13 Jun 2026 04:14:28 +0900 Subject: [PATCH 64/71] fix: grab focus on unmap --- dzgui/views/pages/offline.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/dzgui/views/pages/offline.py b/dzgui/views/pages/offline.py index 85bcc75..ad26002 100644 --- a/dzgui/views/pages/offline.py +++ b/dzgui/views/pages/offline.py @@ -120,6 +120,7 @@ class FolderHBox(HBox): self.pop = ErrorPopover() self.pop.set_relative_to(self.button) + self.pop.connect("unmap", lambda _: self.grab_focus()) self.connect("map", self._on_map) self.connect("unmap", self._on_unmap) @@ -166,11 +167,11 @@ class FolderHBox(HBox): self.unset_button.show() def present_error(self, error: FolderError, msg: str) -> None: + self.folder = "" + self.label.set_text("") self.unset_button.hide() self.pop.set_label(error, msg) self.pop.popup() - self.grab_focus() - class ModFrame(HeadingFrame): def __init__( From b8230cbf43fe9083c199e6f788f58b07f248a4c1 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 14 Jun 2026 01:54:08 +0900 Subject: [PATCH 65/71] feat: make symlink to mission path --- dzgui/managers/connection.py | 1 + dzgui/managers/offline.py | 14 ++++++++++++-- dzgui/util/symlink.py | 11 +++++++++++ 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/dzgui/managers/connection.py b/dzgui/managers/connection.py index a1aa0a9..d127dab 100644 --- a/dzgui/managers/connection.py +++ b/dzgui/managers/connection.py @@ -277,6 +277,7 @@ class ConnectionManager: self.thread_man.show_cancel(False) self.thread_man.update_dialog(waiting_for_launch) while True: + # FIXME: cancel should not be visible here per setting above if self.controller.is_cancel_pending(): # TODO: some facility to also close spawned steam process return diff --git a/dzgui/managers/offline.py b/dzgui/managers/offline.py index 99efca7..b4ab039 100644 --- a/dzgui/managers/offline.py +++ b/dzgui/managers/offline.py @@ -1,4 +1,5 @@ import logging +import time from pathlib import Path from typing import Callable, TYPE_CHECKING, Union @@ -7,10 +8,11 @@ from dzgui.api.mods import is_mission, get_custom_mods from dzgui.api.steam import launch_offline from dzgui.const.constants import APP_NAME, APPID_DAYZ_EXP from dzgui.const.enum import Preferences +from dzgui.init.proc import is_dayz_running from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager from dzgui.model.model_factory import ModelFactory from dzgui.strings import dialogs -from dzgui.util.symlink import create_custom_symlinks +from dzgui.util.symlink import create_custom_symlinks, symlink_mission from dzgui.views.dialogs.filepicker import FolderPicker if TYPE_CHECKING: @@ -92,7 +94,15 @@ class OfflineManager: Path(steam_path), Path(custom_folder), custom_mods ) combined_mods.extend(new_symlinks) - launch_offline(client, appid, name, combined_mods, mission) + + if len(mission) > 0: + relative_link = symlink_mission(Path(steam_path), mission) + + launch_offline(client, appid, name, combined_mods, relative_link) + while True: + if is_dayz_running(): + break + time.sleep(1) def open_folderpicker(self, title: str) -> Union["Path", None]: picker = FolderPicker(self.controller.get_window(), title) diff --git a/dzgui/util/symlink.py b/dzgui/util/symlink.py index 7d21539..715131a 100644 --- a/dzgui/util/symlink.py +++ b/dzgui/util/symlink.py @@ -49,6 +49,17 @@ def create_custom_symlinks( clone_symlinks(steam_path) return links +def symlink_mission(steam_path: Path, target: str) -> None: + dayz_path = PeFile.get_nested_app_path(steam_path, APPID_DAYZ) + for file in dayz_path.iterdir(): + if file.is_symlink() and str(file.stem)[:2] == "@C": + file.unlink() + stem = Path(target).name + suffix = _hash(stem, use_custom=True) + source = dayz_path.joinpath(suffix) + source.symlink_to(Path(target)) + return suffix + def clone_symlinks(steam_path: Path) -> None: """ From b3b98aa407a9601424e57ccaab89bf2fc6cd8339 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 14 Jun 2026 19:11:35 +0900 Subject: [PATCH 66/71] fix: parse parent dir --- dzgui/api/mods.py | 4 ++++ dzgui/const/constants.py | 2 ++ dzgui/managers/offline.py | 2 +- dzgui/util/symlink.py | 38 ++++++++++++++++++++++++-------------- 4 files changed, 31 insertions(+), 15 deletions(-) diff --git a/dzgui/api/mods.py b/dzgui/api/mods.py index a32542e..22f1e24 100644 --- a/dzgui/api/mods.py +++ b/dzgui/api/mods.py @@ -12,6 +12,7 @@ from dzgui.api.servers import get_rules, fqip_to_record from dzgui.const.constants import ( APP_NAME, APPID_DAYZ, + DAYZ_COMMUNITY_ROOT, LIBRARYFOLDERS_PATH, WORKSHOP_PATH, ) @@ -50,6 +51,9 @@ def get_local_mods(workshop_path: Path) -> list[Path]: def is_mission(path: Path) -> bool: # TODO: parse integrity of other files + parent = path.parent.name + if parent != DAYZ_COMMUNITY_ROOT: + return False file = path / "init.c" return file.exists() diff --git a/dzgui/const/constants.py b/dzgui/const/constants.py index 78362f7..d9689d2 100644 --- a/dzgui/const/constants.py +++ b/dzgui/const/constants.py @@ -83,3 +83,5 @@ LOG_FILTERS = ("CRITICAL", "WARNING", "INFO", "DEBUG") TMP_PATH = "/tmp" TMP_TARBALL = "/tmp/dzgui.tar.gz" TMP_EXE = "/tmp/dzgui/dzgui" + +DAYZ_COMMUNITY_ROOT = "Missions" diff --git a/dzgui/managers/offline.py b/dzgui/managers/offline.py index b4ab039..c709176 100644 --- a/dzgui/managers/offline.py +++ b/dzgui/managers/offline.py @@ -12,7 +12,7 @@ from dzgui.init.proc import is_dayz_running from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager from dzgui.model.model_factory import ModelFactory from dzgui.strings import dialogs -from dzgui.util.symlink import create_custom_symlinks, symlink_mission +from dzgui.util.symlink import create_custom_symlinks, clone_symlinks, symlink_mission from dzgui.views.dialogs.filepicker import FolderPicker if TYPE_CHECKING: diff --git a/dzgui/util/symlink.py b/dzgui/util/symlink.py index 715131a..99ed87e 100644 --- a/dzgui/util/symlink.py +++ b/dzgui/util/symlink.py @@ -2,7 +2,7 @@ import logging from pathlib import Path from dzgui.api.mods import get_local_mod_path, get_local_mod_ids, _hash -from dzgui.const.constants import APPID_DAYZ, APPID_DAYZ_EXP, APP_NAME +from dzgui.const.constants import APPID_DAYZ, APPID_DAYZ_EXP, APP_NAME, DAYZ_COMMUNITY_ROOT from dzgui.const.enum import Preferences from dzgui.config.query import lookup @@ -11,18 +11,23 @@ import dzgui.api.pefile as PeFile logger = logging.getLogger(APP_NAME) +def expunge_link(file: Path) -> None: + if not file.is_symlink(): + return + # NOTE: unlink stale symlinks + if file.exists() is False: + file.unlink() + if str(file.stem)[:2] == "@C": + file.unlink() + def rebuild_symlinks(config: Path) -> None: # TODO: pass direct path as argument path = lookup(config, Preferences.DEFAULT) steam_path = Path(path) dayz_path = PeFile.get_nested_app_path(steam_path, APPID_DAYZ) for file in dayz_path.iterdir(): - # NOTE: unlink stale symlinks - if file.is_symlink() and file.exists() is False: - file.unlink() - # NOTE: expunge ephemeral (custom) symlinks - if file.is_symlink() and str(file.stem)[:2] == "@C": - file.unlink() + expunge_link(file) + workshop = get_local_mod_path(steam_path) # NOTE: create symlinks for missing mods for mod_id in get_local_mod_ids(steam_path): @@ -49,16 +54,21 @@ def create_custom_symlinks( clone_symlinks(steam_path) return links + def symlink_mission(steam_path: Path, target: str) -> None: dayz_path = PeFile.get_nested_app_path(steam_path, APPID_DAYZ) for file in dayz_path.iterdir(): - if file.is_symlink() and str(file.stem)[:2] == "@C": - file.unlink() - stem = Path(target).name - suffix = _hash(stem, use_custom=True) - source = dayz_path.joinpath(suffix) - source.symlink_to(Path(target)) - return suffix + expunge_link(file) + path = Path(target) + stem = path.name + parent = path.parent + suffix = _hash(str(parent), use_custom=True) + source = dayz_path.joinpath(DAYZ_COMMUNITY_ROOT) + try: + source.symlink_to(parent) + except Exception: + return "" + return f"{DAYZ_COMMUNITY_ROOT}/{stem}" def clone_symlinks(steam_path: Path) -> None: From cee9052dea5a75a3dfbe9d6ff81fc3b750cd82c7 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 14 Jun 2026 19:31:52 +0900 Subject: [PATCH 67/71] fix: append store to mod manager --- dzgui/managers/mods.py | 3 +++ dzgui/strings/offline.py | 1 + dzgui/views/pages/offline.py | 24 +++++++++++++++++++++++- 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/dzgui/managers/mods.py b/dzgui/managers/mods.py index 6b9974b..05e9996 100644 --- a/dzgui/managers/mods.py +++ b/dzgui/managers/mods.py @@ -66,6 +66,9 @@ class ModManager: func = StoredFunc(self._on_mods_loaded) self.thread_man.set_cleanup_func(func) + def set_store(self, store: "FastInsertListStore") -> None: + self.store = store + def _on_mods_loaded(self) -> None: self.treeview.set_model(self.store) if self.store is None: diff --git a/dzgui/strings/offline.py b/dzgui/strings/offline.py index 1c52d50..875c574 100644 --- a/dzgui/strings/offline.py +++ b/dzgui/strings/offline.py @@ -10,6 +10,7 @@ custom_button = "Set custom mod folder" local_frame = "Installed mods" +no_local_mods = "No local mods found" no_mods = "No valid mods found" no_mission = "Not a valid mission" diff --git a/dzgui/views/pages/offline.py b/dzgui/views/pages/offline.py index ad26002..892ff2f 100644 --- a/dzgui/views/pages/offline.py +++ b/dzgui/views/pages/offline.py @@ -11,6 +11,7 @@ from dzgui.const.constants import ( EDIT_DELETE, ERROR, FOLDER, + WARNING, ) from dzgui.const.enum import ContextMenuGroup, NotebookPage from dzgui.managers.offline import OfflineManager @@ -173,6 +174,7 @@ class FolderHBox(HBox): self.pop.set_label(error, msg) self.pop.popup() + class ModFrame(HeadingFrame): def __init__( self, parent: OfflineLoader, controller: "Controller", label: str @@ -198,20 +200,39 @@ class ModFrame(HeadingFrame): self.tree_vbox = VBox() self.tree_vbox.extend([self.scrolled, self.status]) + self.no_mods = HBox(spacing=10) + no_mods_icon = Icon(WARNING, margin_start=10) + no_mods_label = Gtk.Label(offline.no_local_mods, halign=Gtk.Align.START) + self.no_mods.extend([no_mods_icon, no_mods_label]) + + self.vbox.pack_end(self.no_mods, expand=True, fill=True, padding=3) self.vbox.pack_end(self.tree_vbox, expand=True, fill=True, padding=3) self.frame.add(self.vbox) sel = self.tree.get_selection() sel.connect("changed", self._on_selection_changed) + self.connect("map", self._on_map) self.connect("unmap", self._on_unmap) + def start_empty(self) -> None: + self.tree_vbox.hide() + self.no_mods.show() + + def _on_map(self, widget: Self) -> None: + self.no_mods.hide() + self.tree.set_model(None) + self.tree_vbox.hide() + def _on_unmap(self, widget: Self) -> None: self.tree.set_model(None) self.tree_vbox.hide() + self.no_mods.hide() def start(self, store: "FastInsertListStore") -> None: + # TODO: this manipulates mod man store out of band self.tree.set_model(store) + self.tree.mod_man.set_store(store) self.show_tree() def get_mods(self) -> list[str]: @@ -294,6 +315,7 @@ class CustomModFrame(ModFrame): self.custom_hbox.hide_label() self.tree.set_model(None) self.tree_vbox.hide() + self.no_mods.hide() def present_error(self, folder: str) -> None: self.hide_tree() @@ -459,12 +481,12 @@ class OfflineLoader(Gtk.Box): self.ok.set_sensitive(True) def _on_keypress(self, widget: Self, event: Gdk.EventKey) -> None: - # FIXME: widget is not always in focus if event.keyval == Gdk.KEY_Escape: self.back.emit("clicked") def populate(self, store: Union["FastInsertListStore", None]) -> None: if store is None: + self.local_frame.start_empty() return self.local_frame.start(store) From 5cb4f695007faa3f2dc1be702091ab213dbcc0ed Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 14 Jun 2026 19:45:48 +0900 Subject: [PATCH 68/71] fix: initialize string --- dzgui/api/mods.py | 1 + dzgui/managers/offline.py | 1 + 2 files changed, 2 insertions(+) diff --git a/dzgui/api/mods.py b/dzgui/api/mods.py index 22f1e24..81b6d1c 100644 --- a/dzgui/api/mods.py +++ b/dzgui/api/mods.py @@ -85,6 +85,7 @@ def get_mod_size(path: Path) -> float: def get_custom_mods(path: Path) -> list[Any]: + # TODO: rename this function for clarity ("get local vs. get custom") mods = get_local_mods(path) # TODO: error handling return parse_mods(mods, use_custom=True) diff --git a/dzgui/managers/offline.py b/dzgui/managers/offline.py index c709176..a277518 100644 --- a/dzgui/managers/offline.py +++ b/dzgui/managers/offline.py @@ -95,6 +95,7 @@ class OfflineManager: ) combined_mods.extend(new_symlinks) + relative_link = "" if len(mission) > 0: relative_link = symlink_mission(Path(steam_path), mission) From c818945a28a951583b6be1f8e818cf575e80a5d0 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 14 Jun 2026 21:54:59 +0900 Subject: [PATCH 69/71] chore: add comment --- dzgui/views/pages/offline.py | 1 + 1 file changed, 1 insertion(+) diff --git a/dzgui/views/pages/offline.py b/dzgui/views/pages/offline.py index 892ff2f..b998eea 100644 --- a/dzgui/views/pages/offline.py +++ b/dzgui/views/pages/offline.py @@ -240,6 +240,7 @@ class ModFrame(HeadingFrame): if model is None: return [] if type(self) is CustomModFrame: + # NOTE: custom mod dirs to be hashed return [model[_iter][2] for _iter in treeiters] else: # NOTE: pre-existing, canonical symlinks to published mods From 38bfef383622eaa2dc2c342397a6f6199b0831eb Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 14 Jun 2026 21:56:49 +0900 Subject: [PATCH 70/71] chore: clear typehinting errors --- dzgui/managers/offline.py | 2 +- dzgui/util/symlink.py | 3 +-- dzgui/views/pages/offline.py | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/dzgui/managers/offline.py b/dzgui/managers/offline.py index a277518..5f49f09 100644 --- a/dzgui/managers/offline.py +++ b/dzgui/managers/offline.py @@ -12,7 +12,7 @@ from dzgui.init.proc import is_dayz_running from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager from dzgui.model.model_factory import ModelFactory from dzgui.strings import dialogs -from dzgui.util.symlink import create_custom_symlinks, clone_symlinks, symlink_mission +from dzgui.util.symlink import create_custom_symlinks, symlink_mission from dzgui.views.dialogs.filepicker import FolderPicker if TYPE_CHECKING: diff --git a/dzgui/util/symlink.py b/dzgui/util/symlink.py index 99ed87e..a686dd2 100644 --- a/dzgui/util/symlink.py +++ b/dzgui/util/symlink.py @@ -55,14 +55,13 @@ def create_custom_symlinks( return links -def symlink_mission(steam_path: Path, target: str) -> None: +def symlink_mission(steam_path: Path, target: str) -> str: dayz_path = PeFile.get_nested_app_path(steam_path, APPID_DAYZ) for file in dayz_path.iterdir(): expunge_link(file) path = Path(target) stem = path.name parent = path.parent - suffix = _hash(str(parent), use_custom=True) source = dayz_path.joinpath(DAYZ_COMMUNITY_ROOT) try: source.symlink_to(parent) diff --git a/dzgui/views/pages/offline.py b/dzgui/views/pages/offline.py index b998eea..ff618a9 100644 --- a/dzgui/views/pages/offline.py +++ b/dzgui/views/pages/offline.py @@ -202,7 +202,7 @@ class ModFrame(HeadingFrame): self.no_mods = HBox(spacing=10) no_mods_icon = Icon(WARNING, margin_start=10) - no_mods_label = Gtk.Label(offline.no_local_mods, halign=Gtk.Align.START) + no_mods_label = Gtk.Label(label=offline.no_local_mods, halign=Gtk.Align.START) self.no_mods.extend([no_mods_icon, no_mods_label]) self.vbox.pack_end(self.no_mods, expand=True, fill=True, padding=3) From 43f6c17c4bb6f7fd7d2fd843b979d381b01b22fa Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 14 Jun 2026 22:02:21 +0900 Subject: [PATCH 71/71] chore: port changes from dzgui7 branch --- dzgui/api/steam.py | 11 +++-------- dzgui/managers/connection.py | 10 ++++++---- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/dzgui/api/steam.py b/dzgui/api/steam.py index 6cecbf5..63ca05f 100644 --- a/dzgui/api/steam.py +++ b/dzgui/api/steam.py @@ -73,14 +73,9 @@ def get_local_signatures(version_file: Path) -> dict[str, int]: return hashes -def enqueue_mod(mod: str, appid: int) -> None: - args = [ - "steam", - f"steam://url/CommunityFilePage/{mod}+workshop_download_item", - str(appid), - mod, - ] - subprocess.Popen(["/usr/bin/env", "bash", *args]) +def enqueue_mod(client: str, mod: str, appid: int) -> None: + client_args = concat_bash_args(client) + subprocess.Popen([*client_args, "+workshop_download_item", str(appid), mod]) def get_needs_update( diff --git a/dzgui/managers/connection.py b/dzgui/managers/connection.py index d127dab..c0d092e 100644 --- a/dzgui/managers/connection.py +++ b/dzgui/managers/connection.py @@ -88,6 +88,7 @@ class ConnectionManager: self.record: Record self.workshop: Path + self.client: str self.remote_mod_ids: list[str] = [] self.missing_mods: list[tuple[str, str, int, int]] = [] @@ -171,6 +172,7 @@ class ConnectionManager: client = self.controller.query_config(Preferences.CLIENT) running = is_steam_running(client) steam_proc = SteamProcess(client_name, running) + self.client = client game_mode = prefs.is_game_mode @@ -263,11 +265,10 @@ class ConnectionManager: def _connect_steam(self, menu_only: bool) -> None: addr = f"{self.record.ip}:{self.record.gameport}" playername = self.controller.query_config(Preferences.NAME) - client = self.controller.query_config(Preferences.CLIENT) if menu_only: - rc = load_to_menu(client, self.appid, playername, self.remote_mod_ids) + rc = load_to_menu(self.client, self.appid, playername, self.remote_mod_ids) else: - rc = connect(client, addr, self.appid, playername, self.remote_mod_ids) + rc = connect(self.client, addr, self.appid, playername, self.remote_mod_ids) if rc != 0: # TODO: log/pop the error func = StoredFunc(self.controller.update_status) @@ -299,7 +300,8 @@ class ConnectionManager: for title, mod, stamp, size in self.missing_mods: if self.controller.is_cancel_pending(): return - enqueue_mod(mod, self.appid) + enqueue_mod(self.client, mod, self.appid) + # NOTE: prevents rate limiting time.sleep(3) if raise_window is True: