From 55e6af8fe285455681bac8fc5692c61cb91881f0 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Tue, 31 Mar 2026 23:58:10 +0900 Subject: [PATCH] feat: abstract base server dialog --- CHANGELOG.md | 2 +- dzgui/api/mods.py | 4 +- dzgui/api/servers.py | 11 +-- dzgui/controllers/mc.py | 10 +- dzgui/data/CHANGELOG.md | 2 +- dzgui/main.py | 1 + dzgui/managers/connection.py | 28 ++++-- dzgui/util/format.py | 11 ++- dzgui/util/strings.py | 2 +- dzgui/views/dialogs/servers.py | 148 ++++++++++++------------------ dzgui/views/trees/tree_servers.py | 3 + 11 files changed, 110 insertions(+), 112 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ac452d..17bbf3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ - Redact API key in log table - Integrate add/connect widgets into main menu - Favorite/connect/LAN panels integrated with server views -- Colorized IP/ID validation +- Colorized IP/ID validation fields - Copy favorite server IP to clipboard - Set favorite server from tables - Detailed/copyable trace in critical error dialogs diff --git a/dzgui/api/mods.py b/dzgui/api/mods.py index 4a37fbd..c63819a 100644 --- a/dzgui/api/mods.py +++ b/dzgui/api/mods.py @@ -40,7 +40,7 @@ def get_local_mod_ids(steam_path: Path) -> list[int]: def get_local_mod_path(steam_path: Path) -> Path: - p = PeFile.get_app_path(steam_path / LIBRARYFOLDERS_PATH, APPID_DAYZ) + p = PeFile.get_app_path(steam_path / Path(LIBRARYFOLDERS_PATH), APPID_DAYZ) workshop_path = p / WORKSHOP_PATH return workshop_path @@ -109,7 +109,7 @@ def get_missing_mods(local: list, remote: list) -> list: return [mod for mod in remote if mod not in local] -# FIXME: steam path is missing +# FIXME: steam path is missing when comparing to local mods def get_server_modlist(server: Record, steam: Path) -> list: try: rules = dayzquery.dayz_rules((server.ip, server.qport)) diff --git a/dzgui/api/servers.py b/dzgui/api/servers.py index 5aee04f..ce5f6f5 100644 --- a/dzgui/api/servers.py +++ b/dzgui/api/servers.py @@ -234,7 +234,6 @@ class Ping: @dataclass(slots=True, frozen=True) class Details: - name: Union[str, None] data: Union[list, None] description: str success: bool @@ -282,16 +281,16 @@ def get_details(record: Record) -> Details: info = a2s.info((ip, qport)) name = info.server_name except TimeoutError: - return Details(None, None, default_str, False) + return Details(None, default_str, False) try: rules = dayzquery.dayz_rules((ip, qport)) - except TimeoutError: - return Details(None, None, default_str, False) + except Exception: + return Details(None, default_str, False) try: keywords = info.keywords.split(",") except AttributeError: - return Details(None, None, default_str, False) + return Details(None, default_str, False) battleye = strings.disabled if "battleye" in keywords: @@ -366,7 +365,7 @@ def get_details(record: Record) -> Details: ["Version", version], ] - return Details(name, rows, description, True) + return Details(rows, description, True) def ping(iteration: int, addr: list, qport: int, ping: int) -> Ping: diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index a77bf53..b1ae307 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -46,7 +46,7 @@ from dzgui.util import strings from dzgui.util.diag import write_diagnostic from dzgui.util.format import format_mods, format_player_count from dzgui.util.localize import number -from dzgui.util.open_links import open_user_workshop +from dzgui.util.open_links import open_user_workshop, open_workshop_page from dzgui.views.dialogs.filepicker import FilePicker from dzgui.views.dialogs.generic import ExceptionDialog, WaitDialog @@ -618,3 +618,11 @@ class Controller(GObject.GObject): def get_modlist(self, record: "Record") -> None: ConnectionManager(self).query_modlist(record) + + def get_server_name(self) -> str: + tv = self.get_active_treeview() + return tv.get_name() + + def open_workshop_page(self, mod: str) -> None: + cmd = self.query_config(Preferences.CLIENT) + open_workshop_page(mod, cmd) diff --git a/dzgui/data/CHANGELOG.md b/dzgui/data/CHANGELOG.md index 6ac452d..17bbf3a 100644 --- a/dzgui/data/CHANGELOG.md +++ b/dzgui/data/CHANGELOG.md @@ -14,7 +14,7 @@ - Redact API key in log table - Integrate add/connect widgets into main menu - Favorite/connect/LAN panels integrated with server views -- Colorized IP/ID validation +- Colorized IP/ID validation fields - Copy favorite server IP to clipboard - Set favorite server from tables - Detailed/copyable trace in critical error dialogs diff --git a/dzgui/main.py b/dzgui/main.py index 41b284b..2f8127c 100644 --- a/dzgui/main.py +++ b/dzgui/main.py @@ -91,6 +91,7 @@ def main() -> None: if has_new_config(XDG.config) is False: migrate_legacy_conf(XDG.config) migrate_cols_file(XDG.columns) + # TODO: copy notes file copy_state_files(xdg_paths["XDG_STATE_HOME"]) _format = ( diff --git a/dzgui/managers/connection.py b/dzgui/managers/connection.py index 5c677de..0cefaab 100644 --- a/dzgui/managers/connection.py +++ b/dzgui/managers/connection.py @@ -1,8 +1,11 @@ from typing import TYPE_CHECKING import dzgui.api.servers as Servers + +from dzgui.api.mods import get_local_mod_ids +from dzgui.const.enum import Preferences from dzgui.managers.thread_man import call_on_thread, StoredFunc, ThreadingManager -from dzgui.util.strings import api_warn_msg, dialog, server_timeout +from dzgui.util.strings import api_warn_msg, dialog, server_timeout, checkmark from dzgui.views.dialogs.generic import ExceptionDialog from dzgui.views.dialogs.servers import ServerDetailsDialog, ServerModDialog @@ -69,25 +72,32 @@ class ConnectionManager: @call_on_thread(dialog.querying) def query_modlist(self, record: Servers.Record) -> None: + mods = Servers.get_rules(record) - # TODO: test if locally installed - # see api.mods for this same logic - # call controller to look up steam path from ConfigManager + steam_path = self.controller.query_config(Preferences.DEFAULT) + local = get_local_mod_ids(steam_path) if len(mods) == 0: - # TODO: message for no mods - # TODO: message for actual timeout + # TODO: separate message for no mods + # TODO: separate message for actual timeout self.thread_man.set_cleanup_func( StoredFunc(self._server_timeout), destroy_first=True ) return - alpha_mods = [[mod.name, str(mod.workshop_id), "Test"] for mod in mods] + alpha_mods = [ + [ + mod.name, + str(mod.workshop_id), + checkmark if mod.workshop_id in local else "" + ] + for mod in mods + ] alpha_mods.sort(key=lambda x: x[0]) self.thread_man.set_cleanup_func( - StoredFunc(self._present_modlist_dialog, alpha_mods), destroy_first=True + StoredFunc(self._present_modlist_dialog, alpha_mods), + destroy_first=True, ) - # TODO: data type is dayzquery.DayzMod def _present_modlist_dialog(self, mods: list[str]) -> None: dialog = ServerModDialog(self.controller, mods) dialog.run() diff --git a/dzgui/util/format.py b/dzgui/util/format.py index 3d462f5..78c02bf 100644 --- a/dzgui/util/format.py +++ b/dzgui/util/format.py @@ -1,7 +1,7 @@ import re from dzgui.util.localize import number -from dzgui.util.strings import no_mods, no_servers +from dzgui.util.strings import no_mods, no_servers, workshop import gi @@ -51,6 +51,11 @@ def format_mods(size: int, mods: int) -> str: return f"Found {mods:n} {plural} taking up {l_size} MiB. {suffix}" +def format_server_mods(mods: int) -> str: + plural = pluralize("mods", mods) + return f"Found {mods:n} {plural}. {workshop}" + + def format_player_count(model: Gtk.TreeModel | None, control: list) -> str: players = 0 hits: int @@ -66,7 +71,9 @@ def format_player_count(model: Gtk.TreeModel | None, control: list) -> str: players_pretty = pluralize("players", players) hidden = control_total - hits hidden_pretty = f" ({hidden:n} hidden)" if hidden > 0 else "" - status = f"Showing {hits:n} {hits_pretty}{hidden_pretty}, {players:n} {players_pretty}." + status = ( + f"Showing {hits:n} {hits_pretty}{hidden_pretty}, {players:n} {players_pretty}." + ) return status diff --git a/dzgui/util/strings.py b/dzgui/util/strings.py index a737c30..a4d5145 100644 --- a/dzgui/util/strings.py +++ b/dzgui/util/strings.py @@ -48,7 +48,7 @@ _id = "ID" add_note = "Add note" edit_note = "Edit note" show_mods = "Show server-side mods" -show_details = "Server details" +show_details = "Show server details" refresh_players = "Refresh player count" open_workshop = "Open in Steam Workshop" delete_mod = "Delete mod" diff --git a/dzgui/views/dialogs/servers.py b/dzgui/views/dialogs/servers.py index d0abf8e..a74a9ba 100644 --- a/dzgui/views/dialogs/servers.py +++ b/dzgui/views/dialogs/servers.py @@ -1,12 +1,12 @@ -import textwrap from typing import Self, TYPE_CHECKING from dzgui.const.constants import EXPAND, FILL, NO_PADDING from dzgui.model.model_factory import ModelFactory from dzgui.util import css from dzgui.util import strings -from dzgui.util.format import format_hyperlinks +from dzgui.util.format import format_hyperlinks, format_server_mods from dzgui.views.dialogs.generic import GenericDialog +from dzgui.views.trees.tree_base import TreeView import gi @@ -15,34 +15,57 @@ from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa if TYPE_CHECKING: from dzgui.api.servers import Details + from dzgui.controllers.mc import Controller -# TODO: make a more generic base class -class ServerDetailsDialog(GenericDialog): - def __init__(self, controller, details: "Details"): +class ServerDialog(GenericDialog): + def __init__(self, controller: "Controller", title: str, secondary: str): super().__init__( controller=controller, - text=details.name, + text=title, buttons=Gtk.ButtonsType.OK, mtype=Gtk.MessageType.INFO, - secondary=strings.server_details, + secondary=secondary, ) - dialog_box = self.get_content_area() self.set_default_response(Gtk.ResponseType.OK) self.set_size_request(800, 700) - self.store = Gtk.ListStore(str, str, Pango.Weight) + self.view = TreeView(controller) + self.view.set_fixed_height_mode(True) - self.view = Gtk.TreeView( - enable_search=False, - search_column=-1, - headers_visible=False, - fixed_height_mode=True, - ) - self.view.connect("row-activated", self._on_row_activated) + self.connect("response", self._on_response) self.view.connect("key-press-event", self._on_keypress) + self.scrollable_tree = Gtk.ScrolledWindow() + self.scrollable_tree.add(self.view) + self.scrollable_tree.set_size_request(700, 400) + + self.content = self.get_content_area() + self.content.pack_start(self.scrollable_tree, EXPAND, FILL, 0) + + def pack(self, widget: Gtk.Widget) -> None: + self.content.pack_start(widget, EXPAND, FILL, NO_PADDING) + + def _on_keypress(self, view: Gtk.TreeView, event: Gdk.EventKey) -> None: + # NOTE: ESC normally unfocuses treeview instead of destroying dialog + if event.keyval == Gdk.KEY_Escape: + self.destroy() + return True + return False + + def _on_response(self, dialog: Self, response: Gtk.ResponseType) -> None: + self.destroy() + + +class ServerDetailsDialog(ServerDialog): + def __init__(self, controller, details: "Details"): + name = controller.get_server_name() + super().__init__(controller, strings.server_details, name) + + self.store = Gtk.ListStore(str, str, Pango.Weight) + self.view.connect("row-activated", self._on_row_activated) + for i, column_title in enumerate(["Item", "Details"]): renderer = Gtk.CellRendererText(xalign=0) if i == 0: @@ -55,11 +78,7 @@ class ServerDetailsDialog(GenericDialog): column.set_sort_column_id(i) column.set_expand(True) - scrollable_tree = Gtk.ScrolledWindow() - scrollable_tree.add(self.view) - scrollable_tree.set_size_request(700, 200) - - # TODO: center header text + # TODO: make "Server message" text boldface scrollable_message = Gtk.ScrolledWindow() desc = Gtk.Label(label=strings.server_message, valign=Gtk.Align.START) css.add_class(desc, "details-heading") @@ -71,8 +90,7 @@ class ServerDetailsDialog(GenericDialog): box.add(el) scrollable_message.add(box) - dialog_box.pack_start(scrollable_tree, EXPAND, FILL, NO_PADDING) - dialog_box.pack_start(scrollable_message, EXPAND, FILL, NO_PADDING) + self.pack(scrollable_message) for row in details.data: self.store.append(row + [Pango.Weight.BOLD]) @@ -81,18 +99,8 @@ class ServerDetailsDialog(GenericDialog): text = format_hyperlinks(text) self.description.set_markup(text) - self.connect("response", self._on_response) self.show_all() - def _on_keypress(self, view: Gtk.TreeView, event: Gdk.EventKey) -> None: - if event.keyval == Gdk.KEY_Escape: - self.destroy() - return True - return False - - def _on_response(self, dialog: Self, response: Gtk.ResponseType) -> None: - self.destroy() - def _on_row_activated( self, treeview: Gtk.TreeView, @@ -101,48 +109,19 @@ class ServerDetailsDialog(GenericDialog): ) -> None: self.destroy() - def __init__(self, controller, details: "Details"): - super().__init__( - controller=controller, - text=details.name, - buttons=Gtk.ButtonsType.OK, - mtype=Gtk.MessageType.INFO, - secondary=strings.server_details, - ) - -# TODO: data type is dayzquery.DayzMod - - -class ServerModDialog(GenericDialog): +class ServerModDialog(ServerDialog): def __init__(self, controller, mods: list[str]): - # TODO: center secondary text - msg = textwrap.dedent(strings.workshop) - super().__init__( - controller=controller, - text=msg, - buttons=Gtk.ButtonsType.OK, - mtype=Gtk.MessageType.INFO, - secondary="", - ) + name = controller.get_server_name() + super().__init__(controller, strings.modlist, name) + self.controller = controller self.mod_store = ModelFactory().make_server_mod_store() - dialogBox = self.get_content_area() - self.set_default_response(Gtk.ResponseType.OK) - self.set_size_request(800, 700) - - self.scrollable = Gtk.ScrolledWindow() - self.view = Gtk.TreeView( - enable_search=False, search_column=-1, fixed_height_mode=True - ) - self.scrollable.add(self.view) - - # set_surrounding_margins(self.scrollable, 20) - self.connect("response", self._on_response) - self.view.connect("row-activated", self._on_row_activated) + self.view.set_headers_visible(True) self.view.set_model(self.mod_store) + self.view.connect("row-activated", self._on_row_activated) for i, column_title in enumerate(strings.server_mod_cols): renderer = Gtk.CellRendererText(ellipsize=Pango.EllipsizeMode.END) @@ -158,37 +137,28 @@ class ServerModDialog(GenericDialog): column.set_fixed_width(200) case _: pass - dialogBox.pack_end(self.scrollable, EXPAND, FILL, 0) mod_count = len(mods) - self.set_markup(f"Modlist ({mod_count} mods)") + self._set_footer(mod_count) + for mod in mods: self.mod_store.append(mod) self.show_all() - def _on_response(self, dialog: Self, response: Gtk.ResponseType) -> None: - self.destroy() - - def _on_keypress(self, view: Gtk.TreeView, event: Gdk.EventKey) -> None: - if event.keyval == Gdk.KEY_Escape: - self.destroy() - return True - return False + def _set_footer(self, mods: int) -> None: + footer = Gtk.Label( + valign=Gtk.Align.START, justify=Gtk.Justification.CENTER, wrap=True + ) + footer_text = format_server_mods(mods) + footer.set_text(footer_text) + self.pack(footer) def _on_row_activated( self, treeview: Gtk.TreeView, - tree_iter: Gtk.TreeIter, + path: Gtk.TreePath, col: Gtk.TreeViewColumn, ) -> None: - select = treeview.get_selection() - sels = select.get_selected_rows() - (model, pathlist) = sels - if len(pathlist) < 1: - return - path = pathlist[0] - tree_iter = model.get_iter(path) - mod_id = model.get_value(tree_iter, 1) - print(mod_id) - # call_bash_func("open_workshop_page", mod_id) + mod = self.view.get_value_at_index(1) + self.controller.open_workshop_page(mod) diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index 3e2c380..3e58422 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -291,6 +291,9 @@ class ServerTreeView(ContextMixin, TreeView): return self.start_distcalc() + def get_name(self) -> str: + return self.get_value_at_index(0) + def get_simplified_ip(self) -> str: addr = self.get_value_at_index(7) qport = self.get_value_at_index(8)