From bc76692bc9bc2cd1ce7be7937290c411a9bc459f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-S=C3=A9bastien=20Gosselin?= Date: Wed, 5 Nov 2025 15:49:47 -0500 Subject: [PATCH 01/42] Improve 'format_tooltip' logic --- qtapputils/qthelpers.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/qtapputils/qthelpers.py b/qtapputils/qthelpers.py index 12aef1f..793e1ea 100644 --- a/qtapputils/qthelpers.py +++ b/qtapputils/qthelpers.py @@ -296,19 +296,25 @@ def format_tooltip(text: str, tip: str, shortcuts: list[str] | str): as a widget's tooltip. """ keystr = get_shortcuts_native_text(shortcuts) + # We need to replace the unicode characters < and > by their HTML # code to avoid problem with the HTML formatting of the tooltip. keystr = keystr.replace('<', '<').replace('>', '>') + ttip = "" if text or keystr: ttip += "

" if text: - ttip += "{}".format(text) + (" " if keystr else "") + ttip += f"{text}" if keystr: - ttip += "({})".format(keystr) + ttip += " ({sc_str})" ttip += "

" if tip: - ttip += "

{}

".format(tip or '') + ttip += f"

{tip or ''}

" + + if keystr: + ttip = ttip.format(sc_str=keystr) + return ttip From 9996519460fe32c1d6b731e6603c8b9b4e861ce9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-S=C3=A9bastien=20Gosselin?= Date: Wed, 5 Nov 2025 15:50:14 -0500 Subject: [PATCH 02/42] Create shortcuts.py --- qtapputils/managers/shortcuts.py | 258 +++++++++++++++++++++++++++++++ 1 file changed, 258 insertions(+) create mode 100644 qtapputils/managers/shortcuts.py diff --git a/qtapputils/managers/shortcuts.py b/qtapputils/managers/shortcuts.py new file mode 100644 index 0000000..dedfb9d --- /dev/null +++ b/qtapputils/managers/shortcuts.py @@ -0,0 +1,258 @@ +# -*- coding: utf-8 -*- +# ============================================================================= +# Copyright (C) Les Solutions Géostack, Inc. - All Rights Reserved +# +# This file is part of Seismate. +# Unauthorized copying, distribution, or modification of this file, +# via any medium, is strictly prohibited without explicit permission +# from Les Solutions Géostack, Inc. Proprietary and confidential. +# +# For inquiries, contact: info@geostack.ca +# Repository: https://github.com/geo-stack/seismate +# ============================================================================= + +""" +Centralized Shortcut Manager for PyQt5 Applications +""" + +from abc import ABC, abstractmethod +from typing import Dict, Callable, Optional, List, Union, Tuple, Protocol +from enum import Enum +from PyQt5.QtWidgets import QWidget, QShortcut, QAction +from PyQt5.QtGui import QKeySequence +from PyQt5.QtCore import Qt +from appconfigs.user import UserConfig +from qtapputils.qthelpers import format_tooltip, get_shortcuts_native_text + + +class ShortcutSyncTemplate(Protocol): + """ + Template for synchronizing UI text with keyboard shortcuts. + """ + + def interpolate(self, shortcuts: list[str] | str) -> str: + """ + Interpolate the template with a shortcut string. + + Parameters + ---------- + shortcuts : list[str] | str + The keyboard shortcut string or a list of keyboard + shortcut strings + + Returns + ------- + str: + Formatted string ready to pass to a UI setter. + """ + pass + + +class TitleSyncTemplate: + def __init__(self, text): + self.text = text + + def interpolate(self, shortcuts): + keystr = get_shortcuts_native_text(shortcuts) + if keystr: + return f"{self.text} ({keystr})" + else: + return self.text + + +class ToolTipSyncTemplate: + def __init__(self, title='', text='', alt_text=None): + self.title = title + self.text = text + self.alt_text = text if alt_text is None else alt_text + + def interpolate(self, shortcuts): + if shortcuts: + return format_tooltip(self.title, self.alt_text, shortcuts) + else: + return format_tooltip(self.title, self.text, shortcuts) + + +UISyncTarget = Tuple[Callable[[str], None], ShortcutSyncTemplate] + + +class ShortcutItem: + """Configuration class for shortcut definitions""" + + def __init__(self, + context: str, + name: str, + key_sequence: QKeySequence | str, + callback: Callable, + parent: QWidget, + enabled: bool = True, + description: str = 'Toggle Gain', + synced_ui_data: Optional[List[UISyncTarget]] = None + ): + self.context = context + self.name = name + self.qkey_sequence = QKeySequence(key_sequence) + self.callback = callback + self.parent = parent + self.description = description + + if synced_ui_data is None: + synced_ui_data = [] + self.synced_ui_data = synced_ui_data + + self.shortcut = None + if enabled is True: + self.activate() + + def activate(self): + """Internal method to create a QShortcut""" + if self.shortcut is None: + self.shortcut = QShortcut(self.qkey_sequence, self.parent) + self.shortcut.activated.connect(self.callback) + self.shortcut.setAutoRepeat(False) + self.set_enabled(True) + self._update_ui() + + def deactivate(self): + """Deactivate a specific shortcut""" + if self.shortcut is not None: + self.shortcut.setEnabled(False) + self.shortcut.deleteLater() + del self.shortcut + self.shortcut = None + self.enabled = False + + def set_keyseq(self, qkey_sequence: QKeySequence): + if self.shortcut is not None: + self.qkey_sequence = qkey_sequence + self.shortcut.setKey(self.qkey_sequence) + self._update_ui() + + def set_enabled(self, enabled: bool = True): + """Enable or disable a shortcut""" + self.enabled = enabled + self.shortcut.setEnabled(enabled) + + def _update_ui(self): + for callback, template in self.synced_ui_data: + callback(template.interpolate(self.qkey_sequence.toString())) + + +class ShortcutManager: + """ + Centralized manager for application shortcuts. + """ + + def __init__(self, userconfig: UserConfig = None): + self._userconfig = userconfig + self._shortcuts: Dict[str, ShortcutItem] = {} + + def register_shortcut( + self, + context: str, + name: str, + callback: Callable, + parent: QWidget, + description: str = "", + default_key_sequence: str = None, + synced_ui_data: Optional[List[UISyncTarget]] = None + ): + """ + Register a shortcut configuration. + + Args: + name: Identifier for the shortcut + key_sequence: Key combination (e.g., "Ctrl+S", "Alt+F4") + callback: Function to call when shortcut is triggered + context: Context where shortcut is active + description: Human-readable description + """ + context_name = f"{context}/{name}" + if context_name in self._shortcuts: + raise ValueError( + f"There is already a shortcut '{name}' registered for " + f"context '{context}'." + ) + + if self._userconfig is not None: + if default_key_sequence is not None: + self._userconfig.set_default( + 'shortcuts', context_name, default_key_sequence) + key_sequence = self._userconfig.get( + 'shortcuts', context_name, default_key_sequence) + + if key_sequence is None: + raise ValueError( + "No key sequence found in user configs for shortcut " + "'{}' in context '{}'. Define a default key sequence in " + "the user config of this application or pass a valid " + "'default_key_sequence' value." + ) + + qkey_sequence = QKeySequence(key_sequence) + if qkey_sequence.isEmpty(): + raise ValueError( + f"Key sequence '{key_sequence}' is not valid." + ) + + self._shortcuts[context_name] = ShortcutItem( + context=context, + name=name, + key_sequence=qkey_sequence, + callback=callback, + parent=parent, + synced_ui_data=synced_ui_data + ) + + def unregister_shortcut(self, context: str, name: str): + """Unregister a shortcut""" + self._shortcuts[f"{context}/{name}"].deactivate() + del self._shortcuts[f"{context}/{name}"] + + def activate_shortcut(self, context: str, name: str): + """Activate a specific shortcut on a widget""" + self._shortcuts[f"{context}/{name}"].activate() + + def deactivate_shortcut(self, context: str, name: str): + """Deactivate a specific shortcut""" + self._shortcuts[f"{context}/{name}"].deactivate() + + def enable_shortcut( + self, context: str, name: str, enabled: bool = True): + """Enable or disable a shortcut""" + self._shortcuts[f"{context}/{name}"].set_enabled(enabled) + + def update_key_sequence( + self, context: str, name: str, new_key_sequence: str, + sync_userconfig: bool = False): + """Update the key sequence for a shortcut""" + context_name = f"{context}/{name}" + new_qkey_sequence = QKeySequence(new_key_sequence) + self._shortcuts[context_name].set_keyseq(new_qkey_sequence) + if self._userconfig is not None and sync_userconfig: + self._userconfig.set( + 'shortcuts', + context_name, + new_qkey_sequence.toString() + ) + + def iter_shortcuts(self, context: str = None): + """Iterate over keyboard shortcuts.""" + for sc in self._shortcuts.values(): + if context is None or context == sc.context: + yield sc + + def find_conflicts(self, context: str, name: str, new_key_sequence: str): + """Check shortcuts for conflicts.""" + conflicts = [] + new_qkey_sequence = QKeySequence(new_key_sequence) + no_match = QKeySequence.SequenceMatch.NoMatch + for sc in self.iter_shortcuts(context): + if sc.qkey_sequence.isEmpty(): + continue + if sc.name == name: + continue + if (sc.qkey_sequence.matches(new_qkey_sequence) != no_match or + new_qkey_sequence.matches(sc.qkey_sequence) != no_match): + conflicts.append(sc) + return conflicts From c28cf8bb50643d260e20c931d36cc4bd74d9c7e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-S=C3=A9bastien=20Gosselin?= Date: Wed, 5 Nov 2025 16:02:07 -0500 Subject: [PATCH 03/42] Add lazy imports to the managers module --- qtapputils/managers/__init__.py | 58 +++++++++++++++++++++++++++++++-- 1 file changed, 56 insertions(+), 2 deletions(-) diff --git a/qtapputils/managers/__init__.py b/qtapputils/managers/__init__.py index 3d566aa..6934797 100644 --- a/qtapputils/managers/__init__.py +++ b/qtapputils/managers/__init__.py @@ -6,5 +6,59 @@ # This file is part of QtAppUtils. # Licensed under the terms of the MIT License. # ----------------------------------------------------------------------------- -from .taskmanagers import WorkerBase, TaskManagerBase, LIFOTaskManager -from .fileio import SaveFileManager +import importlib +from typing import TYPE_CHECKING +if TYPE_CHECKING: + # Direct imports for type-checking and IDE introspection. + from .taskmanagers import WorkerBase, TaskManagerBase, LIFOTaskManager + from .fileio import SaveFileManager + from .shortcuts import ShortcutManager +else: + # Module-level exports for explicit __all__. + __all__ = [ + 'WorkerBase', + 'TaskManagerBase', + 'LIFOTaskManager', + 'SaveFileManager', + 'ShortcutManager', + ] + + # Lazy import mapping. + __LAZYIMPORTS__ = { + 'WorkerBase': 'qtapputils.managers.taskmanagers', + 'TaskManagerBase': 'qtapputils.managers.taskmanagers', + 'LIFOTaskManager': 'qtapputils.managers.taskmanagers', + 'SaveFileManager': 'qtapputils.managers.fileio', + 'ShortcutManager': 'qtapputils.managers.shortcuts', + } + + def __getattr__(name): + if name in __LAZYIMPORTS__: + module_path = __LAZYIMPORTS__[name] + + try: + module = importlib.import_module(module_path) + attr = getattr(module, name) + globals()[name] = attr + + return attr + + except ImportError as e: + raise ImportError( + f"Failed to lazy import {name!r} from {module_path!r}: {e}" + ) from e + + except AttributeError as e: + raise AttributeError( + f"Module {module_path!r} has no attribute {name!r}" + ) from e + + # Standard AttributeError for unknown attributes + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + def __dir__(): + return sorted(set( + list(globals().keys()) + + list(__LAZYIMPORTS__.keys()) + + list(__all__) + )) From 6eeb295f752e84bc0553d88b795064a886e6ffd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-S=C3=A9bastien=20Gosselin?= Date: Wed, 5 Nov 2025 16:07:18 -0500 Subject: [PATCH 04/42] Update __init__.py --- qtapputils/managers/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/qtapputils/managers/__init__.py b/qtapputils/managers/__init__.py index 6934797..2c8489c 100644 --- a/qtapputils/managers/__init__.py +++ b/qtapputils/managers/__init__.py @@ -30,6 +30,8 @@ 'LIFOTaskManager': 'qtapputils.managers.taskmanagers', 'SaveFileManager': 'qtapputils.managers.fileio', 'ShortcutManager': 'qtapputils.managers.shortcuts', + 'TitleSyncTemplate': 'qtapputils.managers.shortcuts', + 'ToolTipSyncTemplate': 'qtapputils.managers.shortcuts', } def __getattr__(name): From 3916b3b87c01a07f75f0457eed2fdf25a7f3665e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-S=C3=A9bastien=20Gosselin?= Date: Thu, 6 Nov 2025 00:33:39 -0500 Subject: [PATCH 05/42] Refactor ui sync logic --- qtapputils/managers/__init__.py | 7 +++-- qtapputils/managers/shortcuts.py | 50 ++++++++++---------------------- 2 files changed, 20 insertions(+), 37 deletions(-) diff --git a/qtapputils/managers/__init__.py b/qtapputils/managers/__init__.py index 2c8489c..3ae4e3d 100644 --- a/qtapputils/managers/__init__.py +++ b/qtapputils/managers/__init__.py @@ -12,7 +12,8 @@ # Direct imports for type-checking and IDE introspection. from .taskmanagers import WorkerBase, TaskManagerBase, LIFOTaskManager from .fileio import SaveFileManager - from .shortcuts import ShortcutManager + from .shortcuts import ( + ShortcutManager, TitleSyncTranslator, ToolTipSyncTranslator) else: # Module-level exports for explicit __all__. __all__ = [ @@ -30,8 +31,8 @@ 'LIFOTaskManager': 'qtapputils.managers.taskmanagers', 'SaveFileManager': 'qtapputils.managers.fileio', 'ShortcutManager': 'qtapputils.managers.shortcuts', - 'TitleSyncTemplate': 'qtapputils.managers.shortcuts', - 'ToolTipSyncTemplate': 'qtapputils.managers.shortcuts', + 'TitleSyncTranslator': 'qtapputils.managers.shortcuts', + 'ToolTipSyncTranslator': 'qtapputils.managers.shortcuts', } def __getattr__(name): diff --git a/qtapputils/managers/shortcuts.py b/qtapputils/managers/shortcuts.py index dedfb9d..ed73adc 100644 --- a/qtapputils/managers/shortcuts.py +++ b/qtapputils/managers/shortcuts.py @@ -15,8 +15,7 @@ Centralized Shortcut Manager for PyQt5 Applications """ -from abc import ABC, abstractmethod -from typing import Dict, Callable, Optional, List, Union, Tuple, Protocol +from typing import Dict, Callable, Optional, List, Union, Tuple, Protocol, Any from enum import Enum from PyQt5.QtWidgets import QWidget, QShortcut, QAction from PyQt5.QtGui import QKeySequence @@ -25,55 +24,38 @@ from qtapputils.qthelpers import format_tooltip, get_shortcuts_native_text -class ShortcutSyncTemplate(Protocol): - """ - Template for synchronizing UI text with keyboard shortcuts. - """ - - def interpolate(self, shortcuts: list[str] | str) -> str: - """ - Interpolate the template with a shortcut string. - - Parameters - ---------- - shortcuts : list[str] | str - The keyboard shortcut string or a list of keyboard - shortcut strings - - Returns - ------- - str: - Formatted string ready to pass to a UI setter. - """ - pass +class UISyncTranslator(Protocol): + def __call__(self, shortcut: List[str] | str) -> tuple: + ... -class TitleSyncTemplate: +class TitleSyncTranslator: def __init__(self, text): self.text = text - def interpolate(self, shortcuts): + def __call__(self, shortcuts): keystr = get_shortcuts_native_text(shortcuts) if keystr: - return f"{self.text} ({keystr})" + return f"{self.text} ({keystr})", else: - return self.text + return self.text, -class ToolTipSyncTemplate: +class ToolTipSyncTranslator: def __init__(self, title='', text='', alt_text=None): self.title = title self.text = text self.alt_text = text if alt_text is None else alt_text - def interpolate(self, shortcuts): + def __call__(self, shortcuts): if shortcuts: - return format_tooltip(self.title, self.alt_text, shortcuts) + return format_tooltip(self.title, self.alt_text, shortcuts), else: - return format_tooltip(self.title, self.text, shortcuts) + return format_tooltip(self.title, self.text, shortcuts), -UISyncTarget = Tuple[Callable[[str], None], ShortcutSyncTemplate] +UISyncSetter = Callable[..., Any] +UISyncTarget = Tuple[UISyncSetter, UISyncTranslator] class ShortcutItem: @@ -134,8 +116,8 @@ def set_enabled(self, enabled: bool = True): self.shortcut.setEnabled(enabled) def _update_ui(self): - for callback, template in self.synced_ui_data: - callback(template.interpolate(self.qkey_sequence.toString())) + for setter, translator in self.synced_ui_data: + setter(*translator(self.qkey_sequence.toString())) class ShortcutManager: From 4daa29384f7ed922289cddbefd84b112b813475d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-S=C3=A9bastien=20Gosselin?= Date: Thu, 6 Nov 2025 00:34:01 -0500 Subject: [PATCH 06/42] Don't save default keyseq to user config --- qtapputils/managers/shortcuts.py | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/qtapputils/managers/shortcuts.py b/qtapputils/managers/shortcuts.py index ed73adc..12bb5f5 100644 --- a/qtapputils/managers/shortcuts.py +++ b/qtapputils/managers/shortcuts.py @@ -136,7 +136,7 @@ def register_shortcut( callback: Callable, parent: QWidget, description: str = "", - default_key_sequence: str = None, + default_key_sequence: str = '', synced_ui_data: Optional[List[UISyncTarget]] = None ): """ @@ -157,19 +157,10 @@ def register_shortcut( ) if self._userconfig is not None: - if default_key_sequence is not None: - self._userconfig.set_default( - 'shortcuts', context_name, default_key_sequence) key_sequence = self._userconfig.get( 'shortcuts', context_name, default_key_sequence) - - if key_sequence is None: - raise ValueError( - "No key sequence found in user configs for shortcut " - "'{}' in context '{}'. Define a default key sequence in " - "the user config of this application or pass a valid " - "'default_key_sequence' value." - ) + else: + key_sequence = default_key_sequence qkey_sequence = QKeySequence(key_sequence) if qkey_sequence.isEmpty(): From e0320347bca2aad5b079bc581336eb2d8997fbd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-S=C3=A9bastien=20Gosselin?= Date: Thu, 6 Nov 2025 00:56:21 -0500 Subject: [PATCH 07/42] Improve conflict handling logic --- qtapputils/managers/shortcuts.py | 35 ++++++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/qtapputils/managers/shortcuts.py b/qtapputils/managers/shortcuts.py index 12bb5f5..170bb58 100644 --- a/qtapputils/managers/shortcuts.py +++ b/qtapputils/managers/shortcuts.py @@ -162,8 +162,11 @@ def register_shortcut( else: key_sequence = default_key_sequence + if self.check_conflicts(context, name, key_sequence): + key_sequence = None + qkey_sequence = QKeySequence(key_sequence) - if qkey_sequence.isEmpty(): + if qkey_sequence.isEmpty() and key_sequence is not None: raise ValueError( f"Key sequence '{key_sequence}' is not valid." ) @@ -199,6 +202,9 @@ def update_key_sequence( self, context: str, name: str, new_key_sequence: str, sync_userconfig: bool = False): """Update the key sequence for a shortcut""" + if self.check_conflicts(context, name, new_key_sequence): + return + context_name = f"{context}/{name}" new_qkey_sequence = QKeySequence(new_key_sequence) self._shortcuts[context_name].set_keyseq(new_qkey_sequence) @@ -215,17 +221,30 @@ def iter_shortcuts(self, context: str = None): if context is None or context == sc.context: yield sc - def find_conflicts(self, context: str, name: str, new_key_sequence: str): + def check_conflicts(self, context: str, name: str, key_sequence: str): + conflicts = self.find_conflicts(context, name, key_sequence) + if len(conflicts): + print(f"Cannot set shortcut '{name}' in context '{context}' " + f"to '{key_sequence}' because of the following " + f"conflict(s):") + for sc in conflicts: + print(f" - shortcut '{sc.name}' in context '{sc.context}'") + return True + return False + + def find_conflicts(self, context: str, name: str, key_sequence: str): """Check shortcuts for conflicts.""" conflicts = [] - new_qkey_sequence = QKeySequence(new_key_sequence) + qkey_sequence = QKeySequence(key_sequence) no_match = QKeySequence.SequenceMatch.NoMatch - for sc in self.iter_shortcuts(context): + for sc in self.iter_shortcuts(): if sc.qkey_sequence.isEmpty(): continue - if sc.name == name: + if (sc.context, sc.name) == (context, name): continue - if (sc.qkey_sequence.matches(new_qkey_sequence) != no_match or - new_qkey_sequence.matches(sc.qkey_sequence) != no_match): - conflicts.append(sc) + if sc.context in [context, '_'] or context == '_': + if sc.qkey_sequence.matches(qkey_sequence) != no_match: + conflicts.append(sc) + elif qkey_sequence.matches(sc.qkey_sequence) != no_match: + conflicts.append(sc) return conflicts From 8e89db6a2df9e499916e772dd9803aeda8c5a41b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-S=C3=A9bastien=20Gosselin?= Date: Thu, 6 Nov 2025 09:49:04 -0500 Subject: [PATCH 08/42] Set back default key seq to default config (correctly this time) --- qtapputils/managers/shortcuts.py | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/qtapputils/managers/shortcuts.py b/qtapputils/managers/shortcuts.py index 170bb58..c0c569a 100644 --- a/qtapputils/managers/shortcuts.py +++ b/qtapputils/managers/shortcuts.py @@ -14,7 +14,7 @@ """ Centralized Shortcut Manager for PyQt5 Applications """ - +import configparser as cp from typing import Dict, Callable, Optional, List, Union, Tuple, Protocol, Any from enum import Enum from PyQt5.QtWidgets import QWidget, QShortcut, QAction @@ -136,7 +136,7 @@ def register_shortcut( callback: Callable, parent: QWidget, description: str = "", - default_key_sequence: str = '', + default_key_sequence: str = None, synced_ui_data: Optional[List[UISyncTarget]] = None ): """ @@ -157,10 +157,22 @@ def register_shortcut( ) if self._userconfig is not None: - key_sequence = self._userconfig.get( - 'shortcuts', context_name, default_key_sequence) + if default_key_sequence is not None: + self._userconfig.set_default( + 'shortcuts', context_name, default_key_sequence + ) + + try: + # We don't pass the default value to 'get', because if + # option does not exists in 'shortcuts' section, the default + # is saved in the current user configs and we do not want + # that. + key_sequence = self._userconfig.get('shortcuts', context_name) + except cp.NoOptionError: + key_sequence = default_key_sequence or '' + else: - key_sequence = default_key_sequence + key_sequence = default_key_sequence or '' if self.check_conflicts(context, name, key_sequence): key_sequence = None From dbdce05d20a26bffb609ff420d76253cf9fd9744 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-S=C3=A9bastien=20Gosselin?= Date: Tue, 11 Nov 2025 10:54:05 -0500 Subject: [PATCH 09/42] Add ActionMenuSyncTranslator --- qtapputils/managers/__init__.py | 7 ++++++- qtapputils/managers/shortcuts.py | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/qtapputils/managers/__init__.py b/qtapputils/managers/__init__.py index 3ae4e3d..f078e60 100644 --- a/qtapputils/managers/__init__.py +++ b/qtapputils/managers/__init__.py @@ -13,7 +13,8 @@ from .taskmanagers import WorkerBase, TaskManagerBase, LIFOTaskManager from .fileio import SaveFileManager from .shortcuts import ( - ShortcutManager, TitleSyncTranslator, ToolTipSyncTranslator) + ShortcutManager, TitleSyncTranslator, ToolTipSyncTranslator, + ActionMenuSyncTranslator) else: # Module-level exports for explicit __all__. __all__ = [ @@ -22,6 +23,9 @@ 'LIFOTaskManager', 'SaveFileManager', 'ShortcutManager', + 'TitleSyncTranslator', + 'ToolTipSyncTranslator', + 'ActionMenuSyncTranslator', ] # Lazy import mapping. @@ -33,6 +37,7 @@ 'ShortcutManager': 'qtapputils.managers.shortcuts', 'TitleSyncTranslator': 'qtapputils.managers.shortcuts', 'ToolTipSyncTranslator': 'qtapputils.managers.shortcuts', + 'ActionMenuSyncTranslator': 'qtapputils.managers.shortcuts' } def __getattr__(name): diff --git a/qtapputils/managers/shortcuts.py b/qtapputils/managers/shortcuts.py index c0c569a..f1a2547 100644 --- a/qtapputils/managers/shortcuts.py +++ b/qtapputils/managers/shortcuts.py @@ -24,11 +24,26 @@ from qtapputils.qthelpers import format_tooltip, get_shortcuts_native_text +# ============================================================================= +# UI Sync Translators +# ============================================================================= class UISyncTranslator(Protocol): def __call__(self, shortcut: List[str] | str) -> tuple: ... +class ActionMenuSyncTranslator: + def __init__(self, text): + self.text = text + + def __call__(self, shortcuts): + keystr = get_shortcuts_native_text(shortcuts) + if keystr: + return f"{self.text}\t{keystr}", + else: + return self.text, + + class TitleSyncTranslator: def __init__(self, text): self.text = text @@ -54,6 +69,10 @@ def __call__(self, shortcuts): return format_tooltip(self.title, self.text, shortcuts), +# ============================================================================= +# Shortcuts Manager +# ============================================================================= + UISyncSetter = Callable[..., Any] UISyncTarget = Tuple[UISyncSetter, UISyncTranslator] From e65e9c24d8ca10d48a46d1fd9767f6ca1d964e3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-S=C3=A9bastien=20Gosselin?= Date: Tue, 11 Nov 2025 11:12:54 -0500 Subject: [PATCH 10/42] Improve check shortcuts logic --- qtapputils/managers/shortcuts.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/qtapputils/managers/shortcuts.py b/qtapputils/managers/shortcuts.py index f1a2547..cae0743 100644 --- a/qtapputils/managers/shortcuts.py +++ b/qtapputils/managers/shortcuts.py @@ -194,10 +194,10 @@ def register_shortcut( key_sequence = default_key_sequence or '' if self.check_conflicts(context, name, key_sequence): - key_sequence = None + key_sequence = '' qkey_sequence = QKeySequence(key_sequence) - if qkey_sequence.isEmpty() and key_sequence is not None: + if qkey_sequence.isEmpty() and key_sequence not in (None, ''): raise ValueError( f"Key sequence '{key_sequence}' is not valid." ) @@ -267,6 +267,9 @@ def find_conflicts(self, context: str, name: str, key_sequence: str): """Check shortcuts for conflicts.""" conflicts = [] qkey_sequence = QKeySequence(key_sequence) + if qkey_sequence.isEmpty(): + return conflicts + no_match = QKeySequence.SequenceMatch.NoMatch for sc in self.iter_shortcuts(): if sc.qkey_sequence.isEmpty(): From 9079d44820e2bd1edcd4f738c3622c4ed24cb962 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-S=C3=A9bastien=20Gosselin?= Date: Sun, 16 Nov 2025 13:15:23 -0500 Subject: [PATCH 11/42] ShortcutItem: add key_sequence property --- qtapputils/managers/shortcuts.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/qtapputils/managers/shortcuts.py b/qtapputils/managers/shortcuts.py index cae0743..263839a 100644 --- a/qtapputils/managers/shortcuts.py +++ b/qtapputils/managers/shortcuts.py @@ -87,7 +87,7 @@ def __init__(self, callback: Callable, parent: QWidget, enabled: bool = True, - description: str = 'Toggle Gain', + description: str = '', synced_ui_data: Optional[List[UISyncTarget]] = None ): self.context = context @@ -105,6 +105,13 @@ def __init__(self, if enabled is True: self.activate() + @property + def key_sequence(self, native: bool = False): + if native: + return self.qkey_sequence.toString(QKeySequence.NativeText) + else: + return self.qkey_sequence.toString() + def activate(self): """Internal method to create a QShortcut""" if self.shortcut is None: From 7822d6ea74dbbb91bfb8536591b5a2d63366da0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-S=C3=A9bastien=20Gosselin?= Date: Sun, 16 Nov 2025 13:16:04 -0500 Subject: [PATCH 12/42] ShortcutManager: rename 'update_key_sequence' --- qtapputils/managers/shortcuts.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/qtapputils/managers/shortcuts.py b/qtapputils/managers/shortcuts.py index 263839a..bbf6ac0 100644 --- a/qtapputils/managers/shortcuts.py +++ b/qtapputils/managers/shortcuts.py @@ -236,10 +236,10 @@ def enable_shortcut( """Enable or disable a shortcut""" self._shortcuts[f"{context}/{name}"].set_enabled(enabled) - def update_key_sequence( - self, context: str, name: str, new_key_sequence: str, - sync_userconfig: bool = False): - """Update the key sequence for a shortcut""" + def set_shortcut( + self, context: str, name: str, new_key_sequence: str + ): + """Set the key sequence for a shortcut""" if self.check_conflicts(context, name, new_key_sequence): return From 8784adde6b1ce92421e1f71e8575cb0f21bcdfc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-S=C3=A9bastien=20Gosselin?= Date: Wed, 26 Nov 2025 20:48:09 -0500 Subject: [PATCH 13/42] Major rework to allow separate declaration and binding shortcut operation --- qtapputils/managers/shortcuts.py | 343 +++++++++++++++++++++---------- 1 file changed, 233 insertions(+), 110 deletions(-) diff --git a/qtapputils/managers/shortcuts.py b/qtapputils/managers/shortcuts.py index cae0743..288fda7 100644 --- a/qtapputils/managers/shortcuts.py +++ b/qtapputils/managers/shortcuts.py @@ -1,25 +1,20 @@ # -*- coding: utf-8 -*- -# ============================================================================= -# Copyright (C) Les Solutions Géostack, Inc. - All Rights Reserved -# -# This file is part of Seismate. -# Unauthorized copying, distribution, or modification of this file, -# via any medium, is strictly prohibited without explicit permission -# from Les Solutions Géostack, Inc. Proprietary and confidential. +# ----------------------------------------------------------------------------- +# Copyright © QtAppUtils Project Contributors +# https://github.com/jnsebgosselin/apputils # -# For inquiries, contact: info@geostack.ca -# Repository: https://github.com/geo-stack/seismate -# ============================================================================= +# This file is part of QtAppUtils. +# Licensed under the terms of the MIT License. +# ----------------------------------------------------------------------------- """ Centralized Shortcut Manager for PyQt5 Applications """ +from dataclasses import dataclass, field import configparser as cp -from typing import Dict, Callable, Optional, List, Union, Tuple, Protocol, Any -from enum import Enum -from PyQt5.QtWidgets import QWidget, QShortcut, QAction +from typing import Dict, Callable, Optional, List, Tuple, Protocol, Any +from PyQt5.QtWidgets import QWidget, QShortcut from PyQt5.QtGui import QKeySequence -from PyQt5.QtCore import Qt from appconfigs.user import UserConfig from qtapputils.qthelpers import format_tooltip, get_shortcuts_native_text @@ -70,110 +65,140 @@ def __call__(self, shortcuts): # ============================================================================= -# Shortcuts Manager +# Shortcut Definition (Declarative - no QShortcut yet) # ============================================================================= UISyncSetter = Callable[..., Any] UISyncTarget = Tuple[UISyncSetter, UISyncTranslator] +@dataclass +class ShortcutDefinition: + """ + Declarative shortcut definition - describes a shortcut without + creating any Qt objects. + + This allows complete registration at startup. + """ + context: str + name: str + key_sequence: str + description: str + + def __post_init__(self): + self.current_key_sequence = self.default_key_sequence + + @property + def context_name(self) -> str: + return f"{self.context}/{self.name}" + + @property + def qkey_sequence(self) -> QKeySequence: + return QKeySequence(self.key_sequence) + + @property + def is_bound(self) -> bool: + """Check if this definition has been bound to actual UI.""" + return hasattr(self, '_shortcut') and self._shortcut is not None + + @property + def shortcut(self) -> Optional['ShortcutItem']: + return getattr(self, '_shortcut', None) + + +@dataclass class ShortcutItem: - """Configuration class for shortcut definitions""" - - def __init__(self, - context: str, - name: str, - key_sequence: QKeySequence | str, - callback: Callable, - parent: QWidget, - enabled: bool = True, - description: str = 'Toggle Gain', - synced_ui_data: Optional[List[UISyncTarget]] = None - ): - self.context = context - self.name = name - self.qkey_sequence = QKeySequence(key_sequence) - self.callback = callback - self.parent = parent - self.description = description - - if synced_ui_data is None: - synced_ui_data = [] - self.synced_ui_data = synced_ui_data - - self.shortcut = None - if enabled is True: - self.activate() + """ + A shortcut that has been bound to actual UI elements. + Created when lazy UI is finally instantiated. + """ + definition: ShortcutDefinition + callback: Callable + parent: QWidget + synced_ui_data: List[UISyncTarget] = field(default_factory=list) + shortcut: QShortcut = field(default=None, init=False) + enabled: bool = field(default=True, init=False) def activate(self): - """Internal method to create a QShortcut""" + """Create and activate the QShortcut.""" if self.shortcut is None: - self.shortcut = QShortcut(self.qkey_sequence, self.parent) - self.shortcut.activated.connect(self.callback) + self.shortcut = QShortcut( + self.definition.qkey_sequence, self.parent) + self.shortcut. activated.connect(self.callback) self.shortcut.setAutoRepeat(False) self.set_enabled(True) self._update_ui() def deactivate(self): - """Deactivate a specific shortcut""" + """Deactivate and clean up the QShortcut.""" if self.shortcut is not None: self.shortcut.setEnabled(False) self.shortcut.deleteLater() - del self.shortcut self.shortcut = None self.enabled = False - def set_keyseq(self, qkey_sequence: QKeySequence): + def set_keyseq(self, key_sequence: str): + """Update the key sequence.""" + self.definition.key_sequence = key_sequence if self.shortcut is not None: - self.qkey_sequence = qkey_sequence - self.shortcut.setKey(self.qkey_sequence) + self.shortcut.setKey(self.definition.qkey_sequence) self._update_ui() def set_enabled(self, enabled: bool = True): - """Enable or disable a shortcut""" - self.enabled = enabled - self.shortcut.setEnabled(enabled) + """Enable or disable the shortcut.""" + self. enabled = enabled + if self.shortcut is not None: + self.shortcut.setEnabled(enabled) def _update_ui(self): + """Update synced UI elements with current key sequence.""" + keystr = self.definition.qkey_sequence.toString() for setter, translator in self.synced_ui_data: - setter(*translator(self.qkey_sequence.toString())) + setter(*translator(keystr)) +# ============================================================================= +# Shortcuts Manager +# ============================================================================= class ShortcutManager: """ Centralized manager for application shortcuts. + + Supports two-phase shortcut management: + 1. Declaration phase: Define all shortcuts upfront (even before UI exists) + 2. Binding phase: Bind shortcuts to actual UI when it's created """ def __init__(self, userconfig: UserConfig = None): self._userconfig = userconfig + + # All declared shortcuts (complete list available immediately) + self._definitions: Dict[str, ShortcutDefinition] = {} + + # Shortcuts that have been bound to UI self._shortcuts: Dict[str, ShortcutItem] = {} - def register_shortcut( + # ========================================================================= + # ---- Declaration methods + # ========================================================================= + def declare_shortcut( self, context: str, name: str, - callback: Callable, - parent: QWidget, - description: str = "", default_key_sequence: str = None, - synced_ui_data: Optional[List[UISyncTarget]] = None - ): + description: str = '' + ) -> ShortcutDefinition: """ - Register a shortcut configuration. - - Args: - name: Identifier for the shortcut - key_sequence: Key combination (e.g., "Ctrl+S", "Alt+F4") - callback: Function to call when shortcut is triggered - context: Context where shortcut is active - description: Human-readable description + Declare a shortcut definition. + + This allow populating the complete shortcut list, even before their + UI exists.. """ context_name = f"{context}/{name}" - if context_name in self._shortcuts: + if context_name in self._definitions: raise ValueError( - f"There is already a shortcut '{name}' registered for " - f"context '{context}'." - ) + f"Shortcut '{name}' already declared for context '{context}'." + ) if self._userconfig is not None: if default_key_sequence is not None: @@ -198,63 +223,160 @@ def register_shortcut( qkey_sequence = QKeySequence(key_sequence) if qkey_sequence.isEmpty() and key_sequence not in (None, ''): + # TODO: simply print a warning instead and use '' as shortcut. raise ValueError( f"Key sequence '{key_sequence}' is not valid." ) - self._shortcuts[context_name] = ShortcutItem( + definition = ShortcutDefinition( context=context, name=name, - key_sequence=qkey_sequence, + key_sequence=key_sequence, + description=description + ) + + self._definitions[context_name] = definition + + return definition + + def declare_shortcuts(self, shortcuts: List[dict]): + """Bulk declare shortcuts from a list of definitions.""" + for sc in shortcuts: + self.declare_shortcut(**sc) + + # ========================================================================= + # ---- Binding methods + # ========================================================================= + def bind_shortcut( + self, + context: str, + name: str, + callback: Callable, + parent: QWidget, + synced_ui_data: Optional[List[UISyncTarget]] = None, + activate: bool = True + ) -> ShortcutItem: + """ + Bind a previously declared shortcut to actual UI elements. + Call this when the lazy-loaded UI is finally created. + """ + context_name = f"{context}/{name}" + if context_name not in self._definitions: + raise ValueError( + f"Shortcut '{name}' in context '{context}' was not declared. " + f"Call declare_shortcut() first." + ) + if context_name in self._shortcuts: + raise ValueError( + f"Shortcut '{name}' in context '{context}' is already bound." + ) + + definition = self._definitions[context_name] + shortcut = ShortcutItem( + definition=definition, callback=callback, parent=parent, - synced_ui_data=synced_ui_data + synced_ui_data=synced_ui_data or [] ) - def unregister_shortcut(self, context: str, name: str): - """Unregister a shortcut""" - self._shortcuts[f"{context}/{name}"].deactivate() - del self._shortcuts[f"{context}/{name}"] + # Link back to definition + definition._shortcut = shortcut + self._shortcuts[context_name] = shortcut + + if activate: + shortcut.activate() + + return shortcut + def unbind_shortcut(self, context: str, name: str): + """Unbind a shortcut.""" + context_name = f"{context}/{name}" + if context_name in self._bound: + self._shortcuts[context_name].deactivate() + self._definitions[context_name]._shortcut = None + del self._shortcuts[context_name] + + # ========================================================================= + # Shortcut Control + # ========================================================================= def activate_shortcut(self, context: str, name: str): - """Activate a specific shortcut on a widget""" - self._shortcuts[f"{context}/{name}"].activate() + """Activate a bound shortcut.""" + context_name = f"{context}/{name}" + if context_name in self._bound: + self._shortcuts[context_name].activate() def deactivate_shortcut(self, context: str, name: str): - """Deactivate a specific shortcut""" - self._shortcuts[f"{context}/{name}"].deactivate() - - def enable_shortcut( - self, context: str, name: str, enabled: bool = True): - """Enable or disable a shortcut""" - self._shortcuts[f"{context}/{name}"].set_enabled(enabled) - - def update_key_sequence( - self, context: str, name: str, new_key_sequence: str, - sync_userconfig: bool = False): - """Update the key sequence for a shortcut""" - if self.check_conflicts(context, name, new_key_sequence): - return + """Deactivate a bound shortcut.""" + context_name = f"{context}/{name}" + if context_name in self._bound: + self._shortcuts[context_name].deactivate() + def enable_shortcut(self, context: str, name: str, enabled: bool = True): + """Enable or disable a bound shortcut.""" context_name = f"{context}/{name}" - new_qkey_sequence = QKeySequence(new_key_sequence) - self._shortcuts[context_name].set_keyseq(new_qkey_sequence) + if context_name in self._bound: + self._bound[context_name].set_enabled(enabled) + + def set_key_sequence( + self, + context: str, + name: str, + new_key_sequence: str, + sync_userconfig: bool = False + ): + """Set the key sequence for a shortcut (declared or bound).""" + context_name = f"{context}/{name}" + + if context_name not in self._definitions: + raise ValueError(f"Shortcut '{context_name}' not found.") + + if self.check_conflicts(context, name, new_key_sequence): + return False + + definition = self._definitions[context_name] + definition.key_sequence = new_key_sequence + + # Update bound shortcut if it exists + if context_name in self._shortcuts: + self._shortcuts[context_name].set_keyseq(new_key_sequence) + + # Save to user config if self._userconfig is not None and sync_userconfig: self._userconfig.set( 'shortcuts', context_name, - new_qkey_sequence.toString() + QKeySequence(new_key_sequence).toString() ) - def iter_shortcuts(self, context: str = None): - """Iterate over keyboard shortcuts.""" + return True + + # ========================================================================= + # Iteration & Query + # ========================================================================= + def iter_definitions(self, context: str = None): + """ + Iterate over ALL shortcut definitions (complete list). + Use this for the settings panel. + """ + for definition in self._definitions.values(): + if context is None or context == definition.context: + yield definition + + def iter_bound_shortcuts(self, context: str = None): + """Iterate over bound shortcuts only.""" for sc in self._shortcuts.values(): - if context is None or context == sc.context: + if context is None or context == sc.definition.context: yield sc - def check_conflicts(self, context: str, name: str, key_sequence: str): + # ========================================================================= + # Conflict Detection + # ========================================================================= + def check_conflicts( + self, context: str, name: str, key_sequence: str + ) -> bool: + """Check for conflicts and print warnings.""" conflicts = self.find_conflicts(context, name, key_sequence) - if len(conflicts): + if conflicts: print(f"Cannot set shortcut '{name}' in context '{context}' " f"to '{key_sequence}' because of the following " f"conflict(s):") @@ -271,14 +393,15 @@ def find_conflicts(self, context: str, name: str, key_sequence: str): return conflicts no_match = QKeySequence.SequenceMatch.NoMatch - for sc in self.iter_shortcuts(): - if sc.qkey_sequence.isEmpty(): + for sc_def in self._definitions.values(): + if sc_def.qkey_sequence.isEmpty(): continue - if (sc.context, sc.name) == (context, name): + if (sc_def.context, sc_def.name) == (context, name): continue - if sc.context in [context, '_'] or context == '_': - if sc.qkey_sequence.matches(qkey_sequence) != no_match: - conflicts.append(sc) - elif qkey_sequence.matches(sc.qkey_sequence) != no_match: - conflicts.append(sc) + if sc_def.context in [context, '_'] or context == '_': + if sc_def.qkey_sequence.matches(qkey_sequence) != no_match: + conflicts.append(sc_def) + elif qkey_sequence.matches(sc_def.qkey_sequence) != no_match: + conflicts.append(sc_def) + return conflicts From 3508866ff026974aefbd5fbd91d12b5e0c0ccd8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-S=C3=A9bastien=20Gosselin?= Date: Wed, 26 Nov 2025 22:30:58 -0500 Subject: [PATCH 14/42] Remove '__post_init__' from 'ShortcutDefinition' --- qtapputils/managers/shortcuts.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/qtapputils/managers/shortcuts.py b/qtapputils/managers/shortcuts.py index 5846509..93ab135 100644 --- a/qtapputils/managers/shortcuts.py +++ b/qtapputils/managers/shortcuts.py @@ -85,9 +85,6 @@ class ShortcutDefinition: key_sequence: str description: str - def __post_init__(self): - self.current_key_sequence = self.default_key_sequence - @property def context_name(self) -> str: return f"{self.context}/{self.name}" From 6c5c26b0aac7b7b45d1b9d1287507f7b8a8a625f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-S=C3=A9bastien=20Gosselin?= Date: Mon, 8 Dec 2025 18:28:19 -0500 Subject: [PATCH 15/42] Fix minor regression bug in format_tooltip --- qtapputils/qthelpers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/qtapputils/qthelpers.py b/qtapputils/qthelpers.py index 793e1ea..4684da6 100644 --- a/qtapputils/qthelpers.py +++ b/qtapputils/qthelpers.py @@ -305,9 +305,9 @@ def format_tooltip(text: str, tip: str, shortcuts: list[str] | str): if text or keystr: ttip += "

" if text: - ttip += f"{text}" + ttip += f"{text}" + (" " if keystr else "") if keystr: - ttip += " ({sc_str})" + ttip += "({sc_str})" ttip += "

" if tip: ttip += f"

{tip or ''}

" From 4b8de4dd32de4346d5d8bab2a07b297d64e78940 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-S=C3=A9bastien=20Gosselin?= Date: Mon, 8 Dec 2025 20:41:12 -0500 Subject: [PATCH 16/42] Make 'ShortcutItem' a standard class with a __init__ --- qtapputils/managers/shortcuts.py | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/qtapputils/managers/shortcuts.py b/qtapputils/managers/shortcuts.py index 93ab135..07d35cd 100644 --- a/qtapputils/managers/shortcuts.py +++ b/qtapputils/managers/shortcuts.py @@ -103,18 +103,25 @@ def shortcut(self) -> Optional['ShortcutItem']: return getattr(self, '_shortcut', None) -@dataclass class ShortcutItem: """ A shortcut that has been bound to actual UI elements. Created when lazy UI is finally instantiated. """ - definition: ShortcutDefinition - callback: Callable - parent: QWidget - synced_ui_data: List[UISyncTarget] = field(default_factory=list) - shortcut: QShortcut = field(default=None, init=False) - enabled: bool = field(default=True, init=False) + + def __init__(self, definition: ShortcutDefinition, callback: Callable, + parent: QWidget, synced_ui_data: List[UISyncTarget] = None, + enabled: bool = True): + + self.definition = definition + self.callback = callback + self.parent = parent + self.synced_ui_data = synced_ui_data or [] + + self.shortcut = None + self.enabled = False + + self._update_ui() @property def key_sequence(self, native: bool = False): From eff694690d67e7b528b4e5536da85a23e40c415a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-S=C3=A9bastien=20Gosselin?= Date: Mon, 8 Dec 2025 20:42:00 -0500 Subject: [PATCH 17/42] Return '' key_sequence when shortcut is None and add calls to '_update_ui' --- qtapputils/managers/shortcuts.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/qtapputils/managers/shortcuts.py b/qtapputils/managers/shortcuts.py index 07d35cd..1fd7c11 100644 --- a/qtapputils/managers/shortcuts.py +++ b/qtapputils/managers/shortcuts.py @@ -125,17 +125,21 @@ def __init__(self, definition: ShortcutDefinition, callback: Callable, @property def key_sequence(self, native: bool = False): + if self.shortcut is None: + return '' + if native: - return self.qkey_sequence.toString(QKeySequence.NativeText) + return self.definition.qkey_sequence.toString( + QKeySequence.NativeText) else: - return self.qkey_sequence.toString() + return self.definition.qkey_sequence.toString() def activate(self): """Create and activate the QShortcut.""" if self.shortcut is None: self.shortcut = QShortcut( self.definition.qkey_sequence, self.parent) - self.shortcut. activated.connect(self.callback) + self.shortcut.activated.connect(self.callback) self.shortcut.setAutoRepeat(False) self.set_enabled(True) self._update_ui() @@ -147,6 +151,7 @@ def deactivate(self): self.shortcut.deleteLater() self.shortcut = None self.enabled = False + self._update_ui() def set_keyseq(self, key_sequence: str): """Update the key sequence.""" @@ -157,13 +162,13 @@ def set_keyseq(self, key_sequence: str): def set_enabled(self, enabled: bool = True): """Enable or disable the shortcut.""" - self. enabled = enabled + self.enabled = enabled if self.shortcut is not None: self.shortcut.setEnabled(enabled) def _update_ui(self): """Update synced UI elements with current key sequence.""" - keystr = self.definition.qkey_sequence.toString() + keystr = self.key_sequence for setter, translator in self.synced_ui_data: setter(*translator(keystr)) From 153de0ee8b93cf69a6b49755f6f5dcc11d283528 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-S=C3=A9bastien=20Gosselin?= Date: Mon, 8 Dec 2025 23:48:11 -0500 Subject: [PATCH 18/42] Fix ShortcutManager.declare_shortcut default_key management --- qtapputils/managers/shortcuts.py | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/qtapputils/managers/shortcuts.py b/qtapputils/managers/shortcuts.py index 1fd7c11..11c32a7 100644 --- a/qtapputils/managers/shortcuts.py +++ b/qtapputils/managers/shortcuts.py @@ -110,8 +110,7 @@ class ShortcutItem: """ def __init__(self, definition: ShortcutDefinition, callback: Callable, - parent: QWidget, synced_ui_data: List[UISyncTarget] = None, - enabled: bool = True): + parent: QWidget, synced_ui_data: List[UISyncTarget] = None): self.definition = definition self.callback = callback @@ -201,7 +200,7 @@ def declare_shortcut( self, context: str, name: str, - default_key_sequence: str = None, + default_key_sequence: str = '', description: str = '' ) -> ShortcutDefinition: """ @@ -216,12 +215,8 @@ def declare_shortcut( f"Shortcut '{name}' already declared for context '{context}'." ) + key_sequence = default_key_sequence if self._userconfig is not None: - if default_key_sequence is not None: - self._userconfig.set_default( - 'shortcuts', context_name, default_key_sequence - ) - try: # We don't pass the default value to 'get', because if # option does not exists in 'shortcuts' section, the default @@ -229,10 +224,7 @@ def declare_shortcut( # that. key_sequence = self._userconfig.get('shortcuts', context_name) except cp.NoOptionError: - key_sequence = default_key_sequence or '' - - else: - key_sequence = default_key_sequence or '' + pass if self.check_conflicts(context, name, key_sequence): key_sequence = '' From abf3afc7b0194f0c28ad4ba22a3f6cd2f95dd3bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-S=C3=A9bastien=20Gosselin?= Date: Mon, 8 Dec 2025 23:48:47 -0500 Subject: [PATCH 19/42] Update wrong class attribute name --- qtapputils/managers/shortcuts.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/qtapputils/managers/shortcuts.py b/qtapputils/managers/shortcuts.py index 11c32a7..3b2a8a6 100644 --- a/qtapputils/managers/shortcuts.py +++ b/qtapputils/managers/shortcuts.py @@ -299,7 +299,7 @@ def bind_shortcut( def unbind_shortcut(self, context: str, name: str): """Unbind a shortcut.""" context_name = f"{context}/{name}" - if context_name in self._bound: + if context_name in self._shortcuts: self._shortcuts[context_name].deactivate() self._definitions[context_name]._shortcut = None del self._shortcuts[context_name] @@ -310,20 +310,20 @@ def unbind_shortcut(self, context: str, name: str): def activate_shortcut(self, context: str, name: str): """Activate a bound shortcut.""" context_name = f"{context}/{name}" - if context_name in self._bound: + if context_name in self._shortcuts: self._shortcuts[context_name].activate() def deactivate_shortcut(self, context: str, name: str): """Deactivate a bound shortcut.""" context_name = f"{context}/{name}" - if context_name in self._bound: + if context_name in self._shortcuts: self._shortcuts[context_name].deactivate() def enable_shortcut(self, context: str, name: str, enabled: bool = True): """Enable or disable a bound shortcut.""" context_name = f"{context}/{name}" - if context_name in self._bound: - self._bound[context_name].set_enabled(enabled) + if context_name in self._shortcuts: + self._shortcuts[context_name].set_enabled(enabled) def set_key_sequence( self, From b52238e25c0a07478f08669cb31b5f8ba9b1cd82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-S=C3=A9bastien=20Gosselin?= Date: Tue, 9 Dec 2025 00:31:43 -0500 Subject: [PATCH 20/42] Rename 'iter_bound_shortcuts' --- qtapputils/managers/shortcuts.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qtapputils/managers/shortcuts.py b/qtapputils/managers/shortcuts.py index 3b2a8a6..3ed8085 100644 --- a/qtapputils/managers/shortcuts.py +++ b/qtapputils/managers/shortcuts.py @@ -370,7 +370,7 @@ def iter_definitions(self, context: str = None): if context is None or context == definition.context: yield definition - def iter_bound_shortcuts(self, context: str = None): + def iter_shortcuts(self, context: str = None): """Iterate over bound shortcuts only.""" for sc in self._shortcuts.values(): if context is None or context == sc.definition.context: From 6710056986a17625ea5e663e6f60599ea8076877 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-S=C3=A9bastien=20Gosselin?= Date: Tue, 9 Dec 2025 00:31:47 -0500 Subject: [PATCH 21/42] Create test_shortcut_manager.py --- .../managers/tests/test_shortcut_manager.py | 582 ++++++++++++++++++ 1 file changed, 582 insertions(+) create mode 100644 qtapputils/managers/tests/test_shortcut_manager.py diff --git a/qtapputils/managers/tests/test_shortcut_manager.py b/qtapputils/managers/tests/test_shortcut_manager.py new file mode 100644 index 0000000..cedc671 --- /dev/null +++ b/qtapputils/managers/tests/test_shortcut_manager.py @@ -0,0 +1,582 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright © QtAppUtils Project Contributors +# https://github.com/jnsebgosselin/apputils +# +# This file is part of QtAppUtils. +# Licensed under the terms of the MIT License. +# ----------------------------------------------------------------------------- + +""" +Tests for the Centralized Shortcut Manager +""" + +import configparser as cp +import pytest +from unittest.mock import Mock +from PyQt5.QtWidgets import QPushButton +from PyQt5.QtGui import QKeySequence +from PyQt5.QtCore import Qt +from appconfigs.user import NoDefault +from qtapputils.managers.shortcuts import ( + ShortcutManager, ShortcutDefinition, ShortcutItem, + ActionMenuSyncTranslator, TitleSyncTranslator, ToolTipSyncTranslator) + + +# ============================================================================= +# Fixtures +# ============================================================================= + +@pytest.fixture +def widget(qtbot): + """Create a basic QWidget for testing.""" + w = QPushButton('') + qtbot.addWidget(w) + return w + + +@pytest.fixture +def userconfig(): + """Create a mock UserConfig object.""" + + class UserConfigMock(): + + def __init__(self): + self._config = {'file/save': 'Ctrl+S', + 'file/open': 'Ctrl+O'} + + def get(self, section, option, default=NoDefault): + if section != 'shortcuts': + raise KeyError( + f"'section' should be 'shortcuts', but got {section}.") + + if option in self._config: + return self._config[option] + else: + raise cp.NoOptionError(option, section) + + def set(self, section, option, value): + if section != 'shortcuts': + raise KeyError( + f"'section' should be 'shortcuts', but got {section}.") + self._config[option] = value + + return UserConfigMock() + + +@pytest.fixture +def definition(): + """Create a sample ShortcutDefinition.""" + return ShortcutDefinition( + context="file", + name="save", + key_sequence="Ctrl+S", + description="Save file" + ) + + +@pytest.fixture +def shortcut_item(definition, widget): + """Create a ShortcutItem for testing.""" + title = "Save" + text = "Save the file." + alt_text = "Save with the file with {sc_str}." + + return ShortcutItem( + definition=definition, + callback=Mock(), + parent=widget, + synced_ui_data=[ + (widget.setToolTip, + ToolTipSyncTranslator(title, text, alt_text)), + (widget.setText, + TitleSyncTranslator(title)) + ], + ) + + +@pytest.fixture +def populated_manager(widget): + """Create a manager with multiple shortcuts, some bound.""" + manager = ShortcutManager() + + manager.declare_shortcut( + context="file", name="save", default_key_sequence="Ctrl+S" + ) + manager.declare_shortcut( + context="file", name="open", default_key_sequence="Ctrl+O" + ) + manager.declare_shortcut( + context="edit", name="copy", default_key_sequence="Ctrl+C" + ) + manager.declare_shortcut( + context="_", name="quit", default_key_sequence="Ctrl+Q" + ) + + # Bind only some shortcuts + manager.bind_shortcut( + context="file", name="save", callback=Mock(), parent=widget + ) + manager.bind_shortcut( + context="edit", name="copy", callback=Mock(), parent=widget + ) + + return manager + + +# ============================================================================= +# Tests +# ============================================================================= +def test_uisync_translator(): + + # Test action menu with and without shortcut. + translator = ActionMenuSyncTranslator("Save") + assert translator("Ctrl+S") == ("Save\tCtrl+S",) + assert translator("") == ("Save",) + + # Test title sync translator with and without shortcut. + translator = TitleSyncTranslator("Save File") + assert translator("Ctrl+S") == ("Save File (Ctrl+S)",) + assert translator("") == ("Save File",) + + # Test tooltip sync translator with and without shortcut. + translator = ToolTipSyncTranslator( + title="Save", + text="Save the file.", + alt_text="Save with the file with {sc_str}.") + assert translator("Ctrl+S") == ( + "

" + "Save (Ctrl+S)

" + "

Save with the file with Ctrl+S.

", ) + assert translator("") == ( + "

" + "Save

" + "

Save the file.

", ) + + +def test_shortcut_definition(definition): + assert definition.context_name == "file/save" + assert definition.qkey_sequence == QKeySequence("Ctrl+S") + assert definition.is_bound is False + assert definition.shortcut is None + + # Test the shortcut definition is bound after binding. + definition._shortcut = Mock() + assert definition.is_bound is True + assert definition.shortcut is not None + + +def test_shortcut_item(shortcut_item, widget, qtbot): + widget.show() + qtbot.wait(300) + + # Initially not activated. + assert shortcut_item.shortcut is None + assert widget.text() == 'Save' + assert widget.toolTip() == ( + "

" + "Save

" + "

Save the file.

") + + # Activate shortcut item. + shortcut_item.activate() + assert shortcut_item.shortcut is not None + assert shortcut_item.enabled is True + assert widget.text() == 'Save (Ctrl+S)' + assert widget.toolTip() == ( + "

" + "Save (Ctrl+S)

" + "

Save with the file with Ctrl+S.

") + + # Test callback connection. + qtbot.keyPress(widget, Qt.Key_S, modifier=Qt.ControlModifier) + shortcut_item.callback.call_count == 1 + + # Change key sequence + shortcut_item.set_keyseq("Ctrl+A") + assert widget.text() == 'Save (Ctrl+A)' + assert widget.toolTip() == ( + "

" + "Save (Ctrl+A)

" + "

Save with the file with Ctrl+A.

") + + # Test callback connection. + qtbot.keyPress(widget, Qt.Key_A, modifier=Qt.ControlModifier) + shortcut_item.callback.call_count == 2 + + # Disable the shortcut item. + shortcut_item.set_enabled(False) + assert shortcut_item.shortcut is not None + assert shortcut_item.enabled is False + assert widget.text() == 'Save (Ctrl+A)' + assert widget.toolTip() == ( + "

" + "Save (Ctrl+A)

" + "

Save with the file with Ctrl+A.

") + + qtbot.keyPress(widget, Qt.Key_S, modifier=Qt.ControlModifier) + shortcut_item.callback.call_count == 2 + + # Deactivate shortcut item. + shortcut_item.deactivate() + assert shortcut_item.shortcut is None + assert shortcut_item.enabled is False + assert widget.text() == 'Save' + assert widget.toolTip() == ( + "

" + "Save

" + "

Save the file.

") + + qtbot.keyPress(widget, Qt.Key_S, modifier=Qt.ControlModifier) + shortcut_item.callback.call_count == 2 + + +# ============================================================================= +# ShortcutManager Tests +# ============================================================================= + +def test_declare_shortcut(): + manager = ShortcutManager() + + definition = manager.declare_shortcut( + context="file", + name="save", + default_key_sequence="Ctrl+S", + description="Save file" + ) + + assert isinstance(definition, ShortcutDefinition) + assert definition.context == "file" + assert definition.key_sequence == "Ctrl+S" + assert definition.qkey_sequence.toString() == "Ctrl+S" + + # Test duplicate raise an error. + with pytest.raises(ValueError, match="already declared"): + manager.declare_shortcut( + context="file", + name="save", + default_key_sequence="Ctrl+Shift+S" + ) + + # Test invalid key. + definition = manager.declare_shortcut( + context="file", + name="load", + default_key_sequence="InvalidKey123! @#" + ) + + assert isinstance(definition, ShortcutDefinition) + assert definition.context == "file" + assert definition.key_sequence == "InvalidKey123! @#" + assert definition.qkey_sequence.toString() == '' + + # Bulk shortcuts declaration. + shortcuts = [ + {'context': 'file', 'name': 'print', 'default_key_sequence': 'Ctrl+P'}, + {'context': 'file', 'name': 'open', 'default_key_sequence': 'Ctrl+O'}, + {'context': 'edit', 'name': 'copy', 'default_key_sequence': 'Ctrl+C'} + ] + manager.declare_shortcuts(shortcuts) + + assert len(list(manager.iter_definitions())) == 5 + + +def test_declare_shortcut_with_userconfig(userconfig): + manager = ShortcutManager(userconfig=userconfig) + + # Declare shortcuts that are in the user config. + manager.declare_shortcut( + context="file", name="save", default_key_sequence="Ctrl+Shift+S" + ) + + definition = manager._definitions['file/save'] + assert definition.context == "file" + assert definition.name == "save" + assert definition.qkey_sequence.toString() == "Ctrl+S" + + manager.declare_shortcut( + context="file", name="open" + ) + + definition = manager._definitions['file/open'] + assert definition.context == "file" + assert definition.name == "open" + assert definition.qkey_sequence.toString() == "Ctrl+O" + + # Declare shortcuts that are NOT in the user config. + manager.declare_shortcut( + context="edit", name="copy", default_key_sequence="Ctrl+C" + ) + + definition = manager._definitions['edit/copy'] + assert definition.context == "edit" + assert definition.name == "copy" + assert definition.qkey_sequence.toString() == "Ctrl+C" + + manager.declare_shortcut( + context="edit", name="paste" + ) + + definition = manager._definitions['edit/paste'] + assert definition.context == "edit" + assert definition.name == "paste" + assert definition.qkey_sequence.toString() == "" + + +def test_bind_shortcut(widget): + manager = ShortcutManager() + + # Bind a shortcut. + manager.declare_shortcut( + context="file", name="save", default_key_sequence="Ctrl+S" + ) + shortcut_item = manager.bind_shortcut( + context="file", name="save", callback=Mock(), parent=widget + ) + assert isinstance(shortcut_item, ShortcutItem) + assert shortcut_item.shortcut is not None + + # Unbind a shortcut. + manager.unbind_shortcut("file", "save") + assert shortcut_item.shortcut is None + + # Bind again after unbinding. + shortcut_item = manager.bind_shortcut( + context="file", name="save", callback=Mock(), parent=widget + ) + assert shortcut_item.shortcut is not None + + # Bind a shortcut, but set 'activate' to False. + manager.declare_shortcut( + context="file", name="open", default_key_sequence="Ctrl+O" + ) + shortcut_item = manager.bind_shortcut( + context="file", name="open", callback=Mock(), parent=widget, + activate=False + ) + assert isinstance(shortcut_item, ShortcutItem) + assert shortcut_item.shortcut is None + + # Assert that trying to bind a shortcut that was not declare + # first raise an error + with pytest.raises(ValueError, match="was not declared"): + manager.bind_shortcut( + context="file", name="edit", callback=Mock(), parent=widget + ) + + # Assert that trying to bind a shortcut that is already bound raises + # an error. + with pytest.raises(ValueError, match="already bound"): + manager.bind_shortcut( + context="file", name="save", callback=Mock(), parent=widget + ) + + +def test_set_key_sequence(widget): + manager = ShortcutManager() + + manager.declare_shortcut( + context="file", name="save", default_key_sequence="Ctrl+S" + ) + + # Set a key sequence on an unbound shortcut. + result = manager.set_key_sequence("file", "save", "Ctrl+Shift+S") + + assert result is True + assert [d.key_sequence for d in manager.iter_definitions()] == [ + "Ctrl+Shift+S"] + + # Bind and set a new key sequence. + manager.bind_shortcut( + context="file", name="save", callback=Mock(), parent=widget + ) + result = manager.set_key_sequence( + "file", "save", "Alt+S" + ) + assert result is True + assert [d.key_sequence for d in manager.iter_definitions()] == [ + "Alt+S"] + + # Try setting a key sequence to an invalid shortcut name. + with pytest.raises(ValueError, match="not found"): + manager.set_key_sequence("file", "nonexistent", "Ctrl+S") + + +def test_set_key_sequence_with_userconfig(widget, userconfig): + manager = ShortcutManager(userconfig=userconfig) + + manager.declare_shortcut( + context="file", name="save", default_key_sequence="Ctrl+Shift+S" + ) + manager.bind_shortcut( + context="file", name="save", callback=Mock(), parent=widget + ) + assert [d.key_sequence for d in manager.iter_shortcuts()] == [ + "Ctrl+S"] + + # Set a new key sequence and assert the userconfig is updated + # when sync_userconfig is set to True. + manager.set_key_sequence( + "file", "save", "Ctrl+Shift+S", sync_userconfig=False + ) + assert [d.key_sequence for d in manager.iter_shortcuts()] == [ + "Ctrl+Shift+S"] + assert userconfig._config['file/save'] == 'Ctrl+S' + + manager.set_key_sequence( + "file", "save", "Ctrl+Shift+S", sync_userconfig=True + ) + assert [d.key_sequence for d in manager.iter_shortcuts()] == [ + "Ctrl+Shift+S"] + assert userconfig._config['file/save'] == "Ctrl+Shift+S" + + # Set an invalid key sequence. + manager.set_key_sequence( + "file", "save", "InvalidKey123! @#", sync_userconfig=True + ) + assert [d.key_sequence for d in manager.iter_shortcuts()] == [""] + assert userconfig._config['file/save'] == "" + + +def test_iter_definitions(populated_manager): + all_defs = list(populated_manager.iter_definitions()) + assert len(all_defs) == 4 + + file_defs = list(populated_manager.iter_definitions(context="file")) + assert len(file_defs) == 2 + assert all(d.context == "file" for d in file_defs) + + +def test_iter_bound_shortcuts(populated_manager): + all_bound = list(populated_manager.iter_shortcuts()) + assert len(all_bound) == 2 + + file_bound = list(populated_manager.iter_shortcuts(context="file")) + assert len(file_bound) == 1 + assert file_bound[0].definition. context == "file" + + +def test_find_conflicts(populated_manager): + # context="file", name="save", default_key_sequence="Ctrl+S" + # context="file", name="open", default_key_sequence="Ctrl+O" + # context="edit", name="copy", default_key_sequence="Ctrl+C" + # context="_", name="quit", default_key_sequence="Ctrl+Q" + + # Same context, same key - should conflict. + conflicts = populated_manager.find_conflicts("file", "newaction", "Ctrl+S") + assert len(conflicts) == 1 + assert conflicts[0].name == "save" + + # Different context, no overlap - no conflict + conflicts = populated_manager.find_conflicts("view", "zoom", "Ctrl+Z") + assert len(conflicts) == 0 + + # Global context "_" conflicts with any context + conflicts = populated_manager.find_conflicts("file", "newaction", "Ctrl+Q") + assert len(conflicts) == 1 + assert conflicts[0].name == "quit" + + # Empty key sequence - no conflict + conflicts = populated_manager.find_conflicts("file", "newaction", "") + assert len(conflicts) == 0 + + # Same shortcut shouldn't conflict with itself. + conflicts = populated_manager.find_conflicts("file", "save", "Ctrl+S") + assert len(conflicts) == 0 + + # Declare with conflicting key - the 'default_key_sequence' is ignored. + definition = populated_manager.declare_shortcut( + context="file", name="save_as", default_key_sequence="Ctrl+S" + ) + assert definition.key_sequence == "" + + +def test_check_conflicts_prints_warning(populated_manager, capsys): + has_conflict = populated_manager.check_conflicts( + "file", "newaction", "Ctrl+S") + assert has_conflict is True + + captured = capsys.readouterr() + assert "Cannot set shortcut" in captured.out + assert "conflict" in captured.out + + +def test_full_lifecycle(widget, qtbot): + """Test complete declare -> bind -> use -> unbind lifecycle.""" + widget.show() + qtbot.wait(300) + + manager = ShortcutManager() + callback = Mock() + + # Declare. + definition = manager.declare_shortcut( + context="file", name="save", default_key_sequence="Ctrl+S" + ) + assert definition.is_bound is False + + # Bind. + manager.bind_shortcut( + context="file", name="save", callback=callback, parent=widget + ) + assert definition.is_bound is True + + # Use. + qtbot.keyPress(widget, Qt.Key_S, modifier=Qt.ControlModifier) + callback.assert_called_once() + + # Unbind. + manager.unbind_shortcut("file", "save") + assert definition.is_bound is False + + # Use and assert callback was not called.. + qtbot.keyPress(widget, Qt.Key_S, modifier=Qt.ControlModifier) + callback.assert_called_once() + + +def test_lazy_ui_pattern(qtbot): + """Test declaring shortcuts before UI exists, then binding later.""" + manager = ShortcutManager() + + # Declare before UI exists + manager.declare_shortcuts([ + {'context': 'file', 'name': 'save', 'default_key_sequence': 'Ctrl+S'}, + {'context': 'file', 'name': 'open', 'default_key_sequence': 'Ctrl+O'} + ]) + + assert len(list(manager.iter_definitions())) == 2 + assert len(list(manager.iter_shortcuts())) == 0 + + # Create UI and bind. + widget = QPushButton() + qtbot.addWidget(widget) + + assert widget.text() == '' + assert widget.toolTip() == '' + + title = "Save" + text = "Save the file." + alt_text = "Save with the file with {sc_str}." + + manager.bind_shortcut( + context="file", name="save", callback=Mock(), parent=widget, + synced_ui_data=[ + (widget.setToolTip, + ToolTipSyncTranslator(title, text, alt_text)), + (widget.setText, + TitleSyncTranslator(title)) + ] + ) + + assert len(list(manager.iter_definitions())) == 2 + assert len(list(manager.iter_shortcuts())) == 1 + + assert widget.text() == 'Save (Ctrl+S)' + assert widget.toolTip() == ( + "

" + "Save (Ctrl+S)

" + "

Save with the file with Ctrl+S.

") + + +if __name__ == "__main__": + pytest.main(['-x', __file__, '-vv', '-rw']) From b382d3357dfcc346bfcda3b1c5c038b0b70a859e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-S=C3=A9bastien=20Gosselin?= Date: Tue, 9 Dec 2025 00:52:37 -0500 Subject: [PATCH 22/42] Remove deps to appconfigs --- qtapputils/managers/tests/test_shortcut_manager.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/qtapputils/managers/tests/test_shortcut_manager.py b/qtapputils/managers/tests/test_shortcut_manager.py index cedc671..cc617b0 100644 --- a/qtapputils/managers/tests/test_shortcut_manager.py +++ b/qtapputils/managers/tests/test_shortcut_manager.py @@ -17,7 +17,7 @@ from PyQt5.QtWidgets import QPushButton from PyQt5.QtGui import QKeySequence from PyQt5.QtCore import Qt -from appconfigs.user import NoDefault + from qtapputils.managers.shortcuts import ( ShortcutManager, ShortcutDefinition, ShortcutItem, ActionMenuSyncTranslator, TitleSyncTranslator, ToolTipSyncTranslator) @@ -45,7 +45,7 @@ def __init__(self): self._config = {'file/save': 'Ctrl+S', 'file/open': 'Ctrl+O'} - def get(self, section, option, default=NoDefault): + def get(self, section, option): if section != 'shortcuts': raise KeyError( f"'section' should be 'shortcuts', but got {section}.") From 44bb246126c7eab9600f16f6cd62ea7952d0620c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-S=C3=A9bastien=20Gosselin?= Date: Tue, 9 Dec 2025 00:56:54 -0500 Subject: [PATCH 23/42] Fix appconfig import --- qtapputils/managers/shortcuts.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/qtapputils/managers/shortcuts.py b/qtapputils/managers/shortcuts.py index 3ed8085..4dd8486 100644 --- a/qtapputils/managers/shortcuts.py +++ b/qtapputils/managers/shortcuts.py @@ -10,12 +10,16 @@ """ Centralized Shortcut Manager for PyQt5 Applications """ -from dataclasses import dataclass, field +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from appconfigs.user import UserConfig + +from dataclasses import dataclass import configparser as cp from typing import Dict, Callable, Optional, List, Tuple, Protocol, Any from PyQt5.QtWidgets import QWidget, QShortcut from PyQt5.QtGui import QKeySequence -from appconfigs.user import UserConfig + from qtapputils.qthelpers import format_tooltip, get_shortcuts_native_text From c1954ec27fbf1443d038bb802e329e21eb166a7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-S=C3=A9bastien=20Gosselin?= Date: Tue, 9 Dec 2025 01:10:47 -0500 Subject: [PATCH 24/42] Fix Type hint and bump Python test version to 3.11 --- .github/workflows/python-test.yml | 4 ++-- qtapputils/managers/shortcuts.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index 4a11494..05f5b83 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -11,7 +11,7 @@ on: permissions: contents: read - + jobs: build: @@ -22,7 +22,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v4 with: - python-version: "3.8" + python-version: "3.11" - name: Install dependencies run: | python -m pip install --upgrade pip diff --git a/qtapputils/managers/shortcuts.py b/qtapputils/managers/shortcuts.py index 4dd8486..4bf0387 100644 --- a/qtapputils/managers/shortcuts.py +++ b/qtapputils/managers/shortcuts.py @@ -27,7 +27,7 @@ # UI Sync Translators # ============================================================================= class UISyncTranslator(Protocol): - def __call__(self, shortcut: List[str] | str) -> tuple: + def __call__(self, shortcut: list[str] | str) -> tuple: ... @@ -188,7 +188,7 @@ class ShortcutManager: 2. Binding phase: Bind shortcuts to actual UI when it's created """ - def __init__(self, userconfig: UserConfig = None): + def __init__(self, userconfig: 'UserConfig' = None): self._userconfig = userconfig # All declared shortcuts (complete list available immediately) From 8f2a8c948ff5e31bfb72ba514793cd417091f0c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-S=C3=A9bastien=20Gosselin?= Date: Sat, 13 Dec 2025 11:10:10 -0500 Subject: [PATCH 25/42] Fix cp error handling --- qtapputils/managers/shortcuts.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qtapputils/managers/shortcuts.py b/qtapputils/managers/shortcuts.py index 4bf0387..4016385 100644 --- a/qtapputils/managers/shortcuts.py +++ b/qtapputils/managers/shortcuts.py @@ -227,7 +227,7 @@ def declare_shortcut( # is saved in the current user configs and we do not want # that. key_sequence = self._userconfig.get('shortcuts', context_name) - except cp.NoOptionError: + except (cp.NoOptionError, cp.NoSectionError): pass if self.check_conflicts(context, name, key_sequence): From 44be201bca33c02493bbe27031cd942f8cb5607c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-S=C3=A9bastien=20Gosselin?= Date: Wed, 17 Dec 2025 10:35:50 -0500 Subject: [PATCH 26/42] Add console warning utility with color output --- qtapputils/utils/console.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 qtapputils/utils/console.py diff --git a/qtapputils/utils/console.py b/qtapputils/utils/console.py new file mode 100644 index 0000000..a8f9980 --- /dev/null +++ b/qtapputils/utils/console.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright © QtAppUtils Project Contributors +# https://github.com/jnsebgosselin/apputils +# +# This file is part of QtAppUtils. +# Licensed under the terms of the MIT License. +# ----------------------------------------------------------------------------- + + +# ---- Stantard imports +from colorama import Fore + + +def print_warning(warning_type: str | type, message: str): + """Print a formatted warning message to console.""" + if not isinstance(warning_type, str): + warning_type = warning_type.__name__ + + print(f"\n{Fore.RED}{warning_type}:{Fore.RESET} {message}\n") From 2af80709b3d0d3253c4e7196546ce08fe4d6d597 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-S=C3=A9bastien=20Gosselin?= Date: Wed, 17 Dec 2025 11:35:21 -0500 Subject: [PATCH 27/42] Update imports --- qtapputils/managers/shortcuts.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/qtapputils/managers/shortcuts.py b/qtapputils/managers/shortcuts.py index 4016385..9a19e36 100644 --- a/qtapputils/managers/shortcuts.py +++ b/qtapputils/managers/shortcuts.py @@ -10,17 +10,22 @@ """ Centralized Shortcut Manager for PyQt5 Applications """ -from typing import TYPE_CHECKING +from typing import ( + TYPE_CHECKING, Dict, Callable, Optional, List, Tuple, Protocol, Any) if TYPE_CHECKING: from appconfigs.user import UserConfig +# ---- Standard import from dataclasses import dataclass import configparser as cp -from typing import Dict, Callable, Optional, List, Tuple, Protocol, Any -from PyQt5.QtWidgets import QWidget, QShortcut -from PyQt5.QtGui import QKeySequence +# ---- Third party import +from qtpy.QtWidgets import QWidget, QShortcut +from qtpy.QtGui import QKeySequence + +# ---- Local import from qtapputils.qthelpers import format_tooltip, get_shortcuts_native_text +from qtapputils.utils.console import print_warning # ============================================================================= From d985f8dc72332b4e317fc0451c5e87a96522542d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-S=C3=A9bastien=20Gosselin?= Date: Wed, 17 Dec 2025 11:35:58 -0500 Subject: [PATCH 28/42] Add blocklist support for reserved shortcut keys --- qtapputils/managers/shortcuts.py | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/qtapputils/managers/shortcuts.py b/qtapputils/managers/shortcuts.py index 9a19e36..950c1d1 100644 --- a/qtapputils/managers/shortcuts.py +++ b/qtapputils/managers/shortcuts.py @@ -193,9 +193,13 @@ class ShortcutManager: 2. Binding phase: Bind shortcuts to actual UI when it's created """ - def __init__(self, userconfig: 'UserConfig' = None): + def __init__(self, userconfig: 'UserConfig' = None, + blocklist: list[str] = None): self._userconfig = userconfig + # The list of blocklisted key sequence. + self._blocklist: list[str] = [] if blocklist is None else blocklist + # All declared shortcuts (complete list available immediately) self._definitions: Dict[str, ShortcutDefinition] = {} @@ -240,10 +244,13 @@ def declare_shortcut( qkey_sequence = QKeySequence(key_sequence) if qkey_sequence.isEmpty() and key_sequence not in (None, ''): - # TODO: simply print a warning instead and use '' as shortcut. raise ValueError( f"Key sequence '{key_sequence}' is not valid." ) + if qkey_sequence.toString() in self._blocklist: + raise ValueError( + f"Key sequence '{key_sequence}' is reserved or not allowed." + ) definition = ShortcutDefinition( context=context, @@ -392,6 +399,16 @@ def check_conflicts( self, context: str, name: str, key_sequence: str ) -> bool: """Check for conflicts and print warnings.""" + + if QKeySequence(key_sequence).toString() in self._blocklist: + print_warning( + "Shortcut Error", + f"Cannot set shortcut '{name}' in context '{context}' " + f"because the key sequence '{key_sequence}' is reserved or " + f"not allowed. Please select a different shortcut." + ) + return True + conflicts = self.find_conflicts(context, name, key_sequence) if conflicts: print(f"Cannot set shortcut '{name}' in context '{context}' " From 85a6666e002f1264da11de567b87e7b826cccf31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-S=C3=A9bastien=20Gosselin?= Date: Wed, 17 Dec 2025 11:36:16 -0500 Subject: [PATCH 29/42] Add method to print all shortcuts in a table --- qtapputils/managers/shortcuts.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/qtapputils/managers/shortcuts.py b/qtapputils/managers/shortcuts.py index 950c1d1..25a2f10 100644 --- a/qtapputils/managers/shortcuts.py +++ b/qtapputils/managers/shortcuts.py @@ -439,3 +439,22 @@ def find_conflicts(self, context: str, name: str, key_sequence: str): conflicts.append(sc_def) return conflicts + + # ---- End User Interface + def print_shortcuts(self): + """ + Print all declared shortcuts to the console in a formatted table. + """ + defs = list(self.shortcut_manager.iter_definitions()) + context_w = max(len(scd.context) for scd in defs) + 2 + name_w = max(len(scd.name) for scd in defs) + 2 + + print() + print('-' * (context_w + name_w + 12)) + print(f"{'Context':<{context_w}}{'Name':<{name_w}}{'Key Sequence'}") + print('-' * (context_w + name_w + 12)) + for scd in defs: + print(f"{scd.context:<{context_w}}{scd.name:<{name_w}}" + f"{scd.qkey_sequence.toString()}") + print('-' * (context_w + name_w + 12)) + From 657aa54f36bbfbb12c20264b3b0ebecc1b6376bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-S=C3=A9bastien=20Gosselin?= Date: Wed, 17 Dec 2025 11:36:30 -0500 Subject: [PATCH 30/42] Use print_warning for shortcut conflict messages --- qtapputils/managers/shortcuts.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/qtapputils/managers/shortcuts.py b/qtapputils/managers/shortcuts.py index 25a2f10..f2cb0d2 100644 --- a/qtapputils/managers/shortcuts.py +++ b/qtapputils/managers/shortcuts.py @@ -411,12 +411,16 @@ def check_conflicts( conflicts = self.find_conflicts(context, name, key_sequence) if conflicts: - print(f"Cannot set shortcut '{name}' in context '{context}' " - f"to '{key_sequence}' because of the following " - f"conflict(s):") + print_warning( + "Shortcut Error", + f"Cannot set shortcut '{name}' in context '{context}' " + f"to '{key_sequence}' because of the following " + f"conflict(s):" + ) for sc in conflicts: print(f" - shortcut '{sc.name}' in context '{sc.context}'") return True + return False def find_conflicts(self, context: str, name: str, key_sequence: str): From 0f34330bf0fb457d9369a143456241d0a9a88269 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-S=C3=A9bastien=20Gosselin?= Date: Wed, 17 Dec 2025 11:36:41 -0500 Subject: [PATCH 31/42] Refactor and rename set_key_sequence to set_shortcut --- qtapputils/managers/shortcuts.py | 96 +++++++++++++++++++++----------- 1 file changed, 63 insertions(+), 33 deletions(-) diff --git a/qtapputils/managers/shortcuts.py b/qtapputils/managers/shortcuts.py index f2cb0d2..96b747e 100644 --- a/qtapputils/managers/shortcuts.py +++ b/qtapputils/managers/shortcuts.py @@ -341,39 +341,6 @@ def enable_shortcut(self, context: str, name: str, enabled: bool = True): if context_name in self._shortcuts: self._shortcuts[context_name].set_enabled(enabled) - def set_key_sequence( - self, - context: str, - name: str, - new_key_sequence: str, - sync_userconfig: bool = False - ): - """Set the key sequence for a shortcut (declared or bound).""" - context_name = f"{context}/{name}" - - if context_name not in self._definitions: - raise ValueError(f"Shortcut '{context_name}' not found.") - - if self.check_conflicts(context, name, new_key_sequence): - return False - - definition = self._definitions[context_name] - definition.key_sequence = new_key_sequence - - # Update bound shortcut if it exists - if context_name in self._shortcuts: - self._shortcuts[context_name].set_keyseq(new_key_sequence) - - # Save to user config - if self._userconfig is not None and sync_userconfig: - self._userconfig.set( - 'shortcuts', - context_name, - QKeySequence(new_key_sequence).toString() - ) - - return True - # ========================================================================= # Iteration & Query # ========================================================================= @@ -462,3 +429,66 @@ def print_shortcuts(self): f"{scd.qkey_sequence.toString()}") print('-' * (context_w + name_w + 12)) + def set_shortcut( + self, + context: str, + name: str, + new_key_sequence: str, + sync_userconfig: bool = False + ): + """ + Set a new key sequence for a declared or bound shortcut. + + If the shortcut exists and the new key sequence is valid (i.e., not + blocked or conflicting), the shortcut is updated. If the shortcut is + currently bound to a UI element, the change will take effect + immediately for it. If `sync_userconfig` is True and a user config + has been passed to the shortcut manager, the user's new shortcut + setting is saved for future sessions. + + Parameters + ---------- + context : str + The shortcut context (e.g., "file", "edit"). + name : str + The name identifier of the shortcut within its + context (e.g., "save", "copy"). + new_key_sequence : str + The new key sequence to assign (e.g., "Ctrl+S"). + sync_userconfig : bool, optional + Whether to save the change in the persistent user + configuration. The default is False. + + Returns + ------- + bool + True if the update succeeded, False otherwise. + """ + context_name = f"{context}/{name}" + + if context_name not in self._definitions: + print_warning( + "Shortcut Error", + f"Cannot find shortcut '{name}' in context '{context}'." + ) + return False + + if self.check_conflicts(context, name, new_key_sequence): + return False + + definition = self._definitions[context_name] + definition.key_sequence = new_key_sequence + + # Update bound shortcut if it exists + if context_name in self._shortcuts: + self._shortcuts[context_name].set_keyseq(new_key_sequence) + + # Save to user config + if self._userconfig is not None and sync_userconfig: + self._userconfig.set( + 'shortcuts', + context_name, + QKeySequence(new_key_sequence).toString() + ) + + return True From 442ccf052d11f96bb2c7fc0a65f45be5926ba40d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-S=C3=A9bastien=20Gosselin?= Date: Wed, 17 Dec 2025 14:20:38 -0500 Subject: [PATCH 32/42] Fix method call in print_shortcuts_table --- qtapputils/managers/shortcuts.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qtapputils/managers/shortcuts.py b/qtapputils/managers/shortcuts.py index 96b747e..f1f7fd9 100644 --- a/qtapputils/managers/shortcuts.py +++ b/qtapputils/managers/shortcuts.py @@ -416,7 +416,7 @@ def print_shortcuts(self): """ Print all declared shortcuts to the console in a formatted table. """ - defs = list(self.shortcut_manager.iter_definitions()) + defs = list(self.iter_definitions()) context_w = max(len(scd.context) for scd in defs) + 2 name_w = max(len(scd.name) for scd in defs) + 2 From 5adbf0c0c0976dd219afcd6e32c7db65d7a2ee26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-S=C3=A9bastien=20Gosselin?= Date: Wed, 17 Dec 2025 14:20:42 -0500 Subject: [PATCH 33/42] Update test_shortcut_manager.py --- .../managers/tests/test_shortcut_manager.py | 23 ++++++++----------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/qtapputils/managers/tests/test_shortcut_manager.py b/qtapputils/managers/tests/test_shortcut_manager.py index cc617b0..72a1f87 100644 --- a/qtapputils/managers/tests/test_shortcut_manager.py +++ b/qtapputils/managers/tests/test_shortcut_manager.py @@ -372,7 +372,7 @@ def test_bind_shortcut(widget): ) -def test_set_key_sequence(widget): +def test_set_shortcut(widget): manager = ShortcutManager() manager.declare_shortcut( @@ -380,9 +380,7 @@ def test_set_key_sequence(widget): ) # Set a key sequence on an unbound shortcut. - result = manager.set_key_sequence("file", "save", "Ctrl+Shift+S") - - assert result is True + assert manager.set_shortcut("file", "save", "Ctrl+Shift+S") assert [d.key_sequence for d in manager.iter_definitions()] == [ "Ctrl+Shift+S"] @@ -390,19 +388,16 @@ def test_set_key_sequence(widget): manager.bind_shortcut( context="file", name="save", callback=Mock(), parent=widget ) - result = manager.set_key_sequence( - "file", "save", "Alt+S" - ) - assert result is True + + assert manager.set_shortcut("file", "save", "Alt+S") assert [d.key_sequence for d in manager.iter_definitions()] == [ "Alt+S"] # Try setting a key sequence to an invalid shortcut name. - with pytest.raises(ValueError, match="not found"): - manager.set_key_sequence("file", "nonexistent", "Ctrl+S") + assert manager.set_shortcut("file", "nonexistent", "Ctrl+S") is False -def test_set_key_sequence_with_userconfig(widget, userconfig): +def test_set_shortcut_with_userconfig(widget, userconfig): manager = ShortcutManager(userconfig=userconfig) manager.declare_shortcut( @@ -416,14 +411,14 @@ def test_set_key_sequence_with_userconfig(widget, userconfig): # Set a new key sequence and assert the userconfig is updated # when sync_userconfig is set to True. - manager.set_key_sequence( + manager.set_shortcut( "file", "save", "Ctrl+Shift+S", sync_userconfig=False ) assert [d.key_sequence for d in manager.iter_shortcuts()] == [ "Ctrl+Shift+S"] assert userconfig._config['file/save'] == 'Ctrl+S' - manager.set_key_sequence( + manager.set_shortcut( "file", "save", "Ctrl+Shift+S", sync_userconfig=True ) assert [d.key_sequence for d in manager.iter_shortcuts()] == [ @@ -431,7 +426,7 @@ def test_set_key_sequence_with_userconfig(widget, userconfig): assert userconfig._config['file/save'] == "Ctrl+Shift+S" # Set an invalid key sequence. - manager.set_key_sequence( + manager.set_shortcut( "file", "save", "InvalidKey123! @#", sync_userconfig=True ) assert [d.key_sequence for d in manager.iter_shortcuts()] == [""] From eff0a56584efc10e5129aaed602970f7e34e7959 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-S=C3=A9bastien=20Gosselin?= Date: Wed, 17 Dec 2025 14:46:04 -0500 Subject: [PATCH 34/42] Update warning title from 'Shortcut Error' to 'ShortcutError' --- qtapputils/managers/shortcuts.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/qtapputils/managers/shortcuts.py b/qtapputils/managers/shortcuts.py index f1f7fd9..5922b7d 100644 --- a/qtapputils/managers/shortcuts.py +++ b/qtapputils/managers/shortcuts.py @@ -369,7 +369,7 @@ def check_conflicts( if QKeySequence(key_sequence).toString() in self._blocklist: print_warning( - "Shortcut Error", + "ShortcutError", f"Cannot set shortcut '{name}' in context '{context}' " f"because the key sequence '{key_sequence}' is reserved or " f"not allowed. Please select a different shortcut." @@ -379,7 +379,7 @@ def check_conflicts( conflicts = self.find_conflicts(context, name, key_sequence) if conflicts: print_warning( - "Shortcut Error", + "ShortcutError", f"Cannot set shortcut '{name}' in context '{context}' " f"to '{key_sequence}' because of the following " f"conflict(s):" @@ -468,7 +468,7 @@ def set_shortcut( if context_name not in self._definitions: print_warning( - "Shortcut Error", + "ShortcutError", f"Cannot find shortcut '{name}' in context '{context}'." ) return False From 361b5840db0547bbbf867f301581761a75b51011 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-S=C3=A9bastien=20Gosselin?= Date: Wed, 17 Dec 2025 14:46:24 -0500 Subject: [PATCH 35/42] Add test_blocklist --- .../managers/tests/test_shortcut_manager.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/qtapputils/managers/tests/test_shortcut_manager.py b/qtapputils/managers/tests/test_shortcut_manager.py index 72a1f87..8616cdc 100644 --- a/qtapputils/managers/tests/test_shortcut_manager.py +++ b/qtapputils/managers/tests/test_shortcut_manager.py @@ -451,6 +451,23 @@ def test_iter_bound_shortcuts(populated_manager): assert file_bound[0].definition. context == "file" +def test_blocklist(userconfig): + manager = ShortcutManager(blocklist=['Ctrl+Z']) + + definition = manager.declare_shortcut( + context="file", + name="save", + default_key_sequence='Ctrl+Z', + description="Save file" + ) + assert definition.qkey_sequence.toString() == '' + + assert manager.set_shortcut("file", "save", 'Ctrl+S') + assert definition.qkey_sequence.toString() == 'Ctrl+S' + assert manager.set_shortcut("file", "save", 'Ctrl+Z') is False + assert definition.qkey_sequence.toString() == 'Ctrl+S' + + def test_find_conflicts(populated_manager): # context="file", name="save", default_key_sequence="Ctrl+S" # context="file", name="open", default_key_sequence="Ctrl+O" From 5f948a1d8b1b39148b6e972e6527409d1090a375 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-S=C3=A9bastien=20Gosselin?= Date: Wed, 17 Dec 2025 15:04:15 -0500 Subject: [PATCH 36/42] Don't raise error in 'declare_shortcut', only warns We don't want to break app startup due to incompatibilities in user configs. --- qtapputils/managers/shortcuts.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/qtapputils/managers/shortcuts.py b/qtapputils/managers/shortcuts.py index 5922b7d..c8540be 100644 --- a/qtapputils/managers/shortcuts.py +++ b/qtapputils/managers/shortcuts.py @@ -247,10 +247,6 @@ def declare_shortcut( raise ValueError( f"Key sequence '{key_sequence}' is not valid." ) - if qkey_sequence.toString() in self._blocklist: - raise ValueError( - f"Key sequence '{key_sequence}' is reserved or not allowed." - ) definition = ShortcutDefinition( context=context, @@ -367,7 +363,16 @@ def check_conflicts( ) -> bool: """Check for conflicts and print warnings.""" - if QKeySequence(key_sequence).toString() in self._blocklist: + qkey_sequence = QKeySequence(key_sequence) + + if qkey_sequence.toString() == '' and key_sequence not in (None, ''): + print_warning( + "ShortcutError", + f"Key sequence '{key_sequence}' is not valid." + ) + return True + + if qkey_sequence.toString() in self._blocklist: print_warning( "ShortcutError", f"Cannot set shortcut '{name}' in context '{context}' " From 1bc89c0afe63f8716c451ff0e789783b19c01979 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-S=C3=A9bastien=20Gosselin?= Date: Wed, 17 Dec 2025 15:04:40 -0500 Subject: [PATCH 37/42] Update test_shortcut_manager.py --- qtapputils/managers/tests/test_shortcut_manager.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/qtapputils/managers/tests/test_shortcut_manager.py b/qtapputils/managers/tests/test_shortcut_manager.py index 8616cdc..bfe5a1f 100644 --- a/qtapputils/managers/tests/test_shortcut_manager.py +++ b/qtapputils/managers/tests/test_shortcut_manager.py @@ -267,7 +267,7 @@ def test_declare_shortcut(): assert isinstance(definition, ShortcutDefinition) assert definition.context == "file" - assert definition.key_sequence == "InvalidKey123! @#" + assert definition.key_sequence == '' assert definition.qkey_sequence.toString() == '' # Bulk shortcuts declaration. @@ -426,11 +426,12 @@ def test_set_shortcut_with_userconfig(widget, userconfig): assert userconfig._config['file/save'] == "Ctrl+Shift+S" # Set an invalid key sequence. - manager.set_shortcut( + assert manager.set_shortcut( "file", "save", "InvalidKey123! @#", sync_userconfig=True - ) - assert [d.key_sequence for d in manager.iter_shortcuts()] == [""] - assert userconfig._config['file/save'] == "" + ) is False + assert [d.key_sequence for d in manager.iter_shortcuts()] == [ + "Ctrl+Shift+S"] + assert userconfig._config['file/save'] == "Ctrl+Shift+S" def test_iter_definitions(populated_manager): From 388f9f546e3e6d9b84e0bb4c376db78998720802 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-S=C3=A9bastien=20Gosselin?= Date: Wed, 17 Dec 2025 15:15:00 -0500 Subject: [PATCH 38/42] Update test_shortcut_manager.py --- .../managers/tests/test_shortcut_manager.py | 57 +++++++++++++------ 1 file changed, 41 insertions(+), 16 deletions(-) diff --git a/qtapputils/managers/tests/test_shortcut_manager.py b/qtapputils/managers/tests/test_shortcut_manager.py index bfe5a1f..cd14d71 100644 --- a/qtapputils/managers/tests/test_shortcut_manager.py +++ b/qtapputils/managers/tests/test_shortcut_manager.py @@ -235,7 +235,7 @@ def test_shortcut_item(shortcut_item, widget, qtbot): # ShortcutManager Tests # ============================================================================= -def test_declare_shortcut(): +def test_declare_shortcut(capsys): manager = ShortcutManager() definition = manager.declare_shortcut( @@ -259,12 +259,18 @@ def test_declare_shortcut(): ) # Test invalid key. + captured = capsys.readouterr() + assert "ShortcutError" not in captured.out + definition = manager.declare_shortcut( context="file", name="load", default_key_sequence="InvalidKey123! @#" ) + captured = capsys.readouterr() + assert "ShortcutError" in captured.out + assert isinstance(definition, ShortcutDefinition) assert definition.context == "file" assert definition.key_sequence == '' @@ -372,7 +378,7 @@ def test_bind_shortcut(widget): ) -def test_set_shortcut(widget): +def test_set_shortcut(widget, capsys): manager = ShortcutManager() manager.declare_shortcut( @@ -394,10 +400,17 @@ def test_set_shortcut(widget): "Alt+S"] # Try setting a key sequence to an invalid shortcut name. - assert manager.set_shortcut("file", "nonexistent", "Ctrl+S") is False + captured = capsys.readouterr() + assert "ShortcutError" not in captured.out + + result = manager.set_shortcut("file", "nonexistent", "Ctrl+S") + assert result is False + captured = capsys.readouterr() + assert "ShortcutError" in captured.out -def test_set_shortcut_with_userconfig(widget, userconfig): + +def test_set_shortcut_with_userconfig(widget, userconfig, capsys): manager = ShortcutManager(userconfig=userconfig) manager.declare_shortcut( @@ -426,9 +439,15 @@ def test_set_shortcut_with_userconfig(widget, userconfig): assert userconfig._config['file/save'] == "Ctrl+Shift+S" # Set an invalid key sequence. - assert manager.set_shortcut( + captured = capsys.readouterr() + assert "ShortcutError" not in captured.out + + manager.set_shortcut( "file", "save", "InvalidKey123! @#", sync_userconfig=True - ) is False + ) + + captured = capsys.readouterr() + assert "ShortcutError" in captured.out assert [d.key_sequence for d in manager.iter_shortcuts()] == [ "Ctrl+Shift+S"] assert userconfig._config['file/save'] == "Ctrl+Shift+S" @@ -452,9 +471,12 @@ def test_iter_bound_shortcuts(populated_manager): assert file_bound[0].definition. context == "file" -def test_blocklist(userconfig): +def test_blocklist(userconfig, capsys): manager = ShortcutManager(blocklist=['Ctrl+Z']) + captured = capsys.readouterr() + assert "ShortcutError" not in captured.out + definition = manager.declare_shortcut( context="file", name="save", @@ -463,13 +485,20 @@ def test_blocklist(userconfig): ) assert definition.qkey_sequence.toString() == '' + captured = capsys.readouterr() + assert "ShortcutError" in captured.out + assert manager.set_shortcut("file", "save", 'Ctrl+S') assert definition.qkey_sequence.toString() == 'Ctrl+S' + assert manager.set_shortcut("file", "save", 'Ctrl+Z') is False assert definition.qkey_sequence.toString() == 'Ctrl+S' + captured = capsys.readouterr() + assert "ShortcutError" in captured.out + -def test_find_conflicts(populated_manager): +def test_find_conflicts(populated_manager, capsys): # context="file", name="save", default_key_sequence="Ctrl+S" # context="file", name="open", default_key_sequence="Ctrl+O" # context="edit", name="copy", default_key_sequence="Ctrl+C" @@ -498,20 +527,16 @@ def test_find_conflicts(populated_manager): assert len(conflicts) == 0 # Declare with conflicting key - the 'default_key_sequence' is ignored. + captured = capsys.readouterr() + assert "ShortcutError" not in captured.out + definition = populated_manager.declare_shortcut( context="file", name="save_as", default_key_sequence="Ctrl+S" ) assert definition.key_sequence == "" - -def test_check_conflicts_prints_warning(populated_manager, capsys): - has_conflict = populated_manager.check_conflicts( - "file", "newaction", "Ctrl+S") - assert has_conflict is True - captured = capsys.readouterr() - assert "Cannot set shortcut" in captured.out - assert "conflict" in captured.out + assert "ShortcutError" in captured.out def test_full_lifecycle(widget, qtbot): From 35e5568b9201c8119e580812d2133d54fdda5608 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-S=C3=A9bastien=20Gosselin?= Date: Wed, 17 Dec 2025 15:29:08 -0500 Subject: [PATCH 39/42] Add test_print_shortcuts --- .../managers/tests/test_shortcut_manager.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/qtapputils/managers/tests/test_shortcut_manager.py b/qtapputils/managers/tests/test_shortcut_manager.py index cd14d71..d375b8c 100644 --- a/qtapputils/managers/tests/test_shortcut_manager.py +++ b/qtapputils/managers/tests/test_shortcut_manager.py @@ -616,5 +616,25 @@ def test_lazy_ui_pattern(qtbot): "

Save with the file with Ctrl+S.

") +def test_print_shortcuts(populated_manager, capsys): + captured = capsys.readouterr() + assert captured.out == '' + + populated_manager.print_shortcuts() + + captured = capsys.readouterr() + assert captured.out == ( + "\n" + "------------------------\n" + "ContextName Key Sequence\n" + "------------------------\n" + "file save Ctrl+S\n" + "file open Ctrl+O\n" + "edit copy Ctrl+C\n" + "_ quit Ctrl+Q\n" + "------------------------\n" + ) + + if __name__ == "__main__": pytest.main(['-x', __file__, '-vv', '-rw']) From d0b960a31fc5ee67228af57e8cd3a04d6274f5d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-S=C3=A9bastien=20Gosselin?= Date: Wed, 17 Dec 2025 15:32:29 -0500 Subject: [PATCH 40/42] Remove trailing newline from warning print output --- qtapputils/utils/console.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qtapputils/utils/console.py b/qtapputils/utils/console.py index a8f9980..94b9550 100644 --- a/qtapputils/utils/console.py +++ b/qtapputils/utils/console.py @@ -17,4 +17,4 @@ def print_warning(warning_type: str | type, message: str): if not isinstance(warning_type, str): warning_type = warning_type.__name__ - print(f"\n{Fore.RED}{warning_type}:{Fore.RESET} {message}\n") + print(f"\n{Fore.RED}{warning_type}:{Fore.RESET} {message}") From 1ea3aee297d3af570cf9f1a1d8091828cc529804 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-S=C3=A9bastien=20Gosselin?= Date: Thu, 18 Dec 2025 10:13:03 -0500 Subject: [PATCH 41/42] Remove redundant QKeySequence validation in ShortcutManager --- qtapputils/managers/shortcuts.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/qtapputils/managers/shortcuts.py b/qtapputils/managers/shortcuts.py index c8540be..c45eb73 100644 --- a/qtapputils/managers/shortcuts.py +++ b/qtapputils/managers/shortcuts.py @@ -242,12 +242,6 @@ def declare_shortcut( if self.check_conflicts(context, name, key_sequence): key_sequence = '' - qkey_sequence = QKeySequence(key_sequence) - if qkey_sequence.isEmpty() and key_sequence not in (None, ''): - raise ValueError( - f"Key sequence '{key_sequence}' is not valid." - ) - definition = ShortcutDefinition( context=context, name=name, From bd56b935c709e0b56f685ba1dbe8e031ef03ac50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-S=C3=A9bastien=20Gosselin?= Date: Thu, 18 Dec 2025 10:29:18 -0500 Subject: [PATCH 42/42] Add 'test_shortcut_controls' --- .../managers/tests/test_shortcut_manager.py | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/qtapputils/managers/tests/test_shortcut_manager.py b/qtapputils/managers/tests/test_shortcut_manager.py index d375b8c..b9376e5 100644 --- a/qtapputils/managers/tests/test_shortcut_manager.py +++ b/qtapputils/managers/tests/test_shortcut_manager.py @@ -378,6 +378,62 @@ def test_bind_shortcut(widget): ) +def test_shortcut_controls(widget, qtbot): + manager = ShortcutManager() + + # Bind a shortcut, but don't activate it. + manager.declare_shortcut( + context='file', name='save', default_key_sequence='Ctrl+S' + ) + shortcut_item = manager.bind_shortcut( + context='file', name='save', + callback=Mock(), parent=widget, activate=False + ) + + assert isinstance(shortcut_item, ShortcutItem) + assert shortcut_item.shortcut is None + assert shortcut_item.enabled is False + + qtbot.keyPress(widget, Qt.Key_S, modifier=Qt.ControlModifier) + shortcut_item.callback.call_count == 0 + + # Activate the shortcut. + manager.activate_shortcut('file', 'save') + + assert shortcut_item.shortcut is not None + assert shortcut_item.enabled is True + + qtbot.keyPress(widget, Qt.Key_S, modifier=Qt.ControlModifier) + shortcut_item.callback.call_count == 1 + + # Disable the shortcut. + manager.enable_shortcut('file', 'save', enabled=False) + + assert shortcut_item.shortcut is not None + assert shortcut_item.enabled is False + + qtbot.keyPress(widget, Qt.Key_S, modifier=Qt.ControlModifier) + shortcut_item.callback.call_count == 1 + + # Enable the shortcut. + manager.enable_shortcut('file', 'save', enabled=True) + + assert shortcut_item.shortcut is not None + assert shortcut_item.enabled is True + + qtbot.keyPress(widget, Qt.Key_S, modifier=Qt.ControlModifier) + shortcut_item.callback.call_count == 2 + + # Deactivate the shortcut. + manager.deactivate_shortcut('file', 'save') + + assert shortcut_item.shortcut is None + assert shortcut_item.enabled is False + + qtbot.keyPress(widget, Qt.Key_S, modifier=Qt.ControlModifier) + shortcut_item.callback.call_count == 2 + + def test_set_shortcut(widget, capsys): manager = ShortcutManager()