From 0fbcfdb1eaa1566ab8d82e452df0a855e6a191d9 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Sun, 19 Jul 2026 13:14:06 -0700 Subject: [PATCH] feat(ui): add underline indicator for search bar items --- .../qt/controllers/field_suggest_box.py | 25 +++++++++++------ src/tagstudio/qt/controllers/suggest_box.py | 9 ++++-- .../qt/controllers/tag_suggest_box.py | 23 +++++++++------ .../qt/controllers/underlined_widget.py | 28 +++++++++++++++++++ .../qt/views/stylesheets/stylesheets.py | 7 +++++ src/tagstudio/qt/views/suggest_box_view.py | 4 ++- .../qt/views/underlined_widget_view.py | 27 ++++++++++++++++++ 7 files changed, 103 insertions(+), 20 deletions(-) create mode 100644 src/tagstudio/qt/controllers/underlined_widget.py create mode 100644 src/tagstudio/qt/views/underlined_widget_view.py diff --git a/src/tagstudio/qt/controllers/field_suggest_box.py b/src/tagstudio/qt/controllers/field_suggest_box.py index 9e756f6f..763501d8 100644 --- a/src/tagstudio/qt/controllers/field_suggest_box.py +++ b/src/tagstudio/qt/controllers/field_suggest_box.py @@ -15,6 +15,7 @@ from tagstudio.core.library.alchemy.library import Library from tagstudio.qt.controllers.edit_field_template_modal import EditFieldTemplateModal from tagstudio.qt.controllers.field_template_widget_controller import FieldTemplateWidget from tagstudio.qt.controllers.suggest_box import SuggestBox +from tagstudio.qt.controllers.underlined_widget import UnderlinedWidget from tagstudio.qt.translations import Translations from tagstudio.qt.views.panel_modal import PanelModal, PanelWidget from tagstudio.qt.views.suggest_box_view import SuggestBoxView @@ -97,14 +98,19 @@ class FieldSuggestBox(SuggestBox[BaseFieldTemplate]): @override def _set_item_widget(self, item: BaseFieldTemplate | None, index: int) -> None: """Set the field template of a field template widget at a specific index.""" - field_template_widget: FieldTemplateWidget = self._get_item_widget(index, self._lib) + underlined_widget: UnderlinedWidget = self._get_item_widget(index, self._lib) + field_template_widget = underlined_widget.widget + assert isinstance(field_template_widget, FieldTemplateWidget) field_template_widget.has_remove = False field_template_widget.set_field_template(item) - field_template_widget.setHidden(item is None) + underlined_widget.setHidden(item is None) if item is None: return + # TODO: Add tabbing to different items, and use underline to indicate which will be added + underlined_widget.toggle_underline(index != 0) + # Disconnect previous callbacks with catch_warnings(record=True): field_template_widget.on_edit.disconnect() @@ -139,15 +145,16 @@ class FieldSuggestBox(SuggestBox[BaseFieldTemplate]): self._update_items(self.layout().search_field.text()) @override - def _get_item_widget(self, index: int, library: Library | None) -> FieldTemplateWidget: + def _get_item_widget(self, index: int, library: Library | None) -> UnderlinedWidget: """Gets the item widget at a specific index.""" # Create any new item widgets needed up to the given index if self.layout().content_layout.count() <= index: while self.layout().content_layout.count() <= index: - pad_field_template_widget = FieldTemplateWidget() - pad_field_template_widget.setHidden(True) - self.layout().content_layout.addWidget(pad_field_template_widget) + field_template_widget = FieldTemplateWidget() + widget = UnderlinedWidget(field_template_widget) + widget.setHidden(True) + self.layout().content_layout.addWidget(widget) - field_template_widget: QWidget = self.layout().content_layout.itemAt(index).widget() - assert isinstance(field_template_widget, FieldTemplateWidget) - return field_template_widget + widget_: QWidget = self.layout().content_layout.itemAt(index).widget() + assert isinstance(widget_, UnderlinedWidget) + return widget_ diff --git a/src/tagstudio/qt/controllers/suggest_box.py b/src/tagstudio/qt/controllers/suggest_box.py index d4556fa2..95737d1a 100644 --- a/src/tagstudio/qt/controllers/suggest_box.py +++ b/src/tagstudio/qt/controllers/suggest_box.py @@ -10,6 +10,7 @@ from PySide6.QtWidgets import QGraphicsOpacityEffect, QWidget from tagstudio.core.library.alchemy.library import Library from tagstudio.qt.controllers.autofill_line_edit import QtCore, QtGui +from tagstudio.qt.controllers.underlined_widget import UnderlinedWidget from tagstudio.qt.views.panel_modal import PanelWidget from tagstudio.qt.views.stylesheets.stylesheets import ( autofill_line_edit_style, @@ -82,11 +83,15 @@ class SuggestBox[T](QWidget): opacity_effect = QGraphicsOpacityEffect(self) opacity_effect.setOpacity(0.3) if self.layout().content_layout.count() > 0: - self.layout().content_layout.itemAt(0).widget().setGraphicsEffect(opacity_effect) + underlined_widget = self.layout().content_layout.itemAt(0).widget() + assert isinstance(underlined_widget, UnderlinedWidget) + underlined_widget.widget.setGraphicsEffect(opacity_effect) else: self._is_shift_held = False if self.layout().content_layout.count() > 0: - self.layout().content_layout.itemAt(0).widget().setGraphicsEffect(None) # pyright: ignore[reportArgumentType] + underlined_widget = self.layout().content_layout.itemAt(0).widget() + assert isinstance(underlined_widget, UnderlinedWidget) + underlined_widget.widget.setGraphicsEffect(None) # pyright: ignore[reportArgumentType] def _clear_search_query(self) -> None: self.layout().search_field.setText("") diff --git a/src/tagstudio/qt/controllers/tag_suggest_box.py b/src/tagstudio/qt/controllers/tag_suggest_box.py index b59ae856..e9a92731 100644 --- a/src/tagstudio/qt/controllers/tag_suggest_box.py +++ b/src/tagstudio/qt/controllers/tag_suggest_box.py @@ -14,6 +14,7 @@ from tagstudio.core.library.alchemy.enums import BrowsingState from tagstudio.core.library.alchemy.library import Library from tagstudio.core.library.alchemy.models import Tag from tagstudio.qt.controllers.suggest_box import SuggestBox +from tagstudio.qt.controllers.underlined_widget import UnderlinedWidget from tagstudio.qt.mixed.build_tag import BuildTagPanel from tagstudio.qt.mixed.tag_widget import TagWidget from tagstudio.qt.translations import Translations @@ -109,10 +110,12 @@ class TagSuggestBox(SuggestBox[Tag]): @override def _set_item_widget(self, item: Tag | None, index: int) -> None: """Set the tag of a tag widget at a specific index.""" - tag_widget: TagWidget = self._get_item_widget(index, self._lib) + underlined_widget: UnderlinedWidget = self._get_item_widget(index, self._lib) + tag_widget = underlined_widget.widget + assert isinstance(tag_widget, TagWidget) tag_widget.has_remove = False tag_widget.set_tag(item) - tag_widget.setHidden(item is None) + underlined_widget.setHidden(item is None) opacity_effect = QGraphicsOpacityEffect(self) opacity_effect.setOpacity(0.3) if item and item.id in self.added: @@ -123,6 +126,9 @@ class TagSuggestBox(SuggestBox[Tag]): if item is None: return + # TODO: Add tabbing to different items, and use underline to indicate which will be added + underlined_widget.toggle_underline(index != 0) + # Disconnect previous callbacks with catch_warnings(record=True): tag_widget.on_edit.disconnect() @@ -165,16 +171,17 @@ class TagSuggestBox(SuggestBox[Tag]): self._update_items(self.layout().search_field.text()) @override - def _get_item_widget(self, index: int, library: Library | None) -> TagWidget: + def _get_item_widget(self, index: int, library: Library | None) -> UnderlinedWidget: """Gets the item widget at a specific index.""" # Create any new item widgets needed up to the given index if self.layout().content_layout.count() <= index: while self.layout().content_layout.count() <= index: tag_widget = TagWidget(tag=None, has_edit=True, has_remove=True, library=library) tag_widget.on_remove.connect(self._update_items) - tag_widget.setHidden(True) - self.layout().content_layout.addWidget(tag_widget) + widget = UnderlinedWidget(tag_widget) + widget.setHidden(True) + self.layout().content_layout.addWidget(widget) - tag_widget: QWidget = self.layout().content_layout.itemAt(index).widget() - assert isinstance(tag_widget, TagWidget) - return tag_widget + widget_: QWidget = self.layout().content_layout.itemAt(index).widget() + assert isinstance(widget_, UnderlinedWidget) + return widget_ diff --git a/src/tagstudio/qt/controllers/underlined_widget.py b/src/tagstudio/qt/controllers/underlined_widget.py new file mode 100644 index 00000000..0a7938dd --- /dev/null +++ b/src/tagstudio/qt/controllers/underlined_widget.py @@ -0,0 +1,28 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + + +from typing import override + +from PySide6.QtWidgets import QWidget + +from tagstudio.qt.views.underlined_widget_view import UnderlinedWidgetView + + +class UnderlinedWidget(QWidget): + def __init__(self, widget: QWidget) -> None: + super().__init__() + view = UnderlinedWidgetView(widget) + self.setLayout(view) + + def toggle_underline(self, is_hidden: bool) -> None: + self.layout().underline.setHidden(is_hidden) + + @property + def widget(self) -> QWidget: + return self.layout().itemAt(0).widget() + + @override + def layout(self) -> UnderlinedWidgetView: + """Return the typed layout for this widget.""" + return super().layout() # pyright: ignore[reportReturnType] diff --git a/src/tagstudio/qt/views/stylesheets/stylesheets.py b/src/tagstudio/qt/views/stylesheets/stylesheets.py index ef496f6e..07b80f54 100644 --- a/src/tagstudio/qt/views/stylesheets/stylesheets.py +++ b/src/tagstudio/qt/views/stylesheets/stylesheets.py @@ -410,6 +410,13 @@ def tag_remove_button_style( """ +def widget_underline_style() -> str: + return f""" + background: {Palette.accent()}; + border-radius: 2px; + """ + + def title_line_edit_style() -> str: """Used to mimic an H3-like header style inside a QLineEdit.""" return """ diff --git a/src/tagstudio/qt/views/suggest_box_view.py b/src/tagstudio/qt/views/suggest_box_view.py index b6d1e3d0..6cd85b32 100644 --- a/src/tagstudio/qt/views/suggest_box_view.py +++ b/src/tagstudio/qt/views/suggest_box_view.py @@ -59,7 +59,9 @@ class SuggestBoxView(QVBoxLayout): self.scroll_area.setStyleSheet(scroll_area_style) scroll_area_container_layout.addWidget(self.scroll_area) self.scroll_area.setWidget(contents) - self.scroll_area.setMaximumHeight(28) + search_bar_height = 28 + underline_padding = 7 + self.scroll_area.setMaximumHeight(search_bar_height + underline_padding) self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) self.scroll_area.verticalScrollBar().setEnabled(False) self.scroll_area.setContentsMargins(0, 0, 0, 0) diff --git a/src/tagstudio/qt/views/underlined_widget_view.py b/src/tagstudio/qt/views/underlined_widget_view.py new file mode 100644 index 00000000..029da759 --- /dev/null +++ b/src/tagstudio/qt/views/underlined_widget_view.py @@ -0,0 +1,27 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QPushButton, QVBoxLayout, QWidget + +from tagstudio.qt.views.stylesheets.stylesheets import widget_underline_style + + +class UnderlinedWidgetView(QVBoxLayout): + def __init__(self, widget: QWidget) -> None: + super().__init__() + self.setContentsMargins(0, 0, 0, 0) + self.setSpacing(3) + self.setAlignment(Qt.AlignmentFlag.AlignTop) + + # HACK: I don't know why I can't just use a QFrame for the outline. + # The styling and sizing only seems to work if it's something like a QPushButton. + self.underline = QPushButton() + self.underline.setFlat(True) + self.underline.setDisabled(True) + self.underline.setMaximumHeight(4) + self.underline.setStyleSheet(widget_underline_style()) + + self.addWidget(widget) + self.addWidget(self.underline)