diff --git a/dzgui/controllers/emitter.py b/dzgui/controllers/emitter.py index 6c2481c..55a5e5f 100644 --- a/dzgui/controllers/emitter.py +++ b/dzgui/controllers/emitter.py @@ -83,7 +83,23 @@ class Emitter(GObject.GObject): ) -> None: pass - @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(str,bool,)) + @GObject.Signal( + flags=GObject.SignalFlags.RUN_LAST, + arg_types=( + str, + bool, + ), + ) + def check_toggled(self, label: str, state: bool) -> None: + pass + + @GObject.Signal( + flags=GObject.SignalFlags.RUN_LAST, + arg_types=( + str, + bool, + ), + ) def check_toggled(self, label: str, state: bool) -> None: pass @@ -102,3 +118,15 @@ class Emitter(GObject.GObject): @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object,)) def load_maps(self, store: ListStore) -> None: pass + + @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=()) + def mod_page_loaded(self) -> None: + pass + + @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=()) + def mods_highlighted(self) -> None: + pass + + @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=()) + def api_change_failed(self) -> None: + pass diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 5a30cee..bda2b74 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -1,3 +1,4 @@ +import inspect import logging import shutil import threading @@ -93,11 +94,22 @@ class AppNavigation: filters: "FilterPanel" +class CleanupFunc: + def __init__(self, func: Callable, *args, **kwargs) -> None: + sig = inspect.signature(func) + self.func = func + self.bindings = sig.bind(*args, **kwargs) + + def call(self) -> None: + self.func(*self.bindings.args, *self.bindings.kwargs) + + class Controller(GObject.GObject): def __init__(self) -> None: self.dist_cache: dict[str, "Haversine", "ServerTab"] = {} self.mediator = AppNavigation() self.prefs: UserPrefs + self.cleanup_func: CleanupFunc = None self.model_man = ModelManager() self.emitter = Emitter() @@ -114,10 +126,14 @@ class Controller(GObject.GObject): def decorator(func: Callable) -> Callable: @wraps(func) def wrapper(*args, **kwargs): + def callback() -> None: + func(*args, **kwargs) + GLib.idle_add(self._destroy_on_idle) + self = args[0] self.wait_dialog = WaitDialog(self, dialog_str) self.wait_dialog.show_all() - thread = threading.Thread(target=func, args=args) + thread = threading.Thread(target=callback) thread.start() return wrapper @@ -156,6 +172,7 @@ class Controller(GObject.GObject): def unblock_signals(self) -> None: self.block_signals(False) + @deprecated("Currently unused") def block_signals(self, state: bool = True) -> None: self.suppress_signal( self.mediator.filters, @@ -280,6 +297,7 @@ class Controller(GObject.GObject): self.emitter.emit("distcalc_ended", dist, context) + # TODO: threading def delete_multiple_mods(self) -> None: sel = self.mediator.modtreeview.get_selection() model, pathlist = sel.get_selected_rows() @@ -290,10 +308,13 @@ class Controller(GObject.GObject): total_mods, total_size = self.calc_mod_size() self.update_mod_statusbar() + def load_mods_cleanup(self, model: Gtk.ListStore) -> None: + self.mediator.modtreeview.set_model(model) + self.emitter.emit("mod_page_loaded") + + @call_on_thread(strings.dialog.modlist) def load_mods(self) -> None: - # TODO: threading; could get slow with 100s of mods - model = self.model_man.get_mod_store() - model.clear() + model = self.model_man.new_mod_store() path = self.query_config(Preferences.DEFAULT) mods = get_delimited_mods(Path(path)) @@ -302,6 +323,8 @@ class Controller(GObject.GObject): mod.append(None) model.append(mod) + self.cleanup_func = CleanupFunc(self.load_mods_cleanup, model) + def toggle_config(self, context: Preferences) -> None: config = self.prefs.paths.config try: @@ -484,16 +507,21 @@ class Controller(GObject.GObject): except PeFile.AppNotInstalledError: pass - model = self.model_man.get_mod_store() + model = self.get_mod_store() model.remove(it) + def get_mod_store(self) -> Gtk.ListStore: + return self.mediator.modtreeview.get_model() + def format_mod_statusbar(self) -> None: total_mods, total_size = self.calc_mod_size() msg = format_mods(total_size, total_mods) return msg def calc_mod_size(self) -> tuple[int, int]: - model = self.model_man.get_mod_store() + model = self.get_mod_store() + if model is None: + return 0, 0 total_mods = len(model) total_size = 0 for mod in model: @@ -590,7 +618,7 @@ class Controller(GObject.GObject): self.open_page(NotebookPage.LOG) def select_colorized(self) -> None: - model = self.model_man.get_mod_store() + model = self.get_mod_store() sel = self.mediator.modtreeview.get_selection() for mod in model: it = mod.iter @@ -598,29 +626,14 @@ class Controller(GObject.GObject): if mod[4] == HEX_RED: sel.select_path(path) + # TODO: make as method of tree? def uncolorize_mods(self) -> None: - model = self.model_man.get_mod_store() + model = self.get_mod_store() for mod in model: it = mod.iter path = model.get_path(it) model[path][4] = None - def colorize_mods(self) -> None: - model = self.model_man.get_mod_store() - stale = find_stale_mods(self.prefs.paths.config) - for mod in model: - it = mod.iter - path = model.get_path(it) - # TODO: consider storing in ListStore as int - # TODO: clone existing model and set outside of threai - if int(mod[2]) in stale: - model[path][4] = HEX_RED - - self.destroy_on_idle() - - def unselect_all_mods(self) -> None: - self.mediator.modtreeview.get_selection().unselect_all() - def dump_test_2(self) -> None: import time @@ -810,7 +823,6 @@ class Controller(GObject.GObject): self.new_maps = None treeview.grab_focus() - self.destroy_on_idle() # if self.success is False: # # TODO: different dialogs for server tab contexts, e.g. lan timeout # # TODO: if history/favorites is empty, don't even trigger a call to dump data @@ -818,10 +830,10 @@ class Controller(GObject.GObject): # dialog.run() def cleanup_on_failure(self) -> None: - # TODO: what if refresh action occurred, and the old model is valid? treeview = self.get_active_treeview() treeview.set_model(None) map_man = treeview.get_map_man() + # TODO: what if refresh action occurred, and the old model is still valid? # TODO: disable map, keyword, and filter widgets if model is None? # -> signal driven (servers_empty) # would have to make those unsensitive when changing server tabs @@ -832,7 +844,6 @@ class Controller(GObject.GObject): # TODO: distinguish signals, e.g. "servers_failed_to_load" self.emitter.emit("servers_loaded", context) treeview.grab_focus() - self.destroy_on_idle() dialog = ExceptionDialog(self, "API TIMEOUT") dialog.run() @@ -841,7 +852,7 @@ class Controller(GObject.GObject): treeview.set_loaded(True) # TODO: wipe control model on failure or keep old results? # manager = treeview.get_filter_man() - GLib.idle_add(self.cleanup_on_failure) + self.cleanup_func = CleanupFunc(self.cleanup_on_failure) def push_data(self, data: tuple, mode: Optional[FilterMode], success: bool) -> None: @@ -866,38 +877,31 @@ class Controller(GObject.GObject): self.new_maps = sorted(u_maps) treeview.set_loaded(True) - GLib.idle_add(self.cleanup) + self.cleanup_func = CleanupFunc(self.cleanup) @call_on_thread(strings.dialog.working) def highlight_stale(self) -> None: - self.colorize_mods() + model = self.get_mod_store() + stale = find_stale_mods(self.prefs.paths.config) + for mod in model: + it = mod.iter + path = model.get_path(it) + # TODO: consider storing in ListStore as int + # FIXME: deep copy existing model and set outside of thread + if int(mod[2]) in stale: + model[path][4] = HEX_RED + self.cleanup_func = CleanupFunc(lambda: self.emitter.emit("mods_highlighted")) - def get_callback(self) -> Callable | None: - return self.callback["func"] + def get_cleanup_func(self) -> CleanupFunc: + return self.cleanup_func - def get_callback_args(self) -> Any: - return self.callback["args"] - - def set_callback(self, callback: Callable | None, *args) -> None: - """ - Lets calling widgets register a callback function - when spawning a threaded process - """ - self.callback = {"func": callback, "args": args} - - def destroy_on_idle(self) -> None: - """ - TODO: chiefly responsible for removing spinner dialog - """ + def _destroy_on_idle(self) -> None: self.wait_dialog.destroy() - func = self.get_callback() - self.mediator.window.set_sensitive(True) - # TODO: spawn error dialog if API crawl failed - # most likely going to drop callback method + func = self.get_cleanup_func() if func is not None: - args = self.get_callback_args() - GLib.idle_add(func, *args) - self.set_callback(None, None) + func.call() + self.cleanup_func = None + self.mediator.window.set_sensitive(True) def dump_diagnostics(self) -> None: picker = FilePicker(self.mediator.window) @@ -909,7 +913,8 @@ class Controller(GObject.GObject): dialog = ExceptionDialog(self, str(e)) dialog.run() - def test_api_response(self, text: str, key: Preferences) -> None: + @call_on_thread(strings.dialog.working) + def update_api_key(self, text: str, key: Preferences) -> None: if key is Preferences.STEAM: res = test_steam_api(text) else: @@ -917,17 +922,9 @@ class Controller(GObject.GObject): if res is True: self.update_config(key, text) - self.set_callback(None, None) - self.destroy_on_idle() else: - self.destroy_on_idle() - dialog = ExceptionDialog(self, strings.api_error) - dialog.run() - - @call_on_thread(strings.dialog.working) - # FIXME: entire process is behind thread, including destroy_on_idle() - def update_api_key(self, text: str, key: Preferences) -> None: - self.test_api_response(text, key) + # FIXME: dialog spawning in thread + self.cleanup_func = CleanupFunc(self.emitter.emit("api_change_failed")) def set_resolution(self, window: "OuterWindow") -> None: if self.prefs.is_game_mode: @@ -985,14 +982,12 @@ class Controller(GObject.GObject): self.push_data("", mode, success=True) # FIXME: optional label/map/keyword parameter - # FIXME: drop set_callback + # TODO: consolidate with method above def refilter_model(self, mode: FilterMode, label: Optional[str] = None) -> None: tv = self.get_active_treeview() filter_man = tv.get_filter_man() if filter_man.get_control() is None: return - # TODO: deprecated in this context? - self.set_callback(None, None) self.filter_threaded(mode, label) def populate_model(self) -> None: @@ -1001,18 +996,15 @@ class Controller(GObject.GObject): self.emitter.emit("servers_loaded", treeview.get_enum()) return - # TODO: maybe set tree to none afterwards (swap models) - # treeview.set_model(None) func = treeview.get_query_func() if func is None: self.emitter.emit("servers_loaded", treeview.get_enum()) treeview.set_model(None) return - # TODO: + # TODO: clear ephemeral model if necessary # manager = treeview.get_filter_man() # manager.clear_model() self.first_iteration = True - self.set_callback(None, None) self.run_query_func(func) def get_favorite(self) -> tuple[str, str] | tuple[None, None]: @@ -1034,9 +1026,6 @@ class Controller(GObject.GObject): def get_modlist_store(self) -> Gtk.ListStore: return self.model_man.get_modlist_store() - def get_mod_store(self) -> Gtk.ListStore: - return self.model_man.get_mod_store() - def get_log_store(self) -> Gtk.ListStore: return self.model_man.get_log_store() diff --git a/dzgui/model/misc_model.py b/dzgui/model/misc_model.py index 0fe97c9..39c93eb 100644 --- a/dzgui/model/misc_model.py +++ b/dzgui/model/misc_model.py @@ -47,8 +47,8 @@ class ModelManager: self.map_store = ListStore(str) self.help_store = self.new_model_from_class(MenuCols) - self.mod_store = self.new_model_from_class(ModCols) self.log_store = self.new_model_from_class(LogCols) + # FIXME: needs to be generated on demand self.modlist_store = self.new_model_from_class(ServerModCols) for row in HELP_MENU_ROWS: @@ -65,8 +65,8 @@ class ModelManager: def get_help_store(self) -> ListStore: return self.help_store - def get_mod_store(self) -> ListStore: - return self.mod_store + def new_mod_store(self) -> ListStore: + return self.new_model_from_class(ModCols) def get_log_store(self) -> ListStore: return self.log_store diff --git a/dzgui/views/components/mod_panel.py b/dzgui/views/components/mod_panel.py index fe9d3b3..c92e2de 100644 --- a/dzgui/views/components/mod_panel.py +++ b/dzgui/views/components/mod_panel.py @@ -16,7 +16,7 @@ if TYPE_CHECKING: class EnumeratedModButton(Gtk.Button): def __init__(self, enum: ModButton) -> None: - super().__init__(margin_start=10, margin_end=10) + super().__init__(margin_start=10, margin_end=10, focus_on_click=False) self.enum = enum self.set_label(enum.dict["label"]) @@ -30,6 +30,7 @@ class ModSelectionPanel(Gtk.Box): self.controller = controller emitter = controller.get_emitter() emitter.connect("mod_page_toggled", self._on_mod_page_toggled) + emitter.connect("mods_highlighted", self._on_mods_highlighted) header = BoldLabel(mod_panel.header) @@ -60,14 +61,17 @@ class ModSelectionPanel(Gtk.Box): for el in header, self.main_panel, self.stale_panel: self.pack_start(el, NO_EXPAND, FILL, NO_PADDING) + def _on_mods_highlighted(self, emitter: "Emitter") -> None: + self.swap_sensitive(True) + def _on_mod_page_toggled(self, emitter: "Emitter", state: bool) -> None: self.set_visible(state) - def after_colorize(self) -> None: - # TODO: split into signal that touches two widgets, - # modtreeview and this widget - self.controller.unselect_all_mods() - self.swap_sensitive(True) + #def after_colorize(self) -> None: + # # TODO: split into signal that touches two widgets, + # # modtreeview and this widget + # #self.controller.unselect_all_mods() + # self.swap_sensitive(True) def swap_sensitive(self, state: bool) -> None: for child in self.stale_panel.get_children(): @@ -79,15 +83,12 @@ class ModSelectionPanel(Gtk.Box): def _on_button_clicked(self, button: EnumeratedModButton) -> None: match button.enum: case ModButton.SELECT_ALL: - # TODO: signals self.controller.toggle_mod_selection(True) case ModButton.UNSELECT_ALL: self.controller.toggle_mod_selection(False) case ModButton.DELETE_SELECTED: self.controller.delete_multiple_mods() - case ModButton.HIGHLIGHT_STALE: - self.controller.set_callback(self.after_colorize) self.controller.highlight_stale() case ModButton.UNHIGHLIGHT_STALE: self.controller.uncolorize_mods() diff --git a/dzgui/views/components/right_panel.py b/dzgui/views/components/right_panel.py index 84a2881..a8f7232 100644 --- a/dzgui/views/components/right_panel.py +++ b/dzgui/views/components/right_panel.py @@ -44,6 +44,7 @@ class RightPanel(Gtk.Box): state = self.controller.has_server_model() for widget in (self.refresh_button, self.filters_vbox): widget.set_sensitive(state) + # FIXME: may wish to reload these in place to show new data if context in (ServerTab.RECENT, ServerTab.SAVED): self.refresh_button.set_sensitive(False) diff --git a/dzgui/views/components/statusbar.py b/dzgui/views/components/statusbar.py index 9f4a465..20f69a7 100644 --- a/dzgui/views/components/statusbar.py +++ b/dzgui/views/components/statusbar.py @@ -51,6 +51,11 @@ class Statusbar(Gtk.Grid): self.emitter.connect("distcalc_started", self._on_distcalc_started) self.emitter.connect("distcalc_ended", self._on_distcalc_ended) self.emitter.connect("servers_loaded", self._on_servers_loaded) + self.emitter.connect("mod_page_loaded", self._on_mod_page_loaded) + + def _on_mod_page_loaded(self, emitter: "Emitter") -> None: + msg = self.controller.format_mod_statusbar() + self.set_by_context(NotebookPage.MODS, msg) def _on_notebook_page_changed( self, notebook: "Notebook", child: Gtk.Widget, index: int @@ -75,12 +80,15 @@ class Statusbar(Gtk.Grid): match enum: case NotebookPage.MODS: - bar = self.controller.format_mod_statusbar() + return + # FIXME: format on callback after mods load + #bar = self.controller.format_mod_statusbar() case NotebookPage.HELP: bar = self.controller.get_help_row() case NotebookPage.SERVERS: context = self.controller.get_active_context() - self.emitter.emit("servers_loaded", context) + # FIXME: may be superfluous + #self.emitter.emit("servers_loaded", context) return case NotebookPage.KEYS: bar = question_to_return diff --git a/dzgui/views/pages/options.py b/dzgui/views/pages/options.py index 751af20..05ed658 100644 --- a/dzgui/views/pages/options.py +++ b/dzgui/views/pages/options.py @@ -37,6 +37,7 @@ from gi.repository import Gtk, Gdk # noqa if TYPE_CHECKING: from dzgui.controllers.mc import Controller + from dzgui.controllers.emitter import Emitter class Options(Gtk.Box): @@ -48,6 +49,8 @@ class Options(Gtk.Box): ) self.controller = controller + emitter = controller.get_emitter() + emitter.connect("api_change_failed", self._on_api_change_failed) self.DEFAULT_WIDTH = 1 self.DEFAULT_HEIGHT = 1 @@ -270,6 +273,9 @@ class Options(Gtk.Box): self, button: Gtk.Button, entry: Gtk.Entry, enum: Preferences ) -> None: old_text = self.controller.query_config(enum) + self.old_text = old_text + self.old_entry = entry + button.set_sensitive(False) match enum: case Preferences.NAME: @@ -277,9 +283,14 @@ class Options(Gtk.Box): self.controller.update_config(enum, value) case Preferences.BM | Preferences.STEAM: text = "".join(entry.get_text().split()) - self.controller.set_callback(self.restore_api_text, old_text, entry) + # self.controller.set_callback(self.restore_api_text, old_text, entry) self.controller.update_api_key(text, enum) + def _on_api_change_failed(self, emitter: "Emitter") -> None: + self.old_entry.set_text(self.old_text) + dialog = ExceptionDialog(self.controller, strings.api_error) + dialog.run() + def restore_api_text(self, text: str, entry: Gtk.Entry) -> None: entry.set_text(text) diff --git a/dzgui/views/trees/tree_mods.py b/dzgui/views/trees/tree_mods.py index 12d7656..0425649 100644 --- a/dzgui/views/trees/tree_mods.py +++ b/dzgui/views/trees/tree_mods.py @@ -15,6 +15,7 @@ from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa if TYPE_CHECKING: from dzgui.controllers.mc import Controller + from dzgui.controllers.emitter import Emitter logger = logging.getLogger(__name__) @@ -23,12 +24,14 @@ class ModTreeView(ModsMixin, ContextMixin, TreeView): def __init__(self, controller: "Controller") -> None: super().__init__(controller, menu=ContextMenuGroup.MOD) self.controller = controller + emitter = self.controller.get_emitter() + emitter.connect("mod_page_loaded", self._on_mod_page_loaded) + emitter.connect("mods_highlighted", self._on_mods_highlighted) self.set_fixed_height_mode(True) self.set_headers_visible(True) - mod_store = self.controller.get_mod_store() - self.set_model(mod_store) + self.set_model(None) for i, column_title in enumerate(strings.mod_cols): renderer = Gtk.CellRendererText() @@ -52,26 +55,27 @@ class ModTreeView(ModsMixin, ContextMixin, TreeView): self.connect("generic_treesel_changed", self._parent_selection_changed) self.connect("button-press-event", self.present_menu) self.connect("key-press-event", self.present_menu) - self.connect("map", self._on_map) - def _on_map(self, tree: Self) -> None: - # FIXME: placeholder logic - # identify root cause of row unfocus on init + def _on_mods_highlighted(self, emitter: "Emitter") -> None: + self.get_selection().unselect_all() + + # TODO: test loading with no mods + def _on_mod_page_loaded(self, emitter: "Emitter") -> None: self.set_cursor(0) - # def _on_mods_keypress(self, widget: Gtk.Widget, event: Gdk.EventKey) -> None: - # # TODO: multiselect - # # if event.keyval is Gdk.KEY_space: - # # it = self.get_focused_row_iter() - # # self.get_selection().select_iter(it) - # # path = self.get_focused_row_path() - # # self.set_cursor(path) - # # return False - # self.present_menu(widget, event) + # def _on_mods_keypress(self, widget: Gtk.Widget, event: Gdk.EventKey) -> None: + # # TODO: multiselect + # # if event.keyval is Gdk.KEY_space: + # # it = self.get_focused_row_iter() + # # self.get_selection().select_iter(it) + # # path = self.get_focused_row_path() + # # self.set_cursor(path) + # # return False + # self.present_menu(widget, event) - # def _on_mods_button_press(self, widget: Gtk.Widget, event: Gdk.EventButton) -> None: - # if event.button == 3: - # self.present_menu(widget, event) + # def _on_mods_button_press(self, widget: Gtk.Widget, event: Gdk.EventButton) -> None: + # if event.button == 3: + # self.present_menu(widget, event) def _parent_selection_changed(self, base_class: TreeView, sel: Gtk.TreeSelection): pass