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/__init__.py b/qtapputils/managers/__init__.py index 3d566aa..f078e60 100644 --- a/qtapputils/managers/__init__.py +++ b/qtapputils/managers/__init__.py @@ -6,5 +6,67 @@ # 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, TitleSyncTranslator, ToolTipSyncTranslator, + ActionMenuSyncTranslator) +else: + # Module-level exports for explicit __all__. + __all__ = [ + 'WorkerBase', + 'TaskManagerBase', + 'LIFOTaskManager', + 'SaveFileManager', + 'ShortcutManager', + 'TitleSyncTranslator', + 'ToolTipSyncTranslator', + 'ActionMenuSyncTranslator', + ] + + # Lazy import mapping. + __LAZYIMPORTS__ = { + 'WorkerBase': 'qtapputils.managers.taskmanagers', + 'TaskManagerBase': 'qtapputils.managers.taskmanagers', + 'LIFOTaskManager': 'qtapputils.managers.taskmanagers', + 'SaveFileManager': 'qtapputils.managers.fileio', + 'ShortcutManager': 'qtapputils.managers.shortcuts', + 'TitleSyncTranslator': 'qtapputils.managers.shortcuts', + 'ToolTipSyncTranslator': 'qtapputils.managers.shortcuts', + 'ActionMenuSyncTranslator': '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__) + )) diff --git a/qtapputils/managers/shortcuts.py b/qtapputils/managers/shortcuts.py new file mode 100644 index 0000000..c45eb73 --- /dev/null +++ b/qtapputils/managers/shortcuts.py @@ -0,0 +1,493 @@ +# -*- 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. +# ----------------------------------------------------------------------------- + +""" +Centralized Shortcut Manager for PyQt5 Applications +""" +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 + +# ---- 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 + + +# ============================================================================= +# 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 + + def __call__(self, shortcuts): + keystr = get_shortcuts_native_text(shortcuts) + if keystr: + return f"{self.text} ({keystr})", + else: + return self.text, + + +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 __call__(self, shortcuts): + if shortcuts: + return format_tooltip(self.title, self.alt_text, shortcuts), + else: + return format_tooltip(self.title, self.text, shortcuts), + + +# ============================================================================= +# 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 + + @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) + + +class ShortcutItem: + """ + A shortcut that has been bound to actual UI elements. + Created when lazy UI is finally instantiated. + """ + + def __init__(self, definition: ShortcutDefinition, callback: Callable, + parent: QWidget, synced_ui_data: List[UISyncTarget] = None): + + 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): + if self.shortcut is None: + return '' + + if native: + return self.definition.qkey_sequence.toString( + QKeySequence.NativeText) + else: + 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.setAutoRepeat(False) + self.set_enabled(True) + self._update_ui() + + def deactivate(self): + """Deactivate and clean up the QShortcut.""" + if self.shortcut is not None: + self.shortcut.setEnabled(False) + self.shortcut.deleteLater() + self.shortcut = None + self.enabled = False + self._update_ui() + + def set_keyseq(self, key_sequence: str): + """Update the key sequence.""" + self.definition.key_sequence = key_sequence + if self.shortcut is not None: + self.shortcut.setKey(self.definition.qkey_sequence) + self._update_ui() + + def set_enabled(self, enabled: bool = True): + """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.key_sequence + for setter, translator in self.synced_ui_data: + 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, + 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] = {} + + # Shortcuts that have been bound to UI + self._shortcuts: Dict[str, ShortcutItem] = {} + + # ========================================================================= + # ---- Declaration methods + # ========================================================================= + def declare_shortcut( + self, + context: str, + name: str, + default_key_sequence: str = '', + description: str = '' + ) -> ShortcutDefinition: + """ + 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._definitions: + raise ValueError( + f"Shortcut '{name}' already declared for context '{context}'." + ) + + key_sequence = default_key_sequence + if self._userconfig is not None: + 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, cp.NoSectionError): + pass + + if self.check_conflicts(context, name, key_sequence): + key_sequence = '' + + definition = ShortcutDefinition( + context=context, + name=name, + 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 or [] + ) + + # 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._shortcuts: + 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 bound shortcut.""" + context_name = f"{context}/{name}" + 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._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._shortcuts: + self._shortcuts[context_name].set_enabled(enabled) + + # ========================================================================= + # 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_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: + yield sc + + # ========================================================================= + # Conflict Detection + # ========================================================================= + def check_conflicts( + self, context: str, name: str, key_sequence: str + ) -> bool: + """Check for conflicts and print warnings.""" + + 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}' " + 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_warning( + "ShortcutError", + 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 = [] + qkey_sequence = QKeySequence(key_sequence) + if qkey_sequence.isEmpty(): + return conflicts + + no_match = QKeySequence.SequenceMatch.NoMatch + for sc_def in self._definitions.values(): + if sc_def.qkey_sequence.isEmpty(): + continue + if (sc_def.context, sc_def.name) == (context, name): + continue + 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 + + # ---- End User Interface + def print_shortcuts(self): + """ + Print all declared shortcuts to the console in a formatted table. + """ + 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 + + 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)) + + 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( + "ShortcutError", + 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 diff --git a/qtapputils/managers/tests/test_shortcut_manager.py b/qtapputils/managers/tests/test_shortcut_manager.py new file mode 100644 index 0000000..b9376e5 --- /dev/null +++ b/qtapputils/managers/tests/test_shortcut_manager.py @@ -0,0 +1,696 @@ +# -*- 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 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): + 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(capsys): + 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. + 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 == '' + 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_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() + + manager.declare_shortcut( + context="file", name="save", default_key_sequence="Ctrl+S" + ) + + # Set a key sequence on an unbound shortcut. + assert manager.set_shortcut("file", "save", "Ctrl+Shift+S") + 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 + ) + + 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. + 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, capsys): + 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_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_shortcut( + "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. + captured = capsys.readouterr() + assert "ShortcutError" not in captured.out + + manager.set_shortcut( + "file", "save", "InvalidKey123! @#", sync_userconfig=True + ) + + 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" + + +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_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", + default_key_sequence='Ctrl+Z', + description="Save file" + ) + 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, 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" + # 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. + 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 == "" + + captured = capsys.readouterr() + assert "ShortcutError" 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.

") + + +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']) diff --git a/qtapputils/qthelpers.py b/qtapputils/qthelpers.py index 12aef1f..4684da6 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 else "") 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 diff --git a/qtapputils/utils/console.py b/qtapputils/utils/console.py new file mode 100644 index 0000000..94b9550 --- /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}")