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
@@ -1,22 +1,44 @@
import math
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
from PySide6.QtWidgets import QLayout, QLayoutItem, QScrollArea

from tagstudio.core.constants import TAG_ARCHIVED, TAG_FAVORITE
from tagstudio.core.library.alchemy.enums import ItemType
from tagstudio.core.library.alchemy.models import Entry
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.mixed.item_thumb import BadgeType, ItemThumb
from tagstudio.qt.previews.renderer import ThumbRenderer

Check failure on line 16 in src/tagstudio/qt/thumb_grid_layout.py

View workflow job for this annotation

GitHub Actions / Run Ruff check

Ruff (I001)

src/tagstudio/qt/thumb_grid_layout.py:1:1: I001 Import block is un-sorted or un-formatted

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 @@
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 @@
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 @@

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 @@

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 @@
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 @@

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 @@
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 @@
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 @@
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
@@ -1,3 +1,3 @@
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
Expand Down Expand Up @@ -429,6 +429,14 @@
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 @@
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
@@ -1,49 +1,49 @@
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio


import typing
from collections.abc import Callable
from pathlib import Path

import structlog
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,
QFrame,
QGridLayout,
QHBoxLayout,
QLayout,
QLineEdit,
QMainWindow,
QMenu,
QMenuBar,
QPushButton,
QScrollArea,
QSizePolicy,
QSpacerItem,
QSplitter,
QStatusBar,
QVBoxLayout,
QWidget,
)

from tagstudio.core.enums import ShowFilepathOption
from tagstudio.core.library.alchemy.enums import SortingModeEnum
from tagstudio.qt.controllers.preview_panel_controller import PreviewPanel
from tagstudio.qt.helpers.color_overlay import theme_fg_overlay
from tagstudio.qt.mixed.landing import LandingWidget
from tagstudio.qt.mixed.pagination import Pagination
from tagstudio.qt.mnemonics import assign_mnemonics
from tagstudio.qt.platform_strings import trash_term
from tagstudio.qt.resource_manager import ResourceManager
from tagstudio.qt.thumb_grid_layout import ThumbGridLayout
from tagstudio.qt.translations import Translations

Check failure on line 46 in src/tagstudio/qt/views/main_window.py

View workflow job for this annotation

GitHub Actions / Run Ruff check

Ruff (I001)

src/tagstudio/qt/views/main_window.py:6:1: I001 Import block is un-sorted or un-formatted

# Only import for type checking/autocompletion, will not be imported at runtime.
if typing.TYPE_CHECKING:
Expand All @@ -67,6 +67,8 @@
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 @@
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 @@
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 @@

# endregion

#keyboard navigation of thumb_layout
def eventFilter(self, watched, event):

Check failure on line 721 in src/tagstudio/qt/views/main_window.py

View workflow job for this annotation

GitHub Actions / Run Ruff check

Ruff (N802)

src/tagstudio/qt/views/main_window.py:721:9: N802 Function name `eventFilter` should be lowercase
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
Loading