From 507823bfcd19f3f164ca7d6893f9296e02bb6afc Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:44:36 -0700 Subject: [PATCH] feat(ui): use tab and shift+tab to navigate suggest boxes (#1474) * fix(ui): correct suggest box content padding * feat(ui): use tab and shift+tab to navigate suggest boxes * fix: strip suggest box queries of whitespace * fix(ui): fix shift opacity not targeting correct widget * chore: cleanup comments and variable usage --- .../qt/controllers/autofill_line_edit.py | 41 ++++++---- src/tagstudio/qt/controllers/suggest_box.py | 75 ++++++++++++++----- src/tagstudio/qt/views/suggest_box_view.py | 9 +-- 3 files changed, 87 insertions(+), 38 deletions(-) diff --git a/src/tagstudio/qt/controllers/autofill_line_edit.py b/src/tagstudio/qt/controllers/autofill_line_edit.py index 2247dafd..e0c32228 100644 --- a/src/tagstudio/qt/controllers/autofill_line_edit.py +++ b/src/tagstudio/qt/controllers/autofill_line_edit.py @@ -22,7 +22,8 @@ logger = structlog.get_logger(__name__) class AutofillLineEdit(QLineEdit): return_pressed = Signal() shift_return_pressed = Signal() - shift_holding = Signal(bool) + holding_shift = Signal(bool) + index_updated = Signal(int) def __init__(self, popup: QWidget) -> None: super().__init__() @@ -39,23 +40,35 @@ class AutofillLineEdit(QLineEdit): return super().focusInEvent(arg__1) @override - def keyPressEvent(self, arg__1: QtGui.QKeyEvent) -> None: - if arg__1.key() == QtCore.Qt.Key.Key_Shift: - self.shift_holding.emit(True) # noqa: FBT003 + def event(self, arg__1: QtCore.QEvent) -> bool: + if arg__1.type() == QtCore.QEvent.Type.KeyPress: + assert isinstance(arg__1, QtGui.QKeyEvent) - if arg__1.key() == QtCore.Qt.Key.Key_Escape: - self.setText("") - self.clearFocus() - elif arg__1.key() == QtCore.Qt.Key.Key_Enter or arg__1.key() == QtCore.Qt.Key.Key_Return: - if arg__1.modifiers() and QtCore.Qt.KeyboardModifier.ShiftModifier: - self.shift_return_pressed.emit() - else: - self.return_pressed.emit() + if arg__1.key() == QtCore.Qt.Key.Key_Tab: + self.index_updated.emit(1) + return True + elif arg__1.key() == QtCore.Qt.Key.Key_Backtab: + self.index_updated.emit(-1) + return True - return super().keyPressEvent(arg__1) + if arg__1.key() == QtCore.Qt.Key.Key_Shift: + self.holding_shift.emit(True) # noqa: FBT003 + + if arg__1.key() == QtCore.Qt.Key.Key_Escape: + self.setText("") + self.clearFocus() + elif ( + arg__1.key() == QtCore.Qt.Key.Key_Enter or arg__1.key() == QtCore.Qt.Key.Key_Return + ): + if arg__1.modifiers() == QtCore.Qt.KeyboardModifier.ShiftModifier: + self.shift_return_pressed.emit() + else: + self.return_pressed.emit() + + return super().event(arg__1) @override def keyReleaseEvent(self, arg__1: QtGui.QKeyEvent) -> None: if arg__1.key() == QtCore.Qt.Key.Key_Shift: - self.shift_holding.emit(False) # noqa: FBT003 + self.holding_shift.emit(False) # noqa: FBT003 return super().keyReleaseEvent(arg__1) diff --git a/src/tagstudio/qt/controllers/suggest_box.py b/src/tagstudio/qt/controllers/suggest_box.py index a496f501..a0cdf67a 100644 --- a/src/tagstudio/qt/controllers/suggest_box.py +++ b/src/tagstudio/qt/controllers/suggest_box.py @@ -1,6 +1,7 @@ # SPDX-FileCopyrightText: (c) TagStudio Contributors # SPDX-License-Identifier: GPL-3.0-only +from functools import partial from typing import Any, override import structlog @@ -51,9 +52,10 @@ class SuggestBox[T](QWidget): super().__init__() self._lib = library self._settings = settings - self._limit = 5 + self._limit = 25 self._is_shift_held = False self._search_results: list[T] = [] + self._selection_index = 0 self.added: list[int] = [] self.excluded: list[int] = [] @@ -93,24 +95,58 @@ class SuggestBox[T](QWidget): ) ) - self.layout().search_field.shift_holding.connect(lambda held: self._on_shift_held(held)) + self.layout().search_field.holding_shift.connect(partial(self._on_shift_held)) + self.layout().search_field.index_updated.connect(partial(self._on_index_updated)) def _on_shift_held(self, held: bool) -> None: - if held: - self._is_shift_held = True - opacity_effect = QGraphicsOpacityEffect(self) - opacity_effect.setOpacity(0.3) - if self.layout().content_layout.count() > 0: - underlined_widget = self.layout().content_layout.itemAt(0).widget() - assert isinstance(underlined_widget, UnderlinedWidget) + for i in range(0, self.layout().content_layout.count()): + underlined_widget = self.layout().content_layout.itemAt(i).widget() + assert isinstance(underlined_widget, UnderlinedWidget) + + if held and i == self._selection_index: + self._is_shift_held = True + opacity_effect = QGraphicsOpacityEffect(self) + opacity_effect.setOpacity(0.3) underlined_widget.widget.setGraphicsEffect(opacity_effect) - else: - self._is_shift_held = False - if self.layout().content_layout.count() > 0: - underlined_widget = self.layout().content_layout.itemAt(0).widget() - assert isinstance(underlined_widget, UnderlinedWidget) + else: + self._is_shift_held = False underlined_widget.widget.setGraphicsEffect(None) # pyright: ignore[reportArgumentType] + def _on_index_updated(self, delta: int) -> None: + # Initialize the widget count (non-hidden) + widget_count = 0 + for i in range(0, self.layout().content_layout.count()): + widget = self.layout().content_layout.itemAt(i).widget() + if not widget.isHidden(): + widget_count += 1 + + # Update the index + old_idx = self._selection_index + max_idx = widget_count - 1 + if self._selection_index + delta < 0: + # Can't move further left + self._selection_index = 0 + elif self._selection_index + delta > max_idx: + self._selection_index = max_idx + else: + self._selection_index = self._selection_index + delta + + # Don't update the UI if there's no index change + if old_idx == self._selection_index: + return + + # Draw the correct underline for the selected widget + for i in range(0, widget_count): + underlined_widget = self.layout().content_layout.itemAt(i).widget() + assert isinstance(underlined_widget, UnderlinedWidget) + if i == self._selection_index: + underlined_widget.toggle_underline(is_hidden=False) + self.layout().scroll_area.ensureWidgetVisible( + underlined_widget, xmargin=6, ymargin=0 + ) + else: + underlined_widget.toggle_underline(is_hidden=True) + def _clear_search_query(self) -> None: self.layout().search_field.setText("") @@ -118,7 +154,7 @@ class SuggestBox[T](QWidget): raise NotImplementedError() def _on_search_query_changed(self, query: str) -> None: - self._update_items(query) + self._update_items(query.strip()) def _on_search_query_submitted(self, query: str, always_create: bool = False) -> None: # Focus search field if no query @@ -132,7 +168,7 @@ class SuggestBox[T](QWidget): if (len(self._search_results) <= 0) or always_create: self._on_item_create() else: - self._on_item_chosen(self._search_results[0]) + self._on_item_chosen(self._search_results[self._selection_index]) self._clear_search_query() self._update_items() @@ -152,6 +188,11 @@ class SuggestBox[T](QWidget): def _update_items(self, query: str | None = None) -> None: """Update the item list given a search query.""" logger.info("[SearchPanel] Updating items", limit=self._limit) + self._selection_index = 0 + if self.layout().content_layout.count() > 0: + self.layout().scroll_area.ensureWidgetVisible( + self.layout().content_layout.itemAt(0).widget(), xmargin=6, ymargin=0 + ) # Get results for the search query query_lower = "" if not query else query.lower() @@ -199,7 +240,7 @@ class SuggestBox[T](QWidget): self.layout().search_field.setStyleSheet(autofill_line_edit_style()) else: self.layout().scroll_area.setHidden(False) - self.layout().content_layout.setContentsMargins(6, 6, 6, 6) + self.layout().content_layout.setContentsMargins(4, 6, 4, 6) self.layout().search_field.setStyleSheet(autofill_line_edit_top_style()) def _search_items(self, query: str) -> tuple[list[T], list[T]]: # pyright: ignore[reportUnusedParameter] diff --git a/src/tagstudio/qt/views/suggest_box_view.py b/src/tagstudio/qt/views/suggest_box_view.py index 2f84bbb5..da5dcd66 100644 --- a/src/tagstudio/qt/views/suggest_box_view.py +++ b/src/tagstudio/qt/views/suggest_box_view.py @@ -22,15 +22,9 @@ class SuggestBoxView(QVBoxLayout): self.setContentsMargins(0, 0, 0, 0) self.setSpacing(0) - # HACK: The transparent border allows for the focus border color to - # still show above the tags at the edges... sort of (overlaps on left when h-scrolling) scroll_area_style = """ QScrollArea{ background: transparent; - border: solid; - border-color: transparent; - border-width: 0px 2px; - padding-left: -2px; } QScrollArea > QWidget > QWidget{ background: transparent; @@ -51,6 +45,7 @@ class SuggestBoxView(QVBoxLayout): scroll_area_container.setStyleSheet(autofill_scroll_top_style("container")) self.scroll_area = HorizontalScrollArea() self.scroll_area.setStyleSheet(scroll_area_style) + self.scroll_area.setViewportMargins(2, 0, 2, 0) scroll_area_container_layout.addWidget(self.scroll_area) self.scroll_area.setWidget(contents) search_bar_height = 28 @@ -69,7 +64,7 @@ class SuggestBoxView(QVBoxLayout): self.search_field = AutofillLineEdit(scroll_area_container) self.search_field.setStyleSheet(autofill_line_edit_style()) self.search_field.setObjectName("search_field") - self.search_field.setMinimumHeight(28) + self.search_field.setMinimumHeight(search_bar_height) self.search_field.setPlaceholderText(placeholder_text) self.scroll_area.setFocusProxy(self.search_field) self.search_field.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)