diff --git a/src/tagstudio/qt/thumb_grid_layout.py b/src/tagstudio/qt/thumb_grid_layout.py index 27ad31145..ef279e30a 100644 --- a/src/tagstudio/qt/thumb_grid_layout.py +++ b/src/tagstudio/qt/thumb_grid_layout.py @@ -2,6 +2,7 @@ import time from pathlib import Path from typing import TYPE_CHECKING, Any, override +from collections import deque from PySide6.QtCore import QPoint, QRect, QSize from PySide6.QtGui import QPixmap @@ -17,6 +18,27 @@ if TYPE_CHECKING: from tagstudio.qt.ts_qt import QtDriver +#number of selection states to store (for undo/redo selection) +MAX_HISTORY = 30 + +def log_selection(method): + #decorator to keep a history of selection states + def wrapper(self, *args, **kwargs): + # Only log if the current state differs from the last + if ( + self._selected + and ( + not self._selection_history + or self._selection_history[-1] != self._selected + ) + ): + # copy to avoid mutation issues + self._selection_history.append(dict(self._selected)) + if self._undo_selection_history: + # clear undo history + self._undo_selection_history.clear() + return method(self, *args, **kwargs) + return wrapper class ThumbGridLayout(QLayout): def __init__(self, driver: "QtDriver", scroll_area: QScrollArea) -> None: @@ -28,8 +50,12 @@ def __init__(self, driver: "QtDriver", scroll_area: QScrollArea) -> None: self._items: list[QLayoutItem] = [] # Entry.id -> _entry_ids[index] self._selected: dict[int, int] = {} + self._selection_history:deque[dict[int, int]] = deque(maxlen=MAX_HISTORY) + self._undo_selection_history:deque[dict[int, int]] = deque(maxlen=MAX_HISTORY) # _entry_ids[index] self._last_selected: int | None = None + self._is_shift_key_down: bool = False + self._shift_select_start: int | None = None self._entry_ids: list[int] = [] self._entries: dict[int, Entry] = {} @@ -43,6 +69,7 @@ def __init__(self, driver: "QtDriver", scroll_area: QScrollArea) -> None: self._renderer: ThumbRenderer = ThumbRenderer(self.driver) self._renderer.updated.connect(self._on_rendered) self._render_cutoff: float = 0.0 + self._per_row: int = 0 # _entry_ids[StartIndex:EndIndex] self._last_page_update: tuple[int, int] | None = None @@ -52,6 +79,8 @@ def set_entries(self, entry_ids: list[int]): self._selected.clear() self._last_selected = None + self._selection_history.clear() + self._undo_selection_history.clear() self._entry_ids = entry_ids self._entries.clear() @@ -83,6 +112,107 @@ def set_entries(self, entry_ids: list[int]): self._last_page_update = None + def undo_selection(self): + """Loads selection state from history.""" + if self._selection_history: + self._undo_selection_history.append(dict(self._selected)) + selected = self._selection_history.pop() + for id in self._selected: + if id not in selected: + self._set_selected(id, value=False) + for id in selected: + self._set_selected(id) + self._last_selected = selected[id] + self._selected = selected + + + def redo_selection(self): + """Loads selection state from undo history.""" + if self._undo_selection_history: + self._selection_history.append(dict(self._selected)) + selected = self._undo_selection_history.pop() + for id in self._selected: + if id not in selected: + self._set_selected(id, value=False) + for id in selected: + self._set_selected(id) + self._last_selected = selected[id] + self._selected = selected + + def handle_shift_key_event(self, is_shift_key_pressed:bool): + """Track last_selected and input for shift selecting with directional select.""" + self._is_shift_key_down = is_shift_key_pressed + if is_shift_key_pressed: + self._shift_select_start = self._last_selected + else: + self._shift_select_start = None + + @log_selection + def _enact_directional_select(self,target_index:int): + """Common logic for select_next, prev, up, down. + + Handles multi-select (shift+arrow key). + """ + selection_start_index = None + if self._is_shift_key_down: + #find the multi-select start point + if self._shift_select_start is not None: + selection_start_index = self._shift_select_start + elif self._last_selected is not None: + self._shift_select_start = self._last_selected + selection_start_index = self._last_selected + target_indexes = [target_index] + if selection_start_index is not None: + #get all indexes from start point to target_index + target_indexes = list( + range( + min(selection_start_index, target_index), + max(selection_start_index, target_index) + 1 + ) + ) + #update selection + selected = {self._entry_ids[i]: i for i in target_indexes} + for id in self._selected: + if id not in selected: + self._set_selected(id, value=False) + for id in selected: + self._set_selected(id) + self._selected = selected + self._last_selected = target_index + #return selected because this callback is handled in main_window.py (not ts_qt.py) + return list(self._selected.keys()) + + def select_next(self): + target_index = 0 + if self._last_selected is not None: + target_index = min(self._last_selected+1, len(self._entry_ids)-1) + return self._enact_directional_select(target_index) + + def select_prev(self): + target_index = len(self._entry_ids)-1 + if self._last_selected is not None: + target_index = max(self._last_selected-1, 0) + return self._enact_directional_select(target_index) + + def select_up(self): + target_index = len(self._entry_ids)-1 + if self._last_selected is not None: + target_index = max( + self._last_selected-self._per_row, + self._last_selected % self._per_row + ) + return self._enact_directional_select(target_index) + + def select_down(self): + target_index = 0 + if self._last_selected is not None: + target_index = min( + self._last_selected+self._per_row, + len(self._entry_ids)-1 + ) + return self._enact_directional_select(target_index) + + @log_selection def select_all(self): self._selected.clear() for index, id in enumerate(self._entry_ids): @@ -92,6 +222,7 @@ def select_all(self): for entry_id in self._entry_items: self._set_selected(entry_id) + @log_selection def select_inverse(self): selected = {} for index, id in enumerate(self._entry_ids): @@ -107,6 +238,7 @@ def select_inverse(self): self._selected = selected + @log_selection def select_entry(self, entry_id: int): if entry_id in self._selected: index = self._selected.pop(entry_id) @@ -123,6 +255,7 @@ def select_entry(self, entry_id: int): self._last_selected = index self._set_selected(entry_id) + @log_selection def select_to_entry(self, entry_id: int): index = self._entry_ids.index(entry_id) if len(self._selected) == 0: @@ -144,6 +277,7 @@ def select_to_entry(self, entry_id: int): self._selected[entry_id] = i self._set_selected(entry_id) + @log_selection def clear_selected(self): for entry_id in self._entry_items: self._set_selected(entry_id, value=False) @@ -240,7 +374,7 @@ def _size(self, width: int) -> tuple[int, int, int]: if width_offset == 0: return 0, 0, height_offset per_row = int(width / width_offset) - + self._per_row = per_row return per_row, width_offset, height_offset @override diff --git a/src/tagstudio/qt/ts_qt.py b/src/tagstudio/qt/ts_qt.py index 8d7edde30..f9ff023b6 100644 --- a/src/tagstudio/qt/ts_qt.py +++ b/src/tagstudio/qt/ts_qt.py @@ -429,6 +429,14 @@ def set_open_last_loaded_on_startup(checked: bool): self.main_window.menu_bar.select_inverse_action.triggered.connect( self.select_inverse_action_callback ) + + self.main_window.menu_bar.undo_selection_action.triggered.connect( + self.undo_selection_action_callback + ) + + self.main_window.menu_bar.redo_selection_action.triggered.connect( + self.redo_selection_action_callback + ) self.main_window.menu_bar.clear_select_action.triggered.connect( self.clear_select_action_callback @@ -852,6 +860,20 @@ def add_tags_to_selected_callback(self, tag_ids: list[int]): self.main_window.thumb_layout.add_tags(selected, tag_ids) self.lib.add_tags_to_entries(selected, tag_ids) + def undo_selection_action_callback(self): + """Undo most recent selection change.""" + self.main_window.thumb_layout.undo_selection() + self.set_clipboard_menu_viability() + self.set_select_actions_visibility() + self.main_window.preview_panel.set_selection(self.selected, update_preview=True) + + def redo_selection_action_callback(self): + """Redo most recent selection undo.""" + self.main_window.thumb_layout.redo_selection() + self.set_clipboard_menu_viability() + self.set_select_actions_visibility() + self.main_window.preview_panel.set_selection(self.selected, update_preview=True) + def delete_files_callback(self, origin_path: str | Path, origin_id: int | None = None): """Callback to send on or more files to the system trash. diff --git a/src/tagstudio/qt/views/main_window.py b/src/tagstudio/qt/views/main_window.py index df675fbe6..a76777bb5 100644 --- a/src/tagstudio/qt/views/main_window.py +++ b/src/tagstudio/qt/views/main_window.py @@ -11,7 +11,7 @@ from PIL import Image, ImageQt from PySide6 import QtCore from PySide6.QtCore import QMetaObject, QSize, QStringListModel, Qt -from PySide6.QtGui import QAction, QPixmap +from PySide6.QtGui import QAction, QPixmap, QKeyEvent from PySide6.QtWidgets import ( QComboBox, QCompleter, @@ -67,6 +67,8 @@ class MainMenuBar(QMenuBar): new_tag_action: QAction select_all_action: QAction select_inverse_action: QAction + undo_selection_action: QAction + redo_selection_action: QAction clear_select_action: QAction copy_fields_action: QAction paste_fields_action: QAction @@ -215,6 +217,31 @@ def setup_edit_menu(self): self.select_inverse_action.setEnabled(False) self.edit_menu.addAction(self.select_inverse_action) + # Undo Selection + self.undo_selection_action = QAction( + Translations["select.undo"], self + ) + self.undo_selection_action.setShortcut( + QtCore.QKeyCombination( + QtCore.Qt.Key.Key_R, + ) + ) + self.undo_selection_action.setToolTip("R") + self.edit_menu.addAction(self.undo_selection_action) + + # Redo Selection + self.redo_selection_action = QAction( + Translations["select.redo"], self + ) + self.redo_selection_action.setShortcut( + QtCore.QKeyCombination( + QtCore.Qt.KeyboardModifier(QtCore.Qt.KeyboardModifier.ShiftModifier), + QtCore.Qt.Key.Key_R, + ) + ) + self.redo_selection_action.setToolTip("Shift+R") + self.edit_menu.addAction(self.redo_selection_action) + # Clear Selection self.clear_select_action = QAction(Translations["select.clear"], self) self.clear_select_action.setShortcut(QtCore.Qt.Key.Key_Escape) @@ -450,6 +477,7 @@ class MainWindow(QMainWindow): def __init__(self, driver: "QtDriver", parent: QWidget | None = None) -> None: super().__init__(parent) self.rm = ResourceManager() + self.installEventFilter(self) # region Type declarations for variables that will be initialized in methods # initialized in setup_search_bar @@ -689,6 +717,37 @@ def setup_status_bar(self): # endregion + #keyboard navigation of thumb_layout + def eventFilter(self, watched, event): + if isinstance(event, QKeyEvent): + key = event.key() + # KEY RELEASED + if event.type() == event.Type.KeyRelease: + if key == QtCore.Qt.Key.Key_Shift: + self.thumb_layout.handle_shift_key_event(is_shift_key_pressed=False) + # KEY PRESSED + else: + if key == QtCore.Qt.Key.Key_Shift: + self.thumb_layout.handle_shift_key_event(is_shift_key_pressed=True) + elif key == QtCore.Qt.Key.Key_Right: + selected = self.thumb_layout.select_next() + self.preview_panel.set_selection(selected, update_preview=True) + return True + elif key == QtCore.Qt.Key.Key_Left: + selected = self.thumb_layout.select_prev() + self.preview_panel.set_selection(selected, update_preview=True) + return True + elif key == QtCore.Qt.Key.Key_Up: + selected = self.thumb_layout.select_up() + self.preview_panel.set_selection(selected, update_preview=True) + return True + elif key == QtCore.Qt.Key.Key_Down: + selected = self.thumb_layout.select_down() + self.preview_panel.set_selection(selected, update_preview=True) + return True + return super().eventFilter(watched, event) + + def toggle_landing_page(self, enabled: bool): if enabled: self.entry_scroll_area.setHidden(True) diff --git a/src/tagstudio/resources/translations/en.json b/src/tagstudio/resources/translations/en.json index edda02311..d3cf0c4c5 100644 --- a/src/tagstudio/resources/translations/en.json +++ b/src/tagstudio/resources/translations/en.json @@ -254,6 +254,8 @@ "select.all": "Select All", "select.clear": "Clear Selection", "select.inverse": "Invert Selection", + "select.undo": "Undo Selection", + "select.redo": "Redo Selection", "settings.clear_thumb_cache.title": "Clear Thumbnail Cache", "settings.dateformat.english": "English", "settings.dateformat.international": "International",