From d216259cf2f77c8734bc714b90563eaf08574245 Mon Sep 17 00:00:00 2001 From: VasigaranAndAngel <72515046+VasigaranAndAngel@users.noreply.github.com> Date: Sun, 17 Nov 2024 13:42:42 +0530 Subject: [PATCH] Shortcut manager implementation test_shortcuts_manager.py: bug fix improvements improvements --- tagstudio/src/qt/helpers/ini_helpers.py | 64 ++++ tagstudio/src/qt/shortcuts_manager.py | 279 ++++++++++++++++++ tagstudio/src/qt/ts_qt.py | 79 ++--- .../tests/qt/helpers/test_ini_helpers.py | 29 ++ tagstudio/tests/qt/test_shortcuts_manager.py | 259 ++++++++++++++++ 5 files changed, 673 insertions(+), 37 deletions(-) create mode 100644 tagstudio/src/qt/helpers/ini_helpers.py create mode 100644 tagstudio/src/qt/shortcuts_manager.py create mode 100644 tagstudio/tests/qt/helpers/test_ini_helpers.py create mode 100644 tagstudio/tests/qt/test_shortcuts_manager.py diff --git a/tagstudio/src/qt/helpers/ini_helpers.py b/tagstudio/src/qt/helpers/ini_helpers.py new file mode 100644 index 000000000..84570cad8 --- /dev/null +++ b/tagstudio/src/qt/helpers/ini_helpers.py @@ -0,0 +1,64 @@ +from string import ascii_letters, digits + +import structlog + +logger = structlog.get_logger("ini_helpers") + + +def is_valid_ini_key(key: str) -> bool: + """Check if a given string is a valid INI key for QSettings. + + This function validates whether the provided key is suitable for use in an INI file + managed by QSettings. Valid INI keys are those that are human-readable and do not + require URL encoding when saved. + + A valid key can contain letters (both uppercase and lowercase), digits, + and the characters '-', '_', and '.'. + + Args: + key (str): The string to be checked for validity as an INI key. + + Returns: + bool: True if the key is valid (i.e., human-readable and contains only allowed characters), + False otherwise. + + Notes: + - An empty string is considered invalid. + - This function is designed to ensure that keys remain human-readable + when stored in an INI file, preventing issues with URL encoding. + """ + if not key: + return False + + allowed_chars = ascii_letters + digits + "-_." + + return all(char in allowed_chars for char in key) + + +class IniKey(str): + """A subclass of `str` that ensures the string is a valid INI key. + + A valid INI key can contain letters (both uppercase and lowercase), digits, + and the characters `-`, `_`, and `.`. + + Args: + key (str): INI key. + forced (bool): If True, the key will be considered valid even if it + contains invalid characters. Defaults to False. + + Raises: + ValueError: If the key is invalid and forced is False. + + Notes: + - An empty string is considered invalid. + - This class is designed to ensure that keys remain human-readable + when stored in an INI file, preventing issues with URL encoding. + """ + + def __new__(cls, key: str, forced: bool = False): + if not is_valid_ini_key(key): + if not forced: + raise ValueError(f"Invalid INI key: {key}") + else: + logger.warning(f"Forced INI key: {key}") + return super().__new__(cls, key) diff --git a/tagstudio/src/qt/shortcuts_manager.py b/tagstudio/src/qt/shortcuts_manager.py new file mode 100644 index 000000000..f606b3916 --- /dev/null +++ b/tagstudio/src/qt/shortcuts_manager.py @@ -0,0 +1,279 @@ +from collections.abc import Sequence +from functools import partial +from typing import TYPE_CHECKING, overload + +import structlog +from PySide6.QtCore import QKeyCombination, QMetaObject, QObject, QSettings, Qt, Signal +from PySide6.QtGui import QAction, QKeySequence, QShortcut +from PySide6.QtWidgets import QApplication +from src.qt.helpers.ini_helpers import IniKey + +logger = structlog.get_logger("shortcuts_manager") + +if TYPE_CHECKING: + from src.qt.main_window import Ui_MainWindow + + +all_shortcuts: list["Shortcut"] = [] +"""List of all the shortcuts.""" + + +class Shortcut(QShortcut): + key_changed = Signal(list) + """Emits a list of :class:`QKeySequence` when the shortcut keys are changed or set.""" + + def __init__( + self, + setting_name: IniKey, + default_shortcuts: Sequence[QKeySequence | QKeySequence.StandardKey | Qt.Key], + parent: QObject, + ) -> None: + super().__init__(parent) + self._connected_actions_connections: dict[QAction, QMetaObject.Connection] = {} + """Contains all the actions that are connected to the Shortcut instance.""" + + _default_shortcuts = [QKeySequence(key) for key in default_shortcuts] + self.setKeys(_default_shortcuts, save=False) + self.setProperty("default_shortcuts", _default_shortcuts) + self.setProperty("setting_name", setting_name) + _load_shortcuts(self) + all_shortcuts.append(self) + self.destroyed.connect(partial(all_shortcuts.remove, self)) + + def setKey( # noqa: N802 + self, + key: QKeySequence | QKeyCombination | QKeySequence.StandardKey | str | int, + save: bool = True, + ) -> None: + super().setKey(key) + self.key_changed.emit(self.keys()) + if save: + _save_shortcuts(self) + + @overload + def setKeys(self, key: QKeySequence.StandardKey, save: bool = True) -> None: ... + @overload + def setKeys(self, keys: Sequence[QKeySequence], save: bool = True) -> None: ... + def setKeys(self, *args, save: bool = True, **kwargs) -> None: # noqa: N802 + super().setKeys(*args, **kwargs) + self.key_changed.emit(self.keys()) + if save: + _save_shortcuts(self) + + def connect_action(self, action: QAction) -> None: + """Connects the specified QAction to the Shortcut instance. + + Connects the specified QAction's setShortcuts to the key_changed signal of the Shortcut + instance, stores the connection, and ensures the action is properly disconnected when + destroyed. + Disables the current Shortcut instance, and sets the action's shortcut to the Shortcut + instance's current keys. + + Args: + action (QAction): The QAction to connect to. + + Returns: + None + """ + connection = self.key_changed.connect(action.setShortcuts) + action.destroyed.connect(partial(self.disconnect_action, action)) + action.setShortcuts(self.keys()) + + self._connected_actions_connections[action] = connection + self.setEnabled(False) + + def disconnect_action(self, action: QAction) -> None: + """Disconnects the specified QAction from the key_changed signal of the Shortcut instance. + + Args: + action (QAction): The QAction to disconnect from. + + Returns: + None + """ + if action in self._connected_actions_connections: + connection = self._connected_actions_connections.pop(action) + self.key_changed.disconnect(connection) + else: + logger.warning(f"Failed to disconnect {action}. seems it's not connected to {self}.") + + if not self._connected_actions_connections: + self.setEnabled(True) + + def setting_name(self) -> str: + """Retrieve the setting name associated with this Shortcut instance. + + Returns: + str: The setting name as a string. + """ + return str(self.property("setting_name")) + + +class DefaultShortcuts: + """Creates and manages default shortcuts for the application. + + Returns the singleton instance of DefaultShortcuts, initialized with standard and custom + shortcuts for the main window. + Raises an exception if accessed before being initialized with a main window. + """ + + _instance: "DefaultShortcuts | None" = None + + def __new__(cls, main_window: "Ui_MainWindow | None" = None): + if DefaultShortcuts._instance is None: + if main_window is None: + raise Exception("DefaultShortcuts accessed before initialized with a main window.") + DefaultShortcuts._instance = super().__new__(cls) + + # region standard shorcuts + cls.OPEN = Shortcut(IniKey("Open"), (QKeySequence.StandardKey.Open,), main_window) + cls.SAVE = Shortcut(IniKey("Save"), (QKeySequence.StandardKey.Save,), main_window) + cls.SAVE_AS = Shortcut( + IniKey("Save_As"), (QKeySequence.StandardKey.SaveAs,), main_window + ) + cls.REFRESH = Shortcut( + IniKey("Refresh"), (QKeySequence.StandardKey.Refresh,), main_window + ) + cls.SELECT_ALL = Shortcut( + IniKey("Select_All"), (QKeySequence.StandardKey.SelectAll,), main_window + ) + cls.DESELECT = Shortcut( + IniKey("Deselect"), + (QKeySequence.StandardKey.Deselect, Qt.Key.Key_Escape), + main_window, + ) + # endregion + + # region custom shortcuts + cls.NEW_TAG = Shortcut( + IniKey("New_Tag"), (QKeySequence.fromString("ctrl+t"),), main_window + ) + cls.CLOSE_LIBRARY = Shortcut( + IniKey("Close_Library"), (QKeySequence.fromString("ctrl+w"),), main_window + ) + # endregion + + return DefaultShortcuts._instance + + +def _get_settings() -> QSettings | None: + # region XXX: temporarily getting settings from QApplication.property("driver") + instance = QApplication.instance() + if instance is None: + return None + driver = instance.property("driver") + if driver is None: + return None + settings: QSettings = driver.settings + # endregion + return settings + + +def _save_shortcuts(shortcut: Shortcut | None = None) -> None: + """Save the keys of the specified `Shortcut` or all `Shortcut`s in settings. + + If no `shortcut` is specified, saves all the `shortcut`'s keys in settings. + + Checks if the shortcut keys are the same as the default shortcuts. Removes the settings entry if + they are. Otherwise, saves the shortcut keys in settings. + + Args: + shortcut (Shortcut | None): The shortcut for which keys need to be saved. Defaults to None. + + Returns: + None + """ + settings = _get_settings() + if settings is None: + return + + shortcuts = {shortcut} if shortcut is not None else all_shortcuts + + settings.beginGroup("Shortcuts") + for shortcut in shortcuts: + default_key_sequences: Sequence[QKeySequence] = shortcut.property("default_shortcuts") + current_key_sequences = shortcut.keys() + + # check if current shortcuts and default shortcuts are same. + if (len(current_key_sequences) == len(default_key_sequences)) and all( + any( + (dks.matches(cks) is QKeySequence.SequenceMatch.ExactMatch) + for cks in current_key_sequences + ) + for dks in default_key_sequences + ): + # if they are same, remove the entry from settings. + settings.remove(shortcut.property("setting_name")) + else: + # if they are different, save the shortcuts in settings. + settings.setValue( + shortcut.property("setting_name"), + [ks.toString() for ks in current_key_sequences] or "", + ) + + settings.endGroup() + settings.sync() + + +def _load_shortcuts(shortcut: Shortcut | None = None) -> None: + """Load and assigns the keys of the specified `Shortcut` or all `Shortcut`s from settings. + + If no `shortcut` is specified, loads all the `shortcut`'s keys from settings. + + Args: + shortcut (Shortcut | None): The shortcut for which keys need to be loaded. Defaults to None. + + Returns: + None + """ + settings = _get_settings() + if settings is None: + return + + shortcuts = {shortcut} if shortcut is not None else all_shortcuts + + settings.beginGroup("Shortcuts") + + for shortcut in shortcuts: + _keys = settings.value(shortcut.property("setting_name"), None) + + if isinstance(_keys, str): + key_sequences = [QKeySequence.fromString(_keys)] + elif isinstance(_keys, list): + key_sequences = [QKeySequence.fromString(ks) for ks in _keys] + else: + continue + + # TODO: check if the key sequences are valid. warn if not. + + shortcut.setKeys(key_sequences, save=False) + + settings.endGroup() + + +def is_shortcut_available(shortcut: QKeySequence) -> bool: + """Checks if a given shortcut is available for use. + + Args: + shortcut (QKeySequence): The shortcut to check availability for. + + Returns: + bool: True if the shortcut is available, False otherwise. + """ + for _shortcut in all_shortcuts: + for key in _shortcut.keys(): # noqa: SIM118 (https://github.com/astral-sh/ruff/issues/12578) + if key.matches(shortcut) is QKeySequence.SequenceMatch.ExactMatch: + return False + return True + + +def is_settings_name_available(name: str) -> bool: + """Checks if a given settings name is available for use. + + Args: + name (str): The name to check availability for. + + Returns: + bool: True if the name is available, False otherwise. + """ + return all(sc.property("setting_name").lower() != name.lower() for sc in all_shortcuts) diff --git a/tagstudio/src/qt/ts_qt.py b/tagstudio/src/qt/ts_qt.py index a63e970e5..1c0bbec60 100644 --- a/tagstudio/src/qt/ts_qt.py +++ b/tagstudio/src/qt/ts_qt.py @@ -23,7 +23,6 @@ import src.qt.resources_rc # noqa: F401 import structlog from humanfriendly import format_timespan -from PySide6 import QtCore from PySide6.QtCore import ( QObject, QSettings, @@ -86,6 +85,7 @@ from src.qt.modals.folders_to_tags import FoldersToTagsModal from src.qt.modals.tag_database import TagDatabasePanel from src.qt.resource_manager import ResourceManager +from src.qt.shortcuts_manager import DefaultShortcuts from src.qt.widgets.item_thumb import BadgeType, ItemThumb from src.qt.widgets.panel import PanelModal from src.qt.widgets.preview_panel import PreviewPanel @@ -231,6 +231,7 @@ def start(self) -> None: self.main_window = Ui_MainWindow(self) self.main_window.setWindowTitle(self.base_title) self.main_window.mousePressEvent = self.mouse_navigation # type: ignore + DefaultShortcuts(self.main_window) # self.main_window.setStyleSheet( # f'QScrollBar::{{background:red;}}' # ) @@ -274,31 +275,28 @@ def start(self) -> None: # file_menu.addAction(QAction('&New Library', menu_bar)) # file_menu.addAction(QAction('&Open Library', menu_bar)) - open_library_action = QAction("&Open/Create Library", menu_bar) + open_library_action = QAction() + open_library_action.setText("&Open/Create Library") + open_library_action.setParent(menu_bar) open_library_action.triggered.connect(lambda: self.open_library_from_dialog()) - open_library_action.setShortcut( - QtCore.QKeyCombination( - QtCore.Qt.KeyboardModifier(QtCore.Qt.KeyboardModifier.ControlModifier), - QtCore.Qt.Key.Key_O, - ) + open_shortcut = DefaultShortcuts().OPEN + open_shortcut.connect_action(open_library_action) + open_library_action.setToolTip(open_shortcut.key().toString()) + open_shortcut.key_changed.connect( + lambda _: open_library_action.setToolTip(open_shortcut.key().toString()) ) - open_library_action.setToolTip("Ctrl+O") file_menu.addAction(open_library_action) save_library_backup_action = QAction("&Save Library Backup", menu_bar) save_library_backup_action.triggered.connect( lambda: self.callback_library_needed_check(self.backup_library) ) - save_library_backup_action.setShortcut( - QtCore.QKeyCombination( - QtCore.Qt.KeyboardModifier( - QtCore.Qt.KeyboardModifier.ControlModifier - | QtCore.Qt.KeyboardModifier.ShiftModifier - ), - QtCore.Qt.Key.Key_S, - ) + save_as_shortcut = DefaultShortcuts().SAVE_AS + save_as_shortcut.connect_action(save_library_backup_action) + save_library_backup_action.setStatusTip(save_as_shortcut.key().toString()) + save_as_shortcut.key_changed.connect( + lambda _: save_library_backup_action.setStatusTip(save_as_shortcut.key().toString()) ) - save_library_backup_action.setStatusTip("Ctrl+Shift+S") file_menu.addAction(save_library_backup_action) file_menu.addSeparator() @@ -309,50 +307,57 @@ def start(self) -> None: add_new_files_action.triggered.connect( lambda: self.callback_library_needed_check(self.add_new_files_callback) ) - add_new_files_action.setShortcut( - QtCore.QKeyCombination( - QtCore.Qt.KeyboardModifier(QtCore.Qt.KeyboardModifier.ControlModifier), - QtCore.Qt.Key.Key_R, - ) + refresh_shortcut = DefaultShortcuts().REFRESH + refresh_shortcut.connect_action(add_new_files_action) + add_new_files_action.setStatusTip(refresh_shortcut.key().toString()) + refresh_shortcut.key_changed.connect( + lambda _: add_new_files_action.setStatusTip(refresh_shortcut.key().toString()) ) - add_new_files_action.setStatusTip("Ctrl+R") # file_menu.addAction(refresh_lib_action) file_menu.addAction(add_new_files_action) file_menu.addSeparator() close_library_action = QAction("&Close Library", menu_bar) close_library_action.triggered.connect(self.close_library) + close_library_shortcut = DefaultShortcuts().CLOSE_LIBRARY + close_library_shortcut.connect_action(close_library_action) + close_library_action.setStatusTip(close_library_shortcut.key().toString()) + close_library_shortcut.key_changed.connect( + lambda _: close_library_action.setStatusTip(close_library_shortcut.key().toString()) + ) file_menu.addAction(close_library_action) # Edit Menu ============================================================ new_tag_action = QAction("New &Tag", menu_bar) new_tag_action.triggered.connect(lambda: self.add_tag_action_callback()) - new_tag_action.setShortcut( - QtCore.QKeyCombination( - QtCore.Qt.KeyboardModifier(QtCore.Qt.KeyboardModifier.ControlModifier), - QtCore.Qt.Key.Key_T, - ) + new_tag_shortcut = DefaultShortcuts().NEW_TAG + new_tag_shortcut.connect_action(new_tag_action) + new_tag_action.setStatusTip(new_tag_shortcut.key().toString()) + new_tag_shortcut.key_changed.connect( + lambda _: new_tag_action.setStatusTip(new_tag_shortcut.key().toString()) ) - new_tag_action.setToolTip("Ctrl+T") edit_menu.addAction(new_tag_action) edit_menu.addSeparator() select_all_action = QAction("Select All", menu_bar) select_all_action.triggered.connect(self.select_all_action_callback) - select_all_action.setShortcut( - QtCore.QKeyCombination( - QtCore.Qt.KeyboardModifier(QtCore.Qt.KeyboardModifier.ControlModifier), - QtCore.Qt.Key.Key_A, - ) + select_all_shortcut = DefaultShortcuts().SELECT_ALL + select_all_shortcut.connect_action(select_all_action) + select_all_action.setStatusTip(select_all_shortcut.key().toString()) + select_all_shortcut.key_changed.connect( + lambda _: select_all_action.setStatusTip(select_all_shortcut.key().toString()) ) - select_all_action.setToolTip("Ctrl+A") edit_menu.addAction(select_all_action) clear_select_action = QAction("Clear Selection", menu_bar) clear_select_action.triggered.connect(self.clear_select_action_callback) - clear_select_action.setShortcut(QtCore.Qt.Key.Key_Escape) - clear_select_action.setToolTip("Esc") + deselect_shortcut = DefaultShortcuts().DESELECT + deselect_shortcut.connect_action(clear_select_action) + clear_select_action.setStatusTip(deselect_shortcut.key().toString()) + deselect_shortcut.key_changed.connect( + lambda _: clear_select_action.setStatusTip(deselect_shortcut.key().toString()) + ) edit_menu.addAction(clear_select_action) edit_menu.addSeparator() diff --git a/tagstudio/tests/qt/helpers/test_ini_helpers.py b/tagstudio/tests/qt/helpers/test_ini_helpers.py new file mode 100644 index 000000000..c9c66337d --- /dev/null +++ b/tagstudio/tests/qt/helpers/test_ini_helpers.py @@ -0,0 +1,29 @@ +import pytest +from src.qt.helpers.ini_helpers import IniKey, is_valid_ini_key + + +def test_is_valid_ini_key(): + valid_keys = {"valid_key", "Valid.key_2", "1valid_key", "valid-key", ".valid_key_3", "_valid"} + invalid_keys = {"invalid key", ""} + + for key in valid_keys: + assert is_valid_ini_key(key) + + for key in invalid_keys: + assert not is_valid_ini_key(key) + + +class TestIniKey: + @staticmethod + def test___new__(): + assert IniKey("valid_key") == "valid_key" + assert IniKey("Valid.key_2") == "Valid.key_2" + + invalid_keys = {"invalid key", ""} + + for key in invalid_keys: + with pytest.raises(ValueError): + IniKey(key) + + for key in invalid_keys: + assert IniKey(key, forced=True) == key diff --git a/tagstudio/tests/qt/test_shortcuts_manager.py b/tagstudio/tests/qt/test_shortcuts_manager.py new file mode 100644 index 000000000..73e6d1ac8 --- /dev/null +++ b/tagstudio/tests/qt/test_shortcuts_manager.py @@ -0,0 +1,259 @@ +from pathlib import Path + +import pytest +from PySide6.QtCore import QSettings +from PySide6.QtGui import QAction, QKeySequence +from PySide6.QtWidgets import QApplication, QWidget +from src.qt import shortcuts_manager +from src.qt.helpers.ini_helpers import IniKey +from src.qt.shortcuts_manager import ( + DefaultShortcuts, + Shortcut, + _load_shortcuts, + is_settings_name_available, + is_shortcut_available, +) + + +@pytest.fixture() +def widget(qtbot) -> QWidget: + return QWidget() + + +@pytest.fixture(autouse=True) +def reset__shortcuts_variable(monkeypatch): + original_value = shortcuts_manager.all_shortcuts + monkeypatch.setattr(shortcuts_manager, "all_shortcuts", original_value) + + +@pytest.fixture(scope="module") +def setting_name(): + def generate_names(): + count = 1 + while True: + yield f"shortcut{count}" + count += 1 + + generator = generate_names() + + yield generator.__next__ + + +class TestDefaultShortcuts: + @staticmethod + def test___new__(widget): + instance1 = DefaultShortcuts(widget) + instance2 = DefaultShortcuts(widget) + assert instance1 is instance2 + + @staticmethod + def test_default_shortcuts(widget): + default_shortcuts = DefaultShortcuts(widget) + assert isinstance(default_shortcuts.OPEN, Shortcut) + assert isinstance(default_shortcuts.NEW_TAG, Shortcut) + assert isinstance(default_shortcuts.SAVE, Shortcut) + + +class TestShortcut: + @staticmethod + def test___init__(widget, setting_name): + name_1 = setting_name() + name_2 = setting_name() + shortcut1 = Shortcut(IniKey(name_1), (QKeySequence.fromString("ctrl+alt+o"),), widget) + shortcut2 = Shortcut( + IniKey(name_2), + (QKeySequence.fromString("ctrl+1"), QKeySequence.fromString("ctrl+2")), + widget, + ) + + assert ( + shortcut1.key().matches(QKeySequence.fromString("ctrl+alt+o")) + is QKeySequence.SequenceMatch.ExactMatch + ) + assert ( + shortcut2.key().matches(QKeySequence.fromString("ctrl+1")) + is QKeySequence.SequenceMatch.ExactMatch + ) + + for shortcut, expected in zip(shortcut2.keys(), ("ctrl+1", "ctrl+2")): + assert shortcut.matches(QKeySequence.fromString(expected)) + + @staticmethod + def test_key_changed(widget, setting_name, qtbot): + name_1 = setting_name() + + shortcut1 = Shortcut(IniKey(name_1), (QKeySequence.fromString("ctrl+1"),), widget) + with qtbot.waitSignal(shortcut1.key_changed, timeout=1000) as blocker: + shortcut1.setKey(QKeySequence.fromString("ctrl+2")) + + assert ( + shortcut1.keys()[0].matches(blocker.args[0][0]) is QKeySequence.SequenceMatch.ExactMatch + ) + + @staticmethod + def test_connect_action(widget, setting_name): + name_1 = setting_name() + + shortcut1 = Shortcut(IniKey(name_1), (QKeySequence.fromString("ctrl+1"),), widget) + shortcut1.connect_action(action := QAction()) + + assert shortcut1.isEnabled() is False + assert ( + action.shortcut().matches(QKeySequence.fromString("ctrl+1")) + is QKeySequence.SequenceMatch.ExactMatch + ) + + shortcut1.setKey(QKeySequence.fromString("ctrl+2")) + assert action.shortcut().matches(QKeySequence.fromString("ctrl+2")) + + action.destroyed.emit() + + shortcut1.setKey(QKeySequence.fromString("ctrl+3")) + assert ( + action.shortcut().matches(QKeySequence.fromString("ctrl+2")) + is QKeySequence.SequenceMatch.ExactMatch + ) + + @staticmethod + def test_disconnect_action(widget, setting_name): + name_1 = setting_name() + + shortcut1 = Shortcut(IniKey(name_1), (QKeySequence.fromString("ctrl+1"),), widget) + shortcut1.connect_action(action1 := QAction()) + shortcut1.connect_action(action2 := QAction()) + shortcut1.setKey(QKeySequence.fromString("ctrl+2")) + + assert ( + action1.shortcut().matches(QKeySequence.fromString("ctrl+2")) + is QKeySequence.SequenceMatch.ExactMatch + ) + assert shortcut1.isEnabled() is False + + shortcut1.disconnect_action(action1) + shortcut1.setKey(QKeySequence.fromString("ctrl+3")) + assert ( + action1.shortcut().matches(QKeySequence.fromString("ctrl+2")) + is QKeySequence.SequenceMatch.ExactMatch + ) + assert shortcut1.isEnabled() is False + + shortcut1.disconnect_action(action2) + assert shortcut1.isEnabled() is True + + +def test__load_shortcuts(tmp_path: Path, widget, setting_name): + settings_file = tmp_path / "test_shortcuts_manager_settings.ini" + + name1, name2, name3, name4 = setting_name(), setting_name(), setting_name(), setting_name() + key1, key2, key3 = "ctrl+0", "ctrl+1", "ctrl+2" + + settings_file.write_text( + "\n".join( + ( + "[Shortcuts]", + f"{name1} = {key1}", + f"{name2} = {key2}, {key3}", + f"{name3} = {key3}", + f"{name4} = ", + ) + ) + ) + + # region NOTE: temporary solution for test by making fake driver to use QSettings + app = QApplication.instance() or QApplication([]) + + class Driver: + settings = QSettings(str(settings_file), QSettings.Format.IniFormat, app) + + app.setProperty("driver", Driver) + # endregion + + shortcut1 = Shortcut(IniKey(name1), (QKeySequence.fromString("ctrl+alt+0"),), widget) + shortcut2 = Shortcut(IniKey(name2), (QKeySequence.fromString("ctrl+alt+1"),), widget) + shortcut3 = Shortcut(IniKey(name3), (QKeySequence.fromString("ctrl+alt+2"),), widget) + shortcut4 = Shortcut(IniKey(name4), (QKeySequence.fromString("ctrl+alt+3"),), widget) + _load_shortcuts() + + assert ( + shortcut1.key().matches(QKeySequence.fromString(key1)) + is QKeySequence.SequenceMatch.ExactMatch + ) + assert ( + shortcut2.key().matches(QKeySequence.fromString(key2)) + is QKeySequence.SequenceMatch.ExactMatch + ) + assert ( + shortcut2.keys()[1].matches(QKeySequence.fromString(key3)) + is QKeySequence.SequenceMatch.ExactMatch + ) + assert ( + shortcut3.key().matches(QKeySequence.fromString(key3)) + is QKeySequence.SequenceMatch.ExactMatch + ) + assert shortcut4.key().matches(QKeySequence()) is QKeySequence.SequenceMatch.ExactMatch + + +def test__save_shortcut(tmp_path: Path, widget, setting_name): + settings_file = tmp_path / "test_shortcuts_manager_settings.ini" + + # region NOTE: temporary solution for test by making fake driver to use QSettings + app = QApplication.instance() or QApplication([]) + + class Driver: + settings = QSettings(settings_file.as_posix(), QSettings.Format.IniFormat, app) + + app.setProperty("driver", Driver) + # endregion + + name1, name2, name3, name4 = setting_name(), setting_name(), setting_name(), setting_name() + + shortcut1 = Shortcut(IniKey(name1), (QKeySequence.fromString("ctrl+alt+0"),), widget) + shortcut2 = Shortcut(IniKey(name2), (QKeySequence.fromString("ctrl+alt+1"),), widget) + shortcut3 = Shortcut(IniKey(name3), (QKeySequence.fromString("ctrl+alt+2"),), widget) + shortcut4 = Shortcut(IniKey(name4), (QKeySequence.fromString("ctrl+alt+3"),), widget) + + shortcut1.setKey(QKeySequence.fromString("ctrl+1")) + shortcut2.setKey(QKeySequence.fromString("ctrl+alt+1")) + shortcut3.setKeys( + [ + QKeySequence.fromString("ctrl+2"), + QKeySequence.fromString("ctrl+,"), + QKeySequence.fromString('ctrl+"'), + ] + ) + shortcut4.setKey(QKeySequence()) + + settings = Driver.settings + + settings.beginGroup("Shortcuts") + assert settings.value(name1) == ["Ctrl+1"] + assert settings.value(name3) == ["Ctrl+2", "Ctrl+,", 'Ctrl+"'] + assert settings.value(name4) == "" + settings.endGroup() + + +def test_is_shortcut_available(widget, setting_name): + name1, name2 = setting_name(), setting_name() + + Shortcut(IniKey(name1), (QKeySequence.fromString("ctrl+alt+0"),), widget) + assert is_shortcut_available(QKeySequence.fromString("ctrl+alt+0")) is False + + shortcut = Shortcut( + IniKey(name2), + (QKeySequence.fromString("ctrl+a"), QKeySequence.fromString("ctrl+b")), + widget, + ) + shortcut.setKeys((QKeySequence.fromString("ctrl+a"), QKeySequence.fromString("ctrl+c"))) + + assert is_shortcut_available(QKeySequence.fromString("ctrl+a")) is False + assert is_shortcut_available(QKeySequence.fromString("ctrl+b")) is True + assert is_shortcut_available(QKeySequence.fromString("ctrl+c")) is False + + +def test_is_settings_name_available(widget, setting_name): + unavailable, available = setting_name(), setting_name() + + Shortcut(IniKey(unavailable), (QKeySequence.fromString("ctrl+alt+0"),), widget) + + assert is_settings_name_available(unavailable) is False + assert is_settings_name_available(available) is True