From b9fe87cf901291932a677fa4678462b8a91b0554 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 15 Feb 2026 02:04:54 +0900 Subject: [PATCH] feat: threading with GLib WIP --- dzgui/controllers/emitter.py | 4 + dzgui/controllers/mc.py | 167 +++++++++++++++---------- dzgui/model/filtered_model.py | 14 ++- dzgui/model/misc_model.py | 8 -- dzgui/views/components/filter_panel.py | 34 +---- dzgui/views/components/mod_panel.py | 6 - dzgui/views/components/statusbar.py | 19 +-- dzgui/views/pages/options.py | 3 - dzgui/views/trees/tree_mods.py | 2 +- dzgui/views/trees/tree_servers.py | 4 +- 10 files changed, 134 insertions(+), 127 deletions(-) diff --git a/dzgui/controllers/emitter.py b/dzgui/controllers/emitter.py index 55a5e5f..c055e6c 100644 --- a/dzgui/controllers/emitter.py +++ b/dzgui/controllers/emitter.py @@ -130,3 +130,7 @@ class Emitter(GObject.GObject): @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=()) def api_change_failed(self) -> None: pass + + @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(str,)) + def keyword_set(self, keyword: str) -> None: + pass diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index bda2b74..d205a03 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -6,7 +6,7 @@ import traceback from typing import Optional from warnings import deprecated -from concurrent.futures import wait +from concurrent.futures import wait, as_completed from concurrent.futures import ThreadPoolExecutor from functools import wraps from pathlib import Path @@ -131,7 +131,7 @@ class Controller(GObject.GObject): GLib.idle_add(self._destroy_on_idle) self = args[0] - self.wait_dialog = WaitDialog(self, dialog_str) + self.wait_dialog = WaitDialog(self, dialog_str, True) self.wait_dialog.show_all() thread = threading.Thread(target=callback) thread.start() @@ -388,25 +388,38 @@ class Controller(GObject.GObject): job = Servers.query_api params = Servers.params serv = [] + i = 0 + total = 10 with ThreadPoolExecutor() as executor: futures = [executor.submit(job, key, APPID_DAYZ, param) for param in params] - wait(futures) - for future in futures: - res = future.result() - if res.status != 200 or not res.parsed: - # TODO: pop warning dialog, create enum around various failure states - # TODO: if first iteration, disable map combo. otherwise, do nothing - self.new_maps = None - self.push_data_failure() # None, FilterMode.INITIAL, success=False) - return - j = res.json - serv += j["response"]["servers"] + #wait(futures) + for future in as_completed(futures): + try: + i += 1 + GLib.idle_add(lambda: self.wait_dialog.prog.set_fraction(i/total)) + res = future.result(timeout=3) + if res.status != 200 or not res.parsed: + # TODO: pop warning dialog, create enum around various failure states + # set failure type here + self.cleanup_func = CleanupFunc(self.cleanup_on_failure) + return + j = res.json + serv += j["response"]["servers"] + except Exception as e: + # TODO: save exception text + print(e) + self.cleanup_func = CleanupFunc(self.cleanup_on_failure) + """ TODO: e.g. class method like 'total' + total of 10 params for this call + @call_on_thread(msg, jobs=10) + if total > 1, pop progress bar GLib.idle_add(lambda: self.wait_dialog.format_secondary_text("Pass 2")) - GLib.idle_add(lambda: self.wait_dialog.prog.set_fraction(0.5)) subclass or wrap add fraction and increment, then - calculate off of total + GLib.idle_add(lambda: self.wait_dialog.prog.set_fraction(0.5)) + subclass or wrap add fraction and increment, then calculate off of total + This next step is allowed to fail, since this metadata is incidental """ res = Servers.query_api(key, APPID_DAYZ_EXP, "") if res.status == 200 and res.parsed is True: @@ -416,11 +429,11 @@ class Controller(GObject.GObject): GLib.idle_add( lambda: self.wait_dialog.format_secondary_text("Unpacking servers") ) + GLib.idle_add(lambda: self.wait_dialog.prog.set_fraction(10/total)) # TODO: additional ping column pass, collated - # GLib.idle_add(lambda: self.wait_dialog.prog.set_fraction(0.75)) parsed = Servers.parse_json(serv) - self.push_data(parsed, FilterMode.INITIAL, success=True) + self.push_data_success(parsed, FilterMode.INITIAL) def get_help_row(self) -> str: tv = self.mediator.menu @@ -484,9 +497,22 @@ class Controller(GObject.GObject): # mod = model.get(tree_iter, 2)[0] # return mod, tree_iter + def get_mod_from_tree_path( + self, tree_path: Gtk.TreePath + ) -> tuple[str, Gtk.TreeIter]: + model = self.get_mod_store() + tree_iter = model.get_iter(tree_path) + mod = model.get(tree_iter, 2)[0] + return mod, tree_iter + + def delete_single_mod_cleanup(self, _iter: Gtk.TreeIter) -> None: + self.get_mod_store().remove(_iter) + + # TODO: strings + @call_on_thread("deleting mod") def delete_single_mod(self, tree_path: Gtk.TreePath) -> None: config = self.prefs.paths.config - mod, it = self.model_man.get_mod_from_tree_path(tree_path) + mod, _iter = self.get_mod_from_tree_path(tree_path) path = lookup(config, Preferences.DEFAULT) steam_path = Path(path) @@ -507,8 +533,7 @@ class Controller(GObject.GObject): except PeFile.AppNotInstalledError: pass - model = self.get_mod_store() - model.remove(it) + self.cleanup_func = CleanupFunc(self.delete_single_mod_cleanup, _iter) def get_mod_store(self) -> Gtk.ListStore: return self.mediator.modtreeview.get_model() @@ -551,29 +576,37 @@ class Controller(GObject.GObject): # update config file with IP case ContextMenu.ADD_SERVER: pass + # spawn edit dialog and update cache, notes file case ContextMenu.ADD_NOTE: pass + case ContextMenu.COPY_CLIPBOARD: self.copy_ip(path) + case ContextMenu.COPY_NAME: self.copy_name(path) + case ContextMenu.DELETE_MOD: self.delete_single_mod(path) # TODO: connect to emitter automatically # Gtk.TreeModel, row-inserted/row-deleted # updates statusbar - self.update_mod_statusbar() + # FIXME: signal should instead be emitted off of treeview when rows added/inserted + #self.update_mod_statusbar() remove_stale_signatures( self.prefs.paths.config, self.prefs.paths.version ) + # call a2s on thread and update ephemeral model in situ case ContextMenu.REFRESH_PLAYERS: pass + # update history model, update tab label, pop off of queue, write new list into file # see dq.py case ContextMenu.REMOVE_HISTORY: pass + # reverse of ADD_SERVER case ContextMenu.REMOVE_SERVER: pass @@ -627,6 +660,7 @@ class Controller(GObject.GObject): sel.select_path(path) # TODO: make as method of tree? + # FIXME: move cursor when finished def uncolorize_mods(self) -> None: model = self.get_mod_store() for mod in model: @@ -794,15 +828,12 @@ class Controller(GObject.GObject): False, ], ) - self.push_data(data, FilterMode.INITIAL, success=True) - - # TODO: eg dedicated cleanup on failure, cleanup on sucess - # these can pop their own predefined dialogs, much simpler + self.push_data_success(data, FilterMode.INITIAL) def get_map_man(self) -> "MapManager": return self.get_active_treeview().get_map_man() - def cleanup(self) -> None: + def cleanup_on_success(self) -> None: treeview = self.get_active_treeview() treeview.set_loaded(True) treeview.set_model(self.to_insert) @@ -823,21 +854,15 @@ class Controller(GObject.GObject): self.new_maps = None treeview.grab_focus() - # if self.success is False: - # # TODO: different dialogs for server tab contexts, e.g. lan timeout - # # TODO: if history/favorites is empty, don't even trigger a call to dump data - # dialog = ExceptionDialog(self, "API TIMEOUT") - # dialog.run() def cleanup_on_failure(self) -> None: treeview = self.get_active_treeview() treeview.set_model(None) + treeview.set_loaded(True) map_man = treeview.get_map_man() # TODO: what if refresh action occurred, and the old model is still valid? - # TODO: disable map, keyword, and filter widgets if model is None? + # TODO: disable map, keyword, and filter widgets if model is None # -> signal driven (servers_empty) - # would have to make those unsensitive when changing server tabs - # if model is not None when changing tab, emit other signal map_man.set_unique_maps(None) context = self.get_active_context() @@ -847,50 +872,49 @@ class Controller(GObject.GObject): dialog = ExceptionDialog(self, "API TIMEOUT") dialog.run() - def push_data_failure(self) -> None: - treeview = self.get_active_treeview() - treeview.set_loaded(True) - # TODO: wipe control model on failure or keep old results? - # manager = treeview.get_filter_man() - self.cleanup_func = CleanupFunc(self.cleanup_on_failure) - - def push_data(self, data: tuple, mode: Optional[FilterMode], success: bool) -> None: + #def push_data_failure(self) -> None: + # #treeview = self.get_active_treeview() + # #treeview.set_loaded(True) + # # TODO: wipe control model on failure or keep old results? + # # manager = treeview.get_filter_man() + # self.cleanup_func = CleanupFunc(self.cleanup_on_failure) + def push_data_success(self, data: tuple, mode: Optional[FilterMode]) -> None: + # FIXME: set outside of thread treeview = self.get_active_treeview() manager = treeview.get_filter_man() self.to_insert = None - # TODO: drop - self.success = success - if success: - if data is None: - self.to_insert = None - else: - # TODO: consolidate into filter manager - if mode == FilterMode.INITIAL: - manager.set_control(data) - manager.filter(mode) - self.to_insert = manager.get_model() - # TODO: pre parse maps - u_maps = set([row[1] for row in data]) - self.new_maps = sorted(u_maps) + if data is None: + self.to_insert = None + else: + # TODO: consolidate into filter manager + if mode == FilterMode.INITIAL: + manager.set_control(data) + manager.filter(mode) + self.to_insert = manager.get_model() + # TODO: pre parse maps + u_maps = set([row[1] for row in data]) + self.new_maps = sorted(u_maps) treeview.set_loaded(True) - self.cleanup_func = CleanupFunc(self.cleanup) + self.cleanup_func = CleanupFunc(self.cleanup_on_success) - @call_on_thread(strings.dialog.working) - def highlight_stale(self) -> None: + def highlight_stale_cleanup(self, stale_mods: list) -> None: model = self.get_mod_store() - stale = find_stale_mods(self.prefs.paths.config) for mod in model: it = mod.iter path = model.get_path(it) - # TODO: consider storing in ListStore as int - # FIXME: deep copy existing model and set outside of thread - if int(mod[2]) in stale: + if int(mod[2]) in stale_mods: model[path][4] = HEX_RED - self.cleanup_func = CleanupFunc(lambda: self.emitter.emit("mods_highlighted")) + self.emitter.emit("mods_highlighted") + + + @call_on_thread(strings.dialog.working) + def highlight_stale(self) -> None: + stale = find_stale_mods(self.prefs.paths.config) + self.cleanup_func = CleanupFunc(self.highlight_stale_cleanup, stale) def get_cleanup_func(self) -> CleanupFunc: return self.cleanup_func @@ -923,8 +947,7 @@ class Controller(GObject.GObject): if res is True: self.update_config(key, text) else: - # FIXME: dialog spawning in thread - self.cleanup_func = CleanupFunc(self.emitter.emit("api_change_failed")) + self.cleanup_func = CleanupFunc(lambda: self.emitter.emit("api_change_failed")) def set_resolution(self, window: "OuterWindow") -> None: if self.prefs.is_game_mode: @@ -976,13 +999,15 @@ class Controller(GObject.GObject): @call_on_thread(strings.dialog.filtering) def filter_threaded(self, mode: FilterMode, label: str) -> None: + # FIXME: call outside of thread tv = self.get_active_treeview() filter_man = tv.get_filter_man() filter_man.filter(mode, label) - self.push_data("", mode, success=True) + # TODO: why pushing empty data? + self.push_data_success("", mode) - # FIXME: optional label/map/keyword parameter # TODO: consolidate with method above + # TODO: call filter_man methods directly def refilter_model(self, mode: FilterMode, label: Optional[str] = None) -> None: tv = self.get_active_treeview() filter_man = tv.get_filter_man() @@ -1001,9 +1026,11 @@ class Controller(GObject.GObject): self.emitter.emit("servers_loaded", treeview.get_enum()) treeview.set_model(None) return + # TODO: clear ephemeral model if necessary # manager = treeview.get_filter_man() # manager.clear_model() + self.first_iteration = True self.run_query_func(func) @@ -1034,7 +1061,9 @@ class Controller(GObject.GObject): # TODO: filterman calls back to here, gets convoluted def get_keyword(self) -> str: - return self.mediator.filters.get_keyword_filter() + tv = self.get_active_treeview() + filter_man = tv.get_filter_man() + return filter_man.get_keyword_filter() def get_map_store(self) -> Gtk.ListStore: map_man = self.get_map_man() diff --git a/dzgui/model/filtered_model.py b/dzgui/model/filtered_model.py index 9a6d07e..84be7df 100644 --- a/dzgui/model/filtered_model.py +++ b/dzgui/model/filtered_model.py @@ -14,6 +14,7 @@ from gi.repository import GObject, GLib # noqa E402 if TYPE_CHECKING: from dzgui.controllers.mc import Controller + from dzgui.controllers.emitter import Emitter @dataclass(slots=True, frozen=True) @@ -44,6 +45,8 @@ class FilteredModelManager: def __init__(self, controller: "Controller") -> None: self.controller = controller + self.emitter = controller.get_emitter() + self.emitter.connect("keyword_set", self._on_keyword_set) self.filter_cache = {} self.ping_cache: dict[str, int] = {} @@ -55,6 +58,14 @@ class FilteredModelManager: self.filtered: list = None self.success = True + self.keyword_filter = "" + + def get_keyword_filter(self) -> str: + return self.keyword_filter + + def _on_keyword_set(self, emitter: "Emitter", keyword: str) -> None: + self.keyword_filter = keyword + def append_row(self, row: list) -> None: self.ephemeral_model.append(row) @@ -120,6 +131,7 @@ class FilteredModelManager: self.set_cache(filters, clone, rows) self.set_model(clone) + return clone def sort_rows(self, rows: list) -> list: rows.sort(key=lambda x: re.sub(r"[^A-Za-z0-9]+", "", x[0].lower())) @@ -148,7 +160,7 @@ class FilteredModelManager: return rows def filter_keyword(self, filters: tuple) -> list: - keyword = self.controller.get_keyword() + keyword = self.keyword_filter rows = self.filtered if keyword == "": diff --git a/dzgui/model/misc_model.py b/dzgui/model/misc_model.py index 39c93eb..340a156 100644 --- a/dzgui/model/misc_model.py +++ b/dzgui/model/misc_model.py @@ -80,11 +80,3 @@ class ModelManager: def append_map(self, row: list) -> None: self.map_store.append(row) - - def get_mod_from_tree_path( - self, tree_path: Gtk.TreePath - ) -> tuple[str, Gtk.TreeIter]: - model = self.get_mod_store() - tree_iter = model.get_iter(tree_path) - mod = model.get(tree_iter, 2)[0] - return mod, tree_iter diff --git a/dzgui/views/components/filter_panel.py b/dzgui/views/components/filter_panel.py index e59fb86..86ed56f 100644 --- a/dzgui/views/components/filter_panel.py +++ b/dzgui/views/components/filter_panel.py @@ -3,7 +3,6 @@ from typing import Literal, TYPE_CHECKING from dzgui.const.enum import FilterMode from dzgui.const.constants import EXPAND, NO_EXPAND, NO_FILL, NO_PADDING, SEARCH_ICON -from dzgui.util import strings from dzgui.util.margins import set_surrounding_margins from dzgui.views.components.labels import BoldLabel @@ -17,7 +16,6 @@ logger = logging.getLogger(__name__) if TYPE_CHECKING: from dzgui.controllers.mc import Controller from dzgui.controllers.emitter import Emitter - from dzgui.const.enum import ServerTab class ButtonGrid(Gtk.Grid): @@ -84,8 +82,8 @@ class KeywordEntry(Gtk.Entry): # TODO :strings super().__init__(placeholder_text="Filter by keyword") - self.keyword_filter = "" self.controller = controller + self.emitter = controller.get_emitter() self.connect("activate", self._on_activated) self.connect("key-press-event", self._on_keypress) self.connect("icon-release", lambda *args: self.activate()) @@ -93,9 +91,6 @@ class KeywordEntry(Gtk.Entry): self.set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, SEARCH_ICON) self.set_icon_activatable(Gtk.EntryIconPosition.SECONDARY, True) - def get_keyword(self) -> str: - return self.keyword_filter - def _on_keypress(self, entry: Gtk.Entry, event: Gdk.EventKey) -> bool: match event.keyval: case Gdk.KEY_Up: @@ -110,13 +105,15 @@ class KeywordEntry(Gtk.Entry): def _on_activated(self, entry: Gtk.Entry) -> None: # TODO: investigate this method self.controller.mediator.window.set_keep_below(False) + keyword = entry.get_text().lower() - if keyword == self.keyword_filter: + if keyword == self.controller.get_keyword(): return if keyword.isspace(): return + logger.info(f"User filtered by keyword '{keyword}'") - self.keyword_filter = keyword + self.emitter.emit("keyword_set", keyword) self.controller.refilter_model(FilterMode.KEYWORD, keyword) @@ -124,22 +121,6 @@ class FilterPanel(Gtk.Box): def __init__(self, controller: "Controller") -> None: super().__init__(spacing=6, vexpand=False, orientation=Gtk.Orientation.VERTICAL) - # TODO: get these from metamanager - # self.default_filters = { - # strings.filter_1pp: True, - # strings.filter_day: True, - # strings.filter_empty: False, - # strings.filter_3pp: True, - # strings.filter_night: True, - # strings.filter_full: False, - # strings.filter_lowpop: True, - # strings.filter_nonascii: False, - # strings.filter_duplicate: False, - # strings.filter_official: True, - # strings.filter_unofficial: True, - # strings.filter_modded: True, - # } - self.controller = controller self.controller.register_widget("filters", self) self.emitter = self.controller.get_emitter() @@ -225,15 +206,12 @@ class FilterPanel(Gtk.Box): enabled = self.controller.get_enabled_filters() filters = [] filters.append(selected) - filters.append(self.get_keyword_filter()) + filters.append(self.controller.get_keyword()) for filt in enabled: if enabled[filt] is False: filters.append(filt) return tuple(filters) - def get_keyword_filter(self) -> str: - return self.keyword_entry.get_keyword() - # TODO: should be used when switching ServerTab contexts # use signals here # def reinit_panel(self) -> None: diff --git a/dzgui/views/components/mod_panel.py b/dzgui/views/components/mod_panel.py index c92e2de..5bc4552 100644 --- a/dzgui/views/components/mod_panel.py +++ b/dzgui/views/components/mod_panel.py @@ -67,12 +67,6 @@ class ModSelectionPanel(Gtk.Box): def _on_mod_page_toggled(self, emitter: "Emitter", state: bool) -> None: self.set_visible(state) - #def after_colorize(self) -> None: - # # TODO: split into signal that touches two widgets, - # # modtreeview and this widget - # #self.controller.unselect_all_mods() - # self.swap_sensitive(True) - def swap_sensitive(self, state: bool) -> None: for child in self.stale_panel.get_children(): if child.enum == ModButton.HIGHLIGHT_STALE: diff --git a/dzgui/views/components/statusbar.py b/dzgui/views/components/statusbar.py index 20f69a7..8b1bd6c 100644 --- a/dzgui/views/components/statusbar.py +++ b/dzgui/views/components/statusbar.py @@ -11,6 +11,7 @@ from gi.repository import Gtk, GObject # noqa E402 if TYPE_CHECKING: from dzgui.const.enum import ServerTab from dzgui.controllers.mc import Controller + from dzgui.controllers.emitter import Emitter from dzgui.views.trees.tree_base import TreeView from dzgui.views.base import Notebook @@ -79,19 +80,19 @@ class Statusbar(Gtk.Grid): return match enum: - case NotebookPage.MODS: - return - # FIXME: format on callback after mods load - #bar = self.controller.format_mod_statusbar() case NotebookPage.HELP: bar = self.controller.get_help_row() - case NotebookPage.SERVERS: - context = self.controller.get_active_context() - # FIXME: may be superfluous - #self.emitter.emit("servers_loaded", context) - return + # TODO: drop + #case NotebookPage.MODS: + # return + #case NotebookPage.SERVERS: + # context = self.controller.get_active_context() + # # self.emitter.emit("servers_loaded", context) + # return case NotebookPage.KEYS: bar = question_to_return + case _: + return self.set_by_context(enum, bar) diff --git a/dzgui/views/pages/options.py b/dzgui/views/pages/options.py index 05ed658..270b82e 100644 --- a/dzgui/views/pages/options.py +++ b/dzgui/views/pages/options.py @@ -283,7 +283,6 @@ class Options(Gtk.Box): self.controller.update_config(enum, value) case Preferences.BM | Preferences.STEAM: text = "".join(entry.get_text().split()) - # self.controller.set_callback(self.restore_api_text, old_text, entry) self.controller.update_api_key(text, enum) def _on_api_change_failed(self, emitter: "Emitter") -> None: @@ -305,8 +304,6 @@ class Options(Gtk.Box): # TODO: unimplemented print("UNIMPLEMENTED") # wait_msg = strings.dialog.updating_mods - # show_wait_dialog = True - # call_on_thread(show_wait_dialog, cmd, wait_msg, "") def _on_client_changed(self, combo: Gtk.ComboBoxText) -> None: # TODO: use two columns or constants here, not strings diff --git a/dzgui/views/trees/tree_mods.py b/dzgui/views/trees/tree_mods.py index 0425649..bb09334 100644 --- a/dzgui/views/trees/tree_mods.py +++ b/dzgui/views/trees/tree_mods.py @@ -1,5 +1,5 @@ import logging -from typing import Any, Self, TYPE_CHECKING +from typing import Any, TYPE_CHECKING from dzgui.util import strings, localize from dzgui.views.trees.tree_base import TreeView diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index a8df4fd..8423ef2 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -77,8 +77,8 @@ class ServerTreeView(ContextMixin, TreeView): column.set_sizing(Gtk.TreeViewColumnSizing.FIXED) column.set_resizable(True) column.set_sort_column_id(i) - if column_title == "Ping": - column.set_cell_data_func(renderer, self._get_ping) + #if column_title == "Ping": + # column.set_cell_data_func(renderer, self._get_ping) if valid_json: try: