Skip to content

Commit 2ea2aad

Browse files
authored
feat(ui): add arrow key navigation and selection (#1542)
* feat(ui): arrow key navigation and selection in thumb grid * refactor(ui): consolidate selection logic in `SelectionState` * fix(ui): align selection behavior with other file managers * fix(ui): prune removed entries from selection * fix(ui): correctly set focus for thumb grid shortcuts * fix(ui): move selection commands from edit to select menu * feat(ui): arrow keys can move and select across adjacent pages * chore: make list view comment a bit clearer
1 parent 4f4f1de commit 2ea2aad

20 files changed

Lines changed: 430 additions & 263 deletions

‎src/tagstudio/core/library/alchemy/library.py‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -602,6 +602,19 @@ def get_entries(self, entry_ids: Iterable[int]) -> list[Entry]:
602602
entries = dict((e.id, e) for e in session.scalars(statement))
603603
return [entries[id] for id in entry_ids if id in entries]
604604

605+
def removed_entry_ids(self, entry_ids: Iterable[int]) -> set[int]:
606+
"""Return IDs from `entry_ids` that no longer exist in the library."""
607+
entry_ids = list(entry_ids)
608+
existing: set[int] = set()
609+
610+
with Session(self.engine) as session:
611+
for sub_list in [
612+
entry_ids[i : i + MAX_SQL_VARIABLES]
613+
for i in range(0, len(entry_ids), MAX_SQL_VARIABLES)
614+
]:
615+
existing.update(session.scalars(select(Entry.id).where(Entry.id.in_(sub_list))))
616+
return set(entry_ids) - existing
617+
605618
def get_entries_full(self, entry_ids: list[int] | set[int]) -> Iterator[Entry]:
606619
"""Load entry and join with all joins and all tags."""
607620
with Session(self.engine) as session:

‎src/tagstudio/qt/controllers/inspector.py‎

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,6 @@ def __init__(self, driver: QtDriver) -> None:
5050
self._driver = driver
5151
self._lib = self._driver.lib
5252
self._settings = self._driver.settings
53-
self._selected: list[int] = []
5453
self._current_stats: FileAttributeData | None = None
5554

5655
self._open_tag_search_action = QShortcut(
@@ -88,10 +87,12 @@ def _connect_callbacks(self) -> None:
8887
self.layout().preview_thumb.check_ffmpeg.connect(self._toggle_ffmpeg_warning)
8988

9089
self._driver.entry_state.tags_changed.connect(self._on_tags_changed)
90+
self._driver.selection.changed.connect(self.update_from_selection)
9191

9292
def _on_tags_changed(self, entry_ids: set[int]) -> None:
93-
if len(self._selected) == 1 and self._selected[0] in entry_ids:
94-
self.layout().containers.update_from_entry(self._selected[0])
93+
selected = self._driver.selection.selected
94+
if len(selected) == 1 and selected[0] in entry_ids:
95+
self.layout().containers.update_from_entry(selected[0])
9596
self.layout().tag_search_box.added = self.layout().containers.tags
9697

9798
def _set_item_mode(self, mode: _ItemMode | None):
@@ -144,7 +145,8 @@ def _field_added_callback(self, query: str):
144145
self.layout().field_search_box.layout().search_field.setFocus()
145146

146147
def _thumb_stats_updated_callback(self, filepath: Path, stats: FileAttributeData) -> None:
147-
if len(self._selected) != 1:
148+
selected = self._driver.selection.selected
149+
if len(selected) != 1:
148150
return
149151

150152
if filepath != self.layout().preview_thumb.current_file:
@@ -160,7 +162,7 @@ def _thumb_stats_updated_callback(self, filepath: Path, stats: FileAttributeData
160162
if stats.duration is not None:
161163
self._current_stats.duration = stats.duration
162164

163-
entry = unwrap(self._lib.get_entry(self._selected[0]))
165+
entry = unwrap(self._lib.get_entry(selected[0]))
164166
self.layout().file_attrs.update_stats(filepath, self._current_stats, entry)
165167

166168
def _set_selection_callback(self) -> None:
@@ -174,9 +176,10 @@ def _set_selection_callback(self) -> None:
174176
def _add_field_to_selected(self, template: BaseFieldTemplate) -> None:
175177
self.layout().containers.add_field_to_selected(template)
176178
# TODO: Allow editing of fields across multiple entries at once.
177-
if len(self._selected) == 1:
179+
selected = self._driver.selection.selected
180+
if len(selected) == 1:
178181
if self._driver.settings.edit_field_on_add:
179-
entry = unwrap(self._lib.get_entry_full(self._selected[0]))
182+
entry = unwrap(self._lib.get_entry_full(selected[0]))
180183
entry_field = None
181184
if isinstance(template, TextFieldTemplate):
182185
entry_field = entry.text_fields[-1]
@@ -185,7 +188,7 @@ def _add_field_to_selected(self, template: BaseFieldTemplate) -> None:
185188
if entry_field is not None:
186189
self._edit_field(entry.id, entry_field)
187190

188-
self.layout().containers.update_from_entry(self._selected[0])
191+
self.layout().containers.update_from_entry(selected[0])
189192

190193
def _edit_field(self, entry_id: int, field: BaseField) -> None:
191194
# TODO: A lot of this code is similar to or straight up shared with TileList.
@@ -225,15 +228,14 @@ def _toggle_ffmpeg_warning(self, enable_warning: bool = True) -> None:
225228

226229
self.layout().warning_banner.hide()
227230

228-
def set_selection(self, selected: list[int], update_preview: bool = True) -> None:
229-
"""Render the panel widgets with the newest data from the Library.
231+
def update_from_selection(self, update_preview: bool = True) -> None:
232+
"""Update the panel widgets with the selected entries' newest data from the Library.
230233
231234
Args:
232-
selected (list[int]): List of the IDs of the selected entries.
233235
update_preview (bool): Should the file preview be updated?
234236
(Only works with one or more items selected)
235237
"""
236-
self._selected = selected
238+
selected = self._driver.selection.selected
237239
self._set_item_mode(None)
238240
try:
239241
# No Items Selected

‎src/tagstudio/qt/controllers/item_thumb.py‎

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -143,10 +143,11 @@ def _on_delete(self) -> None:
143143
self._driver.delete_files_callback(self._item_path, self.item_id)
144144

145145
def _toggle_item_selection(self) -> None:
146-
self._driver.toggle_item_selection(
146+
modifiers = QGuiApplication.keyboardModifiers()
147+
self._driver.selection.toggle_item_selection(
147148
self.item_id,
148-
append=(QGuiApplication.keyboardModifiers() == Qt.KeyboardModifier.ControlModifier),
149-
bridge=(QGuiApplication.keyboardModifiers() == Qt.KeyboardModifier.ShiftModifier),
149+
append=bool(modifiers & Qt.KeyboardModifier.ControlModifier),
150+
bridge=bool(modifiers & Qt.KeyboardModifier.ShiftModifier),
150151
)
151152

152153
def _image_rect(self) -> QRect:
@@ -343,7 +344,7 @@ def _show_check_badges(self, show: bool):
343344
badge.setHidden(not (show or badge.isChecked()))
344345

345346
def _on_badge_check(self, tag_id: int):
346-
selected = self._driver.selected
347+
selected = self._driver.selection.selected
347348
entry_ids = selected if self.item_id in selected else [self.item_id]
348349
is_checked = self.layout().badges[tag_id].isChecked()
349350
self._driver.entry_state.set_tag(entry_ids, tag_id, is_checked)
@@ -353,7 +354,7 @@ def _start_drag(self) -> None:
353354
paths: list[QUrl] = []
354355
mimedata = QMimeData()
355356

356-
selected_ids = self._driver.selected
357+
selected_ids = self._driver.selection.selected
357358

358359
for entry_id in selected_ids:
359360
entry = self._lib.get_entry(entry_id)
@@ -444,11 +445,11 @@ def mousePressEvent(self, event: QMouseEvent) -> None:
444445
return super().mousePressEvent(event)
445446

446447
self._press_pos = pos
447-
ctrl = QGuiApplication.keyboardModifiers() == Qt.KeyboardModifier.ControlModifier
448+
ctrl = bool(QGuiApplication.keyboardModifiers() & Qt.KeyboardModifier.ControlModifier)
448449
self._select_on_release = False
449450
if ctrl or not self._selected:
450451
self._toggle_item_selection()
451-
elif len(self._driver.selected) > 1:
452+
elif len(self._driver.selection.selected) > 1:
452453
# Wait for release so clicking a selected item can still drag the existing selection
453454
self._select_on_release = True
454455

@@ -478,6 +479,8 @@ def contextMenuEvent(self, event: QContextMenuEvent) -> None:
478479
if self._item_path is None or not self._image_rect().contains(event.pos()):
479480
return super().contextMenuEvent(event)
480481

482+
if not self._selected:
483+
self._driver.selection.toggle_item_selection(self.item_id, append=False, bridge=False)
481484
view = self.layout()
482485
QMenu.exec(
483486
[view.open_file_action, view.open_explorer_action, view.delete_action],

‎src/tagstudio/qt/controllers/main_window.py‎

Lines changed: 45 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,6 @@ class MainMenuBar(QMenuBar):
6868

6969
edit_menu: QMenu
7070
new_tag_action: QAction
71-
select_all_action: QAction
72-
select_inverse_action: QAction
73-
clear_select_action: QAction
7471
copy_fields_action: QAction
7572
paste_fields_action: QAction
7673
add_tag_to_selected_action: QAction
@@ -80,6 +77,11 @@ class MainMenuBar(QMenuBar):
8077
field_template_manager_action: QAction
8178
color_manager_action: QAction
8279

80+
select_menu: QMenu
81+
select_all_action: QAction
82+
clear_select_action: QAction
83+
select_inverse_action: QAction
84+
8385
view_menu: QMenu
8486
show_filenames_action: QAction
8587

@@ -101,6 +103,7 @@ def __init__(self, parent: QWidget | None = None):
101103

102104
self.setup_file_menu()
103105
self.setup_edit_menu()
106+
self.setup_select_menu()
104107
self.setup_view_menu()
105108
self.setup_tools_menu()
106109
self.setup_macros_menu()
@@ -193,39 +196,6 @@ def setup_edit_menu(self):
193196

194197
self.edit_menu.addSeparator()
195198

196-
# Select All
197-
self.select_all_action = QAction(Translations["select.all"], self)
198-
self.select_all_action.setShortcut(
199-
QtCore.QKeyCombination(
200-
QtCore.Qt.KeyboardModifier(QtCore.Qt.KeyboardModifier.ControlModifier),
201-
QtCore.Qt.Key.Key_A,
202-
)
203-
)
204-
self.select_all_action.setToolTip("Ctrl+A")
205-
self.select_all_action.setEnabled(False)
206-
self.edit_menu.addAction(self.select_all_action)
207-
208-
# Invert Selection
209-
self.select_inverse_action = QAction(Translations["select.inverse"], self)
210-
self.select_inverse_action.setShortcut(
211-
QtCore.QKeyCombination(
212-
QtCore.Qt.KeyboardModifier(
213-
QtCore.Qt.KeyboardModifier.ControlModifier
214-
^ QtCore.Qt.KeyboardModifier.ShiftModifier
215-
),
216-
QtCore.Qt.Key.Key_I,
217-
)
218-
)
219-
self.select_inverse_action.setToolTip("Ctrl+Shift+I")
220-
self.select_inverse_action.setEnabled(False)
221-
self.edit_menu.addAction(self.select_inverse_action)
222-
223-
# Clear Selection
224-
self.clear_select_action = QAction(Translations["select.clear"], self)
225-
self.clear_select_action.setEnabled(False)
226-
self.clear_select_action.setToolTip("Esc")
227-
self.edit_menu.addAction(self.clear_select_action)
228-
229199
# Copy Fields
230200
self.copy_fields_action = QAction(Translations["edit.copy_fields"], self)
231201
self.copy_fields_action.setShortcut(
@@ -310,6 +280,45 @@ def setup_edit_menu(self):
310280
assign_mnemonics(self.edit_menu)
311281
self.addMenu(self.edit_menu)
312282

283+
def setup_select_menu(self):
284+
self.select_menu = QMenu(Translations["menu.select"], self)
285+
286+
# Select All
287+
self.select_all_action = QAction(Translations["select.all"], self)
288+
self.select_all_action.setShortcut(
289+
QtCore.QKeyCombination(
290+
QtCore.Qt.KeyboardModifier(QtCore.Qt.KeyboardModifier.ControlModifier),
291+
QtCore.Qt.Key.Key_A,
292+
)
293+
)
294+
self.select_all_action.setToolTip("Ctrl+A")
295+
self.select_all_action.setEnabled(False)
296+
self.select_menu.addAction(self.select_all_action)
297+
298+
# Clear Selection
299+
self.clear_select_action = QAction(Translations["select.clear"], self)
300+
self.clear_select_action.setEnabled(False)
301+
self.clear_select_action.setToolTip("Esc")
302+
self.select_menu.addAction(self.clear_select_action)
303+
304+
# Invert Selection
305+
self.select_inverse_action = QAction(Translations["select.inverse"], self)
306+
self.select_inverse_action.setShortcut(
307+
QtCore.QKeyCombination(
308+
QtCore.Qt.KeyboardModifier(
309+
QtCore.Qt.KeyboardModifier.ControlModifier
310+
^ QtCore.Qt.KeyboardModifier.ShiftModifier
311+
),
312+
QtCore.Qt.Key.Key_I,
313+
)
314+
)
315+
self.select_inverse_action.setToolTip("Ctrl+Shift+I")
316+
self.select_inverse_action.setEnabled(False)
317+
self.select_menu.addAction(self.select_inverse_action)
318+
319+
assign_mnemonics(self.select_menu)
320+
self.addMenu(self.select_menu)
321+
313322
def setup_view_menu(self):
314323
self.view_menu = QMenu(Translations["menu.view"], self)
315324

0 commit comments

Comments
 (0)