diff --git a/gramps/gen/config.py b/gramps/gen/config.py index 015241357d5..a13c3526d46 100644 --- a/gramps/gen/config.py +++ b/gramps/gen/config.py @@ -249,6 +249,7 @@ def emit(key): register("interface.ignore-gexiv2", False) register("interface.ignore-pil", False) register("interface.ignore-osmgpsmap", False) +register("interface.keybinding-theme", "Default") register("interface.main-window-height", 500) register("interface.main-window-horiz-position", 15) register("interface.main-window-vert-position", 10) diff --git a/gramps/gen/const.py b/gramps/gen/const.py index 6d7e4146aa3..4cc5e182c0d 100644 --- a/gramps/gen/const.py +++ b/gramps/gen/const.py @@ -196,6 +196,13 @@ MERGE_GLADE = os.path.join(GLADE_DIR, "mergedata.glade") RULE_GLADE = os.path.join(GLADE_DIR, "rule.glade") +# Bundled keyboard shortcut themes (e.g. "Default", an editable snapshot +# of the shipped defaults -- "Reset All to Factory Defaults" in the +# shortcuts editor is the authoritative, platform-correct reset, since it +# reads live defaults rather than a static file); user-created themes live +# under VERSION_DIR/keybinding_themes instead. +KEYBINDING_THEMES_DIR = os.path.join(ROOT_DIR, "gui", "keybinding_themes") + PLUGINS_DIR = os.path.join(ROOT_DIR, "plugins") diff --git a/gramps/gui/configure.py b/gramps/gui/configure.py index 5ad0b5d3974..5d8136af96f 100644 --- a/gramps/gui/configure.py +++ b/gramps/gui/configure.py @@ -51,7 +51,12 @@ # ------------------------------------------------------------------------- from gramps.gen.config import config from gramps.gen.const import GRAMPS_LOCALE as glocale -from gramps.gen.const import USER_DATA, URL_WIKISTRING, URL_MANUAL_PAGE +from gramps.gen.const import ( + USER_DATA, + URL_WIKISTRING, + URL_MANUAL_PAGE, + VERSION_DIR, +) from gramps.gen.datehandler import get_date_formats from gramps.gen.display.name import displayer as _nd from gramps.gen.display.name import NameDisplayError @@ -68,8 +73,11 @@ from gramps.gen.lib import Date, FamilyRelType from gramps.gen.lib import Name, Surname, NameOriginType from .managedwindow import ManagedWindow +from .uimanager import accel_display_label +from .uimanager import theme_dirs as _theme_dirs +from .uimanager import theme_path as _resolve_theme_path from .widgets import MarkupLabel, BasicLabel -from .dialog import ErrorDialog, OkDialog +from .dialog import ErrorDialog, OkDialog, QuestionDialog2 from .editors.editplaceformat import EditPlaceFormat from .display import display_help from gramps.gen.plug.utils import available_updates @@ -674,6 +682,7 @@ class GrampsPreferences(ConfigureDialog): "warnings", "researcher", PANEL_INTEGRATIONS, + "shortcuts", ) def __init__(self, uistate, dbstate, initial_panel: str | None = None) -> None: @@ -690,6 +699,7 @@ def __init__(self, uistate, dbstate, initial_panel: str | None = None) -> None: self.add_warnings_panel, self.add_researcher_panel, self.add_integrations_panel, + self.add_shortcuts_panel, ) ConfigureDialog.__init__( self, @@ -795,6 +805,363 @@ def add_researcher_panel(self, configdialog): return _("Researcher"), scroll_window + def add_shortcuts_panel(self, configdialog): + """ + Add the Keyboard Shortcuts tab to the preferences. + """ + vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) + vbox.set_border_width(12) + + search = Gtk.SearchEntry() + search.set_placeholder_text(_("Search actions or shortcuts…")) + search.connect("search-changed", self.cb_accel_search_changed) + vbox.pack_start(search, False, False, 0) + + # columns: group name, action label, shortcut display, action id, + # current raw shortcut + self.accel_store = Gtk.ListStore(str, str, str, str, str) + self._accel_search_text = "" + self.accel_filter = self.accel_store.filter_new() + self.accel_filter.set_visible_func(self.__accel_row_visible) + self.__populate_accel_store() + + accel_tree = Gtk.TreeView(model=self.accel_filter) + self.accel_tree = accel_tree + + group_column = Gtk.TreeViewColumn(_("Category"), Gtk.CellRendererText(), text=0) + group_column.set_sort_column_id(0) + accel_tree.append_column(group_column) + + label_column = Gtk.TreeViewColumn(_("Action"), Gtk.CellRendererText(), text=1) + label_column.set_expand(True) + accel_tree.append_column(label_column) + + accel_renderer = Gtk.CellRendererAccel() + accel_renderer.set_property("editable", True) + accel_renderer.set_property("accel-mode", Gtk.CellRendererAccelMode.OTHER) + accel_renderer.connect("accel-edited", self.cb_accel_edited) + accel_renderer.connect("accel-cleared", self.cb_accel_cleared) + accel_column = Gtk.TreeViewColumn(_("Shortcut"), accel_renderer, text=2) + accel_tree.append_column(accel_column) + + scroll = Gtk.ScrolledWindow() + scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + scroll.set_shadow_type(Gtk.ShadowType.IN) + scroll.set_hexpand(True) + scroll.set_vexpand(True) + scroll.add(accel_tree) + vbox.pack_start(scroll, True, True, 0) + + theme_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + theme_box.pack_start(Gtk.Label(label=_("Theme:")), False, False, 0) + + self.theme_combo = Gtk.ComboBoxText() + self.__populate_theme_combo() + self.theme_combo.connect("changed", self.cb_theme_switch) + theme_box.pack_start(self.theme_combo, True, True, 0) + + new_theme_button = Gtk.Button.new_with_mnemonic(_("_New Theme…")) + new_theme_button.connect("clicked", self.cb_theme_new) + theme_box.pack_start(new_theme_button, False, False, 0) + + vbox.pack_start(theme_box, False, False, 0) + + button_box = Gtk.ButtonBox(orientation=Gtk.Orientation.HORIZONTAL) + button_box.set_layout(Gtk.ButtonBoxStyle.START) + button_box.set_spacing(6) + + reset_button = Gtk.Button.new_with_mnemonic(_("_Reset to Factory Default")) + reset_button.connect("clicked", self.cb_accel_reset_selected) + button_box.add(reset_button) + + reset_all_button = Gtk.Button.new_with_mnemonic( + _("Reset _All to Factory Defaults") + ) + reset_all_button.connect("clicked", self.cb_accel_reset_all) + button_box.add(reset_all_button) + + vbox.pack_start(button_box, False, False, 0) + + return _("Keyboard Shortcuts"), vbox + + # Internal ActionGroup names are not meant for display; map the ones + # that show up in the shortcut editor's Category column to something + # a user can make sense of. Anything not listed here (e.g. per-view + # group names) is already a readable label and is shown as-is. + _CATEGORY_DISPLAY_NAMES = { + "RO": _("General (available without an editable tree)"), + "RW": _("General (requires an editable tree)"), + "FS": _("FamilySearch"), + "FileWindow": _("File"), + "Undo": _("Undo"), + "Redo": _("Redo"), + "AppActions": _("Application"), + "RecentFiles": _("Recent Files"), + "Bookmarks": _("Bookmarks"), + "Tag": _("Tags"), + "viewmenu": _("View switching"), + "AtPopupActions": _("Context menu"), + "Format": _("Text formatting"), + } + + @classmethod + def __category_display(cls, group_name: str) -> str: + """Return a human-readable Category label for an action group name.""" + return cls._CATEGORY_DISPLAY_NAMES.get(group_name, group_name) + + def __populate_accel_store(self) -> None: + """(Re)fill the shortcut table from the running UIManager.""" + self.accel_store.clear() + uimanager = self.uistate.uimanager + actions = uimanager.list_actions() + menu_action_ids = uimanager.menu_action_ids() + categories_with_menu = { + self.__category_display(action["group_name"]) + for action in actions + if action["action_id"] in menu_action_ids + } + + def display_category(action: dict[str, str]) -> str: + category = self.__category_display(action["group_name"]) + if category in categories_with_menu: + return _("%s (menu)") % category + return category + + actions.sort(key=lambda action: (display_category(action), action["label"])) + for action in actions: + accel = action["current_accel"] + self.accel_store.append( + [ + display_category(action), + action["label"], + accel_display_label(accel), + action["action_id"], + accel, + ] + ) + + def __refresh_row(self, row_iter: Gtk.TreeIter, action_id: str) -> None: + """Sync one row of the shortcut table with the current binding.""" + accel = self.uistate.uimanager.get_accel(action_id) + self.accel_store[row_iter][2] = accel_display_label(accel) + self.accel_store[row_iter][4] = accel + + def __refresh_row_by_action(self, action_id: str) -> None: + for row in self.accel_store: + if row[3] == action_id: + self.__refresh_row(row.iter, action_id) + return + + def __save_active_theme(self) -> None: + """Persist the current shortcuts into the active theme's file.""" + theme_name = config.get("interface.keybinding-theme") + theme_dir = os.path.join(VERSION_DIR, "keybinding_themes") + os.makedirs(theme_dir, exist_ok=True) + path = os.path.join(theme_dir, f"{theme_name}.jsonl") + self.uistate.uimanager.save_accels(path, only_changed=False) + + def __accel_row_visible(self, model, row_iter, *_args) -> bool: + text = self._accel_search_text + if not text: + return True + return ( + text in (model[row_iter][0] or "").lower() + or text in (model[row_iter][1] or "").lower() + or text in (model[row_iter][2] or "").lower() + ) + + def cb_accel_search_changed(self, entry) -> None: + """Filter the shortcut table as the search text changes.""" + self._accel_search_text = entry.get_text().lower() + self.accel_filter.refilter() + + def cb_accel_edited( + self, renderer, path, accel_key, accel_mods, hardware_keycode + ) -> None: + """Apply a newly captured shortcut, handling conflicts.""" + accel = Gtk.accelerator_name(accel_key, accel_mods) + child_path = self.accel_filter.convert_path_to_child_path( + Gtk.TreePath.new_from_string(path) + ) + row_iter = self.accel_store.get_iter(child_path) + action_id = self.accel_store[row_iter][3] + old_accel = self.accel_store[row_iter][4] + uimanager = self.uistate.uimanager + + reason = uimanager.check_accel(accel) + if reason: + ErrorDialog( + _("Shortcut Not Allowed"), + _('"%(accel)s" cannot be used: %(reason)s') + % {"accel": accel_display_label(accel), "reason": reason}, + parent=self.window, + ) + self.__refresh_row(row_iter, action_id) + return + + conflicts = uimanager.set_accel(action_id, accel) + if conflicts: + other_labels = ", ".join( + uimanager.get_action_label(other) for other in conflicts + ) + question = QuestionDialog2( + _("Shortcut Already in Use"), + _( + '"%(accel)s" is already assigned to: %(actions)s.\n' + "Reassign it to this action instead?" + ) + % {"accel": accel_display_label(accel), "actions": other_labels}, + _("_Reassign"), + _("_Cancel"), + self.window, + ) + if not question.run(): + if old_accel: + uimanager.set_accel(action_id, old_accel) + else: + uimanager.clear_accel(action_id) + self.__refresh_row(row_iter, action_id) + return + for other in conflicts: + uimanager.clear_accel(other) + self.__refresh_row_by_action(other) + + self.__refresh_row(row_iter, action_id) + self.__save_active_theme() + + def cb_accel_cleared(self, renderer, path) -> None: + """Remove a shortcut.""" + child_path = self.accel_filter.convert_path_to_child_path( + Gtk.TreePath.new_from_string(path) + ) + row_iter = self.accel_store.get_iter(child_path) + action_id = self.accel_store[row_iter][3] + self.uistate.uimanager.clear_accel(action_id) + self.__refresh_row(row_iter, action_id) + self.__save_active_theme() + + def cb_accel_reset_selected(self, button) -> None: + """Reset the selected action's shortcut to its factory default.""" + model, tree_iter = self.accel_tree.get_selection().get_selected() + if tree_iter is None: + return + child_iter = model.convert_iter_to_child_iter(tree_iter) + action_id = self.accel_store[child_iter][3] + self.uistate.uimanager.reset_accel(action_id) + self.__refresh_row(child_iter, action_id) + self.__save_active_theme() + + def cb_accel_reset_all(self, button) -> None: + """Reset every shortcut back to its factory default, after + confirming.""" + question = QuestionDialog2( + _("Reset All Shortcuts?"), + _( + "This will discard all of your customized keyboard " + "shortcuts and restore the factory defaults." + ), + _("_Reset All"), + _("_Cancel"), + self.window, + ) + if not question.run(): + return + uimanager = self.uistate.uimanager + for row in self.accel_store: + uimanager.reset_accel(row[3]) + self.__refresh_row(row.iter, row[3]) + self.__save_active_theme() + + def __list_themes(self) -> list[str]: + """Return every available theme name, bundled and user, deduped.""" + names: set[str] = set() + for theme_dir in _theme_dirs(): + try: + filenames = os.listdir(theme_dir) + except OSError: + continue + names.update(f[: -len(".jsonl")] for f in filenames if f.endswith(".jsonl")) + return sorted(names) + + def __populate_theme_combo(self) -> None: + """Fill the theme combo and select the active theme, falling + back to the first available theme if it no longer exists.""" + self.theme_combo.remove_all() + themes = self.__list_themes() + for name in themes: + self.theme_combo.append_text(name) + active_theme = config.get("interface.keybinding-theme") + try: + self.theme_combo.set_active(themes.index(active_theme)) + except ValueError: + if themes: + self.theme_combo.set_active(0) + + def __ask_theme_name(self) -> str | None: + """Prompt for a theme name; return it, or None if cancelled/empty.""" + dialog = Gtk.Dialog( + title=_("New Keyboard Shortcuts Theme"), + transient_for=self.window, + modal=True, + ) + dialog.add_buttons( + _("_Cancel"), Gtk.ResponseType.CANCEL, _("_Create"), Gtk.ResponseType.OK + ) + box = dialog.get_content_area() + box.set_border_width(12) + box.set_spacing(6) + box.add(Gtk.Label(label=_("Theme name:"))) + entry = Gtk.Entry() + entry.set_activates_default(True) + box.add(entry) + dialog.set_default_response(Gtk.ResponseType.OK) + dialog.show_all() + try: + response = dialog.run() + name = entry.get_text().strip() + finally: + dialog.destroy() + if response != Gtk.ResponseType.OK or not name: + return None + # keep it filesystem-safe -- it becomes ".jsonl" + return "".join(c for c in name if c not in '/\\:*?"<>|') + + def cb_theme_switch(self, combo) -> None: + """Make the selected theme active, replacing the current shortcuts + with its bindings.""" + name = self.theme_combo.get_active_text() + if not name: + return + path = _resolve_theme_path(name) + if not path: + return + try: + self.uistate.uimanager.load_accels(path, merge=False) + except OSError as err: + ErrorDialog(_("Could Not Load Theme"), str(err), parent=self.window) + return + self.__populate_accel_store() + config.set("interface.keybinding-theme", name) + config.save() + + def cb_theme_new(self, button) -> None: + """Save the current shortcuts as a new named theme and make it the + active theme.""" + name = self.__ask_theme_name() + if not name: + return + theme_dir = os.path.join(VERSION_DIR, "keybinding_themes") + os.makedirs(theme_dir, exist_ok=True) + path = os.path.join(theme_dir, f"{name}.jsonl") + try: + self.uistate.uimanager.save_accels(path, only_changed=False) + except OSError as err: + ErrorDialog(_("Could Not Save Theme"), str(err), parent=self.window) + return + config.set("interface.keybinding-theme", name) + config.save() + self.__populate_theme_combo() + def add_idformats_panel(self, configdialog): """ Add the ID prefix tab to the preferences. diff --git a/gramps/gui/editors/editprimary.py b/gramps/gui/editors/editprimary.py index 88919ae4614..646fbecf9d8 100644 --- a/gramps/gui/editors/editprimary.py +++ b/gramps/gui/editors/editprimary.py @@ -143,6 +143,7 @@ def _setup_notebook_tabs(self, notebook): child.set_parent_notebook(notebook) notebook.connect("key-press-event", self.key_pressed, notebook) + self._wire_tab_accels(notebook) def key_pressed(self, obj, event, notebook): """ @@ -153,6 +154,36 @@ def key_pressed(self, obj, event, notebook): if not pag == -1: notebook.get_nth_page(pag).key_pressed(obj, event) + def _wire_tab_accels(self, notebook): + """ + Bind Alt+1..Alt+9 (or the user's customized keys) to jump directly + to the corresponding notebook tab. + + A bare Gtk.Dialog gets no automatic accel routing from + GtkApplication, so this uses its own Gtk.AccelGroup -- the same + pattern as ManagedWindow._wire_dialog_accels for + dialog-ok/dialog-cancel. + """ + if not self.uistate: + return + uimanager = self.uistate.uimanager + accel_group = Gtk.AccelGroup() + self.window.add_accel_group(accel_group) + for i in range(1, min(9, notebook.get_n_pages()) + 1): + accel = uimanager.get_accel(f"app.dialog-goto-tab-{i}") + if not accel: + continue + key, mods = Gtk.accelerator_parse(accel) + if not key: + continue + page_index = i - 1 + accel_group.connect( + key, + mods, + Gtk.AccelFlags.VISIBLE, + lambda *_a, idx=page_index: notebook.set_current_page(idx) or True, + ) + def _switch_page_on_dnd(self, widget, context, x, y, time, notebook, page_no): if notebook.get_current_page() != page_no: notebook.set_current_page(page_no) diff --git a/gramps/gui/glade.py b/gramps/gui/glade.py index 24c94a755c3..f0f572d3235 100644 --- a/gramps/gui/glade.py +++ b/gramps/gui/glade.py @@ -37,6 +37,8 @@ # ------------------------------------------------------------------------ import sys import os +import re +import xml.etree.ElementTree as ET from gi.repository import Gtk # ------------------------------------------------------------------------ @@ -47,6 +49,159 @@ from gramps.gen.const import GLADE_DIR, GRAMPS_LOCALE as glocale from gramps.gen.constfunc import is_quartz +# ------------------------------------------------------------------------ +# +# Glade accelerator scanning/override support +# +# ------------------------------------------------------------------------ + +# Only the modifiers actually used by tags in Gramps' glade +# files need to be here; GDK_META_MASK covers the mac remap below, which +# runs before this and turns GDK_CONTROL_MASK into GDK_META_MASK in the +# text. +_GDK_MASK_TOKENS = { + "GDK_SHIFT_MASK": "", + "GDK_CONTROL_MASK": "", + "GDK_META_MASK": "", + "GDK_MOD1_MASK": "", + "GDK_SUPER_MASK": "", + "GDK_HYPER_MASK": "", +} + + +def _glade_modifiers_to_prefix(modifiers): + """Convert a glade `modifiers="GDK_X_MASK|GDK_Y_MASK"` attribute value + into an accelerator-string modifier prefix, e.g. ''. + """ + tokens = [] + for name in modifiers.split("|"): + name = name.strip() + if name in _GDK_MASK_TOKENS: + tokens.append(_GDK_MASK_TOKENS[name]) + return "".join(tokens) + + +def _find_glade_label(obj): + """Find a human-readable label for a glade : prefer its + tooltip-text property, fall back to its accessible-name. Only the + first line is used, since some tooltips add further explanation on + subsequent lines. + """ + for name in ("tooltip-text", "AtkObject::accessible-name"): + prop = obj.find('.//property[@name="%s"]' % name) + if prop is not None and prop.text: + return prop.text.split("\n", 1)[0] + return None + + +def iter_glade_accelerators(xml_text): + """Yield (object_id, accel, label) for every element in + a glade XML string. + + :param xml_text: the glade file contents, already read from disk + :type xml_text: str + :returns: object_id is the id of the accelerator's enclosing + ; accel is a Gtk accelerator string, e.g. 'a'; + label is pulled from that object's tooltip or accessible-name, + falling back to the raw object id + :rtype: Iterator[tuple[str, str, str]] + """ + tree = ET.fromstring(xml_text) + for obj in tree.iter("object"): + obj_id = obj.get("id") + if not obj_id: + continue + for accel_el in obj.findall("accelerator"): + key = accel_el.get("key") + if not key: + continue + accel = _glade_modifiers_to_prefix(accel_el.get("modifiers", "")) + key + label = _find_glade_label(obj) or obj_id + yield obj_id, accel, label + + +_ACCEL_TOKEN_RE = re.compile(r"<[A-Za-z]+>") +_ACCEL_TOKEN_TO_MASK = { + "": "GDK_SHIFT_MASK", + "": "GDK_CONTROL_MASK", + "": "GDK_CONTROL_MASK", + "": "GDK_CONTROL_MASK", + "": "GDK_MOD1_MASK", + "": "GDK_SUPER_MASK", + "": "GDK_HYPER_MASK", + "": "GDK_META_MASK", +} + + +def _accel_to_glade_key_modifiers(accel): + """Convert an accelerator string like 'a' into the + (key, modifiers) pair glade's attributes expect, e.g. + ('a', 'GDK_CONTROL_MASK|GDK_SHIFT_MASK'). Pure string parsing -- does + not touch Gtk/Gdk, so unlike Gtk.accelerator_parse() it's safe to call + without a real display connection. + + :param accel: a Gtk accelerator string + :type accel: str + :returns: (key, modifiers), or (None, None) if accel has no key part + :rtype: tuple[str | None, str | None] + """ + key = _ACCEL_TOKEN_RE.sub("", accel) + if not key: + return None, None + mask_names = [] + for token in _ACCEL_TOKEN_RE.findall(accel): + mask = _ACCEL_TOKEN_TO_MASK.get(token) + if mask and mask not in mask_names: + mask_names.append(mask) + return key, "|".join(mask_names) + + +def apply_glade_accel_overrides(xml_text, file_stem): + """Rewrite key/modifiers attributes in a glade XML + string to reflect saved user overrides, leaving everything else + untouched. + + :param xml_text: the glade file contents + :type xml_text: str + :param file_stem: the glade file's name without extension, used as + part of the action id namespace (see iter_glade_accelerators) + :type file_stem: str + :returns: the original text, unchanged, if there's no running + application to read overrides from, or nothing to override; + otherwise the rewritten XML + :rtype: str + """ + if "True True none + Add a new place True diff --git a/gramps/gui/grampsgui.py b/gramps/gui/grampsgui.py index 595fb282586..705b5a4a1aa 100644 --- a/gramps/gui/grampsgui.py +++ b/gramps/gui/grampsgui.py @@ -703,7 +703,7 @@ def startgramps(errors, argparser): # we do the following import here to avoid the Gtk require version warning -from .uimanager import UIManager +from .uimanager import UIManager, theme_path from gramps.gen.constfunc import is_quartz @@ -721,8 +721,18 @@ def do_startup(self): self.uimanager.show_groups = ["OSX"] self.uimanager.update_menu(init=True) - if os.path.exists(os.path.join(DATA_DIR, "gramps.accel")): - self.uimanager.load_accels(os.path.join(DATA_DIR, "gramps.accel")) + # load_accels() already tolerates a malformed *line* on its own + # (see its docstring), but still raises OSError for a file it + # can't open at all, and startup must never fail just because a + # hand-edited keybinding file is missing or unreadable. + try: + if os.path.exists(os.path.join(DATA_DIR, "gramps.jsonl")): + self.uimanager.load_accels(os.path.join(DATA_DIR, "gramps.jsonl")) + active_theme = theme_path(config.get("interface.keybinding-theme")) + if active_theme: + self.uimanager.load_accels(active_theme, merge=True) + except Exception: + LOG.exception("Failed to load keyboard shortcut overrides") try: from .dialog import ErrorDialog diff --git a/gramps/gui/keybinding_themes/Default.jsonl b/gramps/gui/keybinding_themes/Default.jsonl new file mode 100644 index 00000000000..0c7290e7721 --- /dev/null +++ b/gramps/gui/keybinding_themes/Default.jsonl @@ -0,0 +1,189 @@ +{"id": "app.about", "label": "About", "category": "AppActions", "accel": ""} +{"id": "app.dialog-cancel", "label": "Cancel Dialog", "category": "Dialogs", "accel": "c"} +{"id": "app.dialog-ok", "label": "Accept Dialog (OK)", "category": "Dialogs", "accel": "o"} +{"id": "app.preferences", "label": "Preferences...", "category": "AppActions", "accel": ""} +{"id": "app.quit", "label": "Quit", "category": "AppActions", "accel": "q"} +{"id": "glade.book.button52", "label": "Add an item to the book", "category": "Book Editor", "accel": "a"} +{"id": "glade.book.button62", "label": "Save current set of configured selections", "category": "Book Editor", "accel": "r"} +{"id": "glade.book.button64", "label": "Manage previously created books", "category": "Book Editor", "accel": "s"} +{"id": "glade.editaddress.date_stat", "label": "Invoke date editor", "category": "Address Editor", "accel": "d"} +{"id": "glade.editaddress.private", "label": "Private", "category": "Address Editor", "accel": "p"} +{"id": "glade.editattribute.private", "label": "Private", "category": "Attribute Editor", "accel": "p"} +{"id": "glade.editchildref.edit", "label": "Open person editor of this child", "category": "Child Reference Editor", "accel": "e"} +{"id": "glade.editchildref.private", "label": "Private", "category": "Child Reference Editor", "accel": "p"} +{"id": "glade.editcitation.date_stat", "label": "Invoke date editor", "category": "Citation Editor", "accel": "d"} +{"id": "glade.editcitation.privacy", "label": "Private", "category": "Citation Editor", "accel": "p"} +{"id": "glade.editdate.newyear", "label": "Month-Day of first day of new year (e.g., \"1-1\", \"3-1\", \"3-25\")", "category": "Date Editor", "accel": "w"} +{"id": "glade.editevent.add_del_place", "label": "Add a new place", "category": "Event Editor", "accel": "a"} +{"id": "glade.editevent.date_stat", "label": "Invoke date editor", "category": "Event Editor", "accel": "d"} +{"id": "glade.editevent.private", "label": "Private", "category": "Event Editor", "accel": "p"} +{"id": "glade.editevent.select_place", "label": "Place", "category": "Event Editor", "accel": "s"} +{"id": "glade.editeventref.eer_date_stat", "label": "Invoke date editor", "category": "Event Reference Editor", "accel": "d"} +{"id": "glade.editeventref.eer_ref_priv", "label": "Private", "category": "Event Reference Editor", "accel": "p"} +{"id": "glade.editfamily.fbutton_edit", "label": "Edit", "category": "Family Editor", "accel": "f"} +{"id": "glade.editfamily.mbutton_edit", "label": "Edit", "category": "Family Editor", "accel": "m"} +{"id": "glade.editfamily.private", "label": "Indicates if the record is private", "category": "Family Editor", "accel": "p"} +{"id": "glade.editldsord.date_stat", "label": "Invoke date editor", "category": "LDS Ordinance Editor", "accel": "d"} +{"id": "glade.editldsord.parents_select", "label": "Select Family", "category": "LDS Ordinance Editor", "accel": "s"} +{"id": "glade.editldsord.private", "label": "Private", "category": "LDS Ordinance Editor", "accel": "p"} +{"id": "glade.editlink.button1", "label": "Link", "category": "Link Editor", "accel": "s"} +{"id": "glade.editmedia.date_edit", "label": "Invoke date editor", "category": "Media Editor", "accel": "d"} +{"id": "glade.editmedia.private", "label": "Private", "category": "Media Editor", "accel": "p"} +{"id": "glade.editmediaref.date_edit", "label": "Invoke date editor", "category": "Media Reference Editor", "accel": "d"} +{"id": "glade.editmediaref.private", "label": "Private", "category": "Media Reference Editor", "accel": "p"} +{"id": "glade.editname.date_stat", "label": "Invoke date editor", "category": "Name Editor", "accel": "d"} +{"id": "glade.editname.priv", "label": "Private", "category": "Name Editor", "accel": "p"} +{"id": "glade.editnote.private", "label": "Private", "category": "Note Editor", "accel": "p"} +{"id": "glade.editperson.editnamebtn", "label": "Go to Name Editor to add more information about this name", "category": "Person Editor", "accel": "e"} +{"id": "glade.editperson.multsurnamebtn", "label": "Use Multiple Surnames", "category": "Person Editor", "accel": "a"} +{"id": "glade.editperson.private", "label": "Set person as private data", "category": "Person Editor", "accel": "p"} +{"id": "glade.editpersonref.add_del", "label": "Select a person that has an association to the edited person.", "category": "Person Reference Editor", "accel": "s"} +{"id": "glade.editpersonref.private", "label": "Private", "category": "Person Reference Editor", "accel": "p"} +{"id": "glade.editpersonref.select", "label": "Select a person that has an association to the edited person.", "category": "Person Reference Editor", "accel": "s"} +{"id": "glade.editplace.private", "label": "Private", "category": "Place Editor", "accel": "p"} +{"id": "glade.editplacename.date_stat", "label": "Invoke date editor", "category": "Place Name Editor", "accel": "d"} +{"id": "glade.editplaceref.private", "label": "Private", "category": "Place Reference Editor", "accel": "p"} +{"id": "glade.editreporef.private_ref", "label": "Indicates if the record is private", "category": "Repository Reference Editor", "accel": "p"} +{"id": "glade.editrepository.private", "label": "Indicates if the record is private", "category": "Repository Editor", "accel": "p"} +{"id": "glade.editsource.private", "label": "Indicates if the record is private", "category": "Source Editor", "accel": "p"} +{"id": "glade.editurl.priv", "label": "Private", "category": "Internet Address Editor", "accel": "p"} +{"id": "glade.importprogen.imp_citation_priv", "label": "Private", "category": "ProGen Import Assistant", "accel": "p"} +{"id": "glade.importprogen.imp_source_priv", "label": "Private", "category": "ProGen Import Assistant", "accel": "p"} +{"id": "glade.importprogen.tag_default_date_btn", "label": "Invoke date editor", "category": "ProGen Import Assistant", "accel": "d"} +{"id": "glade.notrelated.tagcombo", "label": "tagcombo", "category": "Not Related Tool", "accel": "t"} +{"id": "ste.BOLD", "label": "Bold", "category": "Note Editor", "accel": "b"} +{"id": "ste.CLEAR", "label": "Clear Markup", "category": "Note Editor", "accel": ""} +{"id": "ste.FONTCOLOR", "label": "Font Color", "category": "Note Editor", "accel": ""} +{"id": "ste.HIGHLIGHT", "label": "Background Color", "category": "Note Editor", "accel": ""} +{"id": "ste.ITALIC", "label": "Italic", "category": "Note Editor", "accel": "i"} +{"id": "ste.LINK", "label": "Link", "category": "Note Editor", "accel": ""} +{"id": "ste.STRIKETHROUGH", "label": "Strikethrough", "category": "Note Editor", "accel": "s"} +{"id": "ste.STRedo", "label": "Redo", "category": "Note Editor", "accel": "z"} +{"id": "ste.STUndo", "label": "Undo", "category": "Note Editor", "accel": "z"} +{"id": "ste.SUBSCRIPT", "label": "Subscript", "category": "Note Editor", "accel": "r"} +{"id": "ste.SUPERSCRIPT", "label": "Superscript", "category": "Note Editor", "accel": "p"} +{"id": "ste.UNDERLINE", "label": "Underline", "category": "Note Editor", "accel": "u"} +{"id": "win.Abandon", "label": "Abandon Changes and Quit", "category": "RO", "accel": ""} +{"id": "win.Add", "label": "Add", "category": "Notes", "accel": "Insert"} +{"id": "win.AddBook", "label": "Add Bookmark", "category": "Notes", "accel": "d"} +{"id": "win.AddParents", "label": "Add Parents", "category": "Relationships", "accel": ""} +{"id": "win.AddSpouse", "label": "Add Spouse", "category": "Relationships", "accel": ""} +{"id": "win.AddonManager", "label": "Addon Manager...", "category": "FileWindow", "accel": ""} +{"id": "win.Back", "label": "Go Back", "category": "Notes", "accel": "Left"} +{"id": "win.Backup", "label": "Make Backup...", "category": "RO", "accel": ""} +{"id": "win.Books", "label": "Books...", "category": "RO", "accel": ""} +{"id": "win.Bottombar", "label": "Bottombar", "category": "Notes", "accel": "b"} +{"id": "win.ChangeOrder", "label": "Change Order", "category": "Relationships", "accel": ""} +{"id": "win.CitationAdd", "label": "Citation", "category": "RW", "accel": "c"} +{"id": "win.Clipboard", "label": "Clipboard", "category": "RW", "accel": "b"} +{"id": "win.Close", "label": "Close", "category": "RO", "accel": "w"} +{"id": "win.ConfigView", "label": "Configure...", "category": "RW", "accel": "c"} +{"id": "win.CopyToClipboard", "label": "Copy to Clipboard", "category": "Notes", "accel": "c"} +{"id": "win.Edit", "label": "Edit", "category": "Notes", "accel": "Return"} +{"id": "win.EditBook", "label": "Edit Bookmarks", "category": "Notes", "accel": "d"} +{"id": "win.EventAdd", "label": "Event", "category": "RW", "accel": "e"} +{"id": "win.Export", "label": "Export...", "category": "RO", "accel": "e"} +{"id": "win.ExportTab", "label": "Export View", "category": "Notes", "accel": ""} +{"id": "win.ExtraPlugins", "label": "Extra Reports/Tools", "category": "FileWindow", "accel": ""} +{"id": "win.F2", "label": "F2", "category": "Pedigree", "accel": "F2"} +{"id": "win.FAQ", "label": "FAQ", "category": "FileWindow", "accel": ""} +{"id": "win.FamilyAdd", "label": "Family", "category": "RW", "accel": "f"} +{"id": "win.FilterEdit", "label": "Edit Filter", "category": "Notes", "accel": ""} +{"id": "win.Forward", "label": "Go Forward", "category": "Notes", "accel": "Right"} +{"id": "win.Fullscreen", "label": "Full Screen", "category": "FileWindow", "accel": "F11"} +{"id": "win.HomePage", "label": "Gramps Home Page", "category": "FileWindow", "accel": ""} +{"id": "win.HomePerson", "label": "Go to Home Person", "category": "Notes", "accel": "Home"} +{"id": "win.Import", "label": "Import...", "category": "RW", "accel": "i"} +{"id": "win.KeyBindings", "label": "Key Bindings", "category": "FileWindow", "accel": ""} +{"id": "win.Login", "label": "FamilySearch Sign in...", "category": "FS", "accel": "l"} +{"id": "win.MailingLists", "label": "Gramps Mailing Lists", "category": "FileWindow", "accel": ""} +{"id": "win.MediaAdd", "label": "Media", "category": "RW", "accel": "m"} +{"id": "win.Merge", "label": "Merge", "category": "Notes", "accel": ""} +{"id": "win.Navigator", "label": "Navigator", "category": "FileWindow", "accel": "m"} +{"id": "win.NoteAdd", "label": "Note", "category": "RW", "accel": "n"} +{"id": "win.Open", "label": "Manage Family Trees...", "category": "FileWindow", "accel": "o"} +{"id": "win.PRIMARY-0", "label": "PRIMARY-0", "category": "RO", "accel": "0"} +{"id": "win.PRIMARY-1", "label": "PRIMARY-1", "category": "RO", "accel": "1"} +{"id": "win.PRIMARY-2", "label": "PRIMARY-2", "category": "RO", "accel": "2"} +{"id": "win.PRIMARY-3", "label": "PRIMARY-3", "category": "RO", "accel": "3"} +{"id": "win.PRIMARY-4", "label": "PRIMARY-4", "category": "RO", "accel": "4"} +{"id": "win.PRIMARY-5", "label": "PRIMARY-5", "category": "RO", "accel": "5"} +{"id": "win.PRIMARY-6", "label": "PRIMARY-6", "category": "RO", "accel": "6"} +{"id": "win.PRIMARY-7", "label": "PRIMARY-7", "category": "RO", "accel": "7"} +{"id": "win.PRIMARY-8", "label": "PRIMARY-8", "category": "RO", "accel": "8"} +{"id": "win.PRIMARY-9", "label": "PRIMARY-9", "category": "RO", "accel": "9"} +{"id": "win.PRIMARY-BackSpace", "label": "Remove (Alternate)", "category": "Notes", "accel": "BackSpace"} +{"id": "win.PRIMARY-J", "label": "Go to Gramps ID", "category": "Notes", "accel": "j"} +{"id": "win.PRIMARY-N", "label": "PRIMARY-N", "category": "RO", "accel": "n"} +{"id": "win.PRIMARY-P", "label": "PRIMARY-P", "category": "RO", "accel": "p"} +{"id": "win.PersonAdd", "label": "Person", "category": "RW", "accel": "p"} +{"id": "win.PlaceAdd", "label": "Place", "category": "RW", "accel": "l"} +{"id": "win.PluginStatus", "label": "Plugin Manager", "category": "FileWindow", "accel": ""} +{"id": "win.PrintView", "label": "Print...", "category": "2-Way Fan Chart", "accel": "p"} +{"id": "win.Redo", "label": "Redo", "category": "Redo", "accel": "z"} +{"id": "win.Remove", "label": "Remove", "category": "Notes", "accel": "Delete"} +{"id": "win.ReportBug", "label": "Report a Bug", "category": "FileWindow", "accel": ""} +{"id": "win.Reports", "label": "Reports", "category": "RO", "accel": ""} +{"id": "win.RepositoryAdd", "label": "Repository", "category": "RW", "accel": "r"} +{"id": "win.SetActive", "label": "Set as Home Person", "category": "Notes", "accel": ""} +{"id": "win.ShareFamily", "label": "Share Existing Family", "category": "Relationships", "accel": ""} +{"id": "win.Sidebar", "label": "Sidebar", "category": "Notes", "accel": "r"} +{"id": "win.SourceAdd", "label": "Source", "category": "RW", "accel": "s"} +{"id": "win.TipOfDay", "label": "Tip of the Day", "category": "FileWindow", "accel": ""} +{"id": "win.Toolbar", "label": "Toolbar", "category": "FileWindow", "accel": ""} +{"id": "win.Tools", "label": "Tools", "category": "RW", "accel": ""} +{"id": "win.Undo", "label": "Undo", "category": "Undo", "accel": "z"} +{"id": "win.UndoHistory", "label": "Undo History", "category": "RW", "accel": "h"} +{"id": "win.UserManual", "label": "User Manual", "category": "FileWindow", "accel": "F1"} +{"id": "win.WebCal", "label": "Web Calendar...", "category": "Reports", "accel": ""} +{"id": "win.ancestor-chart", "label": "Ancestor Tree...", "category": "Reports", "accel": ""} +{"id": "win.ancestor-report", "label": "Ahnentafel Report...", "category": "Reports", "accel": ""} +{"id": "win.birthday-report", "label": "Birthday and Anniversary Report...", "category": "Reports", "accel": ""} +{"id": "win.calendar", "label": "Calendar...", "category": "Reports", "accel": ""} +{"id": "win.check", "label": "Check and Repair Database...", "category": "Tools", "accel": ""} +{"id": "win.chname", "label": "Fix Capitalization of Family Names...", "category": "Tools", "accel": ""} +{"id": "win.chtype", "label": "Rename Event Types...", "category": "Tools", "accel": ""} +{"id": "win.descend-chart", "label": "Descendant Tree...", "category": "Reports", "accel": ""} +{"id": "win.descend-report", "label": "Descendant Report...", "category": "Reports", "accel": ""} +{"id": "win.det-ancestor-report", "label": "Detailed Ancestral Report...", "category": "Reports", "accel": ""} +{"id": "win.det-descendant-report", "label": "Detailed Descendant Report...", "category": "Reports", "accel": ""} +{"id": "win.dgenstats", "label": "Dump Gender Statistics...", "category": "Tools", "accel": ""} +{"id": "win.dupfind", "label": "Find Possible Duplicate People...", "category": "Tools", "accel": ""} +{"id": "win.editowner", "label": "Edit Database Owner Information...", "category": "Tools", "accel": ""} +{"id": "win.endofline-report", "label": "End of Line Report...", "category": "Reports", "accel": ""} +{"id": "win.eventcmp", "label": "Compare Individual Events...", "category": "Tools", "accel": ""} +{"id": "win.evname", "label": "Extract Event Description...", "category": "Tools", "accel": ""} +{"id": "win.family-descend-chart", "label": "Family Descendant Tree...", "category": "Reports", "accel": ""} +{"id": "win.family-group", "label": "Family Group Report...", "category": "Reports", "accel": ""} +{"id": "win.familylines-graph", "label": "Family Lines Graph...", "category": "Reports", "accel": ""} +{"id": "win.fan-chart", "label": "Fan Chart...", "category": "Reports", "accel": ""} +{"id": "win.hourglass-graph", "label": "Hourglass Graph...", "category": "Reports", "accel": ""} +{"id": "win.indiv-complete", "label": "Complete Individual Report...", "category": "Reports", "accel": ""} +{"id": "win.kinship-report", "label": "Kinship Report...", "category": "Reports", "accel": ""} +{"id": "win.loop", "label": "Find database loop...", "category": "Tools", "accel": ""} +{"id": "win.mediaman", "label": "Media Manager...", "category": "Tools", "accel": ""} +{"id": "win.mergecitations", "label": "Merge Citations...", "category": "Tools", "accel": ""} +{"id": "win.navwebpage", "label": "Narrated Web Site...", "category": "Reports", "accel": ""} +{"id": "win.not-related", "label": "Not Related...", "category": "Tools", "accel": ""} +{"id": "win.notelinkreport", "label": "Note Link Report...", "category": "Reports", "accel": ""} +{"id": "win.number-of-ancestors", "label": "Number of Ancestors Report...", "category": "Reports", "accel": ""} +{"id": "win.patchnames", "label": "Extract Information from Names...", "category": "Tools", "accel": ""} +{"id": "win.place-report", "label": "Place Report...", "category": "Reports", "accel": ""} +{"id": "win.populatesources", "label": "Populate Sources and Citations...", "category": "Tools", "accel": ""} +{"id": "win.rebuild", "label": "Rebuild Secondary Indexes...", "category": "Tools", "accel": ""} +{"id": "win.rebuild-genstats", "label": "Rebuild Gender Statistics...", "category": "Tools", "accel": ""} +{"id": "win.rebuild-refmap", "label": "Rebuild Reference Maps...", "category": "Tools", "accel": ""} +{"id": "win.records", "label": "Records Report...", "category": "Reports", "accel": ""} +{"id": "win.rel-graph", "label": "Relationship Graph...", "category": "Reports", "accel": ""} +{"id": "win.relcalc", "label": "Relationship Calculator...", "category": "Tools", "accel": ""} +{"id": "win.remove-unused", "label": "Remove Unused Objects...", "category": "Tools", "accel": ""} +{"id": "win.removespaces", "label": "Clean input data...", "category": "Tools", "accel": ""} +{"id": "win.reorder-ids", "label": "Reorder Gramps IDs...", "category": "Tools", "accel": ""} +{"id": "win.sortevents", "label": "Sort events...", "category": "Tools", "accel": ""} +{"id": "win.statistics-chart", "label": "Statistics Charts...", "category": "Reports", "accel": ""} +{"id": "win.summary", "label": "Database Summary Report...", "category": "Reports", "accel": ""} +{"id": "win.tag-report", "label": "Tag Report...", "category": "Reports", "accel": ""} +{"id": "win.test-for-date-parser-and-displayer", "label": "Check Localized Date Displayer and Parser...", "category": "Tools", "accel": ""} +{"id": "win.testcasegenerator", "label": "Generate Testcases for Persons and Families...", "category": "Tools", "accel": ""} +{"id": "win.timeline", "label": "Timeline Chart...", "category": "Reports", "accel": ""} +{"id": "win.verify", "label": "Verify the Data...", "category": "Tools", "accel": ""} diff --git a/gramps/gui/managedwindow.py b/gramps/gui/managedwindow.py index 010d25db0da..e78e9a5daf7 100644 --- a/gramps/gui/managedwindow.py +++ b/gramps/gui/managedwindow.py @@ -52,14 +52,16 @@ # Gramps modules # # ------------------------------------------------------------------------- -from gramps.gen.const import GLADE_FILE, ICON +from gramps.gen.const import GLADE_FILE, ICON, GRAMPS_LOCALE as glocale from gramps.gen.errors import WindowActiveError from gramps.gen.config import config from gramps.gen.constfunc import is_quartz -from .uimanager import ActionGroup, valid_action_name +from .uimanager import ActionGroup, accel_display_label, valid_action_name from .utils import get_display_size from .glade import Glade +_ = glocale.translation.gettext + # ------------------------------------------------------------------------- # # Window manager @@ -119,7 +121,7 @@ def __init__(self, uimanager): self.uimanager = uimanager self.window_tree = [] self.id2item = {} - self.action_group = ActionGroup(name="WindowManger") + self.action_group = ActionGroup(name="WindowManager") self.active = DISABLED self.ui = _win_top + _win_btm @@ -335,7 +337,7 @@ def build_windows_menu(self): self.uimanager.remove_ui(self.active) self.uimanager.remove_action_group(self.action_group) - self.action_group = ActionGroup(name="WindowManger") + self.action_group = ActionGroup(name="WindowManager") action_data = [] data = StringIO() @@ -519,6 +521,12 @@ def set_window(self, window, title, text, msg=None, isWindow=False): Gdk.ModifierType.CONTROL_MASK | Gdk.ModifierType.MOD1_MASK ) + # Associate this window with the application (keeps it alive + # together, allows shared menu/action lookups). + if self.uistate: + self.window.set_application(self.uistate.uimanager.app) + self._wire_dialog_accels() + if self.modal: self.window.set_modal(True) # The following makes sure that we only have one modal window open; @@ -530,6 +538,80 @@ def set_window(self, window, title, text, msg=None, isWindow=False): self.window.set_modal(True) self.modal = True + def _wire_dialog_accels(self): + """ + Wire the "Accept Dialog (OK)" / "Cancel Dialog" shortcuts for this + window, respecting per-user customization. + + GtkApplication's automatic accelerator-to-action routing only + applies to genuine GtkApplicationWindow instances -- a plain + Gtk.Dialog never receives it, even once associated with the + application via set_application() -- so each dialog gets its own + Gtk.AccelGroup here instead. + + When the current shortcut still matches the shipped default, the + button's own glade-defined mnemonic (e.g. "_OK") already handles + both activation and the Alt-hold underline, so nothing further is + needed. Once the user customizes (or clears) the shortcut, that + mnemonic no longer reflects reality: the mnemonic is stripped from + the button's label, the real key is bound with + Gtk.Widget.add_accelerator() -- so assistive technology still sees + an actual widget-level binding, unlike a bare AccelGroup callback + -- and the current shortcut is shown as a tooltip instead. (An + arbitrary user-chosen key, e.g. F9, cannot be represented as an + underlined letter in a translated "OK"/"Cancel" label at all, so a + tooltip is the only affordance that works for every binding.) + """ + if not isinstance(self.window, Gtk.Dialog): + return + uimanager = self.uistate.uimanager + accel_group = Gtk.AccelGroup() + self.window.add_accel_group(accel_group) + for action_name, response_id in ( + ("dialog-ok", Gtk.ResponseType.OK), + ("dialog-cancel", Gtk.ResponseType.CANCEL), + ): + action_id = f"app.{action_name}" + accel = uimanager.get_accel(action_id) + if accel == uimanager.default_accels.get(action_id, ""): + continue + + widget = self.window.get_widget_for_response(response_id) + if widget is not None: + label = widget.get_label() + if label: + widget.set_label(label.replace("_", "", 1)) + widget.set_tooltip_text( + _("Shortcut: %s") % accel_display_label(accel) if accel else None + ) + + if not accel: + continue + key, mods = Gtk.accelerator_parse(accel) + if not key: + _LOG.debug( + "ManagedWindow: could not parse accel %r for %s", + accel, + action_name, + ) + continue + + if widget is not None: + widget.add_accelerator( + "clicked", accel_group, key, mods, Gtk.AccelFlags.VISIBLE + ) + else: + # No button registered for this response (e.g. no entry) -- fall back to driving the dialog's + # response directly. + def cb_dialog_response(*_args, response_id=response_id): + self.window.response(response_id) + return True + + accel_group.connect( + key, mods, Gtk.AccelFlags.VISIBLE, cb_dialog_response + ) + def get_window(self): """ Return the managed window. diff --git a/gramps/gui/test/glade_test.py b/gramps/gui/test/glade_test.py new file mode 100644 index 00000000000..e9b7c8836e5 --- /dev/null +++ b/gramps/gui/test/glade_test.py @@ -0,0 +1,222 @@ +# +# Gramps - a GTK+/GNOME based genealogy program +# +# Copyright (C) 2026 Doug Blank +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, see . +# + +"""Tests for scanning and overriding tags in .glade files.""" + +# python3 -m unittest gramps.gui.test.glade_test -v + +# ------------------------------------------------------------------------- +# +# Standard Python modules +# +# ------------------------------------------------------------------------- +import os +import unittest +import xml.etree.ElementTree as ET +from unittest.mock import MagicMock + +os.environ.setdefault("GDK_BACKEND", "-") +os.environ.setdefault("LANG", "en_US.utf-8") + +import gi + +gi.require_version("Gtk", "3.0") + +# ------------------------------------------------------------------------- +# +# Gramps modules +# +# ------------------------------------------------------------------------- +from gi.repository import Gtk +from gramps.gen.const import GLADE_DIR +from gramps.gui.glade import ( + apply_glade_accel_overrides, + iter_glade_accelerators, + _accel_to_glade_key_modifiers, + _glade_modifiers_to_prefix, +) + +SAMPLE_XML = """ + True + + + Add + + + + +""" + +SAMPLE_XML_TOOLTIP = """ + Invoke date editor + + + Date + + + + +""" + +SAMPLE_XML_MULTILINE_TOOLTIP = """ + Use Multiple Surnames +Indicate that the surname consists of different parts. + + +""" + +SAMPLE_XML_NO_LABEL = """ + + +""" + +SAMPLE_XML_MULTI = """ + + + + + + + + +""" + + +class ModifierConversionTest(unittest.TestCase): + """Pure string conversions between glade attributes and accel strings + must not require Gtk/Gdk or a real display.""" + + def test_glade_modifiers_to_prefix(self): + self.assertEqual(_glade_modifiers_to_prefix("GDK_CONTROL_MASK"), "") + self.assertEqual( + _glade_modifiers_to_prefix("GDK_CONTROL_MASK|GDK_SHIFT_MASK"), + "", + ) + self.assertEqual(_glade_modifiers_to_prefix("GDK_MOD1_MASK"), "") + self.assertEqual(_glade_modifiers_to_prefix(""), "") + + def test_accel_to_glade_key_modifiers(self): + self.assertEqual( + _accel_to_glade_key_modifiers("a"), + ("a", "GDK_CONTROL_MASK|GDK_SHIFT_MASK"), + ) + self.assertEqual( + _accel_to_glade_key_modifiers("w"), ("w", "GDK_MOD1_MASK") + ) + self.assertEqual(_accel_to_glade_key_modifiers("a"), ("a", "")) + self.assertEqual(_accel_to_glade_key_modifiers(""), (None, None)) + + def test_round_trip_modifiers(self): + prefix = _glade_modifiers_to_prefix("GDK_CONTROL_MASK|GDK_SHIFT_MASK") + key, modifiers = _accel_to_glade_key_modifiers(prefix + "a") + self.assertEqual(key, "a") + self.assertEqual( + set(modifiers.split("|")), {"GDK_CONTROL_MASK", "GDK_SHIFT_MASK"} + ) + + +class IterGladeAcceleratorsTest(unittest.TestCase): + def test_finds_accelerator_and_accessible_name_label(self): + results = list(iter_glade_accelerators(SAMPLE_XML)) + self.assertEqual(results, [("mybutton", "a", "Add")]) + + def test_prefers_tooltip_over_accessible_name(self): + results = list(iter_glade_accelerators(SAMPLE_XML_TOOLTIP)) + self.assertEqual(results, [("datebtn", "d", "Invoke date editor")]) + + def test_multiline_tooltip_uses_first_line_only(self): + results = list(iter_glade_accelerators(SAMPLE_XML_MULTILINE_TOOLTIP)) + self.assertEqual( + results, [("multsurnamebtn", "a", "Use Multiple Surnames")] + ) + + def test_falls_back_to_object_id_when_no_label(self): + results = list(iter_glade_accelerators(SAMPLE_XML_NO_LABEL)) + self.assertEqual(results, [("mystery", "w", "mystery")]) + + def test_nested_object_attributed_to_its_own_id(self): + results = list(iter_glade_accelerators(SAMPLE_XML_MULTI)) + self.assertEqual(results, [("inner", "s", "inner")]) + + def test_no_accelerators_yields_nothing(self): + results = list(iter_glade_accelerators('')) + self.assertEqual(results, []) + + def test_every_real_glade_file_parses_without_error(self): + """Regression smoke test: every .glade file under GLADE_DIR must + be scannable, not just the ones known to carry accelerators.""" + filenames = [f for f in os.listdir(GLADE_DIR) if f.endswith(".glade")] + self.assertGreater(len(filenames), 0) + for filename in filenames: + path = os.path.join(GLADE_DIR, filename) + with open(path, "r", encoding="utf-8") as handle: + data = handle.read() + try: + list(iter_glade_accelerators(data)) + except ET.ParseError as err: + self.fail(f"{filename} failed to parse: {err}") + + +class ApplyGladeAccelOverridesTest(unittest.TestCase): + def setUp(self): + self._orig_get_default = Gtk.Application.get_default + self.addCleanup(setattr, Gtk.Application, "get_default", self._orig_get_default) + + def _set_fake_app(self, accel_dict): + app = MagicMock() + app.uimanager.accel_dict = accel_dict + Gtk.Application.get_default = staticmethod(lambda: app) + + def test_no_accelerator_tag_returns_input_unchanged(self): + xml = 'True' + self._set_fake_app({}) + self.assertIs(apply_glade_accel_overrides(xml, "somefile"), xml) + + def test_no_running_application_returns_input_unchanged(self): + Gtk.Application.get_default = staticmethod(lambda: None) + self.assertEqual( + apply_glade_accel_overrides(SAMPLE_XML, "somefile"), SAMPLE_XML + ) + + def test_no_matching_override_leaves_xml_unchanged(self): + self._set_fake_app({"glade.somefile.unrelated": "z"}) + result = apply_glade_accel_overrides(SAMPLE_XML, "somefile") + self.assertEqual( + list(iter_glade_accelerators(result)), [("mybutton", "a", "Add")] + ) + + def test_matching_override_rewrites_only_that_element(self): + self._set_fake_app({"glade.somefile.mybutton": "a"}) + result = apply_glade_accel_overrides(SAMPLE_XML, "somefile") + self.assertEqual( + list(iter_glade_accelerators(result)), + [("mybutton", "a", "Add")], + ) + + def test_override_does_not_touch_unrelated_elements(self): + two_buttons = f"{SAMPLE_XML}{SAMPLE_XML_TOOLTIP}" + self._set_fake_app({"glade.somefile.mybutton": "a"}) + result = apply_glade_accel_overrides(two_buttons, "somefile") + results = dict((r[0], r[1]) for r in iter_glade_accelerators(result)) + self.assertEqual(results["mybutton"], "a") + self.assertEqual(results["datebtn"], "d") + + +if __name__ == "__main__": + unittest.main() diff --git a/gramps/gui/test/managedwindow_test.py b/gramps/gui/test/managedwindow_test.py new file mode 100644 index 00000000000..ee38e703e9b --- /dev/null +++ b/gramps/gui/test/managedwindow_test.py @@ -0,0 +1,125 @@ +# +# Gramps - a GTK+/GNOME based genealogy program +# +# Copyright (C) 2026 Doug Blank +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, see . +# + +"""Tests for ManagedWindow's OK/Cancel shortcut wiring.""" + +# python3 -m unittest gramps.gui.test.managedwindow_test -v + +# ------------------------------------------------------------------------- +# +# Standard Python modules +# +# ------------------------------------------------------------------------- +import os +import unittest +from unittest.mock import MagicMock + +os.environ.setdefault("GDK_BACKEND", "-") +os.environ.setdefault("LANG", "en_US.utf-8") + +import gi + +gi.require_version("Gtk", "3.0") + +# ------------------------------------------------------------------------- +# +# Gramps modules +# +# ------------------------------------------------------------------------- +from gi.repository import Gtk +from gramps.gui.managedwindow import ManagedWindow + +DEFAULT_ACCELS = {"app.dialog-ok": "o", "app.dialog-cancel": "c"} + + +def _make_dialog(ok_label="_OK", cancel_label="_Cancel"): + """A mock standing in for a Gtk.Dialog, with mock OK/Cancel buttons + registered the way glade's would wire them up. + Real Gtk widgets can't be instantiated under this project's headless + (GDK_BACKEND=-) test convention, so behavior is verified against + these mocks instead. + """ + ok_button = MagicMock(name="ok_button") + ok_button.get_label.return_value = ok_label + cancel_button = MagicMock(name="cancel_button") + cancel_button.get_label.return_value = cancel_label + + dialog = MagicMock(spec=Gtk.Dialog) + buttons_by_response = { + Gtk.ResponseType.OK: ok_button, + Gtk.ResponseType.CANCEL: cancel_button, + } + dialog.get_widget_for_response.side_effect = buttons_by_response.get + return dialog, ok_button, cancel_button + + +def _make_managed_window(dialog, current_accels): + """A ManagedWindow with only the attributes _wire_dialog_accels needs.""" + managed_window = ManagedWindow.__new__(ManagedWindow) + managed_window.window = dialog + uimanager = MagicMock() + uimanager.default_accels = DEFAULT_ACCELS + uimanager.get_accel.side_effect = lambda action_id: current_accels.get( + action_id, DEFAULT_ACCELS.get(action_id, "") + ) + managed_window.uistate = MagicMock(uimanager=uimanager) + return managed_window + + +# +# WireDialogAccelsTest +# +class WireDialogAccelsTest(unittest.TestCase): + def test_default_accel_leaves_mnemonic_and_tooltip_untouched(self): + dialog, ok_button, cancel_button = _make_dialog() + _make_managed_window(dialog, {})._wire_dialog_accels() + ok_button.set_label.assert_not_called() + cancel_button.set_label.assert_not_called() + ok_button.set_tooltip_text.assert_not_called() + + def test_custom_accel_strips_mnemonic_and_sets_tooltip(self): + dialog, ok_button, cancel_button = _make_dialog() + current = {"app.dialog-ok": "F9"} + _make_managed_window(dialog, current)._wire_dialog_accels() + ok_button.set_label.assert_called_once_with("OK") + (tooltip,), _kwargs = ok_button.set_tooltip_text.call_args + self.assertIn("F9", tooltip) + ok_button.add_accelerator.assert_called_once() + # Cancel was not customized, so it is left alone. + cancel_button.set_label.assert_not_called() + cancel_button.set_tooltip_text.assert_not_called() + + def test_cleared_accel_strips_mnemonic_with_no_tooltip(self): + dialog, ok_button, _cancel_button = _make_dialog() + current = {"app.dialog-ok": ""} + _make_managed_window(dialog, current)._wire_dialog_accels() + ok_button.set_label.assert_called_once_with("OK") + ok_button.set_tooltip_text.assert_called_once_with(None) + ok_button.add_accelerator.assert_not_called() + + def test_non_dialog_window_is_a_no_op(self): + managed_window = ManagedWindow.__new__(ManagedWindow) + managed_window.window = MagicMock(spec=Gtk.Window) + managed_window.uistate = MagicMock() + managed_window._wire_dialog_accels() + managed_window.uistate.uimanager.get_accel.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/gramps/gui/test/uimanager_test.py b/gramps/gui/test/uimanager_test.py new file mode 100644 index 00000000000..039148d6352 --- /dev/null +++ b/gramps/gui/test/uimanager_test.py @@ -0,0 +1,632 @@ +# +# Gramps - a GTK+/GNOME based genealogy program +# +# Copyright (C) 2026 Doug Blank +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, see . +# + +"""Tests for the user-customizable keyboard shortcut API on UIManager.""" + +# python3 -m unittest gramps.gui.test.uimanager_test -v + +# ------------------------------------------------------------------------- +# +# Standard Python modules +# +# ------------------------------------------------------------------------- +import json +import os +import tempfile +import unittest +from unittest.mock import MagicMock + +os.environ.setdefault("GDK_BACKEND", "-") +os.environ.setdefault("LANG", "en_US.utf-8") + +import gi + +gi.require_version("Gtk", "3.0") + +# ------------------------------------------------------------------------- +# +# Gramps modules +# +# ------------------------------------------------------------------------- +from gramps.gui.uimanager import ActionGroup, UIManager, _normalize_accel, check_accel + +SAMPLE_XML = """ + + +
+ + win.Clipboard + Clip_board + +
+
+
+""" + + +def make_manager(): + """Build a UIManager with a mock Gtk.Application-like accel backend.""" + bindings = {} + + app = MagicMock() + app.set_accels_for_action.side_effect = lambda action_id, accels: bindings.update( + {action_id: list(accels)} + ) + app.get_accels_for_action.side_effect = lambda action_id: bindings.get( + action_id, [] + ) + app.get_actions_for_accel.side_effect = lambda accel: [ + action_id for action_id, accels in bindings.items() if accel in accels + ] + + manager = UIManager(app, SAMPLE_XML) + group = ActionGroup( + "Main", + [ + ("Clipboard", None, "b"), + ("Undo", None, "z"), + ], + ) + manager.insert_action_group(group, MagicMock()) + return manager + + +def _read_jsonl_ids(path): + """Return {id: accel} from a JSON Lines accel file, for assertions.""" + ids = {} + with open(path) as hndl: + for line in hndl: + line = line.strip() + if not line: + continue + entry = json.loads(line) + ids[entry["id"]] = entry["accel"] + return ids + + +def _write_jsonl(path, entries): + """Write a minimal JSON Lines accel file from an {id: accel} dict.""" + with open(path, "w") as hndl: + for action_id, accel in entries.items(): + hndl.write(json.dumps({"id": action_id, "accel": accel}) + "\n") + + +class NormalizeAccelTest(unittest.TestCase): + """ + _normalize_accel() must be a safe no-op when running headless (as + this test suite always does, per GDK_BACKEND=-): Gtk.accelerator_parse + silently drops modifiers like without a real display, so + normalizing under test would corrupt values rather than canonicalize + them. + """ + + def test_empty_string_stays_empty(self): + self.assertEqual(_normalize_accel(""), "") + + def test_headless_passthrough_leaves_value_unchanged(self): + self.assertEqual(os.environ.get("GDK_BACKEND"), "-") + self.assertEqual(_normalize_accel("b"), "b") + self.assertEqual(_normalize_accel("R"), "R") + + +class DefaultAccelsTest(unittest.TestCase): + """default_accels must capture the hard-coded accel for each action.""" + + def test_defaults_recorded_on_insert(self): + manager = make_manager() + self.assertEqual(manager.default_accels["win.Clipboard"], "b") + self.assertEqual(manager.default_accels["win.Undo"], "z") + + +class ActionLabelTest(unittest.TestCase): + """get_action_label pulls from the menu XML, falling back to the name.""" + + def test_label_from_menu_strips_mnemonic(self): + manager = make_manager() + self.assertEqual(manager.get_action_label("win.Clipboard"), "Clipboard") + + def test_label_falls_back_to_action_name(self): + manager = make_manager() + self.assertEqual(manager.get_action_label("win.Undo"), "Undo") + + +class ListActionsTest(unittest.TestCase): + def test_list_actions_reports_current_and_default(self): + manager = make_manager() + actions = {a["action_id"]: a for a in manager.list_actions()} + self.assertEqual(actions["win.Clipboard"]["current_accel"], "b") + self.assertEqual(actions["win.Clipboard"]["default_accel"], "b") + self.assertEqual(actions["win.Clipboard"]["group_name"], "Main") + + def test_window_manager_group_is_excluded(self): + """The WindowManager group holds per-open-window switcher actions + (generate_id() in managedwindow.py keys them off the window's + instance id), so they are not stable, listable commands and must + not clutter the shortcut editor.""" + manager = make_manager() + group = ActionGroup("WindowManager", [("wm-12345", None, "")]) + manager.insert_action_group(group, MagicMock()) + actions = {a["action_id"] for a in manager.list_actions()} + self.assertNotIn("win.wm-12345", actions) + + def test_label_and_category_survive_group_removal(self): + """A view's own menu actions are only "live" while that view is + showing (switching views removes its action groups and menu XML -- + see ViewManager.__disconnect_previous_page), so list_actions() must + remember a label/category once seen instead of losing it the + moment a different view becomes active.""" + manager = make_manager() + # Establish the cache entries while the group is still live. + manager.list_actions() + for group in list(manager.action_groups): + manager.remove_action_group(group) + + actions = {a["action_id"]: a for a in manager.list_actions()} + self.assertEqual(actions["win.Clipboard"]["label"], "Clipboard") + self.assertEqual(actions["win.Clipboard"]["group_name"], "Main") + self.assertEqual(actions["win.Clipboard"]["current_accel"], "b") + # win.Undo has no menu-XML label at all, even while live -- the + # id-derived fallback itself must still survive the same way. + self.assertEqual(actions["win.Undo"]["label"], "Undo") + self.assertEqual(actions["win.Undo"]["group_name"], "Main") + + def test_window_manager_group_stays_excluded_after_removal(self): + """The dynamic-group exclusion must hold even via the persisted- + label fallback path, not just while the group is still live.""" + manager = make_manager() + group = ActionGroup("WindowManager", [("wm-12345", None, "")]) + manager.insert_action_group(group, MagicMock()) + manager.list_actions() + manager.remove_action_group(group) + actions = {a["action_id"] for a in manager.list_actions()} + self.assertNotIn("win.wm-12345", actions) + + +class MenuActionIdsTest(unittest.TestCase): + """menu_action_ids reports actions reachable by clicking a menu item, + as opposed to toolbar-only or keyboard-only actions.""" + + def test_action_present_in_menu_xml_is_reported(self): + manager = make_manager() + self.assertIn("win.Clipboard", manager.menu_action_ids()) + + def test_action_absent_from_menu_xml_is_not_reported(self): + manager = make_manager() + self.assertNotIn("win.Undo", manager.menu_action_ids()) + + +class SetClearResetAccelTest(unittest.TestCase): + def test_set_accel_rebinds_and_records_override(self): + manager = make_manager() + conflicts = manager.set_accel("win.Undo", "y") + self.assertEqual(conflicts, []) + self.assertEqual(manager.get_accel("win.Undo"), "y") + self.assertEqual(manager.accel_dict["win.Undo"], "y") + + def test_set_accel_reports_conflicting_actions(self): + manager = make_manager() + conflicts = manager.set_accel("win.Undo", "b") + self.assertEqual(conflicts, ["win.Clipboard"]) + # both are left bound; the caller decides whether to clear one + self.assertEqual(manager.get_accel("win.Clipboard"), "b") + self.assertEqual(manager.get_accel("win.Undo"), "b") + + def test_clear_accel_empties_binding(self): + manager = make_manager() + manager.clear_accel("win.Clipboard") + self.assertEqual(manager.get_accel("win.Clipboard"), "") + self.assertEqual(manager.accel_dict["win.Clipboard"], "") + + def test_reset_accel_restores_default_and_drops_override(self): + manager = make_manager() + manager.set_accel("win.Clipboard", "k") + manager.reset_accel("win.Clipboard") + self.assertEqual(manager.get_accel("win.Clipboard"), "b") + self.assertNotIn("win.Clipboard", manager.accel_dict) + + def test_reset_accel_with_no_default_clears(self): + manager = make_manager() + manager.set_accel("win.Clipboard", "k") + manager.default_accels.pop("win.Clipboard") + manager.reset_accel("win.Clipboard") + self.assertEqual(manager.get_accel("win.Clipboard"), "") + + +class CheckAccelTest(unittest.TestCase): + def test_empty_string_is_allowed(self): + self.assertEqual(check_accel(""), "") + + def test_modified_letter_is_allowed(self): + # Gtk.accelerator_name() -- what the shortcut editor actually + # feeds check_accel() -- always emits a concrete modifier name + # like '', never the virtual ''. + self.assertEqual(check_accel("a"), "") + + def test_bare_letter_is_reserved(self): + self.assertNotEqual(check_accel("a"), "") + + def test_bare_space_is_reserved(self): + self.assertNotEqual(check_accel("space"), "") + + def test_bare_escape_is_reserved(self): + self.assertNotEqual(check_accel("Escape"), "") + + def test_bare_return_is_reserved(self): + self.assertNotEqual(check_accel("Return"), "") + + def test_bare_delete_is_reserved(self): + self.assertNotEqual(check_accel("Delete"), "") + + def test_bare_tab_is_reserved(self): + self.assertNotEqual(check_accel("Tab"), "") + + def test_bare_arrow_is_reserved(self): + self.assertNotEqual(check_accel("Up"), "") + + def test_modified_arrow_is_allowed(self): + # Only a bare arrow key conflicts with cursor movement; Gramps' + # own "Go Back"/"Go Forward" default shortcuts are Left and + # Right (see navigationview.py), so a modified arrow must + # not be rejected as reserved. + self.assertEqual(check_accel("Left"), "") + + def test_bare_function_key_is_allowed(self): + self.assertEqual(check_accel("F1"), "") + + def test_bare_menu_key_is_allowed(self): + self.assertEqual(check_accel("Menu"), "") + + def test_uimanager_method_delegates(self): + manager = make_manager() + self.assertEqual(manager.check_accel("a"), "") + self.assertNotEqual(manager.check_accel("a"), "") + + +class SaveLoadAccelsTest(unittest.TestCase): + def setUp(self): + self.tmpdir = tempfile.TemporaryDirectory() + self.addCleanup(self.tmpdir.cleanup) + + def _path(self, name): + return os.path.join(self.tmpdir.name, name) + + def test_save_only_changed_omits_untouched_defaults(self): + manager = make_manager() + manager.set_accel("win.Clipboard", "k") + path = self._path("gramps.jsonl") + manager.save_accels(path, only_changed=True) + self.assertEqual(_read_jsonl_ids(path), {"win.Clipboard": "k"}) + + def test_save_writes_one_json_object_per_line(self): + manager = make_manager() + manager.set_accel("win.Clipboard", "k") + path = self._path("gramps.jsonl") + manager.save_accels(path, only_changed=True) + with open(path) as hndl: + lines = [line for line in hndl if line.strip()] + self.assertEqual(len(lines), 1) + entry = json.loads(lines[0]) + self.assertEqual( + entry, + { + "id": "win.Clipboard", + "label": "Clipboard", + "category": "Main", + "accel": "k", + }, + ) + + def test_save_full_dump_includes_every_action(self): + manager = make_manager() + path = self._path("gramps.jsonl") + manager.save_accels(path, only_changed=False) + self.assertEqual( + _read_jsonl_ids(path), + {"win.Clipboard": "b", "win.Undo": "z"}, + ) + + def test_load_accels_replaces_by_default(self): + manager = make_manager() + manager.accel_dict = {"win.Undo": "y"} + path = self._path("gramps.jsonl") + _write_jsonl(path, {"win.Clipboard": "x"}) + manager.load_accels(path) + self.assertEqual(manager.accel_dict, {"win.Clipboard": "x"}) + self.assertEqual(manager.get_accel("win.Clipboard"), "x") + + def test_load_accels_merge_layers_on_top(self): + manager = make_manager() + manager.accel_dict = {"win.Undo": "y"} + path = self._path("gramps.jsonl") + _write_jsonl(path, {"win.Clipboard": "x"}) + manager.load_accels(path, merge=True) + self.assertEqual( + manager.accel_dict, + {"win.Undo": "y", "win.Clipboard": "x"}, + ) + + def test_load_accels_skips_blank_and_comment_lines(self): + manager = make_manager() + path = self._path("gramps.jsonl") + with open(path, "w") as hndl: + hndl.write("# a hand-written comment\n") + hndl.write("\n") + hndl.write(json.dumps({"id": "win.Clipboard", "accel": "x"})) + hndl.write("\n") + manager.load_accels(path) + self.assertEqual(manager.accel_dict, {"win.Clipboard": "x"}) + + def test_load_accels_skips_one_malformed_line_but_keeps_the_rest(self): + manager = make_manager() + path = self._path("gramps.jsonl") + with open(path, "w") as hndl: + hndl.write("not valid json at all\n") + hndl.write(json.dumps({"id": "win.Clipboard", "accel": "x"})) + hndl.write("\n") + manager.load_accels(path) + self.assertEqual(manager.accel_dict, {"win.Clipboard": "x"}) + + def test_load_accels_skips_entry_with_disallowed_accel(self): + manager = make_manager() + path = self._path("gramps.jsonl") + with open(path, "w") as hndl: + hndl.write(json.dumps({"id": "win.Clipboard", "accel": "a"})) + hndl.write("\n") + manager.load_accels(path) + self.assertEqual(manager.accel_dict, {}) + + def test_load_accels_raises_for_unreadable_file(self): + manager = make_manager() + path = self._path("does-not-exist.jsonl") + with self.assertRaises(OSError): + manager.load_accels(path) + + def test_save_then_load_round_trip(self): + manager = make_manager() + manager.set_accel("win.Clipboard", "k") + path = self._path("gramps.jsonl") + manager.save_accels(path, only_changed=True) + + reloaded = make_manager() + reloaded.load_accels(path) + self.assertEqual(reloaded.get_accel("win.Clipboard"), "k") + + +class StaticRegistrationTest(unittest.TestCase): + """ + register_static_shortcuts() lets an action be known -- listed, bound, + reset, exported -- without its action group ever having been inserted + into the UIManager. This is what makes the shortcuts editor complete + for a view that hasn't been visited yet this session, instead of only + showing whatever view happens to be active right now. + """ + + def _manager_with_static_action(self): + manager = make_manager() + manager.register_static_shortcuts( + [("Sidebar", "R", "_Sidebar")], + "List Views", + prefix="win", + ) + return manager + + def test_never_live_action_appears_in_list_actions(self): + manager = self._manager_with_static_action() + actions = {a["action_id"]: a for a in manager.list_actions()} + self.assertIn("win.Sidebar", actions) + self.assertEqual(actions["win.Sidebar"]["current_accel"], "R") + self.assertEqual(actions["win.Sidebar"]["default_accel"], "R") + self.assertEqual(actions["win.Sidebar"]["group_name"], "List Views") + + def test_never_live_action_label_is_mnemonic_stripped(self): + manager = self._manager_with_static_action() + self.assertEqual(manager.get_action_label("win.Sidebar"), "Sidebar") + + def test_never_live_action_get_accel_returns_default(self): + manager = self._manager_with_static_action() + self.assertEqual(manager.get_accel("win.Sidebar"), "R") + + def test_never_live_action_can_be_bound(self): + manager = self._manager_with_static_action() + conflicts = manager.set_accel("win.Sidebar", "k") + self.assertEqual(conflicts, []) + self.assertEqual(manager.get_accel("win.Sidebar"), "k") + + def test_conflict_detected_against_never_live_action(self): + manager = self._manager_with_static_action() + # "win.Clipboard" is a real, currently-live action from make_manager(); + # binding it to the still-never-instantiated Sidebar's default accel + # must be flagged even though Sidebar's action group was never + # inserted into the UIManager. + conflicts = manager.set_accel("win.Clipboard", "R") + self.assertEqual(conflicts, ["win.Sidebar"]) + + def test_reset_never_live_action_after_override(self): + manager = self._manager_with_static_action() + manager.set_accel("win.Sidebar", "k") + manager.reset_accel("win.Sidebar") + self.assertEqual(manager.get_accel("win.Sidebar"), "R") + + def test_save_full_dump_includes_never_live_action(self): + manager = self._manager_with_static_action() + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "gramps.jsonl") + manager.save_accels(path, only_changed=False) + data = _read_jsonl_ids(path) + self.assertEqual(data["win.Sidebar"], "R") + + def test_action_with_no_default_accel_is_listed_but_unbound(self): + manager = make_manager() + manager.register_static_shortcuts( + [("Merge", "", "Merge")], "List Views", prefix="win" + ) + actions = {a["action_id"]: a for a in manager.list_actions()} + self.assertIn("win.Merge", actions) + self.assertEqual(actions["win.Merge"]["current_accel"], "") + self.assertEqual(actions["win.Merge"]["default_accel"], "") + + +class PluginMenuAccelTest(unittest.TestCase): + """ + Tools and Reports menu entries are built dynamically per installed + plugin by viewmanager.build_plugin_menu(), one action per plugin, with + no hard-coded default accelerator. Confirm they are still fully + bindable through the shortcuts editor, conflict-checked against other + actions, and that a saved binding survives the menu being torn down + and rebuilt (as happens on a 'plugins-reloaded' event). + """ + + def _insert_tool_group(self, manager, action_name="VerifyTheData"): + group = ActionGroup(name="Tools") + group.add_actions([(action_name, None, "")]) + manager.insert_action_group(group) + return group + + def test_plugin_action_is_listed_with_no_default(self): + manager = make_manager() + self._insert_tool_group(manager) + actions = {a["action_id"]: a for a in manager.list_actions()} + self.assertIn("win.VerifyTheData", actions) + self.assertEqual(actions["win.VerifyTheData"]["default_accel"], "") + self.assertEqual(actions["win.VerifyTheData"]["current_accel"], "") + self.assertEqual(actions["win.VerifyTheData"]["group_name"], "Tools") + + def test_plugin_action_can_be_bound(self): + manager = make_manager() + self._insert_tool_group(manager) + conflicts = manager.set_accel("win.VerifyTheData", "V") + self.assertEqual(conflicts, []) + self.assertEqual(manager.get_accel("win.VerifyTheData"), "V") + + def test_plugin_action_conflict_detected(self): + manager = make_manager() + self._insert_tool_group(manager) + # "z" is make_manager()'s default binding for "win.Undo" + conflicts = manager.set_accel("win.VerifyTheData", "z") + self.assertEqual(conflicts, ["win.Undo"]) + + def test_plugin_action_binding_survives_menu_rebuild(self): + manager = make_manager() + group = self._insert_tool_group(manager) + manager.set_accel("win.VerifyTheData", "V") + + # Simulate __build_tools_menu() rebuilding the menu, e.g. after a + # 'plugins-reloaded' event: the old group is removed and a fresh + # one, from re-scanning installed plugins, takes its place. + manager.remove_action_group(group) + self._insert_tool_group(manager) + + self.assertEqual(manager.get_accel("win.VerifyTheData"), "V") + + +class GladeAccelConflictScopeTest(unittest.TestCase): + """ + Each .glade file's tags are scoped to that dialog's own + implicit Gtk.AccelGroup -- editor dialogs are never open at the same + time, so the same key reused across two different dialogs is not a + real conflict, unlike within the same dialog. + """ + + def _manager_with_two_dialogs(self): + manager = make_manager() + manager.register_static_shortcuts( + [ + ("select", "s", "Person"), + ("add_del", "s", "Person"), + ], + "Person Reference Editor", + prefix="glade.editpersonref", + ) + manager.register_static_shortcuts( + [("select_place", "d", "Place")], + "Event Editor", + prefix="glade.editevent", + ) + return manager + + def test_same_dialog_conflict_is_flagged(self): + manager = self._manager_with_two_dialogs() + conflicts = manager.set_accel("glade.editpersonref.select", "s") + self.assertEqual(conflicts, ["glade.editpersonref.add_del"]) + + def test_cross_dialog_same_key_is_not_flagged(self): + manager = self._manager_with_two_dialogs() + conflicts = manager.set_accel("glade.editevent.select_place", "s") + self.assertEqual(conflicts, []) + + def test_non_glade_actions_still_use_global_conflict_scope(self): + manager = self._manager_with_two_dialogs() + # "win.Clipboard" defaults to "b" in make_manager(); a + # glade-scoped accel must not be able to silently collide with it + # since win.* actions are unaffected by the glade-only scoping. + conflicts = manager.set_accel("win.Undo", "b") + self.assertEqual(conflicts, ["win.Clipboard"]) + + +class StaticAppActionConflictScopeTest(unittest.TestCase): + """ + Static-only "app." entries such as "app.dialog-ok"/"app.dialog-cancel" + have no live Gio.SimpleAction: ManagedWindow wires them into a + per-dialog Gtk.AccelGroup that only fires while that dialog holds + keyboard focus. A background view's "win." actions can never receive + that same keypress while a dialog is focused, so they must not be + reported as conflicting -- only other "app." actions (dispatched + regardless of window focus) or a dialog's own "glade." shortcuts + (live in that same focused window) can genuinely collide. + """ + + def _manager_with_static_dialog_action(self): + manager = make_manager() + manager.register_static_shortcuts( + [("dialog-ok", "o", "Accept Dialog (OK)")], + "Dialogs", + prefix="app", + ) + manager.register_static_shortcuts( + [("select", "d", "Select")], + "Note Editor", + prefix="glade.editnote", + ) + live_app_group = ActionGroup( + "App", [("quit", None, "q")], prefix="app" + ) + manager.insert_action_group(live_app_group, MagicMock()) + return manager + + def test_no_conflict_with_win_action(self): + manager = self._manager_with_static_dialog_action() + # "win.Clipboard" defaults to "b" in make_manager(); a + # dialog can never be open and focused at the same moment as the + # main window's own view actions are live, so this must not flag. + conflicts = manager.set_accel("app.dialog-ok", "b") + self.assertEqual(conflicts, []) + + def test_conflict_with_live_app_action(self): + manager = self._manager_with_static_dialog_action() + conflicts = manager.set_accel("app.dialog-ok", "q") + self.assertEqual(conflicts, ["app.quit"]) + + def test_conflict_with_glade_action(self): + manager = self._manager_with_static_dialog_action() + conflicts = manager.set_accel("app.dialog-ok", "d") + self.assertEqual(conflicts, ["glade.editnote.select"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/gramps/gui/uimanager.py b/gramps/gui/uimanager.py index a2697981140..31a464f0292 100644 --- a/gramps/gui/uimanager.py +++ b/gramps/gui/uimanager.py @@ -22,15 +22,24 @@ """ import copy +import json +import os import sys import logging import xml.etree.ElementTree as ET +from collections.abc import Iterable -from gi.repository import GLib, Gio, Gtk +import gi + +gi.require_version("Gdk", "3.0") +from gi.repository import Gdk, GLib, Gio, Gtk from ..gen.const import GRAMPS_LOCALE as glocale +from ..gen.const import KEYBINDING_THEMES_DIR, VERSION_DIR from ..gen.config import config +_ = glocale.translation.gettext + LOG = logging.getLogger("gui.uimanager") @@ -39,6 +48,127 @@ ACTION_ACC = 2 # tuple index for action accelerator ACTION_ST = 3 # tuple index for action state +# Action groups whose actions are generated per open-window instance +# (e.g. the "Windows" switcher list) rather than representing a fixed +# command. Their action ids are not stable across sessions -- or even +# across the lifetime of the window they refer to -- so they are not +# meaningful targets for a customizable keyboard shortcut and must be +# excluded from the shortcut editor's action list. +_DYNAMIC_GROUP_NAMES = frozenset({"WindowManager"}) + +# Keyvals that must stay unmodified everywhere, because GTK's own focus +# and cursor navigation depends on receiving them within any widget. +_NAV_KEYVALS = frozenset( + Gdk.keyval_from_name(name) + for name in ("Tab", "ISO_Left_Tab", "Up", "Down", "Left", "Right") +) + +# Keyvals safe to bind with no modifier at all: nothing in Gramps' +# editors or views consumes these for text entry, cursor movement, or +# list navigation. +_SAFE_BARE_KEYVALS = frozenset( + Gdk.keyval_from_name(name) for name in [f"F{i}" for i in range(1, 13)] + ["Menu"] +) + + +def check_accel(accel: str) -> str: + """Return '' if accel is safe for a user to bind, otherwise a + user-facing reason it is reserved. + + Gtk.CellRendererAccelMode.OTHER (used by the shortcut editor) lets a + user capture almost any key, including ones GTK itself never blocks + -- a bare letter, Escape, Return, Delete -- that would collide with + typing or in-place editing in Gramps' many text-entry fields. + Gtk.accelerator_valid() alone does not protect against this: it only + rejects Tab/arrow keys and bare modifier keys. + + :param accel: a Gtk accelerator string, e.g. 'c' or 'a' + :type accel: str + """ + if not accel: + return "" + accel = _normalize_accel(accel) + parseable = accel + if os.environ.get("GDK_BACKEND") == "-": + # Without a real display, Gtk.accelerator_parse() can't resolve a + # virtual modifier like to a concrete one and silently + # drops it instead -- which would make an ordinary "c" + # look like a bare, unmodified "c" below. Substitute a concrete + # stand-in for this validity check only (the accel returned to + # the caller is untouched); this only matters for headless test + # runs, since a live Gramps process always has a real display for + # Gtk to resolve it with. + parseable = parseable.replace("", "").replace( + "", "" + ) + keyval, mods = Gtk.accelerator_parse(parseable) + mods &= Gtk.accelerator_get_default_mod_mask() + if not Gtk.accelerator_valid(keyval, mods): + return _("Not a valid keyboard shortcut.") + if mods == 0 and keyval in _NAV_KEYVALS: + return _("This key is reserved for keyboard navigation.") + if mods == 0 and keyval not in _SAFE_BARE_KEYVALS: + return _( + "This key needs a modifier such as Ctrl, Alt, or Super -- " + "otherwise it would conflict with typing in text fields." + ) + return "" + + +def _normalize_accel(accel: str) -> str: + """Normalize an accelerator string to Gtk's own canonical spelling + (e.g. 'b' and 'b' both become 'b'), so + accels from different sources (hand-written literals, a captured + keypress) compare equal. + + Without a real display connection, Gtk.accelerator_parse() silently + drops virtual modifiers like instead of raising, which + would corrupt the value rather than normalize it. Since that only + happens running headless (as this project's test suite always does, + per its GDK_BACKEND=- convention), leave the string untouched in that + case -- there's no live-captured value to reconcile against anyway. + + :param accel: a Gtk accelerator string, or '' for none + :type accel: str + """ + if not accel: + return "" + if os.environ.get("GDK_BACKEND") == "-": + return accel + key, mods = Gtk.accelerator_parse(accel) + return Gtk.accelerator_name(key, mods) if key else accel + + +def accel_display_label(accel: str) -> str: + """Return a human-readable label for a Gtk accelerator string, e.g. + 'c' -> 'Ctrl+C', for display in tooltips and the shortcuts + editor. + + :param accel: a Gtk accelerator string, or '' for none + :type accel: str + """ + if not accel: + return "" + key, mods = Gtk.accelerator_parse(accel) + return Gtk.accelerator_get_label(key, mods) if key else accel + + +def theme_dirs() -> list[str]: + """Directories to search for keybinding theme files, in precedence + order -- user-saved themes take precedence over bundled ones with + the same name.""" + return [os.path.join(VERSION_DIR, "keybinding_themes"), KEYBINDING_THEMES_DIR] + + +def theme_path(name: str) -> str | None: + """Resolve a theme name to a file, preferring a user theme over a + bundled one with the same name.""" + for theme_dir in theme_dirs(): + path = os.path.join(theme_dir, f"{name}.jsonl") + if os.path.exists(path): + return path + return None + class ActionGroup: """This class represents a group of actions that con be manipulated @@ -136,6 +266,12 @@ def __init__(self, app, initial_xml): self.action_groups = [] # current list of action groups self.show_groups = [] # groups to show at the moment self.accel_dict = {} # used to store accel overrides from file + self.default_accels = {} # hard-coded accel for each action_id + self.static_registry = {} # label/category for statically-known actions + # label/category last seen for an action whose group is not + # currently live (e.g. a different view's own menu action) -- + # see list_actions() for why this needs to persist. + self.label_cache = {} def update_menu(self, init=False): """This updates the menus and toolbar when there is a change in the @@ -386,6 +522,11 @@ def insert_action_group(self, group, gio_group=None): for item in group.actionlist: if not Gio.action_name_is_valid(item[ACTION_NAME]): LOG.warning("**Invalid action name %s", item[ACTION_NAME]) + action_id = group.prefix + item[ACTION_NAME] + # record the hard-coded accelerator as the default, so a + # user override can later be reset back to it + if len(item) > 2 and item[ACTION_ACC]: + self.default_accels[action_id] = _normalize_accel(item[ACTION_ACC]) # deal with accelerator overrides from a file accel = self.accel_dict.get(group.prefix + item[ACTION_NAME]) if accel: @@ -532,44 +673,349 @@ def enable_all_actions(self, state): # UIManager yet action.set_enabled(group.sensitive if state else False) - def dump_all_accels(self): - """A function used diagnostically to see what accels are present. - This will only dump the current accel set, if other non-open windows - or views have accels, you will need to open them and run this again - and manually merge the result files. The results are in a - 'gramps.accel' file located in the current working directory.""" - out_dict = {} + def load_accels(self, filename: str, merge: bool = False) -> None: + """Load accels from a JSON Lines file such as one written by + save_accels: one JSON object per line, of the form + ``{"id": action_id, "label": ..., "category": ..., "accel": ...}``. + "label" and "category" are metadata for a human reader (so the + file is self-documenting when opened directly in a text editor) + and are ignored on load. Blank lines and lines starting with '#' + are skipped, so an exported file can be hand-trimmed or annotated. + + A malformed *line* is logged and skipped rather than aborting the + whole load: this file is meant to be safely hand-editable, and one + typo should not cost every other binding in it. A caller that + wants the whole load to be best-effort (e.g. at startup, where a + bad keybinding file must never prevent Gramps from launching) + should additionally catch OSError around the call; a caller that + wants to report a genuinely unreadable file to the user (e.g. the + shortcuts editor's theme switcher) can let it propagate. + + If used before any insert_action_group calls, this overrides the + accels defined in other Gramps code. If used afterwards (e.g. an + "Import" of a user keymap while Gramps is running), the loaded + accels are also applied immediately to any matching, already + registered actions. + + :param filename: path of the accel file to load + :type filename: str + :param merge: when True, update the existing overrides instead of + replacing them, so a system-level file and a user-level file + can be layered + :type merge: bool + :raises OSError: if filename can't be opened + """ + loaded: dict[str, str] = {} + with open(filename, "r", encoding="utf-8") as hndl: + lines = hndl.readlines() + + for line_number, raw_line in enumerate(lines, start=1): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + try: + entry = json.loads(line) + action_id = entry["id"] + if not isinstance(action_id, str): + raise TypeError(f"'id' must be a string, got {action_id!r}") + accel = _normalize_accel(entry.get("accel", "")) + except (json.JSONDecodeError, KeyError, TypeError) as err: + LOG.warning( + "load_accels: skipping malformed line %d in %s: %s", + line_number, + filename, + err, + ) + continue + reason = check_accel(accel) + if reason: + LOG.warning( + "load_accels: skipping %r for %s in %s: %s", + accel, + action_id, + filename, + reason, + ) + continue + loaded[action_id] = accel + + if merge: + self.accel_dict.update(loaded) + else: + self.accel_dict = loaded + for action_id, accel in loaded.items(): + self.app.set_accels_for_action(action_id, [accel] if accel else []) + + def register_static_shortcuts( + self, specs: list[tuple[str, str, str]], category: str, prefix: str = "win" + ) -> None: + """Register a fixed list of actions a view or widget type always + defines, independent of whether any live instance of it currently + exists. This lets the customizable keyboard shortcuts editor list + every such action -- with its label, category and default -- even + before a matching view has been visited in this session. + + :param specs: (action_name, default_accel, label) triples + :type specs: list[tuple[str, str, str]] + :param category: display category shown in the shortcuts editor + :type category: str + :param prefix: the action prefix, e.g. 'win' or 'ste' + :type prefix: str + """ + for name, accel, label in specs: + action_id = f"{prefix}.{name}" + if accel: + self.default_accels[action_id] = _normalize_accel(accel) + self.static_registry[action_id] = {"label": label, "group_name": category} + + def _all_known_action_ids(self) -> set[str]: + """Return every action_id known from static registration, a saved + override, a recorded default, or a currently live action group. + + Excludes _DYNAMIC_GROUP_NAMES groups: their ids are generated + per-open-window (e.g. "win.wm-") and are not meaningful + targets for a customizable shortcut, conflict check, or exported + theme entry -- the same reason list_actions() excludes them. + """ + ids = ( + set(self.static_registry) | set(self.default_accels) | set(self.accel_dict) + ) for group in self.action_groups: + if group.name in _DYNAMIC_GROUP_NAMES: + continue for item in group.actionlist: - act = group.prefix + item[ACTION_NAME] - accels = self.app.get_accels_for_action( - group.prefix + item[ACTION_NAME] - ) - out_dict[act] = accels[0] if accels else "" - import json - - with open( - "gramps.accel", - "w", - ) as hndl: - accels = json.dumps(out_dict, indent=0).replace('\n"', '\n# "') - hndl.write(accels) - - def load_accels(self, filename): - """This function loads accels from a file such as created by - dump_all_accels. The file contents is basically a Python dict - definition. As such it contains a line for each dict element. - These elements can be commented out with '#' at the beginning of the - line. - - If used, this file overrides the accels defined in other Gramps code. - As such it must be loaded before any insert_action_group calls. + ids.add(group.prefix + item[ACTION_NAME]) + return ids + + def _live_action_ids(self) -> set[str]: + """Return action ids backed by an actual live Gio.SimpleAction.""" + return { + group.prefix + item[ACTION_NAME] + for group in self.action_groups + for item in group.actionlist + } + + def get_action_label(self, action_id: str) -> str: + """Return a human-readable label for an action: from the static + registry if known, otherwise from its menu entry if it has one, + otherwise the raw action name. + + :param action_id: the fully prefixed action id, e.g. 'win.Clipboard' + :type action_id: str + :rtype: str + """ + static = self.static_registry.get(action_id) + if static: + return static["label"].replace("_", "", 1) + for item in self.et_xml.iter("item"): + attrs = { + attr.get("name"): (attr.text or "") + for attr in item.findall("attribute") + } + if attrs.get("action") == action_id: + label = attrs.get("label") + if label: + return label.replace("_", "", 1) + return action_id.split(".", 1)[-1] + + def menu_action_ids(self) -> set[str]: + """Return the action ids that appear as a clickable entry + somewhere in the current menu XML (main menu bar or a popup + context menu), as opposed to being reachable only via a toolbar + button or a bare keyboard shortcut. + + :rtype: set[str] + """ + return { + attr.text + for item in self.et_xml.iter() + if item.tag in ("item", "submenu") + for attr in item.findall("attribute") + if attr.get("name") == "action" and attr.text + } + + def get_accel(self, action_id: str) -> str: + """Return the current accelerator string for action_id, or ''. + + This is the saved override if there is one, otherwise the known + default -- computed independent of whether action_id's action + group is currently live, so it stays correct for actions + belonging to a view or widget not yet instantiated this session. + + :param action_id: the fully prefixed action id + :type action_id: str """ - import ast + return self.accel_dict.get(action_id, self.default_accels.get(action_id, "")) + + def list_actions(self) -> list[dict[str, str]]: + """Return metadata for every known action -- statically registered, + currently live, or previously seen live earlier this session -- for + use by a keyboard-shortcut editor. + + A view's own menu actions (as opposed to ones registered via + register_static_shortcuts) are only "live" -- with a resolvable + label and category -- while that view is the one currently showing: + switching views tears down the previous view's action groups and + menu XML (see ViewManager.__disconnect_previous_page). Without + label_cache, an action like a Geography view's "Print..." command + would only appear labeled in the editor while that specific view + happened to be the one on screen, and would look blank the rest of + the time. label_cache remembers the label/category the first time + each such action is seen live, so it keeps showing correctly once + any view defining it has been visited at all this session. - with open(filename, "r") as hndl: - accels = hndl.read() - self.accel_dict = ast.literal_eval(accels) + :returns: one dict per action with keys 'action_id', 'label', + 'group_name', 'current_accel', 'default_accel' + :rtype: list[dict] + """ + actions = {} + for action_id, meta in self.static_registry.items(): + actions[action_id] = { + "action_id": action_id, + "label": meta["label"].replace("_", "", 1), + "group_name": meta["group_name"], + "current_accel": self.get_accel(action_id), + "default_accel": self.default_accels.get(action_id, ""), + } + for group in self.action_groups: + if group.name in _DYNAMIC_GROUP_NAMES: + continue + for item in group.actionlist: + action_id = group.prefix + item[ACTION_NAME] + if action_id in actions: + continue + label = self.get_action_label(action_id) + self.label_cache[action_id] = (label, group.name) + actions[action_id] = { + "action_id": action_id, + "label": label, + "group_name": group.name, + "current_accel": self.get_accel(action_id), + "default_accel": self.default_accels.get(action_id, ""), + } + for action_id in self._all_known_action_ids(): + if action_id in actions: + continue + label, group_name = self.label_cache.get( + action_id, (action_id.split(".", 1)[-1], "") + ) + actions[action_id] = { + "action_id": action_id, + "label": label, + "group_name": group_name, + "current_accel": self.get_accel(action_id), + "default_accel": self.default_accels.get(action_id, ""), + } + return list(actions.values()) + + def check_accel(self, accel: str) -> str: + """Return '' if accel is safe for a user to bind, otherwise a + user-facing reason it is reserved. See the module-level + :py:func:`check_accel` for details. + + :param accel: a Gtk accelerator string, e.g. 'c' or 'a' + :type accel: str + """ + return check_accel(accel) + + def set_accel(self, action_id: str, accel: str) -> list[str]: + """Bind action_id to a new accelerator, recording the override. + + :param action_id: the fully prefixed action id + :type action_id: str + :param accel: the new Gtk accelerator string, e.g. 'c' + :type accel: str + :returns: any other action ids that were already bound to accel + :rtype: list[str] + """ + accel = _normalize_accel(accel) + candidates: Iterable[str] = self._all_known_action_ids() + if action_id.startswith("glade."): + # Editor-dialog-local shortcuts only conflict with others in + # the same dialog: each .glade file gets its own implicit + # Gtk.AccelGroup, scoped to that dialog's own window, so the + # same key in an unrelated dialog is never actually ambiguous. + scope = action_id.rsplit(".", 1)[0] + "." + candidates = [c for c in candidates if c.startswith(scope)] + elif action_id.startswith("app.") and action_id not in self._live_action_ids(): + # Static-only "app." entries such as "dialog-ok"/"dialog-cancel" + # have no live Gio.SimpleAction of their own: they are dispatched + # by a per-dialog Gtk.AccelGroup (see ManagedWindow.set_window) + # that only fires while that dialog holds keyboard focus. A + # background view's "win." actions can never receive that same + # keypress while a dialog is focused, so they can't really + # conflict -- only a genuinely global "app." action (dispatched + # regardless of window focus) or another dialog's own "glade." + # shortcut (live in the same focused window) can. + candidates = [ + c for c in candidates if c.startswith("app.") or c.startswith("glade.") + ] + conflicts = [ + other + for other in candidates + if other != action_id and self.get_accel(other) == accel + ] + self.app.set_accels_for_action(action_id, [accel]) + self.accel_dict[action_id] = accel + return conflicts + + def clear_accel(self, action_id: str) -> None: + """Remove any accelerator from action_id. + + :param action_id: the fully prefixed action id + :type action_id: str + """ + self.app.set_accels_for_action(action_id, []) + self.accel_dict[action_id] = "" + + def reset_accel(self, action_id: str) -> None: + """Restore action_id to its hard-coded default accelerator. + + :param action_id: the fully prefixed action id + :type action_id: str + """ + default = self.default_accels.get(action_id, "") + self.app.set_accels_for_action(action_id, [default] if default else []) + self.accel_dict.pop(action_id, None) + + def save_accels(self, filename: str, only_changed: bool = True) -> None: + """Write the current accelerator set to filename, in the same JSON + Lines format read by load_accels: one ``{"id", "label", + "category", "accel"}`` object per line. "label" and "category" are + included purely so the file is self-documenting to a human reader; + load_accels ignores them. + + :param filename: path to write to + :type filename: str + :param only_changed: when True, only write accels that differ from + their hard-coded default (a compact, forward-compatible + personal override file); when False, write every currently + known accel (a self-contained file suitable for export/sharing) + :type only_changed: bool + """ + if only_changed: + action_ids = [ + action_id + for action_id, accel in self.accel_dict.items() + if accel != self.default_accels.get(action_id, "") + ] + else: + action_ids = sorted(self._all_known_action_ids()) + meta_by_id = {action["action_id"]: action for action in self.list_actions()} + with open(filename, "w", encoding="utf-8") as hndl: + for action_id in action_ids: + meta = meta_by_id.get(action_id, {}) + hndl.write( + json.dumps( + { + "id": action_id, + "label": meta.get("label", ""), + "category": meta.get("group_name", ""), + "accel": self.get_accel(action_id), + } + ) + + "\n" + ) INVALID_CHARS = [" ", "_", "(", ")", ",", "'"] diff --git a/gramps/gui/viewmanager.py b/gramps/gui/viewmanager.py index bc8d503c6a5..2f5c8bb1a96 100644 --- a/gramps/gui/viewmanager.py +++ b/gramps/gui/viewmanager.py @@ -92,12 +92,16 @@ WIKI_EXTRAPLUGINS, URL_BUGHOME, DATA_DIR, + GLADE_DIR, + PLUGINS_DIR, ) from gramps.gen.constfunc import is_quartz from gramps.gen.config import config from gramps.gen.errors import WindowActiveError from .dialog import ErrorDialog, WarningDialog, QuestionDialog2, InfoDialog from .widgets import Statusbar +from .widgets.styledtexteditor import StyledTextEditor +from .glade import iter_glade_accelerators from .undohistory import UndoHistory from gramps.gen.utils.file import media_path_full from .dbloader import DbLoader @@ -156,6 +160,44 @@ } """ +# Glade files (by stem, no directory or extension) that carry +# tags, mapped to a human category shown in the keyboard shortcuts editor. +# See gramps.gui.glade.iter_glade_accelerators(). +GLADE_ACCEL_CATEGORIES = { + "editaddress": _("Address Editor"), + "editattribute": _("Attribute Editor"), + "editchildref": _("Child Reference Editor"), + "editcitation": _("Citation Editor"), + "editdate": _("Date Editor"), + "editevent": _("Event Editor"), + "editeventref": _("Event Reference Editor"), + "editfamily": _("Family Editor"), + "editldsord": _("LDS Ordinance Editor"), + "editlink": _("Link Editor"), + "editmedia": _("Media Editor"), + "editmediaref": _("Media Reference Editor"), + "editname": _("Name Editor"), + "editnote": _("Note Editor"), + "editperson": _("Person Editor"), + "editpersonref": _("Person Reference Editor"), + "editplace": _("Place Editor"), + "editplacename": _("Place Name Editor"), + "editplaceref": _("Place Reference Editor"), + "editreporef": _("Repository Reference Editor"), + "editrepository": _("Repository Editor"), + "editsource": _("Source Editor"), + "editurl": _("Internet Address Editor"), + "book": _("Book Editor"), + "importprogen": _("ProGen Import Assistant"), + "notrelated": _("Not Related Tool"), +} + +# Glade files that carry tags but live outside GLADE_DIR. +GLADE_ACCEL_EXTRA_DIRS = [ + os.path.join(PLUGINS_DIR, "importer"), + os.path.join(PLUGINS_DIR, "tool"), +] + # ------------------------------------------------------------------------- # @@ -783,11 +825,106 @@ def __prev_view(self, action, value): else: self.goto_page(len(self.current_views) - 1, None) + def __register_static_shortcuts(self): + """ + Register the keyboard shortcuts that view and widget classes + declare statically (via get_shortcut_specs()), so the customizable + keyboard shortcuts editor lists them even before a matching view + has been visited in this session. + """ + seen = set() + for cat_views in self.views: + for pdata, viewclass in cat_views: + if viewclass in seen: + continue + seen.add(viewclass) + get_specs = getattr(viewclass, "get_shortcut_specs", None) + if get_specs is None: + continue + category = ( + pdata.category[1] + if isinstance(pdata.category, tuple) + else pdata.category + ) + self.uimanager.register_static_shortcuts( + get_specs(), category, prefix="win" + ) + + self.uimanager.register_static_shortcuts( + StyledTextEditor.get_shortcut_specs(), _("Note Editor"), prefix="ste" + ) + + self.uimanager.register_static_shortcuts( + [ + ("dialog-ok", "o", _("Accept Dialog (OK)")), + ("dialog-cancel", "c", _("Cancel Dialog")), + ], + _("Dialogs"), + prefix="app", + ) + + self.uimanager.register_static_shortcuts( + [ + (f"dialog-goto-tab-{i}", f"{i}", _("Go to Tab %d") % i) + for i in range(1, 10) + ], + _("Dialogs"), + prefix="app", + ) + + self.__register_glade_shortcuts() + + def __register_glade_shortcuts(self): + """ + Scan every .glade file that carries tags and + register them, so editor-dialog-local shortcuts (e.g. Ctrl+A on + an "Add" button) appear in the keyboard shortcuts editor too. + Pure file I/O and XML parsing -- no dialog is built, so this + costs nothing meaningful even though it runs on every startup. + """ + for glade_dir in [GLADE_DIR] + GLADE_ACCEL_EXTRA_DIRS: + try: + filenames = sorted(os.listdir(glade_dir)) + except OSError: + continue + for filename in filenames: + if not filename.endswith(".glade"): + continue + file_stem = filename[: -len(".glade")] + try: + with open( + os.path.join(glade_dir, filename), "r", encoding="utf-8" + ) as handle: + data = handle.read() + except OSError: + continue + if "\n" @@ -1779,7 +1919,7 @@ def build_plugin_menu(self, text, item_list, categories, func): new_key = valid_action_name(pdata.id) name = html.escape(pdata.name.replace("_", "__")) ofile.write(menuitem % (new_key, name)) - actions.append((new_key, func(pdata, self.dbstate, self.uistate))) + actions.append((new_key, func(pdata, self.dbstate, self.uistate), "")) ofile.write("\n") # If there are any unsupported items we add separator @@ -1795,7 +1935,7 @@ def build_plugin_menu(self, text, item_list, categories, func): new_key = valid_action_name(pdata.id) name = html.escape(pdata.name.replace("_", "__")) ofile.write(menuitem % (new_key, name)) - actions.append((new_key, func(pdata, self.dbstate, self.uistate))) + actions.append((new_key, func(pdata, self.dbstate, self.uistate), "")) ofile.write("\n") ofile.write("\n") diff --git a/gramps/gui/views/listview.py b/gramps/gui/views/listview.py index eabae458654..479a5d3dfce 100644 --- a/gramps/gui/views/listview.py +++ b/gramps/gui/views/listview.py @@ -210,6 +210,25 @@ def build_widget(self): self.list.restore_column_size() return self.vbox + @classmethod + def get_shortcut_specs(cls): + """ + Return the (action_name, default_accel, label) triples this view + type always defines, independent of any live instance. Used to + populate the customizable keyboard shortcuts list without needing + to build a real view. Keep in sync with define_actions(). + """ + return super().get_shortcut_specs() + [ + ("Add", "Insert", _("Add")), + ("Remove", "Delete", _("Remove")), + ("PRIMARY-BackSpace", "BackSpace", _("Remove (Alternate)")), + ("Merge", "", _("Merge")), + ("ExportTab", "", _("Export View")), + ("Edit", "Return", _("Edit")), + ("PRIMARY-J", "J", _("Go to Gramps ID")), + ("FilterEdit", "", _("Edit Filter")), + ] + def define_actions(self): """ Required define_actions function for PageView. Builds the action @@ -220,12 +239,13 @@ def define_actions(self): NavigationView.define_actions(self) + accels = {name: accel for name, accel, _label in self.get_shortcut_specs()} self.edit_action = ActionGroup(name=self.title + "/Edits") self.edit_action.add_actions( [ - ("Add", self.add, "Insert"), - ("Remove", self.remove, "Delete"), - ("PRIMARY-BackSpace", self.remove, "BackSpace"), + ("Add", self.add, accels["Add"]), + ("Remove", self.remove, accels["Remove"]), + ("PRIMARY-BackSpace", self.remove, accels["PRIMARY-BackSpace"]), ("Merge", self.merge), ] ) @@ -234,8 +254,8 @@ def define_actions(self): self.action_list.extend( [ ("ExportTab", self.export), - ("Edit", self.edit, "Return"), - ("PRIMARY-J", self.jump, "J"), + ("Edit", self.edit, accels["Edit"]), + ("PRIMARY-J", self.jump, accels["PRIMARY-J"]), ("FilterEdit", self.filter_editor), ] ) diff --git a/gramps/gui/views/navigationview.py b/gramps/gui/views/navigationview.py index 7b886080bfa..7b4fea7d764 100644 --- a/gramps/gui/views/navigationview.py +++ b/gramps/gui/views/navigationview.py @@ -38,7 +38,6 @@ # gtk # # ---------------------------------------------------------------- -from gi.repository import Gdk from gi.repository import Gtk # ---------------------------------------------------------------- @@ -53,7 +52,6 @@ from ..uimanager import ActionGroup from gramps.gen.utils.db import navigation_label from gramps.gen.constfunc import mod_key -from ..utils import match_primary_mask DISABLED = -1 MRU_SIZE = 10 @@ -98,6 +96,25 @@ def navigation_type(self): """ return None + @classmethod + def get_shortcut_specs(cls): + """ + Return the (action_name, default_accel, label) triples this view + type always defines, independent of any live instance. Used to + populate the customizable keyboard shortcuts list without needing + to build a real view. Keep in sync with bookmark_actions() and + navigation_actions(). + """ + return super().get_shortcut_specs() + [ + ("AddBook", "d", _("Add Bookmark")), + ("EditBook", "D", _("Edit Bookmarks")), + ("Forward", "%sRight" % mod_key(), _("Go Forward")), + ("Back", "%sLeft" % mod_key(), _("Go Back")), + ("HomePerson", "%sHome" % mod_key(), _("Go to Home Person")), + ("SetActive", "", _("Set as Home Person")), + ("CopyToClipboard", "c", _("Copy to Clipboard")), + ] + def define_actions(self): """ Define menu actions. @@ -263,11 +280,12 @@ def bookmark_actions(self): """ Define the bookmark menu actions. """ + accels = {name: accel for name, accel, _label in self.get_shortcut_specs()} self.book_action = ActionGroup(name=self.title + "/Bookmark") self.book_action.add_actions( [ - ("AddBook", self.add_bookmark, "d"), - ("EditBook", self.edit_bookmarks, "D"), + ("AddBook", self.add_bookmark, accels["AddBook"]), + ("EditBook", self.edit_bookmarks, accels["EditBook"]), ] ) @@ -280,19 +298,20 @@ def navigation_actions(self): """ Define the navigation menu actions. """ + accels = {name: accel for name, accel, _label in self.get_shortcut_specs()} + # add the Forward action group to handle the Forward button self.fwd_action = ActionGroup(name=self.title + "/Forward") - self.fwd_action.add_actions( - [("Forward", self.fwd_clicked, "%sRight" % mod_key())] - ) + self.fwd_action.add_actions([("Forward", self.fwd_clicked, accels["Forward"])]) # add the Backward action group to handle the Forward button self.back_action = ActionGroup(name=self.title + "/Backward") - self.back_action.add_actions( - [("Back", self.back_clicked, "%sLeft" % mod_key())] - ) + self.back_action.add_actions([("Back", self.back_clicked, accels["Back"])]) - self._add_action("HomePerson", self.home, "%sHome" % mod_key()) + self._add_action("HomePerson", self.home, accels["HomePerson"]) + self._add_action( + "CopyToClipboard", self.cb_copy_to_clipboard, accels["CopyToClipboard"] + ) self.other_action = ActionGroup(name=self.title + "/PersonOther") self.other_action.add_actions([("SetActive", self.set_default_person)]) @@ -478,17 +497,6 @@ def build_widget(self): the base class. Returns a gtk container widget. """ - def key_press_handler(self, widget, event): - """ - Handle the control+c (copy) and control+v (paste), or pass it on. - """ - if self.active: - if event.type == Gdk.EventType.KEY_PRESS: - if event.keyval == Gdk.KEY_c and match_primary_mask(event.get_state()): - self.call_copy() - return True - return super(NavigationView, self).key_press_handler(widget, event) - def button_press_handler(self, widget, event): """ Handle forward and backward buttons, or pass it on. @@ -502,10 +510,10 @@ def button_press_handler(self, widget, event): return True return super(NavigationView, self).button_press_handler(widget, event) - def call_copy(self): + def cb_copy_to_clipboard(self, *obj): """ - Navigation specific copy (control+c) hander. If the - copy can be handled, it returns true, otherwise false. + Navigation specific copy (Ctrl+C) action callback. If the copy can + be handled, it returns true, otherwise false. The code brings up the Clipboard (if already exists) or creates it. The copy is handled through the drag and drop diff --git a/gramps/gui/views/pageview.py b/gramps/gui/views/pageview.py index cf77693a1bd..ffb098449c9 100644 --- a/gramps/gui/views/pageview.py +++ b/gramps/gui/views/pageview.py @@ -467,22 +467,36 @@ def build_widget(self): by the base class. Returns a gtk container widget. """ + @classmethod + def get_shortcut_specs(cls): + """ + Return the (action_name, default_accel, label) triples this view + type always defines, independent of any live instance. Used to + populate the customizable keyboard shortcuts list without needing + to build a real view. Keep in sync with define_actions(). + """ + return [ + ("Sidebar", "R", _("_Sidebar")), + ("Bottombar", "B", _("_Bottombar")), + ] + def define_actions(self): """ Defines the UIManager actions. Called by the ViewManager to set up the View. The user typically defines self.action_list and self.action_toggle_list in this function. """ + accels = {name: accel for name, accel, _label in self.get_shortcut_specs()} self._add_toggle_action( "Sidebar", self.__sidebar_toggled, - "R", + accels["Sidebar"], self.sidebar.get_property("visible"), ) self._add_toggle_action( "Bottombar", self.__bottombar_toggled, - "B", + accels["Bottombar"], self.bottombar.get_property("visible"), ) diff --git a/gramps/gui/widgets/styledtexteditor.py b/gramps/gui/widgets/styledtexteditor.py index a079daa142c..c93b462ff93 100644 --- a/gramps/gui/widgets/styledtexteditor.py +++ b/gramps/gui/widgets/styledtexteditor.py @@ -607,6 +607,29 @@ def _connect_signals(self): self.connect("button-release-event", self.on_button_release_event) self.connect("populate-popup", self.on_populate_popup) + @classmethod + def get_shortcut_specs(cls): + """ + Return the (action_name, default_accel, label) triples this widget + always defines, independent of any live instance. Used to populate + the customizable keyboard shortcuts list without needing to build a + real editor. Keep in sync with create_toolbar(). + """ + return [ + ("ITALIC", "i", _("Italic")), + ("BOLD", "b", _("Bold")), + ("UNDERLINE", "u", _("Underline")), + ("STRIKETHROUGH", "s", _("Strikethrough")), + ("SUPERSCRIPT", "p", _("Superscript")), + ("SUBSCRIPT", "r", _("Subscript")), + ("FONTCOLOR", "", _("Font Color")), + ("HIGHLIGHT", "", _("Background Color")), + ("LINK", "", _("Link")), + ("CLEAR", "", _("Clear Markup")), + ("STUndo", "z", _("Undo")), + ("STRedo", "z", _("Redo")), + ] + def create_toolbar(self, uimanager, window): """ Create a formatting toolbar. @@ -621,19 +644,30 @@ def create_toolbar(self, uimanager, window): builder.add_from_string(FORMAT_TOOLBAR) # define the actions... + accels = {name: accel for name, accel, _label in self.get_shortcut_specs()} _actions = [ - ("ITALIC", self._on_toggle_action_activate, "i", False), - ("BOLD", self._on_toggle_action_activate, "b", False), - ("UNDERLINE", self._on_toggle_action_activate, "u", False), - ("STRIKETHROUGH", self._on_toggle_action_activate, "s", False), - ("SUPERSCRIPT", self._on_toggle_action_activate, "p", False), - ("SUBSCRIPT", self._on_toggle_action_activate, "r", False), + ("ITALIC", self._on_toggle_action_activate, accels["ITALIC"], False), + ("BOLD", self._on_toggle_action_activate, accels["BOLD"], False), + ("UNDERLINE", self._on_toggle_action_activate, accels["UNDERLINE"], False), + ( + "STRIKETHROUGH", + self._on_toggle_action_activate, + accels["STRIKETHROUGH"], + False, + ), + ( + "SUPERSCRIPT", + self._on_toggle_action_activate, + accels["SUPERSCRIPT"], + False, + ), + ("SUBSCRIPT", self._on_toggle_action_activate, accels["SUBSCRIPT"], False), ("FONTCOLOR", self._on_action_activate), ("HIGHLIGHT", self._on_action_activate), ("LINK", self._on_link_activate), ("CLEAR", self._format_clear_cb), - ("STUndo", self.undo, "z"), - ("STRedo", self.redo, "z"), + ("STUndo", self.undo, accels["STUndo"]), + ("STRedo", self.redo, accels["STRedo"]), ] # the following are done manually rather than using actions diff --git a/gramps/plugins/view/relview.py b/gramps/plugins/view/relview.py index bcb317a3982..1f5ee2fcc7d 100644 --- a/gramps/plugins/view/relview.py +++ b/gramps/plugins/view/relview.py @@ -560,8 +560,27 @@ def build_widget(self): """, ] + @classmethod + def get_shortcut_specs(cls): + """ + Return the (action_name, default_accel, label) triples this view + type always defines, independent of any live instance. Used to + populate the customizable keyboard shortcuts list without needing + to build a real view. Keep in sync with define_actions(). + """ + return super().get_shortcut_specs() + [ + ("Edit", "Return", _("Edit")), + ("AddSpouse", "", _("Add Spouse")), + ("AddParents", "", _("Add Parents")), + ("ShareFamily", "", _("Share Existing Family")), + ("ChangeOrder", "", _("Change Order")), + ("FilterEdit", "", _("Edit Filter")), + ("PRIMARY-J", "J", _("Go to Gramps ID")), + ] + def define_actions(self): NavigationView.define_actions(self) + accels = {name: accel for name, accel, _label in self.get_shortcut_specs()} self.order_action = ActionGroup(name=self.title + "/ChangeOrder") self.order_action.add_actions([("ChangeOrder", self.reorder)]) @@ -569,7 +588,7 @@ def define_actions(self): self.family_action = ActionGroup(name=self.title + "/Family") self.family_action.add_actions( [ - ("Edit", self.edit_active, "Return"), + ("Edit", self.edit_active, accels["Edit"]), ("AddSpouse", self.add_spouse), ("AddParents", self.add_parents), ("ShareFamily", self.select_parents), @@ -577,7 +596,7 @@ def define_actions(self): ) self._add_action("FilterEdit", callback=self.filter_editor) - self._add_action("PRIMARY-J", self.jump, "J") + self._add_action("PRIMARY-J", self.jump, accels["PRIMARY-J"]) self._add_action_group(self.order_action) self._add_action_group(self.family_action) diff --git a/po/POTFILES.in b/po/POTFILES.in index 611eb3a3475..af911637101 100755 --- a/po/POTFILES.in +++ b/po/POTFILES.in @@ -562,6 +562,7 @@ gramps/gui/grampsgui.py gramps/gui/logger/_errorreportassistant.py gramps/gui/logger/_errorview.py gramps/gui/makefilter.py +gramps/gui/managedwindow.py gramps/gui/merge/mergecitation.py gramps/gui/merge/mergeevent.py gramps/gui/merge/mergefamily.py @@ -600,6 +601,7 @@ gramps/gui/selectors/selectrepository.py gramps/gui/selectors/selectsource.py gramps/gui/spell.py gramps/gui/tipofday.py +gramps/gui/uimanager.py gramps/gui/undohistory.py gramps/gui/utils.py gramps/gui/viewmanager.py diff --git a/po/POTFILES.skip b/po/POTFILES.skip index 6164615deaf..f8146e0fd3b 100644 --- a/po/POTFILES.skip +++ b/po/POTFILES.skip @@ -345,10 +345,8 @@ gramps/gui/ddtargets.py gramps/gui/display.py gramps/gui/glade.py gramps/gui/listmodel.py -gramps/gui/managedwindow.py gramps/gui/navigator.py gramps/gui/pluginmanager.py -gramps/gui/uimanager.py gramps/gui/user.py gramps/gui/utilscairo.py # @@ -463,6 +461,8 @@ gramps/gui/selectors/selectorfactory.py # gui.test package # gramps/gui/test/display_test.py +gramps/gui/test/glade_test.py +gramps/gui/test/uimanager_test.py gramps/gui/test/user_test.py # # gui/views - the GUI views package diff --git a/scripts/regenerate_default_theme.py b/scripts/regenerate_default_theme.py new file mode 100644 index 00000000000..554f8a17663 --- /dev/null +++ b/scripts/regenerate_default_theme.py @@ -0,0 +1,151 @@ +# +# Gramps - a GTK+/GNOME based genealogy program +# +# Copyright (C) 2026 Doug Blank +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# + +""" +Regenerate gramps/gui/keybinding_themes/Default.jsonl from a live Gramps +session. + +The bundled "Default" theme file needs a real, running Gramps session to +regenerate accurately: most shortcuts are known statically at startup (see +ViewManager.__register_static_shortcuts), but each view's own ordinary +menu/toolbar actions (e.g. "win.NewTag", "win.SourceAdd") are only known +once that view has actually been built, which requires a real display for +GTK to construct real widgets and menu XML against. There is currently no +lighter-weight way to capture those labels. + +This script boots the real UIManager/ViewManager stack (not the full +GrampsApplication -- that would auto-open the Family Tree manager dialog, +which we don't want here), visits every registered view once so all of +their actions register, then calls save_accels(only_changed=False) and +copies the result over the bundled Default.jsonl. + +It runs against a throwaway, isolated GRAMPSHOME so it never reads or +writes a developer's real Gramps settings, and never loads any existing +accel/theme file, so the output reflects Gramps' true hard-coded defaults +rather than whatever the previous Default.jsonl happened to contain. + +Requires a real X display, since GTK cannot resolve virtual modifiers +like or build real widgets/menus without one: + + xvfb-run -a python3 scripts/regenerate_default_theme.py + +Re-run this after adding, renaming, or removing a keyboard shortcut +anywhere in Gramps, then review the diff to gramps/gui/keybinding_themes/ +Default.jsonl before committing. +""" + +# ------------------------------------------------------------------------- +# +# Standard Python modules +# +# ------------------------------------------------------------------------- +import argparse +import os +import shutil +import sys +import tempfile + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +DEFAULT_OUTPUT = os.path.join( + REPO_ROOT, "gramps", "gui", "keybinding_themes", "Default.jsonl" +) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--output", + default=DEFAULT_OUTPUT, + help="file to overwrite with the regenerated theme (default: %(default)s)", + ) + args = parser.parse_args() + + if not os.environ.get("DISPLAY"): + sys.exit( + "No DISPLAY set. This needs a real X display to build real " + "widgets/menus and resolve modifiers like correctly " + "-- run it under Xvfb, e.g.:\n\n" + " xvfb-run -a python3 scripts/regenerate_default_theme.py" + ) + + os.environ.pop("GDK_BACKEND", None) + os.environ.setdefault("GRAMPS_RESOURCES", os.path.join(REPO_ROOT, "build", "share")) + + # An isolated, throwaway profile: this must never read or write a + # developer's real Gramps settings, and must never load an existing + # accel/theme file, so the output reflects Gramps' true hard-coded + # defaults rather than whatever a previous Default.jsonl contained. + with tempfile.TemporaryDirectory(prefix="gramps-regen-home-") as gramps_home: + os.environ["GRAMPSHOME"] = gramps_home + with tempfile.NamedTemporaryFile(suffix=".jsonl", delete=False) as tmp: + tmp_path = tmp.name + try: + _generate(tmp_path) + shutil.copyfile(tmp_path, args.output) + finally: + os.remove(tmp_path) + + print(f"Wrote {args.output}") + + +def _generate(output_path): + """Build a live UIManager/ViewManager, visit every view once, and + save every known shortcut (with label/category) to output_path.""" + sys.path.insert(0, REPO_ROOT) + + import gi + + gi.require_version("Gtk", "3.0") + from gi.repository import Gtk + + from gramps.gen.config import config + from gramps.gen.dbstate import DbState + from gramps.gui.grampsgui import UIDEFAULT + from gramps.gui.uimanager import UIManager + from gramps.gui.viewmanager import ViewManager + + app = Gtk.Application(application_id="org.gramps_project.RegenerateDefaultTheme") + app.register() + + app.uimanager = UIManager(app, UIDEFAULT) + app.uimanager.update_menu(init=True) + # Deliberately skip loading any existing gramps.jsonl/theme file here -- + # this run must only ever see Gramps' own hard-coded defaults. + + dbstate = DbState() + view_manager = ViewManager(app, dbstate, config.get("interface.view-categories")) + view_manager.init_interface() + + for cat_num, cat_views in enumerate(view_manager.views): + for view_num in range(len(cat_views)): + view_manager.goto_page(cat_num, view_num) + # UIManager.list_actions() only remembers a view's own + # label/category the moment it's actually live (see its + # label_cache docstring) -- call it now, while this view is + # still the active one, so an action whose only view isn't + # the last one visited doesn't lose its label/category by the + # time save_accels() runs below. + view_manager.uimanager.list_actions() + + view_manager.uimanager.save_accels(output_path, only_changed=False) + + +if __name__ == "__main__": + main()