Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 135 additions & 1 deletion src/tagstudio/qt/thumb_grid_layout.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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] = {}
Expand All @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -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):
Expand All @@ -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):
Expand All @@ -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)
Expand All @@ -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:
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions src/tagstudio/qt/ts_qt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down
61 changes: 60 additions & 1 deletion src/tagstudio/qt/views/main_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions src/tagstudio/resources/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down