From fdc01b7491724fe0e9b6fb14b18a690c600020d4 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Fri, 17 Jul 2026 17:06:12 -0700 Subject: [PATCH 01/23] feat(ui): replace add tag modal with autocomplete search/create bar --- docs/usage.md | 2 +- .../qt/controllers/autofill_line_edit.py | 64 ++++ .../controllers/preview_panel_controller.py | 88 ++++-- src/tagstudio/qt/controllers/return_button.py | 27 ++ .../qt/controllers/suggest_box_controller.py | 250 ++++++++++++++++ .../controllers/tag_suggest_box_controller.py | 273 ++++++++++++++++++ src/tagstudio/qt/mixed/field_containers.py | 23 +- src/tagstudio/qt/mixed/tag_widget.py | 14 +- src/tagstudio/qt/models/palette.py | 22 ++ src/tagstudio/qt/ts_qt.py | 25 +- src/tagstudio/qt/views/main_window.py | 10 +- src/tagstudio/qt/views/preview_panel_view.py | 117 ++++---- .../qt/views/stylesheets/stylesheets.py | 170 ++++++++++- src/tagstudio/qt/views/suggest_box_view.py | 90 ++++++ src/tagstudio/qt/views/tag_box_view.py | 5 - .../qt/views/tag_search_panel_view.py | 3 + .../qt/views/tag_suggest_box_view.py | 16 + src/tagstudio/resources/translations/en.json | 5 +- 18 files changed, 1075 insertions(+), 129 deletions(-) create mode 100644 src/tagstudio/qt/controllers/autofill_line_edit.py create mode 100644 src/tagstudio/qt/controllers/return_button.py create mode 100644 src/tagstudio/qt/controllers/suggest_box_controller.py create mode 100644 src/tagstudio/qt/controllers/tag_suggest_box_controller.py create mode 100644 src/tagstudio/qt/views/suggest_box_view.py create mode 100644 src/tagstudio/qt/views/tag_suggest_box_view.py diff --git a/docs/usage.md b/docs/usage.md index 2901e1074..cc8f81cba 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -40,7 +40,7 @@ Hover over the field and click the pencil icon. From there, add or edit text in ## Creating Tags -Create a new tag by accessing the "New Tag" option from the Edit menu or by pressing Ctrl+T. In the tag creation panel, enter a tag name, optional shorthand name, optional tag aliases, optional parent tags, and an optional color. +Create a new tag by accessing the "New Tag" option from the Edit menu or by pressing Ctrl+N. In the tag creation panel, enter a tag name, optional shorthand name, optional tag aliases, optional parent tags, and an optional color. - The tag **name** is the base name of the tag. **_This does NOT have to be unique!_** - The tag **shorthand** is a special type of alias that displays in situations where screen space is more valuable, notably with name disambiguation. diff --git a/src/tagstudio/qt/controllers/autofill_line_edit.py b/src/tagstudio/qt/controllers/autofill_line_edit.py new file mode 100644 index 000000000..099e41574 --- /dev/null +++ b/src/tagstudio/qt/controllers/autofill_line_edit.py @@ -0,0 +1,64 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + +from typing import TYPE_CHECKING, override + +import structlog +from PySide6 import QtCore, QtGui +from PySide6.QtCore import Signal +from PySide6.QtWidgets import ( + QLineEdit, + QWidget, +) + +from tagstudio.qt.views.stylesheets.stylesheets import ( + autofill_scroll_top_focus_style, + autofill_scroll_top_style, +) + +if TYPE_CHECKING: + pass + +logger = structlog.get_logger(__name__) + + +class AutofillLineEdit(QLineEdit): + return_pressed = Signal() + shift_return_pressed = Signal() + shift_holding = Signal(bool) + + def __init__(self, popup: QWidget) -> None: + super().__init__() + self._popup = popup + + @override + def focusOutEvent(self, arg__1: QtGui.QFocusEvent) -> None: + self._popup.setStyleSheet(autofill_scroll_top_style("container")) + return super().focusOutEvent(arg__1) + + @override + def focusInEvent(self, arg__1: QtGui.QFocusEvent) -> None: + self._popup.setStyleSheet(autofill_scroll_top_focus_style("container")) + 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 + + 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() + + return super().keyPressEvent(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 + return super().keyReleaseEvent(arg__1) diff --git a/src/tagstudio/qt/controllers/preview_panel_controller.py b/src/tagstudio/qt/controllers/preview_panel_controller.py index 5b868bec4..6d236c216 100644 --- a/src/tagstudio/qt/controllers/preview_panel_controller.py +++ b/src/tagstudio/qt/controllers/preview_panel_controller.py @@ -3,49 +3,99 @@ import typing +from pathlib import Path +from typing import override from warnings import catch_warnings +import structlog +from PySide6 import QtCore +from PySide6.QtGui import QShortcut + from tagstudio.core.library.alchemy.fields import BaseFieldTemplate from tagstudio.core.library.alchemy.library import Library from tagstudio.core.utils.ffmpeg_status import FfmpegStatus, FfprobeStatus -from tagstudio.qt.controllers.field_template_search_panel_controller import FieldTemplateSearchModal -from tagstudio.qt.controllers.tag_search_panel_controller import TagSearchModal -from tagstudio.qt.translations import Translations +from tagstudio.qt.mixed.file_attributes import FileAttributeData from tagstudio.qt.views.preview_panel_view import PreviewPanelView if typing.TYPE_CHECKING: from tagstudio.qt.ts_qt import QtDriver +logger = structlog.get_logger(__name__) + class PreviewPanel(PreviewPanelView): def __init__(self, library: Library, driver: "QtDriver") -> None: super().__init__(library, driver) - self.__add_field_modal = FieldTemplateSearchModal(self.lib, is_field_template_chooser=True) - self.__add_tag_modal = TagSearchModal( - self.lib, title=Translations["tag.add.plural"], is_tag_chooser=True - ) - self.__add_tag_modal.tsp.set_driver(driver) + self.__current_stats: FileAttributeData | None = None self._thumb.check_ffmpeg.connect(self._toggle_ffmpeg_warning) - @typing.override + key = QtCore.QKeyCombination( + QtCore.Qt.KeyboardModifier(QtCore.Qt.KeyboardModifier.ControlModifier), + QtCore.Qt.Key.Key_T, + ) + self.add_tag_action = QShortcut(key, self) + + self.__connect_callbacks() + + def __connect_callbacks(self) -> None: + self._add_field_button.clicked.connect(self._add_field_button_callback) + self._add_tag_button.clicked.connect(self._add_tag_button_callback) + self._thumb.stats_updated.connect(self.__thumb_stats_updated_callback) + self.add_tag_action.activated.connect(self._add_tag_button.setFocus) + self.add_tag_action.activated.connect(self._add_tag_button.click) + + self.tag_search.done.connect(self.tag_added_callback) + self.tag_search.tags_updated.connect(self.update_added_callback) + def _add_field_button_callback(self) -> None: - self.__add_field_modal.show() + # self.__add_field_modal.show() + pass - @typing.override def _add_tag_button_callback(self) -> None: - self.__add_tag_modal.show() + self.tag_search.added = self._containers.tags + self.tag_search.view.search_field.setDisabled(False) + self.tag_search.setHidden(False) + self._add_tag_button.setHidden(True) + self._add_field_button.setHidden(True) + + def tag_added_callback(self): + self.tag_search.setHidden(True) + self._add_tag_button.setHidden(False) + self._add_field_button.setHidden(False) + + self._add_tag_button.setFocus() - @typing.override + def update_added_callback(self): + self.tag_search.added = self._containers.tags + + def __thumb_stats_updated_callback(self, filepath: Path, stats: FileAttributeData) -> None: + if len(self._selected) != 1: + return + + if filepath != self._thumb.current_file: + return + + if self.__current_stats is None: + self.__current_stats = FileAttributeData() + + if stats.width is not None: + self.__current_stats.width = stats.width + if stats.height is not None: + self.__current_stats.height = stats.height + if stats.duration is not None: + self.__current_stats.duration = stats.duration + + self._file_attrs.update_stats(filepath, self.__current_stats) + + @override def _set_selection_callback(self) -> None: with catch_warnings(record=True): - self.__add_field_modal.search_panel.field_template_chosen.disconnect() - self.__add_tag_modal.tsp.item_chosen.disconnect() + self.field_search.field_template_chosen.disconnect() + self.tag_search.item_chosen.disconnect() - self.__add_field_modal.search_panel.field_template_chosen.connect( - self._add_field_to_selected - ) - self.__add_tag_modal.tsp.item_chosen.connect(self._add_tag_to_selected) + self.field_search.field_template_chosen.connect(self._add_field_to_selected) + self.tag_search.item_chosen.connect(self._add_tag_to_selected) def _add_field_to_selected(self, template: BaseFieldTemplate) -> None: self._containers.add_field_to_selected(template) diff --git a/src/tagstudio/qt/controllers/return_button.py b/src/tagstudio/qt/controllers/return_button.py new file mode 100644 index 000000000..1a13d9c1e --- /dev/null +++ b/src/tagstudio/qt/controllers/return_button.py @@ -0,0 +1,27 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + + +import typing +from typing import override + +import structlog +from PySide6 import QtCore, QtGui +from PySide6.QtWidgets import QPushButton + +if typing.TYPE_CHECKING: + pass + +logger = structlog.get_logger(__name__) + + +class ReturnButton(QPushButton): + def __init__(self, *args, **kwargs) -> None: # pyright: ignore + super().__init__(*args, **kwargs) + + @override + def keyPressEvent(self, arg__1: QtGui.QKeyEvent) -> None: + if self.hasFocus() and arg__1.key() in {QtCore.Qt.Key.Key_Enter, QtCore.Qt.Key.Key_Return}: + self.click() + + super().keyPressEvent(arg__1) diff --git a/src/tagstudio/qt/controllers/suggest_box_controller.py b/src/tagstudio/qt/controllers/suggest_box_controller.py new file mode 100644 index 000000000..da948988b --- /dev/null +++ b/src/tagstudio/qt/controllers/suggest_box_controller.py @@ -0,0 +1,250 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + +from typing import TYPE_CHECKING, Any, override + +import structlog +from PySide6.QtCore import Signal +from PySide6.QtGui import QShowEvent +from PySide6.QtWidgets import QGraphicsOpacityEffect, QVBoxLayout + +from tagstudio.core.library.alchemy.library import Library +from tagstudio.qt.views.panel_modal import PanelWidget +from tagstudio.qt.views.stylesheets.stylesheets import ( + autofill_line_edit_style, + autofill_line_edit_top_style, +) +from tagstudio.qt.views.suggest_box_view import SuggestBoxView + +logger = structlog.get_logger(__name__) + +# Only import for type checking/autocompletion, will not be imported at runtime. +if TYPE_CHECKING: + from tagstudio.qt.ts_qt import QtDriver + + +def _item_id(item: object) -> int: + item_id: Any = getattr(item, "id") # noqa: B009 # pyright: ignore[reportExplicitAny] + + if isinstance(item_id, int): + return item_id + else: + raise AttributeError() + + +def _item_name(item: object) -> str: + item_name: Any = getattr(item, "name") # noqa: B009 # pyright: ignore[reportExplicitAny] + + if isinstance(item_name, str): + return item_name + else: + raise AttributeError() + + +class SuggestBox[T](PanelWidget): + item_chosen = Signal(int) + done = Signal() + tags_updated = Signal() + + def __init__( + self, view: SuggestBoxView, exclude: list[int] | None = None, is_chooser: bool = True + ) -> None: + super().__init__() + self.view = view + self.is_chooser = is_chooser + self._layout = QVBoxLayout(self) + self._layout.setContentsMargins(0, 0, 0, 0) + self._layout.addWidget(self.view) + self._driver: QtDriver | None = None + self.exclude: list[int] = exclude or [] + self.added: list[int] = exclude or [] + self.create_and_add_button_in_layout: bool = False + self.limit = 5 + self.shift_held = False + + # Limits + self._unlimited_limit_item_label: str = "All Items" + self._limit_items: list[tuple[str, int]] = [ + ("25", 25), + ("50", 50), + ("100", 100), + ("250", 250), + ("500", 500), + (self._unlimited_limit_item_label, -1), + ] + self._default_limit_index: int = 0 + self._previous_limit_index: int = self._default_limit_index + + # Items + self._search_results: list[T] = [] + + self._create_and_add_button_label_key: str = "" + self.connect_callbacks() + + def connect_callbacks(self) -> None: + self.view.search_field.textChanged.connect(self.on_search_query_changed) + self.view.search_field.editingFinished.connect(self.test_editing_finished) + self.view.search_field.return_pressed.connect( + lambda: self.on_search_query_submitted(self.view.search_field.text()) + ) + self.view.search_field.shift_return_pressed.connect( + lambda: self.on_search_query_submitted( + self.view.search_field.text(), always_create=True + ) + ) + + self.view.search_field.shift_holding.connect(lambda held: self.on_shift_held(held)) + + def on_shift_held(self, held: bool): + if held: + self.shift_held = True + opacity_effect = QGraphicsOpacityEffect(self) + opacity_effect.setOpacity(0.3) + if self.view.content_layout.count() > 0: + self.view.content_layout.itemAt(0).widget().setGraphicsEffect(opacity_effect) + else: + self.shift_held = False + if self.view.content_layout.count() > 0: + self.view.content_layout.itemAt(0).widget().setGraphicsEffect(None) # pyright: ignore[reportArgumentType] + + def focus_search_box(self, select_all: bool = False) -> None: + if not self.isHidden(): + self.view.search_field.setFocus() + if select_all: + self.view.search_field.selectAll() + + def clear_search_query(self) -> None: + self.view.search_field.setText("") + + def get_item_widget(self, index: int, library: Library) -> Any: # pyright: ignore[reportExplicitAny] + return self.get_item_widget(index, library) + + def set_driver(self, driver: "QtDriver") -> None: + self._driver = driver + + def _get_previous_limit(self) -> tuple[str, int]: + return self._limit_items[self._previous_limit_index] + + def _get_max_limit(self) -> int: + raise NotImplementedError() + + def on_search_query_changed(self, query: str) -> None: + self.update_items(query) + + def on_search_query_submitted(self, query: str, always_create: bool = False) -> None: + # Focus search field if no query + logger.info("Query submitted") + if not query: + self.done.emit() + self.disappear() + return + elif not self.isHidden(): + self.view.search_field.setFocus() + + # Create and add item if no search results + if (len(self._search_results) <= 0) or always_create: + self.on_item_create(add_to_entry=True) + elif self.is_chooser: + self._on_item_chosen(self._search_results[0]) + + self.clear_search_query() + self.update_items() + + def on_item_create(self, add_to_entry: bool = False) -> None: # pyright: ignore[reportUnusedParameter] + raise NotImplementedError() + + def on_item_edit(self, item: T) -> None: # pyright: ignore[reportUnusedParameter] + raise NotImplementedError() + + def _on_item_remove(self, item: T) -> None: # pyright: ignore[reportUnusedParameter] + raise NotImplementedError() + + def _on_item_chosen(self, item: T) -> None: # pyright: ignore[reportUnusedParameter] + raise NotImplementedError() + + def _is_excluded(self, item: T) -> bool: + return _item_id(item) in self.exclude + + 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) + + # Get results for the search query + query_lower = "" if not query else query.lower() + search_results: tuple[list[T], list[T]] = self.search_items(query_lower) + + # Sort and prioritize the results + direct_results = list({item for item in search_results[0] if not self._is_excluded(item)}) + direct_results.sort(key=lambda item: _item_name(item).lower()) + + ancestor_results = list({item for item in search_results[1] if not self._is_excluded(item)}) + ancestor_results.sort(key=lambda item: _item_name(item).lower()) + + raw_results = list(direct_results + ancestor_results) + priority_results: set[T] = set() + + if query and query.strip(): + for raw_item in raw_results: + if _item_name(raw_item).lower().startswith(query_lower): + priority_results.add(raw_item) + + all_results: list[T] = sorted(list(priority_results), key=lambda i: len(_item_name(i))) + [ + item for item in raw_results if item not in priority_results + ] + + # Target items already added to a selection and move them to the end of the list + already_added: list[T] = [i for i in all_results if _item_id(i) in self.added] + for item in already_added: + if item in all_results: + all_results.remove(item) + all_results = all_results + already_added + + if self.limit > 0: + all_results = all_results[: self.limit] + + self._search_results = all_results + logger.info("[SearchPanel] Search results", results=self._search_results) + + for i in range(0, self.limit): + item: T | None = all_results[i] if i < len(all_results) else None + self.set_item_widget(item=item, index=i) + + if self.view.content_layout.isEmpty(): + self.view.scroll_area.setHidden(True) + self.view.content_layout.setContentsMargins(0, 0, 0, 0) + self.view.search_field.setStyleSheet(autofill_line_edit_style()) + else: + self.view.scroll_area.setHidden(False) + self.view.content_layout.setContentsMargins(6, 6, 6, 6) + self.view.search_field.setStyleSheet(autofill_line_edit_top_style()) + + def search_items(self, query: str) -> tuple[list[T], list[T]]: # pyright: ignore[reportUnusedParameter] + raise NotImplementedError() + + def set_item_widget(self, item: T | None, index: int) -> None: # pyright: ignore[reportUnusedParameter] + raise NotImplementedError() + + @override + def showEvent(self, event: QShowEvent) -> None: + self.update_items() + self.on_shift_held(held=False) + self.clear_search_query() + return super().showEvent(event) + + def test_editing_finished(self): + logger.info("Editing finished") + self.tags_updated.emit() + if self.view.search_field.text() == "": + self.done.emit() + self.disappear() + + def disappear(self): + self.hide() + self.view.search_field.setDisabled(True) + self.on_shift_held(held=False) + + def create_item(self, edit_item_panel: PanelWidget, choose_item: bool = False) -> None: # pyright: ignore[reportUnusedParameter] + raise NotImplementedError() + + def edit_item(self, edit_item_panel: PanelWidget) -> None: # pyright: ignore[reportUnusedParameter] + raise NotImplementedError() diff --git a/src/tagstudio/qt/controllers/tag_suggest_box_controller.py b/src/tagstudio/qt/controllers/tag_suggest_box_controller.py new file mode 100644 index 000000000..687e1708b --- /dev/null +++ b/src/tagstudio/qt/controllers/tag_suggest_box_controller.py @@ -0,0 +1,273 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + + +from typing import override +from warnings import catch_warnings + +import structlog +from PySide6.QtWidgets import QGraphicsOpacityEffect, QMessageBox, QSizePolicy, QWidget + +from tagstudio.core.constants import RESERVED_TAG_END, RESERVED_TAG_START +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_controller import SuggestBox +from tagstudio.qt.mixed.tag_widget import TagWidget +from tagstudio.qt.translations import Translations +from tagstudio.qt.views.panel_modal import PanelModal, PanelWidget +from tagstudio.qt.views.tag_suggest_box_view import TagSuggestBoxView + +logger = structlog.get_logger(__name__) + + +class TagSuggestBox(SuggestBox[Tag]): + def __init__( + self, + library: Library, + exclude: list[int] | None = None, + is_tag_chooser: bool = True, + view: TagSuggestBoxView | None = None, + ): + super().__init__( + view=view or TagSuggestBoxView(is_tag_chooser), + exclude=exclude, + is_chooser=is_tag_chooser, + ) + self.__lib = library + + self._unlimited_limit_item_label = Translations["tag.all_tags"] + self._create_and_add_button_label_key = "tag.create_add" + + @override + def _get_max_limit(self) -> int: + return len(self.__lib.tags) + + @override + def on_item_create(self, add_to_entry: bool = False) -> None: + """Opens panel to create a new tag and optionally add it to an entry. + + Populates name field using current search query. + + Args: + add_to_entry (bool): Should this item be added to currently selected entries? + """ + # TODO: Move this to a top-level import + + query: str = self.view.search_field.text() + + # panel: BuildTagPanel = BuildTagPanel(self.__lib) + # modal: PanelModal = PanelModal( + # panel, + # Translations["tag.new"], + # Translations["tag.add"] if add_to_entry else Translations["tag.new"], + # is_savable=True, + # ) + + # if query.strip(): + # panel.name_field.setText(query) + + # modal.saved.connect(lambda: self.create_item(panel, choose_item=add_to_entry)) + # modal.show() + tag = Tag(name=query) + self.__lib.add_tag(tag) + if add_to_entry: + self._on_item_chosen(tag) + self.clear_search_query() + + @override + def on_item_edit(self, item: Tag) -> None: + # TODO: Move this to a top-level import + from tagstudio.qt.mixed.build_tag import BuildTagPanel # here due to circular imports + + edit_tag_panel: BuildTagPanel = BuildTagPanel(self.__lib, tag=item) + edit_tag_modal: PanelModal = PanelModal( + edit_tag_panel, + self.__lib.tag_display_name(item), + Translations["tag.edit"], + is_savable=True, + ) + edit_tag_modal.saved.connect(lambda: self.edit_item(edit_tag_panel)) + edit_tag_modal.show() + + @override + def _on_item_remove(self, item: Tag) -> None: + if self.is_chooser: + return + + if item.id in range(RESERVED_TAG_START, RESERVED_TAG_END): + return + + message_box = QMessageBox( + QMessageBox.Icon.Question, + Translations["tag.remove"], + Translations.format("tag.confirm_delete", tag_name=self.__lib.tag_display_name(item)), + QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel, + ) + + result = message_box.exec() + + if result != QMessageBox.StandardButton.Ok: + return + + self.__lib.remove_tag(item.id) + self.update_items(self.view.search_field.text()) + + @override + def _on_item_chosen(self, item: Tag) -> None: + self.item_chosen.emit(item.id) + self.done.emit() + + @override + def search_items(self, query: str) -> tuple[list[Tag], list[Tag]]: + if query != "": + # return self.__lib.search_tags(name=query, limit=self._get_limit()[1]) + return self.__lib.search_tags(name=query, limit=0) + else: + return ([], []) + + @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) + tag_widget.set_tag(item) + tag_widget.setHidden(item is None) + if item and item.id in self.added: + opacity_effect = QGraphicsOpacityEffect(self) + opacity_effect.setOpacity(0.3) + tag_widget.setGraphicsEffect(opacity_effect) + else: + tag_widget.setGraphicsEffect(None) # pyright: ignore[reportArgumentType] + + if item is None: + return + assert item is not None + + tag_widget.has_remove = not self.is_chooser and item.id not in range( + RESERVED_TAG_START, RESERVED_TAG_END + ) + + # Disconnect previous callbacks + with catch_warnings(record=True): + tag_widget.on_edit.disconnect() + tag_widget.on_remove.disconnect() + tag_widget.bg_button.clicked.disconnect() + tag_widget.search_for_tag_action.triggered.disconnect() + + # Connect callbacks + tag_widget.on_edit.connect(lambda edit_tag=item: self.on_item_edit(edit_tag)) + tag_widget.on_remove.connect(lambda remove_tag=item: self._on_item_remove(remove_tag)) + if self.is_chooser: + tag_widget.bg_button.clicked.connect( + lambda checked=False, tag=item: self._on_item_chosen(tag) + ) + else: + tag_widget.bg_button.clicked.connect( + lambda checked=False, edit_tag=item: self.on_item_edit(edit_tag) + ) + + # Connect search action + if self._driver is not None: + tag_widget.search_for_tag_action.triggered.connect( + lambda checked=False, tag_id=item.id: self.search_for_tag(tag_id) + ) + tag_widget.search_for_tag_action.setEnabled(True) + else: + logger.warning( + "[TagSearchPanel] No driver was set for this TagSearchPanel. Was this on purpose?" + ) + tag_widget.search_for_tag_action.setEnabled(False) + + @override + def create_item(self, edit_item_panel: PanelWidget, choose_item: bool = False) -> None: + # TODO: Move this to a top-level import + from tagstudio.qt.mixed.build_tag import BuildTagPanel # here due to circular imports + + if isinstance(edit_item_panel, BuildTagPanel): + tag: Tag = edit_item_panel.build_tag() + self.__lib.add_tag( + tag, parent_ids=edit_item_panel.parent_ids, aliases=edit_item_panel.aliases + ) + + if choose_item: + self._on_item_chosen(tag) + self.clear_search_query() + + edit_item_panel.hide() + self.on_search_query_changed(self.view.search_field.text()) + + @override + def edit_item(self, edit_item_panel: PanelWidget) -> None: + # TODO: Move this to a top-level import + from tagstudio.qt.mixed.build_tag import BuildTagPanel # here due to circular imports + + if not isinstance(edit_item_panel, BuildTagPanel): + return + + self.__lib.update_tag( + tag=edit_item_panel.build_tag(), + parent_ids=edit_item_panel.parent_ids, + aliases=edit_item_panel.aliases, + ) + self.update_items(self.view.search_field.text()) + + def search_for_tag(self, tag_id: int) -> None: + if self._driver is None: + return + + self._driver.main_window.search_field.setText(f"tag_id:{tag_id}") + self._driver.update_browsing_state( + BrowsingState.from_tag_id(tag_id, self._driver.browsing_history.current) + ) + + @override + def get_item_widget(self, index: int, library: Library | None) -> TagWidget: + """Gets the item widget at a specific index.""" + # Create any new item widgets needed up to the given index + if self.view.content_layout.count() <= index: + # opacity_effect = QGraphicsOpacityEffect(self) + # opacity_effect.setOpacity(0.3) + while self.view.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.bg_button.setSizePolicy( + QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum + ) + tag_widget.setHidden(True) + # if index > 0: + # tag_widget.setGraphicsEffect(opacity_effect) + self.view.content_layout.addWidget(tag_widget) + + tag_widget: QWidget = self.view.content_layout.itemAt(index).widget() + assert isinstance(tag_widget, TagWidget) + return tag_widget + + # @override + # def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: + # # When Escape is pressed, focus back on the search box. + # # If focus is already on the search box, close the modal. + # pass + # # if event.key() in {QtCore.Qt.Key.Key_Escape, QtCore.Qt.Key.Key_Backspace, }: + # # if self.search_field.hasFocus(): + + # # self.hide() + + # @override + # def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: + # # When Escape is pressed, focus back on the search box. + # # If focus is already on the search box, close the modal. + # # if event.key() == QtCore.Qt.Key.Key_Escape: + # # if self.search_field.hasFocus(): + # # self.hide() + # logger.info(event.key) + # if event.key() in { + # QtCore.Qt.Key.Key_Escape, + # QtCore.Qt.Key.Key_Enter, + # QtCore.Qt.Key.Key_Return, + # }: + # if self.search_field.hasFocus(): + # logger.info("Hiding") + # self.hide() + # elif event.key() in {QtCore.Qt.Key.Key_Backspace, QtCore.Qt.Key.Key_Delete}: + # if self.search_field.hasFocus() and self.search_field.text() == "": + # # self.hide() diff --git a/src/tagstudio/qt/mixed/field_containers.py b/src/tagstudio/qt/mixed/field_containers.py index 6a7d5555d..31a9ca646 100644 --- a/src/tagstudio/qt/mixed/field_containers.py +++ b/src/tagstudio/qt/mixed/field_containers.py @@ -10,7 +10,6 @@ import structlog from PySide6.QtCore import Qt -from PySide6.QtGui import QGuiApplication from PySide6.QtWidgets import ( QFrame, QHBoxLayout, @@ -21,7 +20,6 @@ QWidget, ) -from tagstudio.core.enums import Theme from tagstudio.core.library.alchemy.fields import ( BaseField, BaseFieldTemplate, @@ -38,6 +36,7 @@ from tagstudio.qt.mixed.text_field import TextContainerWidget from tagstudio.qt.translations import FIELD_TYPE_KEYS, Translations from tagstudio.qt.views.panel_modal import PanelModal +from tagstudio.qt.views.stylesheets.stylesheets import inset_container_style if typing.TYPE_CHECKING: from tagstudio.qt.ts_qt import QtDriver @@ -60,12 +59,6 @@ def __init__(self, library: Library, driver: "QtDriver") -> None: self.cached_entries: list[Entry] = [] self.containers: list[FieldContainer] = [] - self.panel_bg_color = ( - Theme.COLOR_BG_DARK.value - if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark - else Theme.COLOR_BG_LIGHT.value - ) - self.scroll_layout = QVBoxLayout() self.scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop) self.scroll_layout.setContentsMargins(3, 3, 3, 3) @@ -92,9 +85,7 @@ def __init__(self, library: Library, driver: "QtDriver") -> None: # background and NOT the scroll container background, so that the # rounded corners are maintained when scrolling. I was unable to # find the right trick to only select that particular element. - self.scroll_area.setStyleSheet( - f"QWidget#entryScrollContainer{{background:{self.panel_bg_color};border-radius:6px;}}" - ) + self.scroll_area.setStyleSheet(inset_container_style("entryScrollContainer")) self.scroll_area.setWidget(scroll_container) root_layout = QHBoxLayout(self) @@ -480,3 +471,13 @@ def remove_message_box(self, prompt: str, callback: Callable[..., None]) -> None result = remove_mb.exec_() if result == QMessageBox.ButtonRole.ActionRole.value: callback() + + @property + def tags(self) -> list[int]: + if len(self.cached_entries) <= 0: + return [] + entry = self.cached_entries[0] + entry_ = self.lib.get_entry_full(entry.id, with_fields=False) + if not entry_: + return [] + return [tag.id for tag in entry_.tags] diff --git a/src/tagstudio/qt/mixed/tag_widget.py b/src/tagstudio/qt/mixed/tag_widget.py index cf114f100..67112cb8a 100644 --- a/src/tagstudio/qt/mixed/tag_widget.py +++ b/src/tagstudio/qt/mixed/tag_widget.py @@ -8,7 +8,7 @@ import structlog from PySide6.QtCore import QEvent, Qt, Signal from PySide6.QtGui import QAction, QColor, QEnterEvent, QFontMetrics -from PySide6.QtWidgets import QHBoxLayout, QLineEdit, QPushButton, QVBoxLayout, QWidget +from PySide6.QtWidgets import QHBoxLayout, QLineEdit, QPushButton, QSizePolicy, QVBoxLayout, QWidget from tagstudio.core.library.alchemy.enums import TagColorEnum from tagstudio.core.library.alchemy.models import Tag @@ -121,6 +121,7 @@ def __init__( # if on_click_callback: self.setCursor(Qt.CursorShape.PointingHandCursor) self.base_layout = QVBoxLayout(self) + self.base_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) self.base_layout.setObjectName("baseLayout") self.base_layout.setContentsMargins(0, 0, 0, 0) @@ -148,23 +149,24 @@ def __init__( self.inner_layout = QHBoxLayout() self.inner_layout.setObjectName("innerLayout") self.inner_layout.setContentsMargins(0, 0, 0, 0) + self.inner_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) self._delete_button = QPushButton(self) self._delete_button.setFlat(True) self._delete_button.setText("–") self._delete_button.setHidden(True) - self._delete_button.setMinimumSize(22, 22) - self._delete_button.setMaximumSize(22, 22) + self._delete_button.setFixedSize(22, 22) self._delete_button.clicked.connect(self.on_remove.emit) self._delete_button.setHidden(True) self.inner_layout.addWidget(self._delete_button) - self.inner_layout.addStretch(1) self.bg_button.setLayout(self.inner_layout) self.bg_button.setMinimumSize(44, 22) - self.bg_button.setMinimumHeight(22) - self.bg_button.setMaximumHeight(22) + self.bg_button.setFixedHeight(22) + + self.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum) + self.bg_button.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum) self.base_layout.addWidget(self.bg_button) diff --git a/src/tagstudio/qt/models/palette.py b/src/tagstudio/qt/models/palette.py index 120dab723..006b6bcf0 100644 --- a/src/tagstudio/qt/models/palette.py +++ b/src/tagstudio/qt/models/palette.py @@ -7,12 +7,34 @@ from typing import Any import structlog +from PySide6.QtGui import QPalette from tagstudio.core.library.alchemy.enums import TagColorEnum +from tagstudio.core.utils.singleton import Singleton logger = structlog.get_logger(__name__) +class Palette(metaclass=Singleton): + _palette: QPalette | None = None + _accent: str | None = None + + @staticmethod + def set_palette(palette: QPalette) -> None: + Palette._palette = palette + + @staticmethod + def accent() -> str: + if not Palette._palette: + logger.error("[Style] No QPalette set!") + return get_ui_color(ColorType.PRIMARY, UiColor.BLUE) + if not Palette._accent: + Palette._accent = ( + f"rgba{QPalette.color(Palette._palette, QPalette.ColorRole.Accent).toTuple()}" + ) + return Palette._accent + + class ColorType(IntEnum): PRIMARY = 0 TEXT = 1 diff --git a/src/tagstudio/qt/ts_qt.py b/src/tagstudio/qt/ts_qt.py index f18ff1acb..ac555f924 100644 --- a/src/tagstudio/qt/ts_qt.py +++ b/src/tagstudio/qt/ts_qt.py @@ -51,8 +51,6 @@ from tagstudio.core.media_types import MediaCategories from tagstudio.core.query_lang.util import ParsingError from tagstudio.core.ts_core import TagStudioCore - -# This import has side-effect of importing PySide resources from tagstudio.core.utils.ffmpeg_status import FfmpegStatus, FfprobeStatus from tagstudio.core.utils.module_status import ModuleStatus from tagstudio.core.utils.ripgrep_status import RipgrepStatus @@ -77,7 +75,7 @@ from tagstudio.qt.mixed.progress_bar import ProgressWidget from tagstudio.qt.mixed.settings_panel import SettingsPanel from tagstudio.qt.mixed.tag_color_manager import TagColorManager -from tagstudio.qt.models.palette import ColorType, UiColor, get_ui_color +from tagstudio.qt.models.palette import ColorType, Palette, UiColor, get_ui_color from tagstudio.qt.platform_strings import trash_term from tagstudio.qt.resource_manager import ResourceManager from tagstudio.qt.translations import Translations @@ -274,7 +272,7 @@ def open_library_from_dialog(self): dir = QFileDialog.getExistingDirectory( parent=None, caption=Translations["window.title.open_create_library"], - dir="/", + dir=str(Path.home()), options=QFileDialog.Option.ShowDirsOnly, ) if dir not in (None, ""): @@ -303,19 +301,28 @@ def start(self) -> None: elif self.settings.theme == Theme.LIGHT: self.app.styleHints().setColorScheme(Qt.ColorScheme.Light) + pal: QPalette = self.app.palette() + # BUG: Changing the palette in any way here seems to affect the accent colors of certain + # widgets, like QLineEdit focused borders and QComboBox highlighted items and borders. + # Need to figure out the cause of this. if ( platform.system() == "Darwin" or platform.system() == "Windows" ) and QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark: - pal: QPalette = self.app.palette() pal.setColor(QPalette.ColorGroup.Normal, QPalette.ColorRole.Window, QColor("#1e1e1e")) pal.setColor(QPalette.ColorGroup.Normal, QPalette.ColorRole.Button, QColor("#1e1e1e")) + pal.setColor( + QPalette.ColorGroup.Inactive, QPalette.ColorRole.ToolTipBase, QColor("#1e1e1e") + ) + pal.setColor( + QPalette.ColorGroup.Inactive, QPalette.ColorRole.ToolTipText, QColor("#FFFFFF") + ) pal.setColor(QPalette.ColorGroup.Inactive, QPalette.ColorRole.Window, QColor("#232323")) pal.setColor(QPalette.ColorGroup.Inactive, QPalette.ColorRole.Button, QColor("#232323")) pal.setColor( QPalette.ColorGroup.Inactive, QPalette.ColorRole.ButtonText, QColor("#666666") ) - - self.app.setPalette(pal) + Palette.set_palette(pal) + self.app.setPalette(pal) # Handle OS signals self.setup_signals() @@ -627,8 +634,10 @@ def on_visible_changed(entry_id: int | None): if path_result.success and path_result.library_path: self.open_library(path_result.library_path) - self.check_for_update() + self.main_window.search_field.setFocus() + self.app.exec() + self.check_for_update() self.shutdown() def show_error_message(self, error_name: str, error_desc: str | None = None): diff --git a/src/tagstudio/qt/views/main_window.py b/src/tagstudio/qt/views/main_window.py index 322fed8c3..5607268b8 100644 --- a/src/tagstudio/qt/views/main_window.py +++ b/src/tagstudio/qt/views/main_window.py @@ -182,10 +182,10 @@ def setup_edit_menu(self): self.new_tag_action.setShortcut( QtCore.QKeyCombination( QtCore.Qt.KeyboardModifier(QtCore.Qt.KeyboardModifier.ControlModifier), - QtCore.Qt.Key.Key_T, + QtCore.Qt.Key.Key_N, ) ) - self.new_tag_action.setToolTip("Ctrl+T") + self.new_tag_action.setToolTip("Ctrl+N") self.new_tag_action.setEnabled(False) self.edit_menu.addAction(self.new_tag_action) @@ -220,8 +220,8 @@ def setup_edit_menu(self): # Clear Selection self.clear_select_action = QAction(Translations["select.clear"], self) - self.clear_select_action.setShortcut(QtCore.Qt.Key.Key_Escape) - self.clear_select_action.setToolTip("Esc") + # self.clear_select_action.setShortcut(QtCore.Qt.Key.Key_Escape) + # self.clear_select_action.setToolTip("Esc") self.clear_select_action.setEnabled(False) self.edit_menu.addAction(self.clear_select_action) @@ -704,6 +704,8 @@ def setup_preview_panel(self, driver: "QtDriver"): self.content_splitter.addWidget(self.preview_panel) def setup_status_bar(self): + # BUG: Clicking the status bar does not count as losing focus on other widgets + # (for example, the "Add Tag" line edit). Can this be fixed? self.status_bar = QStatusBar(self) self.status_bar.setObjectName("status_bar") status_bar_size_policy = QSizePolicy( diff --git a/src/tagstudio/qt/views/preview_panel_view.py b/src/tagstudio/qt/views/preview_panel_view.py index 7ebc8c56e..d0f2292fb 100644 --- a/src/tagstudio/qt/views/preview_panel_view.py +++ b/src/tagstudio/qt/views/preview_panel_view.py @@ -10,25 +10,23 @@ import structlog from PySide6.QtCore import Qt from PySide6.QtGui import QDesktopServices -from PySide6.QtWidgets import ( - QHBoxLayout, - QLabel, - QPushButton, - QSplitter, - QVBoxLayout, - QWidget, -) +from PySide6.QtWidgets import QHBoxLayout, QLabel, QSplitter, QVBoxLayout, QWidget from tagstudio.core.constants import FFMPEG_HELP_URL from tagstudio.core.library.alchemy.library import Library from tagstudio.core.library.alchemy.models import Entry from tagstudio.core.utils.types import unwrap +from tagstudio.qt.controllers.field_template_search_panel_controller import FieldTemplateSearchPanel from tagstudio.qt.controllers.preview_thumb_controller import PreviewThumb +from tagstudio.qt.controllers.return_button import ReturnButton +from tagstudio.qt.controllers.tag_suggest_box_controller import TagSuggestBox from tagstudio.qt.mixed.field_containers import FieldContainers from tagstudio.qt.mixed.file_attributes import FileAttributeData, FileAttributes from tagstudio.qt.resource_manager import ResourceManager from tagstudio.qt.translations import Translations +from tagstudio.qt.views.field_template_search_panel_view import FieldTemplateSearchPanelView from tagstudio.qt.views.stylesheets.stylesheets import button_style, preview_warning_style +from tagstudio.qt.views.tag_suggest_box_view import TagSuggestBoxView if typing.TYPE_CHECKING: from tagstudio.qt.ts_qt import QtDriver @@ -38,7 +36,6 @@ class PreviewPanelView(QWidget): lib: Library - _selected: list[int] def __init__(self, library: Library, driver: "QtDriver") -> None: @@ -46,18 +43,32 @@ def __init__(self, library: Library, driver: "QtDriver") -> None: self.lib = library rm = ResourceManager() + self.field_search: FieldTemplateSearchPanel = FieldTemplateSearchPanel( + library, + is_field_template_chooser=True, + view=FieldTemplateSearchPanelView(is_field_template_chooser=True), + ) + self.tag_search = TagSuggestBox( + library, + is_tag_chooser=True, + view=TagSuggestBoxView(is_tag_chooser=True), + ) + self.tag_search.set_driver(driver) + self.tag_search.hide() + self._thumb = PreviewThumb(self.lib, driver) self._file_attrs = FileAttributes(self.lib, driver) self._containers = FieldContainers( self.lib, driver ) # TODO: this should be name mangled, but is still needed on the controller side atm - self.__current_stats: FileAttributeData | None = None + # Visual Preview preview_section = QWidget() preview_layout = QVBoxLayout(preview_section) preview_layout.setContentsMargins(0, 0, 0, 0) preview_layout.setSpacing(6) + # Warning Banner (Missing FFmpeg, etc.) self._ffmpeg_warning_widget = QWidget() self._ffmpeg_warning_widget.setObjectName("ffmpeg_widget") ffmpeg_warning_layout = QHBoxLayout(self._ffmpeg_warning_widget) @@ -82,9 +93,9 @@ def __init__(self, library: Library, driver: "QtDriver") -> None: ffmpeg_warning_layout.addWidget(warning_icon) ffmpeg_warning_layout.addWidget(ffmpeg_warning_label) ffmpeg_warning_layout.setStretch(1, 1) - self._ffmpeg_warning_widget.hide() + # File Information info_section = QWidget() info_layout = QVBoxLayout(info_section) info_layout.setContentsMargins(0, 0, 0, 0) @@ -99,20 +110,22 @@ def __init__(self, library: Library, driver: "QtDriver") -> None: add_buttons_layout.setContentsMargins(0, 0, 0, 0) add_buttons_layout.setSpacing(6) - self.__add_tag_button = QPushButton(Translations["tag.add"]) - self.__add_tag_button.setEnabled(False) - self.__add_tag_button.setCursor(Qt.CursorShape.PointingHandCursor) - self.__add_tag_button.setMinimumHeight(28) - self.__add_tag_button.setStyleSheet(button_style()) + self._add_tag_button = ReturnButton(Translations["tag.add"]) + self._add_tag_button.setEnabled(False) + self._add_tag_button.setCursor(Qt.CursorShape.PointingHandCursor) + self._add_tag_button.setMinimumHeight(30) + self._add_tag_button.setStyleSheet(button_style()) - self.__add_field_button = QPushButton(Translations["field.add"]) - self.__add_field_button.setEnabled(False) - self.__add_field_button.setCursor(Qt.CursorShape.PointingHandCursor) - self.__add_field_button.setMinimumHeight(28) - self.__add_field_button.setStyleSheet(button_style()) + self._add_field_button = ReturnButton(Translations["field.add"]) + self._add_field_button.setEnabled(False) + self._add_field_button.setCursor(Qt.CursorShape.PointingHandCursor) + self._add_field_button.setMinimumHeight(30) + self._add_field_button.setStyleSheet(button_style()) - add_buttons_layout.addWidget(self.__add_tag_button) - add_buttons_layout.addWidget(self.__add_field_button) + add_buttons_layout.addWidget(self._add_tag_button) + add_buttons_layout.addWidget(self._add_field_button) + add_buttons_layout.addWidget(self.tag_search) + # add_buttons_layout.addWidget(self.field_search) preview_layout.addWidget(self._thumb) info_layout.addWidget(self._ffmpeg_warning_widget) @@ -128,38 +141,6 @@ def __init__(self, library: Library, driver: "QtDriver") -> None: root_layout.addWidget(splitter) root_layout.addWidget(add_buttons_container) - self.__connect_callbacks() - - def __connect_callbacks(self) -> None: - self.__add_field_button.clicked.connect(self._add_field_button_callback) - self.__add_tag_button.clicked.connect(self._add_tag_button_callback) - self._thumb.stats_updated.connect(self.__thumb_stats_updated_callback) - - def _add_field_button_callback(self) -> None: - raise NotImplementedError() - - def _add_tag_button_callback(self) -> None: - raise NotImplementedError() - - def __thumb_stats_updated_callback(self, filepath: Path, stats: FileAttributeData) -> None: - if len(self._selected) != 1: - return - - if filepath != self._thumb.current_file: - return - - if self.__current_stats is None: - self.__current_stats = FileAttributeData() - - if stats.width is not None: - self.__current_stats.width = stats.width - if stats.height is not None: - self.__current_stats.height = stats.height - if stats.duration is not None: - self.__current_stats.duration = stats.duration - - self._file_attrs.update_stats(filepath, self.__current_stats) - def _set_selection_callback(self) -> None: raise NotImplementedError() @@ -181,7 +162,11 @@ def set_selection(self, selected: list[int], update_preview: bool = True) -> Non self._file_attrs.update_date_label() self._containers.hide_containers() - self.add_buttons_enabled = False + self._add_tag_button.setEnabled(False) + self._add_field_button.setEnabled(False) + self._add_tag_button.setHidden(False) + self._add_field_button.setHidden(False) + self.tag_search.disappear() # One Item Selected elif len(selected) == 1: @@ -201,7 +186,11 @@ def set_selection(self, selected: list[int], update_preview: bool = True) -> Non self._set_selection_callback() - self.add_buttons_enabled = True + self._add_tag_button.setEnabled(True) + self._add_field_button.setEnabled(True) + self._add_tag_button.setHidden(False) + self._add_field_button.setHidden(False) + self.tag_search.disappear() # Multiple Selected Items elif len(selected) > 1: @@ -214,7 +203,11 @@ def set_selection(self, selected: list[int], update_preview: bool = True) -> Non self._set_selection_callback() - self.add_buttons_enabled = True + self._add_tag_button.setEnabled(True) + self._add_field_button.setEnabled(True) + self._add_tag_button.setHidden(False) + self._add_field_button.setHidden(False) + self.tag_search.disappear() except Exception as e: logger.error("[Preview Panel] Error updating selection", error=e) @@ -222,15 +215,15 @@ def set_selection(self, selected: list[int], update_preview: bool = True) -> Non @property def add_buttons_enabled(self) -> bool: # needed for the tests - field = self.__add_field_button.isEnabled() - tag = self.__add_tag_button.isEnabled() + field = self._add_field_button.isEnabled() + tag = self._add_tag_button.isEnabled() assert field == tag return field @add_buttons_enabled.setter def add_buttons_enabled(self, enabled: bool) -> None: - self.__add_field_button.setEnabled(enabled) - self.__add_tag_button.setEnabled(enabled) + self._add_field_button.setEnabled(enabled) + self._add_tag_button.setEnabled(enabled) @property def _file_attributes_widget(self) -> FileAttributes: # needed for the tests diff --git a/src/tagstudio/qt/views/stylesheets/stylesheets.py b/src/tagstudio/qt/views/stylesheets/stylesheets.py index 6a9e722bf..ef496f6e3 100644 --- a/src/tagstudio/qt/views/stylesheets/stylesheets.py +++ b/src/tagstudio/qt/views/stylesheets/stylesheets.py @@ -8,7 +8,7 @@ from tagstudio.core.enums import Theme from tagstudio.core.library.alchemy.enums import TagColorEnum from tagstudio.core.library.alchemy.models import Tag -from tagstudio.qt.models.palette import ColorType, UiColor, get_tag_color, get_ui_color +from tagstudio.qt.models.palette import ColorType, Palette, UiColor, get_tag_color, get_ui_color # TODO: There's plenty of good opportunities here to consolidate similar styles. # Work should be done to more closely use Qt's theming systems rather than override them. @@ -53,18 +53,29 @@ def button_style() -> str: border-radius: 6px; font-weight: 500; text-align: center; + padding: 0px 12px; }} QPushButton::hover{{ background-color: {Theme.COLOR_HOVER.value}; - border-color: {get_ui_color(ColorType.BORDER, UiColor.THEME_DARK)}; border-style: solid; border-width: 2px; + border-color: {get_ui_color(ColorType.BORDER, UiColor.THEME_DARK)}; + padding: 0px 8px; }} QPushButton::pressed{{ - background-color: {Theme.COLOR_PRESSED.value}; - border-color: {get_ui_color(ColorType.LIGHT_ACCENT, UiColor.THEME_DARK)}; + outline: none; + background-color: palette(light); border-style: solid; border-width: 2px; + border-color: {get_ui_color(ColorType.BORDER, UiColor.THEME_DARK)}; + padding: 0px 8px; + }} + QPushButton::focus{{ + outline: none; + border: solid; + border-width: 2px; + border-color: {Palette.accent()}; + padding: 0px 8px; }} QPushButton::disabled{{ background-color: {Theme.COLOR_DISABLED_BG.value}; @@ -72,6 +83,40 @@ def button_style() -> str: """ +def line_edit_style_main() -> str: + """Style used for common QLineEdits.""" + bg_color = ( + Theme.COLOR_BG_DARK.value + if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark + else Theme.COLOR_BG_LIGHT.value + ) + + return f""" + QLineEdit{{ + background: {bg_color}; + border-radius: 6px; + font-weight: 500; + text-align: center; + padding: 0px 4px; + }} + QLineEdit::hover{{ + border-style: solid; + border-width: 2px; + border-color: {get_ui_color(ColorType.BORDER, UiColor.THEME_DARK)}; + padding: 0px 2px; + }} + QLineEdit::focus{{ + border-style: solid; + border-width: 2px; + border-color: {Palette.accent()}; + padding: 0px 2px; + }} + QLineEdit::disabled{{ + background-color: {Theme.COLOR_DISABLED_BG.value}; + }} +""" + + def checkbox_style() -> str: """Style used for QCheckBoxes.""" primary_color = QColor(get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT)) @@ -229,6 +274,7 @@ def line_edit_style() -> str: def list_button_style( color: QColor | None = None, border_style: str = "solid", + italic: bool = False, ) -> str: """Style used for special QPushButtons found in lists.""" if color is None: @@ -243,6 +289,7 @@ def list_button_style( background: rgba{color.toTuple()}; color: rgba{text_color.toTuple()}; font-weight: 600; + {"font: italic;" if italic else ""} border-color: rgba{border_color.toTuple()}; border-radius: 6px; border-style: {border_style}; @@ -310,9 +357,9 @@ def tag_style( border-radius: 6px; border-style: {border_style}; border-width: 2px; + font-size: 13px; padding-right: 4px; padding-left: 4px; - font-size: 13px }} QPushButton::hover{{ border-color: rgba{highlight_color.toTuple()}; @@ -323,12 +370,9 @@ def tag_style( border-color: rgba{primary_color.toTuple()}; }} QPushButton::focus{{ - padding-right: 0px; - padding-left: 0px; - outline-style: solid; - outline-width: 1px; - outline-radius: 4px; - outline-color: rgba{text_color.toTuple()}; + outline: none; + border-width: 3px; + border-color: rgba{text_color.toTuple()}; }} """ @@ -374,6 +418,110 @@ def title_line_edit_style() -> str: """ +def inset_container_style(object_name: str = "") -> str: + """Used for darkened inset areas.""" + bg_color = ( + Theme.COLOR_BG_DARK.value + if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark + else Theme.COLOR_BG_LIGHT.value + ) + + return f""" + QWidget{"#" + object_name if object_name else ""}{{ + background: {bg_color}; + border-radius: 6px; + }} + """ + + +# TODO: Combine the autofill styles into one method? +def autofill_scroll_top_style(object_name: str = "") -> str: + """Used autofill lists positioned on top of line edits.""" + bg_color = ( + Theme.COLOR_BG_DARK.value + if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark + else Theme.COLOR_BG_LIGHT.value + ) + + return f""" + QWidget{"#" + object_name if object_name else ""}{{ + background: {bg_color}; + border-top-left-radius: 6px; + border-top-right-radius: 6px; + border: none; + }} + """ + + +def autofill_scroll_top_focus_style(object_name: str = "") -> str: + """Used autofill lists positioned on top of line edits.""" + bg_color = ( + Theme.COLOR_BG_DARK.value + if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark + else Theme.COLOR_BG_LIGHT.value + ) + + return f""" + QWidget{"#" + object_name if object_name else ""}{{ + background: {bg_color}; + border-top-left-radius: 6px; + border-top-right-radius: 6px; + border: solid; + border-width: 2px 2px 0px 2px; + border-color: {Palette.accent()}; + }} + """ + + +def autofill_line_edit_style() -> str: + """Used for QLineEdits.""" + bg_color = ( + Theme.COLOR_BG_DARK.value + if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark + else Theme.COLOR_BG_LIGHT.value + ) + + return f""" + QLineEdit{{ + background: {bg_color}; + border-radius: 6px; + padding: 3px 6px; + }} + QLineEdit::focus{{ + padding: 4px 4px; + border: solid; + border-width: 2px; + border-color: {Palette.accent()}; + }} + """ + + +def autofill_line_edit_top_style() -> str: + """Used for QLineEdits when there's a top autofill section present.""" + bg_color = ( + Theme.COLOR_BG_DARK.value + if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark + else Theme.COLOR_BG_LIGHT.value + ) + + return f""" + QLineEdit{{ + background: {bg_color}; + border-top-left-radius: 0px; + border-top-right-radius: 0px; + border-bottom-left-radius: 6px; + border-bottom-right-radius: 6px; + padding: 0px 0px 2px 6px; + }} + QLineEdit::focus{{ + padding: 4px 4px; + border: solid; + border-width: 0px 2px 2px 2px; + border-color: {Palette.accent()}; + }} + """ + + def preview_warning_style() -> str: return f""" QWidget#ffmpeg_widget {{ diff --git a/src/tagstudio/qt/views/suggest_box_view.py b/src/tagstudio/qt/views/suggest_box_view.py new file mode 100644 index 000000000..9357b5805 --- /dev/null +++ b/src/tagstudio/qt/views/suggest_box_view.py @@ -0,0 +1,90 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + +from typing import TYPE_CHECKING + +import structlog +from PySide6.QtCore import Qt +from PySide6.QtWidgets import ( + QFrame, + QHBoxLayout, + QScrollArea, + QSizePolicy, + QVBoxLayout, + QWidget, +) + +from tagstudio.qt.controllers.autofill_line_edit import AutofillLineEdit +from tagstudio.qt.views.stylesheets.stylesheets import ( + autofill_line_edit_style, + autofill_scroll_top_style, +) + +if TYPE_CHECKING: + pass + +logger = structlog.get_logger(__name__) + + +class SuggestBoxView(QWidget): + def __init__(self, is_chooser: bool) -> None: + self.is_chooser: bool = is_chooser + super().__init__() + + self._root_layout = QVBoxLayout(self) + self._root_layout.setContentsMargins(0, 0, 0, 0) + self._root_layout.setSpacing(0) + + # Scroll area + self.contents = QWidget() + + self.content_layout = QHBoxLayout(self.contents) + self.content_layout.setSpacing(6) + self.content_layout.setAlignment(Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignLeft) + self.content_layout.setContentsMargins(0, 0, 0, 0) + + self.scroll_area_container = QWidget() + self.scroll_area_container.setObjectName("container") + self.scroll_area_container_layout = QHBoxLayout(self.scroll_area_container) + self.scroll_area_container_layout.setContentsMargins(0, 0, 0, 0) + self.scroll_area_container_layout.setSpacing(0) + self.scroll_area_container.setStyleSheet(autofill_scroll_top_style("container")) + + # Search field + self.search_field = AutofillLineEdit(self.scroll_area_container) + self.search_field.setStyleSheet(autofill_line_edit_style()) + self.search_field.setObjectName("search_field") + self.search_field.setMinimumHeight(28) + + # HACK: The transparent border allows for the focus border color to + # still show above the tags at the edges. Sort of. + scroll_area_style = """ + QScrollArea{ + background: transparent; + border: solid; + border-color: transparent; + border-width: 0px 2px; + padding-left: -2px; + } + QScrollArea > QWidget > QWidget{ + background: transparent; + } + """ + + self.scroll_area = QScrollArea() + self.scroll_area.setFocusProxy(self.search_field) + self.scroll_area.setStyleSheet(scroll_area_style) + self.scroll_area_container_layout.addWidget(self.scroll_area) + self.scroll_area.setWidget(self.contents) + self.scroll_area.setMaximumHeight(28) + self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.scroll_area.verticalScrollBar().setEnabled(False) + self.scroll_area.setContentsMargins(0, 0, 0, 0) + self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.scroll_area.setWidgetResizable(True) + self.scroll_area.setFrameShadow(QFrame.Shadow.Plain) + self.scroll_area.setFrameShape(QFrame.Shape.NoFrame) + self.scroll_area.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum) + + self._root_layout.addWidget(self.scroll_area_container) + self._root_layout.addWidget(self.search_field) diff --git a/src/tagstudio/qt/views/tag_box_view.py b/src/tagstudio/qt/views/tag_box_view.py index e5c96462d..a3097cb17 100644 --- a/src/tagstudio/qt/views/tag_box_view.py +++ b/src/tagstudio/qt/views/tag_box_view.py @@ -39,17 +39,12 @@ def set_tags(self, tags: Iterable[Tag]) -> None: for tag in tags_: tag_widget = TagWidget(tag, library=self.__lib, has_edit=True, has_remove=True) - tag_widget.on_click.connect(lambda t=tag: self._on_click(t)) - tag_widget.on_remove.connect(lambda t=tag: self._on_remove(t)) - tag_widget.on_edit.connect(lambda t=tag: self._on_edit(t)) - tag_widget.search_for_tag_action.triggered.connect( lambda checked=False, t=tag: self._on_search(t) ) - self.__root_layout.addWidget(tag_widget) def _on_click(self, tag: Tag) -> None: diff --git a/src/tagstudio/qt/views/tag_search_panel_view.py b/src/tagstudio/qt/views/tag_search_panel_view.py index 1b081703b..be67f63f1 100644 --- a/src/tagstudio/qt/views/tag_search_panel_view.py +++ b/src/tagstudio/qt/views/tag_search_panel_view.py @@ -1,6 +1,8 @@ # SPDX-FileCopyrightText: (c) TagStudio Contributors # SPDX-License-Identifier: GPL-3.0-only +from typing import override + from PySide6.QtWidgets import QWidget from tagstudio.core.library.alchemy.library import Library @@ -16,6 +18,7 @@ def __init__(self, is_tag_chooser: bool) -> None: self.search_field.setPlaceholderText(Translations["home.search_tags"]) self.create_button.setText(Translations["tag.create"]) + @override def get_item_widget(self, index: int, library: Library | None) -> TagWidget: """Gets the item widget at a specific index.""" # Create any new item widgets needed up to the given index diff --git a/src/tagstudio/qt/views/tag_suggest_box_view.py b/src/tagstudio/qt/views/tag_suggest_box_view.py new file mode 100644 index 000000000..51601bf18 --- /dev/null +++ b/src/tagstudio/qt/views/tag_suggest_box_view.py @@ -0,0 +1,16 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + + +from tagstudio.qt.translations import Translations +from tagstudio.qt.views.suggest_box_view import SuggestBoxView + + +# TODO: Get rid of this class +class TagSuggestBoxView(SuggestBoxView): + def __init__(self, is_tag_chooser: bool) -> None: + super().__init__(is_tag_chooser) + placeholder = ( + f"{Translations['home.search_tags']} {Translations['home.search.how_to_exit']}" + ) + self.search_field.setPlaceholderText(placeholder) diff --git a/src/tagstudio/resources/translations/en.json b/src/tagstudio/resources/translations/en.json index 0742b93dd..6dacb4375 100644 --- a/src/tagstudio/resources/translations/en.json +++ b/src/tagstudio/resources/translations/en.json @@ -161,9 +161,10 @@ "generic.yes": "Yes", "home.search": "Search", "home.search_entries": "Search Entries", - "home.search_field_templates": "Search Field Templates", + "home.search_field_templates": "Search Field Templates...", "home.search_library": "Search Library", - "home.search_tags": "Search Tags", + "home.search_tags": "Search Tags...", + "home.search.how_to_exit": "(Esc/Enter to Exit)", "home.search.view_limit": "View Limit:", "home.show_hidden_entries": "Show Hidden Entries", "home.thumbnail_size": "Thumbnail Size", From 34d00d7e9af72c16ba619fdc8b9d70ca3f05bff4 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Fri, 17 Jul 2026 19:45:47 -0700 Subject: [PATCH 02/23] fix(ui): restore esc to deselect entries behavior --- src/tagstudio/qt/views/main_window.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/tagstudio/qt/views/main_window.py b/src/tagstudio/qt/views/main_window.py index 5607268b8..e9be73988 100644 --- a/src/tagstudio/qt/views/main_window.py +++ b/src/tagstudio/qt/views/main_window.py @@ -5,10 +5,11 @@ import typing from collections.abc import Callable from pathlib import Path +from typing import override import structlog from PIL import Image, ImageQt -from PySide6 import QtCore +from PySide6 import QtCore, QtGui from PySide6.QtCore import QMetaObject, QSize, QStringListModel, Qt from PySide6.QtGui import QAction, QPixmap from PySide6.QtWidgets import ( @@ -748,3 +749,9 @@ def thumb_size(self) -> int: def show_hidden_entries(self) -> bool: """Whether to show entries tagged with hidden tags.""" return self.show_hidden_entries_checkbox.isChecked() + + @override + def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: + if event.key() == QtCore.Qt.Key.Key_Escape: + self.menu_bar.clear_select_action.trigger() + return super().keyPressEvent(event) From f2cecb26484b9ffa4798b35b6016a65909395bdd Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Sat, 18 Jul 2026 02:40:43 -0700 Subject: [PATCH 03/23] refactor: code cleanup and fixes --- .../controllers/preview_panel_controller.py | 27 ++- .../qt/controllers/suggest_box_controller.py | 169 ++++++-------- .../controllers/tag_suggest_box_controller.py | 209 +++++------------- src/tagstudio/qt/global_settings.py | 1 + src/tagstudio/qt/views/main_window.py | 2 +- src/tagstudio/qt/views/preview_panel_view.py | 43 ++-- src/tagstudio/qt/views/suggest_box_view.py | 65 +++--- .../qt/views/tag_suggest_box_view.py | 16 -- tests/qt/test_field_containers.py | 57 +++-- tests/qt/test_file_path_options.py | 22 +- tests/qt/test_preview_panel.py | 13 +- 11 files changed, 234 insertions(+), 390 deletions(-) delete mode 100644 src/tagstudio/qt/views/tag_suggest_box_view.py diff --git a/src/tagstudio/qt/controllers/preview_panel_controller.py b/src/tagstudio/qt/controllers/preview_panel_controller.py index 6d236c216..16b20920d 100644 --- a/src/tagstudio/qt/controllers/preview_panel_controller.py +++ b/src/tagstudio/qt/controllers/preview_panel_controller.py @@ -12,7 +12,6 @@ from PySide6.QtGui import QShortcut from tagstudio.core.library.alchemy.fields import BaseFieldTemplate -from tagstudio.core.library.alchemy.library import Library from tagstudio.core.utils.ffmpeg_status import FfmpegStatus, FfprobeStatus from tagstudio.qt.mixed.file_attributes import FileAttributeData from tagstudio.qt.views.preview_panel_view import PreviewPanelView @@ -24,8 +23,8 @@ class PreviewPanel(PreviewPanelView): - def __init__(self, library: Library, driver: "QtDriver") -> None: - super().__init__(library, driver) + def __init__(self, driver: "QtDriver") -> None: + super().__init__(driver) self.__current_stats: FileAttributeData | None = None self._thumb.check_ffmpeg.connect(self._toggle_ffmpeg_warning) @@ -45,29 +44,29 @@ def __connect_callbacks(self) -> None: self.add_tag_action.activated.connect(self._add_tag_button.setFocus) self.add_tag_action.activated.connect(self._add_tag_button.click) - self.tag_search.done.connect(self.tag_added_callback) - self.tag_search.tags_updated.connect(self.update_added_callback) + self.tag_search_box.done.connect(self.tag_added_callback) + self.tag_search_box.tags_updated.connect(self.update_added_callback) def _add_field_button_callback(self) -> None: # self.__add_field_modal.show() pass def _add_tag_button_callback(self) -> None: - self.tag_search.added = self._containers.tags - self.tag_search.view.search_field.setDisabled(False) - self.tag_search.setHidden(False) + self.tag_search_box.added = self._containers.tags + self.tag_search_box.layout().search_field.setDisabled(False) + self.tag_search_box.setHidden(False) self._add_tag_button.setHidden(True) self._add_field_button.setHidden(True) def tag_added_callback(self): - self.tag_search.setHidden(True) + self.tag_search_box.setHidden(True) self._add_tag_button.setHidden(False) self._add_field_button.setHidden(False) self._add_tag_button.setFocus() def update_added_callback(self): - self.tag_search.added = self._containers.tags + self.tag_search_box.added = self._containers.tags def __thumb_stats_updated_callback(self, filepath: Path, stats: FileAttributeData) -> None: if len(self._selected) != 1: @@ -91,11 +90,11 @@ def __thumb_stats_updated_callback(self, filepath: Path, stats: FileAttributeDat @override def _set_selection_callback(self) -> None: with catch_warnings(record=True): - self.field_search.field_template_chosen.disconnect() - self.tag_search.item_chosen.disconnect() + self.field_search_box.field_template_chosen.disconnect() + self.tag_search_box.item_chosen.disconnect() - self.field_search.field_template_chosen.connect(self._add_field_to_selected) - self.tag_search.item_chosen.connect(self._add_tag_to_selected) + self.field_search_box.field_template_chosen.connect(self._add_field_to_selected) + self.tag_search_box.item_chosen.connect(self._add_tag_to_selected) def _add_field_to_selected(self, template: BaseFieldTemplate) -> None: self._containers.add_field_to_selected(template) diff --git a/src/tagstudio/qt/controllers/suggest_box_controller.py b/src/tagstudio/qt/controllers/suggest_box_controller.py index da948988b..15389ed05 100644 --- a/src/tagstudio/qt/controllers/suggest_box_controller.py +++ b/src/tagstudio/qt/controllers/suggest_box_controller.py @@ -6,9 +6,10 @@ import structlog from PySide6.QtCore import Signal from PySide6.QtGui import QShowEvent -from PySide6.QtWidgets import QGraphicsOpacityEffect, QVBoxLayout +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.views.panel_modal import PanelWidget from tagstudio.qt.views.stylesheets.stylesheets import ( autofill_line_edit_style, @@ -41,93 +42,56 @@ def _item_name(item: object) -> str: raise AttributeError() -class SuggestBox[T](PanelWidget): +class SuggestBox[T](QWidget): item_chosen = Signal(int) done = Signal() tags_updated = Signal() - def __init__( - self, view: SuggestBoxView, exclude: list[int] | None = None, is_chooser: bool = True - ) -> None: + def __init__(self, driver: "QtDriver", view: SuggestBoxView) -> None: super().__init__() - self.view = view - self.is_chooser = is_chooser - self._layout = QVBoxLayout(self) - self._layout.setContentsMargins(0, 0, 0, 0) - self._layout.addWidget(self.view) - self._driver: QtDriver | None = None - self.exclude: list[int] = exclude or [] - self.added: list[int] = exclude or [] - self.create_and_add_button_in_layout: bool = False - self.limit = 5 - self.shift_held = False - - # Limits - self._unlimited_limit_item_label: str = "All Items" - self._limit_items: list[tuple[str, int]] = [ - ("25", 25), - ("50", 50), - ("100", 100), - ("250", 250), - ("500", 500), - (self._unlimited_limit_item_label, -1), - ] - self._default_limit_index: int = 0 - self._previous_limit_index: int = self._default_limit_index - - # Items + self._layout = view + self._driver = driver + self._limit = 5 + self._is_shift_held = False self._search_results: list[T] = [] + self.added: list[int] = [] + self.excluded: list[int] = [] - self._create_and_add_button_label_key: str = "" - self.connect_callbacks() + self.setLayout(self._layout) + self._connect_callbacks() - def connect_callbacks(self) -> None: - self.view.search_field.textChanged.connect(self.on_search_query_changed) - self.view.search_field.editingFinished.connect(self.test_editing_finished) - self.view.search_field.return_pressed.connect( - lambda: self.on_search_query_submitted(self.view.search_field.text()) + def _connect_callbacks(self) -> None: + self._layout.search_field.textChanged.connect(self.on_search_query_changed) + self._layout.search_field.editingFinished.connect(self.test_editing_finished) + self._layout.search_field.return_pressed.connect( + lambda: self.on_search_query_submitted(self._layout.search_field.text()) ) - self.view.search_field.shift_return_pressed.connect( + self._layout.search_field.shift_return_pressed.connect( lambda: self.on_search_query_submitted( - self.view.search_field.text(), always_create=True + self._layout.search_field.text(), always_create=True ) ) - self.view.search_field.shift_holding.connect(lambda held: self.on_shift_held(held)) + self._layout.search_field.shift_holding.connect(lambda held: self._on_shift_held(held)) - def on_shift_held(self, held: bool): + def _on_shift_held(self, held: bool): if held: - self.shift_held = True + self._is_shift_held = True opacity_effect = QGraphicsOpacityEffect(self) opacity_effect.setOpacity(0.3) - if self.view.content_layout.count() > 0: - self.view.content_layout.itemAt(0).widget().setGraphicsEffect(opacity_effect) + if self._layout.content_layout.count() > 0: + self._layout.content_layout.itemAt(0).widget().setGraphicsEffect(opacity_effect) else: - self.shift_held = False - if self.view.content_layout.count() > 0: - self.view.content_layout.itemAt(0).widget().setGraphicsEffect(None) # pyright: ignore[reportArgumentType] - - def focus_search_box(self, select_all: bool = False) -> None: - if not self.isHidden(): - self.view.search_field.setFocus() - if select_all: - self.view.search_field.selectAll() + self._is_shift_held = False + if self._layout.content_layout.count() > 0: + self._layout.content_layout.itemAt(0).widget().setGraphicsEffect(None) # pyright: ignore[reportArgumentType] def clear_search_query(self) -> None: - self.view.search_field.setText("") + self._layout.search_field.setText("") def get_item_widget(self, index: int, library: Library) -> Any: # pyright: ignore[reportExplicitAny] return self.get_item_widget(index, library) - def set_driver(self, driver: "QtDriver") -> None: - self._driver = driver - - def _get_previous_limit(self) -> tuple[str, int]: - return self._limit_items[self._previous_limit_index] - - def _get_max_limit(self) -> int: - raise NotImplementedError() - def on_search_query_changed(self, query: str) -> None: self.update_items(query) @@ -136,38 +100,35 @@ def on_search_query_submitted(self, query: str, always_create: bool = False) -> logger.info("Query submitted") if not query: self.done.emit() - self.disappear() + self.hide_and_reset() return elif not self.isHidden(): - self.view.search_field.setFocus() + self._layout.search_field.setFocus() # Create and add item if no search results if (len(self._search_results) <= 0) or always_create: - self.on_item_create(add_to_entry=True) - elif self.is_chooser: + self.on_item_create() + else: self._on_item_chosen(self._search_results[0]) self.clear_search_query() self.update_items() - def on_item_create(self, add_to_entry: bool = False) -> None: # pyright: ignore[reportUnusedParameter] + def on_item_create(self) -> None: raise NotImplementedError() def on_item_edit(self, item: T) -> None: # pyright: ignore[reportUnusedParameter] raise NotImplementedError() - def _on_item_remove(self, item: T) -> None: # pyright: ignore[reportUnusedParameter] - raise NotImplementedError() - def _on_item_chosen(self, item: T) -> None: # pyright: ignore[reportUnusedParameter] raise NotImplementedError() def _is_excluded(self, item: T) -> bool: - return _item_id(item) in self.exclude + return _item_id(item) in self.excluded 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) + logger.info("[SearchPanel] Updating items", limit=self._limit) # Get results for the search query query_lower = "" if not query else query.lower() @@ -199,24 +160,24 @@ def update_items(self, query: str | None = None) -> None: all_results.remove(item) all_results = all_results + already_added - if self.limit > 0: - all_results = all_results[: self.limit] + if self._limit > 0: + all_results = all_results[: self._limit] self._search_results = all_results logger.info("[SearchPanel] Search results", results=self._search_results) - for i in range(0, self.limit): + for i in range(0, self._limit): item: T | None = all_results[i] if i < len(all_results) else None self.set_item_widget(item=item, index=i) - if self.view.content_layout.isEmpty(): - self.view.scroll_area.setHidden(True) - self.view.content_layout.setContentsMargins(0, 0, 0, 0) - self.view.search_field.setStyleSheet(autofill_line_edit_style()) + if self._layout.content_layout.isEmpty(): + self._layout.scroll_area.setHidden(True) + self._layout.content_layout.setContentsMargins(0, 0, 0, 0) + self._layout.search_field.setStyleSheet(autofill_line_edit_style()) else: - self.view.scroll_area.setHidden(False) - self.view.content_layout.setContentsMargins(6, 6, 6, 6) - self.view.search_field.setStyleSheet(autofill_line_edit_top_style()) + self._layout.scroll_area.setHidden(False) + self._layout.content_layout.setContentsMargins(6, 6, 6, 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] raise NotImplementedError() @@ -224,27 +185,41 @@ def search_items(self, query: str) -> tuple[list[T], list[T]]: # pyright: ignor def set_item_widget(self, item: T | None, index: int) -> None: # pyright: ignore[reportUnusedParameter] raise NotImplementedError() - @override - def showEvent(self, event: QShowEvent) -> None: - self.update_items() - self.on_shift_held(held=False) - self.clear_search_query() - return super().showEvent(event) - def test_editing_finished(self): logger.info("Editing finished") self.tags_updated.emit() - if self.view.search_field.text() == "": + if self._layout.search_field.text() == "": self.done.emit() - self.disappear() + self.hide_and_reset() - def disappear(self): + def hide_and_reset(self): self.hide() - self.view.search_field.setDisabled(True) - self.on_shift_held(held=False) + self._layout.search_field.setDisabled(True) + self._on_shift_held(held=False) - def create_item(self, edit_item_panel: PanelWidget, choose_item: bool = False) -> None: # pyright: ignore[reportUnusedParameter] + def create_item_from_modal(self, edit_item_panel: PanelWidget) -> None: # pyright: ignore[reportUnusedParameter] raise NotImplementedError() def edit_item(self, edit_item_panel: PanelWidget) -> None: # pyright: ignore[reportUnusedParameter] raise NotImplementedError() + + @override + def showEvent(self, event: QShowEvent) -> None: + self.update_items() + self._on_shift_held(held=False) + self.clear_search_query() + return super().showEvent(event) + + @override + def layout(self) -> SuggestBoxView: + return self._layout + + @override + def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: + # When Escape is pressed, focus back on the search box. + if event.key() in { + QtCore.Qt.Key.Key_Escape, + QtCore.Qt.Key.Key_Enter, + QtCore.Qt.Key.Key_Return, + }: + self.hide_and_reset() diff --git a/src/tagstudio/qt/controllers/tag_suggest_box_controller.py b/src/tagstudio/qt/controllers/tag_suggest_box_controller.py index 687e1708b..6e18aec01 100644 --- a/src/tagstudio/qt/controllers/tag_suggest_box_controller.py +++ b/src/tagstudio/qt/controllers/tag_suggest_box_controller.py @@ -2,49 +2,38 @@ # SPDX-License-Identifier: GPL-3.0-only +import typing from typing import override from warnings import catch_warnings import structlog -from PySide6.QtWidgets import QGraphicsOpacityEffect, QMessageBox, QSizePolicy, QWidget +from PySide6.QtWidgets import QGraphicsOpacityEffect, QWidget -from tagstudio.core.constants import RESERVED_TAG_END, RESERVED_TAG_START 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_controller import SuggestBox +from tagstudio.qt.mixed.build_tag import BuildTagPanel from tagstudio.qt.mixed.tag_widget import TagWidget from tagstudio.qt.translations import Translations from tagstudio.qt.views.panel_modal import PanelModal, PanelWidget -from tagstudio.qt.views.tag_suggest_box_view import TagSuggestBoxView +from tagstudio.qt.views.suggest_box_view import SuggestBoxView + +if typing.TYPE_CHECKING: + from tagstudio.qt.ts_qt import QtDriver logger = structlog.get_logger(__name__) class TagSuggestBox(SuggestBox[Tag]): - def __init__( - self, - library: Library, - exclude: list[int] | None = None, - is_tag_chooser: bool = True, - view: TagSuggestBoxView | None = None, - ): - super().__init__( - view=view or TagSuggestBoxView(is_tag_chooser), - exclude=exclude, - is_chooser=is_tag_chooser, - ) - self.__lib = library - - self._unlimited_limit_item_label = Translations["tag.all_tags"] - self._create_and_add_button_label_key = "tag.create_add" + def __init__(self, driver: "QtDriver", view: SuggestBoxView | None = None): + super().__init__(driver, view=view or SuggestBoxView()) + self._driver = driver + self._lib = self._driver.lib + self._edit_on_create = self._driver.settings.edit_tag_on_create @override - def _get_max_limit(self) -> int: - return len(self.__lib.tags) - - @override - def on_item_create(self, add_to_entry: bool = False) -> None: + def on_item_create(self) -> None: """Opens panel to create a new tag and optionally add it to an entry. Populates name field using current search query. @@ -52,67 +41,36 @@ def on_item_create(self, add_to_entry: bool = False) -> None: Args: add_to_entry (bool): Should this item be added to currently selected entries? """ - # TODO: Move this to a top-level import + query: str = self._layout.search_field.text() - query: str = self.view.search_field.text() - - # panel: BuildTagPanel = BuildTagPanel(self.__lib) - # modal: PanelModal = PanelModal( - # panel, - # Translations["tag.new"], - # Translations["tag.add"] if add_to_entry else Translations["tag.new"], - # is_savable=True, - # ) - - # if query.strip(): - # panel.name_field.setText(query) + if self._edit_on_create: + panel: BuildTagPanel = BuildTagPanel(self._lib) + modal: PanelModal = PanelModal( + panel, Translations["tag.new"], Translations["tag.new"], is_savable=True + ) + if query.strip(): + panel.name_field.setText(query) - # modal.saved.connect(lambda: self.create_item(panel, choose_item=add_to_entry)) - # modal.show() - tag = Tag(name=query) - self.__lib.add_tag(tag) - if add_to_entry: + modal.saved.connect(lambda: self.create_item_from_modal(panel)) + modal.show() + else: + tag = Tag(name=query) + self._lib.add_tag(tag) self._on_item_chosen(tag) self.clear_search_query() @override def on_item_edit(self, item: Tag) -> None: - # TODO: Move this to a top-level import - from tagstudio.qt.mixed.build_tag import BuildTagPanel # here due to circular imports - - edit_tag_panel: BuildTagPanel = BuildTagPanel(self.__lib, tag=item) + edit_tag_panel: BuildTagPanel = BuildTagPanel(self._lib, tag=item) edit_tag_modal: PanelModal = PanelModal( edit_tag_panel, - self.__lib.tag_display_name(item), + self._lib.tag_display_name(item), Translations["tag.edit"], is_savable=True, ) edit_tag_modal.saved.connect(lambda: self.edit_item(edit_tag_panel)) edit_tag_modal.show() - @override - def _on_item_remove(self, item: Tag) -> None: - if self.is_chooser: - return - - if item.id in range(RESERVED_TAG_START, RESERVED_TAG_END): - return - - message_box = QMessageBox( - QMessageBox.Icon.Question, - Translations["tag.remove"], - Translations.format("tag.confirm_delete", tag_name=self.__lib.tag_display_name(item)), - QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel, - ) - - result = message_box.exec() - - if result != QMessageBox.StandardButton.Ok: - return - - self.__lib.remove_tag(item.id) - self.update_items(self.view.search_field.text()) - @override def _on_item_chosen(self, item: Tag) -> None: self.item_chosen.emit(item.id) @@ -121,80 +79,55 @@ def _on_item_chosen(self, item: Tag) -> None: @override def search_items(self, query: str) -> tuple[list[Tag], list[Tag]]: if query != "": - # return self.__lib.search_tags(name=query, limit=self._get_limit()[1]) - return self.__lib.search_tags(name=query, limit=0) + return self._lib.search_tags(name=query, limit=0) else: return ([], []) @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) + tag_widget: TagWidget = self.get_item_widget(index, self._lib) + tag_widget.has_remove = False tag_widget.set_tag(item) tag_widget.setHidden(item is None) + opacity_effect = QGraphicsOpacityEffect(self) + opacity_effect.setOpacity(0.3) if item and item.id in self.added: - opacity_effect = QGraphicsOpacityEffect(self) - opacity_effect.setOpacity(0.3) tag_widget.setGraphicsEffect(opacity_effect) else: tag_widget.setGraphicsEffect(None) # pyright: ignore[reportArgumentType] if item is None: return - assert item is not None - - tag_widget.has_remove = not self.is_chooser and item.id not in range( - RESERVED_TAG_START, RESERVED_TAG_END - ) # Disconnect previous callbacks with catch_warnings(record=True): tag_widget.on_edit.disconnect() - tag_widget.on_remove.disconnect() tag_widget.bg_button.clicked.disconnect() tag_widget.search_for_tag_action.triggered.disconnect() # Connect callbacks tag_widget.on_edit.connect(lambda edit_tag=item: self.on_item_edit(edit_tag)) - tag_widget.on_remove.connect(lambda remove_tag=item: self._on_item_remove(remove_tag)) - if self.is_chooser: - tag_widget.bg_button.clicked.connect( - lambda checked=False, tag=item: self._on_item_chosen(tag) - ) - else: - tag_widget.bg_button.clicked.connect( - lambda checked=False, edit_tag=item: self.on_item_edit(edit_tag) - ) - - # Connect search action - if self._driver is not None: - tag_widget.search_for_tag_action.triggered.connect( - lambda checked=False, tag_id=item.id: self.search_for_tag(tag_id) - ) - tag_widget.search_for_tag_action.setEnabled(True) - else: - logger.warning( - "[TagSearchPanel] No driver was set for this TagSearchPanel. Was this on purpose?" - ) - tag_widget.search_for_tag_action.setEnabled(False) + tag_widget.bg_button.clicked.connect( + lambda checked=False, tag=item: self._on_item_chosen(tag) + ) + tag_widget.search_for_tag_action.triggered.connect( + lambda checked=False, tag_id=item.id: self.search_for_tag(tag_id) + ) + tag_widget.search_for_tag_action.setEnabled(True) @override - def create_item(self, edit_item_panel: PanelWidget, choose_item: bool = False) -> None: - # TODO: Move this to a top-level import - from tagstudio.qt.mixed.build_tag import BuildTagPanel # here due to circular imports - + def create_item_from_modal(self, edit_item_panel: PanelWidget) -> None: if isinstance(edit_item_panel, BuildTagPanel): tag: Tag = edit_item_panel.build_tag() - self.__lib.add_tag( + self._lib.add_tag( tag, parent_ids=edit_item_panel.parent_ids, aliases=edit_item_panel.aliases ) - - if choose_item: - self._on_item_chosen(tag) - self.clear_search_query() + self._on_item_chosen(tag) + self.clear_search_query() edit_item_panel.hide() - self.on_search_query_changed(self.view.search_field.text()) + self.on_search_query_changed(self._layout.search_field.text()) @override def edit_item(self, edit_item_panel: PanelWidget) -> None: @@ -204,17 +137,14 @@ def edit_item(self, edit_item_panel: PanelWidget) -> None: if not isinstance(edit_item_panel, BuildTagPanel): return - self.__lib.update_tag( + self._lib.update_tag( tag=edit_item_panel.build_tag(), parent_ids=edit_item_panel.parent_ids, aliases=edit_item_panel.aliases, ) - self.update_items(self.view.search_field.text()) + self.update_items(self._layout.search_field.text()) def search_for_tag(self, tag_id: int) -> None: - if self._driver is None: - return - self._driver.main_window.search_field.setText(f"tag_id:{tag_id}") self._driver.update_browsing_state( BrowsingState.from_tag_id(tag_id, self._driver.browsing_history.current) @@ -224,50 +154,13 @@ def search_for_tag(self, tag_id: int) -> None: def get_item_widget(self, index: int, library: Library | None) -> TagWidget: """Gets the item widget at a specific index.""" # Create any new item widgets needed up to the given index - if self.view.content_layout.count() <= index: - # opacity_effect = QGraphicsOpacityEffect(self) - # opacity_effect.setOpacity(0.3) - while self.view.content_layout.count() <= 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.bg_button.setSizePolicy( - QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum - ) tag_widget.setHidden(True) - # if index > 0: - # tag_widget.setGraphicsEffect(opacity_effect) - self.view.content_layout.addWidget(tag_widget) + self._layout.content_layout.addWidget(tag_widget) - tag_widget: QWidget = self.view.content_layout.itemAt(index).widget() + tag_widget: QWidget = self._layout.content_layout.itemAt(index).widget() assert isinstance(tag_widget, TagWidget) return tag_widget - - # @override - # def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: - # # When Escape is pressed, focus back on the search box. - # # If focus is already on the search box, close the modal. - # pass - # # if event.key() in {QtCore.Qt.Key.Key_Escape, QtCore.Qt.Key.Key_Backspace, }: - # # if self.search_field.hasFocus(): - - # # self.hide() - - # @override - # def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: - # # When Escape is pressed, focus back on the search box. - # # If focus is already on the search box, close the modal. - # # if event.key() == QtCore.Qt.Key.Key_Escape: - # # if self.search_field.hasFocus(): - # # self.hide() - # logger.info(event.key) - # if event.key() in { - # QtCore.Qt.Key.Key_Escape, - # QtCore.Qt.Key.Key_Enter, - # QtCore.Qt.Key.Key_Return, - # }: - # if self.search_field.hasFocus(): - # logger.info("Hiding") - # self.hide() - # elif event.key() in {QtCore.Qt.Key.Key_Backspace, QtCore.Qt.Key.Key_Delete}: - # if self.search_field.hasFocus() and self.search_field.text() == "": - # # self.hide() diff --git a/src/tagstudio/qt/global_settings.py b/src/tagstudio/qt/global_settings.py index 85fe1f362..58fb71927 100644 --- a/src/tagstudio/qt/global_settings.py +++ b/src/tagstudio/qt/global_settings.py @@ -77,6 +77,7 @@ class GlobalSettings(BaseModel): theme: Theme = Field(default=Theme.SYSTEM) splash: Splash = Field(default=Splash.DEFAULT) windows_start_command: bool = Field(default=False) + edit_tag_on_create: bool = Field(default=False) date_format: str = Field(default="%x") hour_format: bool = Field(default=True) diff --git a/src/tagstudio/qt/views/main_window.py b/src/tagstudio/qt/views/main_window.py index e9be73988..de0516bea 100644 --- a/src/tagstudio/qt/views/main_window.py +++ b/src/tagstudio/qt/views/main_window.py @@ -701,7 +701,7 @@ def setup_entry_list(self, driver: "QtDriver"): self.content_splitter.addWidget(self.entry_list_container) def setup_preview_panel(self, driver: "QtDriver"): - self.preview_panel = PreviewPanel(driver.lib, driver) + self.preview_panel = PreviewPanel(driver) self.content_splitter.addWidget(self.preview_panel) def setup_status_bar(self): diff --git a/src/tagstudio/qt/views/preview_panel_view.py b/src/tagstudio/qt/views/preview_panel_view.py index d0f2292fb..908f67885 100644 --- a/src/tagstudio/qt/views/preview_panel_view.py +++ b/src/tagstudio/qt/views/preview_panel_view.py @@ -13,7 +13,6 @@ from PySide6.QtWidgets import QHBoxLayout, QLabel, QSplitter, QVBoxLayout, QWidget from tagstudio.core.constants import FFMPEG_HELP_URL -from tagstudio.core.library.alchemy.library import Library from tagstudio.core.library.alchemy.models import Entry from tagstudio.core.utils.types import unwrap from tagstudio.qt.controllers.field_template_search_panel_controller import FieldTemplateSearchPanel @@ -26,7 +25,7 @@ from tagstudio.qt.translations import Translations from tagstudio.qt.views.field_template_search_panel_view import FieldTemplateSearchPanelView from tagstudio.qt.views.stylesheets.stylesheets import button_style, preview_warning_style -from tagstudio.qt.views.tag_suggest_box_view import TagSuggestBoxView +from tagstudio.qt.views.suggest_box_view import SuggestBoxView if typing.TYPE_CHECKING: from tagstudio.qt.ts_qt import QtDriver @@ -35,31 +34,31 @@ class PreviewPanelView(QWidget): - lib: Library _selected: list[int] - def __init__(self, library: Library, driver: "QtDriver") -> None: + def __init__(self, driver: "QtDriver") -> None: super().__init__() - self.lib = library + self._lib = driver.lib rm = ResourceManager() - self.field_search: FieldTemplateSearchPanel = FieldTemplateSearchPanel( - library, + self.field_search_box: FieldTemplateSearchPanel = FieldTemplateSearchPanel( + self._lib, is_field_template_chooser=True, view=FieldTemplateSearchPanelView(is_field_template_chooser=True), ) - self.tag_search = TagSuggestBox( - library, - is_tag_chooser=True, - view=TagSuggestBoxView(is_tag_chooser=True), + + tag_placeholder_text = ( + f"{Translations['home.search_tags']} {Translations['home.search.how_to_exit']}" + ) + self.tag_search_box = TagSuggestBox( + driver, view=SuggestBoxView(placeholder=tag_placeholder_text) ) - self.tag_search.set_driver(driver) - self.tag_search.hide() + self.tag_search_box.hide() - self._thumb = PreviewThumb(self.lib, driver) - self._file_attrs = FileAttributes(self.lib, driver) + self._thumb = PreviewThumb(self._lib, driver) + self._file_attrs = FileAttributes(self._lib, driver) self._containers = FieldContainers( - self.lib, driver + self._lib, driver ) # TODO: this should be name mangled, but is still needed on the controller side atm # Visual Preview @@ -124,7 +123,7 @@ def __init__(self, library: Library, driver: "QtDriver") -> None: add_buttons_layout.addWidget(self._add_tag_button) add_buttons_layout.addWidget(self._add_field_button) - add_buttons_layout.addWidget(self.tag_search) + add_buttons_layout.addWidget(self.tag_search_box) # add_buttons_layout.addWidget(self.field_search) preview_layout.addWidget(self._thumb) @@ -166,14 +165,14 @@ def set_selection(self, selected: list[int], update_preview: bool = True) -> Non self._add_field_button.setEnabled(False) self._add_tag_button.setHidden(False) self._add_field_button.setHidden(False) - self.tag_search.disappear() + self.tag_search_box.hide_and_reset() # One Item Selected elif len(selected) == 1: entry_id = selected[0] - entry: Entry = unwrap(self.lib.get_entry(entry_id)) + entry: Entry = unwrap(self._lib.get_entry(entry_id)) - filepath: Path = unwrap(self.lib.library_dir) / entry.path + filepath: Path = unwrap(self._lib.library_dir) / entry.path if filepath != self._thumb.current_file: self.__current_stats = None @@ -190,7 +189,7 @@ def set_selection(self, selected: list[int], update_preview: bool = True) -> Non self._add_field_button.setEnabled(True) self._add_tag_button.setHidden(False) self._add_field_button.setHidden(False) - self.tag_search.disappear() + self.tag_search_box.hide_and_reset() # Multiple Selected Items elif len(selected) > 1: @@ -207,7 +206,7 @@ def set_selection(self, selected: list[int], update_preview: bool = True) -> Non self._add_field_button.setEnabled(True) self._add_tag_button.setHidden(False) self._add_field_button.setHidden(False) - self.tag_search.disappear() + self.tag_search_box.hide_and_reset() except Exception as e: logger.error("[Preview Panel] Error updating selection", error=e) diff --git a/src/tagstudio/qt/views/suggest_box_view.py b/src/tagstudio/qt/views/suggest_box_view.py index 9357b5805..fc961a6d2 100644 --- a/src/tagstudio/qt/views/suggest_box_view.py +++ b/src/tagstudio/qt/views/suggest_box_view.py @@ -26,38 +26,15 @@ logger = structlog.get_logger(__name__) -class SuggestBoxView(QWidget): - def __init__(self, is_chooser: bool) -> None: - self.is_chooser: bool = is_chooser +class SuggestBoxView(QVBoxLayout): + def __init__(self, placeholder: str = "") -> None: super().__init__() - - self._root_layout = QVBoxLayout(self) - self._root_layout.setContentsMargins(0, 0, 0, 0) - self._root_layout.setSpacing(0) - - # Scroll area - self.contents = QWidget() - - self.content_layout = QHBoxLayout(self.contents) - self.content_layout.setSpacing(6) - self.content_layout.setAlignment(Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignLeft) - self.content_layout.setContentsMargins(0, 0, 0, 0) - - self.scroll_area_container = QWidget() - self.scroll_area_container.setObjectName("container") - self.scroll_area_container_layout = QHBoxLayout(self.scroll_area_container) - self.scroll_area_container_layout.setContentsMargins(0, 0, 0, 0) - self.scroll_area_container_layout.setSpacing(0) - self.scroll_area_container.setStyleSheet(autofill_scroll_top_style("container")) - - # Search field - self.search_field = AutofillLineEdit(self.scroll_area_container) - self.search_field.setStyleSheet(autofill_line_edit_style()) - self.search_field.setObjectName("search_field") - self.search_field.setMinimumHeight(28) + # Init layout + 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. + # still show above the tags at the edges... sort of (overlaps on left when h-scrolling) scroll_area_style = """ QScrollArea{ background: transparent; @@ -71,11 +48,22 @@ def __init__(self, is_chooser: bool) -> None: } """ + # Autocomplete ScrollArea + contents = QWidget() + self.content_layout = QHBoxLayout(contents) + self.content_layout.setSpacing(6) + self.content_layout.setAlignment(Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignLeft) + self.content_layout.setContentsMargins(0, 0, 0, 0) + scroll_area_container = QWidget() + scroll_area_container.setObjectName("container") + scroll_area_container_layout = QHBoxLayout(scroll_area_container) + scroll_area_container_layout.setContentsMargins(0, 0, 0, 0) + scroll_area_container_layout.setSpacing(0) + scroll_area_container.setStyleSheet(autofill_scroll_top_style("container")) self.scroll_area = QScrollArea() - self.scroll_area.setFocusProxy(self.search_field) self.scroll_area.setStyleSheet(scroll_area_style) - self.scroll_area_container_layout.addWidget(self.scroll_area) - self.scroll_area.setWidget(self.contents) + scroll_area_container_layout.addWidget(self.scroll_area) + self.scroll_area.setWidget(contents) self.scroll_area.setMaximumHeight(28) self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) self.scroll_area.verticalScrollBar().setEnabled(False) @@ -86,5 +74,14 @@ def __init__(self, is_chooser: bool) -> None: self.scroll_area.setFrameShape(QFrame.Shape.NoFrame) self.scroll_area.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum) - self._root_layout.addWidget(self.scroll_area_container) - self._root_layout.addWidget(self.search_field) + # Search Field + 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.setPlaceholderText(placeholder) + self.scroll_area.setFocusProxy(self.search_field) + + # Finalize layout + self.addWidget(scroll_area_container) + self.addWidget(self.search_field) diff --git a/src/tagstudio/qt/views/tag_suggest_box_view.py b/src/tagstudio/qt/views/tag_suggest_box_view.py deleted file mode 100644 index 51601bf18..000000000 --- a/src/tagstudio/qt/views/tag_suggest_box_view.py +++ /dev/null @@ -1,16 +0,0 @@ -# SPDX-FileCopyrightText: (c) TagStudio Contributors -# SPDX-License-Identifier: GPL-3.0-only - - -from tagstudio.qt.translations import Translations -from tagstudio.qt.views.suggest_box_view import SuggestBoxView - - -# TODO: Get rid of this class -class TagSuggestBoxView(SuggestBoxView): - def __init__(self, is_tag_chooser: bool) -> None: - super().__init__(is_tag_chooser) - placeholder = ( - f"{Translations['home.search_tags']} {Translations['home.search.how_to_exit']}" - ) - self.search_field.setPlaceholderText(placeholder) diff --git a/tests/qt/test_field_containers.py b/tests/qt/test_field_containers.py index 0d19e4e09..9a0a3c520 100644 --- a/tests/qt/test_field_containers.py +++ b/tests/qt/test_field_containers.py @@ -2,15 +2,14 @@ # SPDX-License-Identifier: GPL-3.0-only -from tagstudio.core.library.alchemy.library import Library from tagstudio.core.library.alchemy.models import Entry, Tag from tagstudio.core.utils.types import unwrap from tagstudio.qt.controllers.preview_panel_controller import PreviewPanel from tagstudio.qt.ts_qt import QtDriver -def test_update_selection_empty(qt_driver: QtDriver, library: Library): - panel = PreviewPanel(library, qt_driver) +def test_update_selection_empty(qt_driver: QtDriver): + panel = PreviewPanel(qt_driver) # Clear the library selection (selecting 1 then unselecting 1) qt_driver.toggle_item_selection(1, append=False, bridge=False) @@ -22,8 +21,8 @@ def test_update_selection_empty(qt_driver: QtDriver, library: Library): assert container.isHidden() -def test_update_selection_single(qt_driver: QtDriver, library: Library, entry_full: Entry): - panel = PreviewPanel(library, qt_driver) +def test_update_selection_single(qt_driver: QtDriver, entry_full: Entry): + panel = PreviewPanel(qt_driver) # Select the single entry qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False) @@ -34,10 +33,10 @@ def test_update_selection_single(qt_driver: QtDriver, library: Library, entry_fu assert not container.isHidden() -def test_update_selection_multiple(qt_driver: QtDriver, library: Library): +def test_update_selection_multiple(qt_driver: QtDriver): # TODO: Implement mixed field editing. Currently these containers will be hidden, # same as the empty selection behavior. - panel = PreviewPanel(library, qt_driver) + panel = PreviewPanel(qt_driver) # Select the multiple entries qt_driver.toggle_item_selection(1, append=False, bridge=False) @@ -49,8 +48,8 @@ def test_update_selection_multiple(qt_driver: QtDriver, library: Library): assert container.isHidden() -def test_add_tag_to_selection_single(qt_driver: QtDriver, library: Library, entry_full: Entry): - panel = PreviewPanel(library, qt_driver) +def test_add_tag_to_selection_single(qt_driver: QtDriver, entry_full: Entry): + panel = PreviewPanel(qt_driver) assert {t.id for t in entry_full.tags} == {1000} @@ -62,12 +61,12 @@ def test_add_tag_to_selection_single(qt_driver: QtDriver, library: Library, entr panel.field_containers_widget.add_tags_to_selected(2000) # Then reload entry - refreshed_entry: Entry = next(library.all_entries(with_joins=True)) + refreshed_entry: Entry = next(qt_driver.lib.all_entries(with_joins=True)) assert {t.id for t in refreshed_entry.tags} == {1000, 2000} -def test_add_same_tag_to_selection_single(qt_driver: QtDriver, library: Library, entry_full: Entry): - panel = PreviewPanel(library, qt_driver) +def test_add_same_tag_to_selection_single(qt_driver: QtDriver, entry_full: Entry): + panel = PreviewPanel(qt_driver) assert {t.id for t in entry_full.tags} == {1000} @@ -79,13 +78,13 @@ def test_add_same_tag_to_selection_single(qt_driver: QtDriver, library: Library, panel.field_containers_widget.add_tags_to_selected(1000) # Then reload entry - refreshed_entry = next(library.all_entries(with_joins=True)) + refreshed_entry = next(qt_driver.lib.all_entries(with_joins=True)) assert {t.id for t in refreshed_entry.tags} == {1000} -def test_add_tag_to_selection_multiple(qt_driver: QtDriver, library: Library): - panel = PreviewPanel(library, qt_driver) - all_entries = library.all_entries(with_joins=True) +def test_add_tag_to_selection_multiple(qt_driver: QtDriver): + panel = PreviewPanel(qt_driver) + all_entries = qt_driver.lib.all_entries(with_joins=True) # We want to verify that tag 1000 is on some, but not all entries already. tag_present_on_some: bool = False @@ -101,7 +100,7 @@ def test_add_tag_to_selection_multiple(qt_driver: QtDriver, library: Library): assert tag_absent_on_some # Select the multiple entries - for i, e in enumerate(library.all_entries(with_joins=True), start=0): + for i, e in enumerate(qt_driver.lib.all_entries(with_joins=True), start=0): qt_driver.toggle_item_selection(e.id, append=(True if i == 0 else False), bridge=False) # noqa: SIM210 panel.set_selection(qt_driver.selected) @@ -109,7 +108,7 @@ def test_add_tag_to_selection_multiple(qt_driver: QtDriver, library: Library): panel.field_containers_widget.add_tags_to_selected(1000) # Then reload all entries and recheck the presence of tag 1000 - refreshed_entries = library.all_entries(with_joins=True) + refreshed_entries = qt_driver.lib.all_entries(with_joins=True) tag_present_on_some = False tag_absent_on_some = False @@ -123,11 +122,11 @@ def test_add_tag_to_selection_multiple(qt_driver: QtDriver, library: Library): assert not tag_absent_on_some -def test_meta_tag_category(qt_driver: QtDriver, library: Library, entry_full: Entry): - panel = PreviewPanel(library, qt_driver) +def test_meta_tag_category(qt_driver: QtDriver, entry_full: Entry): + panel = PreviewPanel(qt_driver) # Ensure the Favorite tag is on entry_full - library.add_tags_to_entries(1, entry_full.id) + qt_driver.lib.add_tags_to_entries(1, entry_full.id) # Select the single entry qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False) @@ -139,7 +138,7 @@ def test_meta_tag_category(qt_driver: QtDriver, library: Library, entry_full: En match i: case 0: # Check if the container is the Meta Tags category - tag: Tag = unwrap(library.get_tag(2)) + tag: Tag = unwrap(qt_driver.lib.get_tag(2)) assert container.title == f"

{tag.name}

" case 1: # Check if the container is the Tags category @@ -151,18 +150,16 @@ def test_meta_tag_category(qt_driver: QtDriver, library: Library, entry_full: En pass -def test_custom_tag_category(qt_driver: QtDriver, library: Library, entry_full: Entry): - panel = PreviewPanel(library, qt_driver) +def test_custom_tag_category(qt_driver: QtDriver, entry_full: Entry): + panel = PreviewPanel(qt_driver) # Set tag 1000 (foo) as a category - tag: Tag = unwrap(library.get_tag(1000)) + tag: Tag = unwrap(qt_driver.lib.get_tag(1000)) tag.is_category = True - library.update_tag( - tag, - ) + qt_driver.lib.update_tag(tag) # Ensure the Favorite tag is on entry_full - library.add_tags_to_entries(1, entry_full.id) + qt_driver.lib.add_tags_to_entries(1, entry_full.id) # Select the single entry qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False) @@ -174,7 +171,7 @@ def test_custom_tag_category(qt_driver: QtDriver, library: Library, entry_full: match i: case 0: # Check if the container is the Meta Tags category - tag_2: Tag = unwrap(library.get_tag(2)) + tag_2: Tag = unwrap(qt_driver.lib.get_tag(2)) assert container.title == f"

{tag_2.name}

" case 1: # Check if the container is the custom "foo" category diff --git a/tests/qt/test_file_path_options.py b/tests/qt/test_file_path_options.py index 1d28f33db..ef5958066 100644 --- a/tests/qt/test_file_path_options.py +++ b/tests/qt/test_file_path_options.py @@ -1,6 +1,7 @@ # SPDX-FileCopyrightText: (c) TagStudio Contributors # SPDX-License-Identifier: GPL-3.0-only +# pyright: reportPrivateUsage=false, reportAttributeAccessIssue=false import os from collections.abc import Callable @@ -8,9 +9,7 @@ from unittest.mock import patch import pytest -from PySide6.QtGui import ( - QAction, -) +from PySide6.QtGui import QAction from PySide6.QtWidgets import QMenu, QMenuBar from pytestqt.qtbot import QtBot @@ -60,11 +59,10 @@ def test_filepath_setting(qtbot: QtBot, qt_driver: QtDriver, filepath_option: Sh ) def test_file_path_display( qt_driver: QtDriver, - library: Library, filepath_option: ShowFilepathOption, expected_path: Callable[[Library], Path], ): - panel = PreviewPanel(library, qt_driver) + panel = PreviewPanel(qt_driver) # Select 2 qt_driver.toggle_item_selection(2, append=False, bridge=False) @@ -73,15 +71,17 @@ def test_file_path_display( qt_driver.settings.show_filepath = filepath_option # Apply the mock value - entry = library.get_entry(2) + entry = qt_driver.lib.get_entry(2) assert isinstance(entry, Entry) filename = entry.path - panel._file_attributes_widget.update_stats(filepath=unwrap(library.library_dir) / filename) # pyright: ignore[reportPrivateUsage] + panel._file_attributes_widget.update_stats( + filepath=unwrap(qt_driver.lib.library_dir) / filename + ) # Generate the expected file string. # This is copied directly from the file_attributes.py file # can be imported as a function in the future - display_path: Path = expected_path(library) + display_path: Path = expected_path(qt_driver.lib) file_str: str = "" separator: str = f"{os.path.sep}" # Gray for i, part in enumerate(display_path.parts): @@ -94,7 +94,7 @@ def test_file_path_display( file_str += f"{'\u200b'.join(part_)}" # Assert the file path is displayed correctly - assert panel._file_attributes_widget.file_label.text() == file_str # pyright: ignore[reportPrivateUsage] + assert panel._file_attributes_widget.file_label.text() == file_str @pytest.mark.parametrize( @@ -146,7 +146,7 @@ def test_title_update( qt_driver.main_window.menu_bar.folders_to_tags_action = QAction(menu_bar) # Trigger the update - qt_driver._init_library(library_dir, open_status) # pyright: ignore[reportPrivateUsage] + qt_driver._init_library(library_dir, open_status) # Assert the title is updated correctly - qt_driver.main_window.setWindowTitle.assert_called_with(expected_title(library_dir, base_title)) # pyright: ignore[reportAttributeAccessIssue] + qt_driver.main_window.setWindowTitle.assert_called_with(expected_title(library_dir, base_title)) diff --git a/tests/qt/test_preview_panel.py b/tests/qt/test_preview_panel.py index b2e3ef5a8..e00e60930 100644 --- a/tests/qt/test_preview_panel.py +++ b/tests/qt/test_preview_panel.py @@ -2,14 +2,13 @@ # SPDX-License-Identifier: GPL-3.0-only -from tagstudio.core.library.alchemy.library import Library from tagstudio.core.library.alchemy.models import Entry from tagstudio.qt.controllers.preview_panel_controller import PreviewPanel from tagstudio.qt.ts_qt import QtDriver -def test_update_selection_empty(qt_driver: QtDriver, library: Library): - panel = PreviewPanel(library, qt_driver) +def test_update_selection_empty(qt_driver: QtDriver): + panel = PreviewPanel(qt_driver) # Clear the library selection (selecting 1 then unselecting 1) qt_driver.toggle_item_selection(1, append=False, bridge=False) @@ -20,8 +19,8 @@ def test_update_selection_empty(qt_driver: QtDriver, library: Library): assert not panel.add_buttons_enabled -def test_update_selection_single(qt_driver: QtDriver, library: Library, entry_full: Entry): - panel = PreviewPanel(library, qt_driver) +def test_update_selection_single(qt_driver: QtDriver, entry_full: Entry): + panel = PreviewPanel(qt_driver) # Select the single entry qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False) @@ -31,8 +30,8 @@ def test_update_selection_single(qt_driver: QtDriver, library: Library, entry_fu assert panel.add_buttons_enabled -def test_update_selection_multiple(qt_driver: QtDriver, library: Library): - panel = PreviewPanel(library, qt_driver) +def test_update_selection_multiple(qt_driver: QtDriver): + panel = PreviewPanel(qt_driver) # Select the multiple entries qt_driver.toggle_item_selection(1, append=False, bridge=False) From cb3164a5ffbf4941b1f4dd57c150ec46fe0d2072 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Sat, 18 Jul 2026 02:50:21 -0700 Subject: [PATCH 04/23] refactor: RADICAL mvc refactor... --- .../controllers/{suggest_box_controller.py => suggest_box.py} | 0 .../{tag_suggest_box_controller.py => tag_suggest_box.py} | 2 +- src/tagstudio/qt/views/preview_panel_view.py | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename src/tagstudio/qt/controllers/{suggest_box_controller.py => suggest_box.py} (100%) rename src/tagstudio/qt/controllers/{tag_suggest_box_controller.py => tag_suggest_box.py} (98%) diff --git a/src/tagstudio/qt/controllers/suggest_box_controller.py b/src/tagstudio/qt/controllers/suggest_box.py similarity index 100% rename from src/tagstudio/qt/controllers/suggest_box_controller.py rename to src/tagstudio/qt/controllers/suggest_box.py diff --git a/src/tagstudio/qt/controllers/tag_suggest_box_controller.py b/src/tagstudio/qt/controllers/tag_suggest_box.py similarity index 98% rename from src/tagstudio/qt/controllers/tag_suggest_box_controller.py rename to src/tagstudio/qt/controllers/tag_suggest_box.py index 6e18aec01..c4c9afb5f 100644 --- a/src/tagstudio/qt/controllers/tag_suggest_box_controller.py +++ b/src/tagstudio/qt/controllers/tag_suggest_box.py @@ -12,7 +12,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_controller import SuggestBox +from tagstudio.qt.controllers.suggest_box import SuggestBox from tagstudio.qt.mixed.build_tag import BuildTagPanel from tagstudio.qt.mixed.tag_widget import TagWidget from tagstudio.qt.translations import Translations diff --git a/src/tagstudio/qt/views/preview_panel_view.py b/src/tagstudio/qt/views/preview_panel_view.py index 908f67885..7e0dbd457 100644 --- a/src/tagstudio/qt/views/preview_panel_view.py +++ b/src/tagstudio/qt/views/preview_panel_view.py @@ -18,7 +18,7 @@ from tagstudio.qt.controllers.field_template_search_panel_controller import FieldTemplateSearchPanel from tagstudio.qt.controllers.preview_thumb_controller import PreviewThumb from tagstudio.qt.controllers.return_button import ReturnButton -from tagstudio.qt.controllers.tag_suggest_box_controller import TagSuggestBox +from tagstudio.qt.controllers.tag_suggest_box import TagSuggestBox from tagstudio.qt.mixed.field_containers import FieldContainers from tagstudio.qt.mixed.file_attributes import FileAttributeData, FileAttributes from tagstudio.qt.resource_manager import ResourceManager From 669b95d580440bde403a3bd83c88b265f8adb6dd Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Sat, 18 Jul 2026 03:04:06 -0700 Subject: [PATCH 05/23] feat: add setting to open edit window when creating tags --- src/tagstudio/qt/controllers/tag_suggest_box.py | 3 +-- src/tagstudio/qt/global_settings.py | 2 +- src/tagstudio/qt/mixed/settings_panel.py | 9 +++++++++ src/tagstudio/resources/translations/en.json | 1 + 4 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/tagstudio/qt/controllers/tag_suggest_box.py b/src/tagstudio/qt/controllers/tag_suggest_box.py index c4c9afb5f..0b4aac21c 100644 --- a/src/tagstudio/qt/controllers/tag_suggest_box.py +++ b/src/tagstudio/qt/controllers/tag_suggest_box.py @@ -30,7 +30,6 @@ def __init__(self, driver: "QtDriver", view: SuggestBoxView | None = None): super().__init__(driver, view=view or SuggestBoxView()) self._driver = driver self._lib = self._driver.lib - self._edit_on_create = self._driver.settings.edit_tag_on_create @override def on_item_create(self) -> None: @@ -43,7 +42,7 @@ def on_item_create(self) -> None: """ query: str = self._layout.search_field.text() - if self._edit_on_create: + if self._driver.settings.edit_tag_on_create: panel: BuildTagPanel = BuildTagPanel(self._lib) modal: PanelModal = PanelModal( panel, Translations["tag.new"], Translations["tag.new"], is_savable=True diff --git a/src/tagstudio/qt/global_settings.py b/src/tagstudio/qt/global_settings.py index 58fb71927..b72df4dc3 100644 --- a/src/tagstudio/qt/global_settings.py +++ b/src/tagstudio/qt/global_settings.py @@ -74,10 +74,10 @@ class GlobalSettings(BaseModel): infinite_scroll: bool = Field(default=True) show_filepath: ShowFilepathOption = Field(default=ShowFilepathOption.DEFAULT) tag_click_action: TagClickActionOption = Field(default=TagClickActionOption.DEFAULT) + edit_tag_on_create: bool = Field(default=False) theme: Theme = Field(default=Theme.SYSTEM) splash: Splash = Field(default=Splash.DEFAULT) windows_start_command: bool = Field(default=False) - edit_tag_on_create: bool = Field(default=False) date_format: str = Field(default="%x") hour_format: bool = Field(default=True) diff --git a/src/tagstudio/qt/mixed/settings_panel.py b/src/tagstudio/qt/mixed/settings_panel.py index 61bd93a79..37e630fb2 100644 --- a/src/tagstudio/qt/mixed/settings_panel.py +++ b/src/tagstudio/qt/mixed/settings_panel.py @@ -216,6 +216,13 @@ def on_page_size_changed(): Translations["settings.tag_click_action.label"], self.tag_click_action_combobox ) + # Open Edit Window When Creating Tag + self.edit_tag_on_create_checkbox = QCheckBox() + self.edit_tag_on_create_checkbox.setChecked(self.driver.settings.edit_tag_on_create) + form_layout.addRow( + Translations["settings.edit_tag_on_create"], self.edit_tag_on_create_checkbox + ) + # TODO: Implement Library Settings def __build_library_settings(self): # pyright: ignore[reportUnusedFunction] form_layout = QFormLayout(self.library_settings_container) @@ -366,6 +373,7 @@ def get_settings(self) -> dict[str, Any]: # pyright: ignore[reportExplicitAny] "show_filepath": self.filepath_combobox.currentData(), "theme": self.theme_combobox.currentData(), "tag_click_action": self.tag_click_action_combobox.currentData(), + "edit_tag_on_create": self.edit_tag_on_create_checkbox.isChecked(), "date_format": self.dateformat_combobox.currentData(), "hour_format": self.hourformat_checkbox.isChecked(), "zero_padding": self.zeropadding_checkbox.isChecked(), @@ -388,6 +396,7 @@ def update_settings(self, driver: "QtDriver"): driver.settings.show_filepath = settings["show_filepath"] driver.settings.theme = settings["theme"] driver.settings.tag_click_action = settings["tag_click_action"] + driver.settings.edit_tag_on_create = settings["edit_tag_on_create"] driver.settings.date_format = settings["date_format"] driver.settings.hour_format = settings["hour_format"] driver.settings.zero_padding = settings["zero_padding"] diff --git a/src/tagstudio/resources/translations/en.json b/src/tagstudio/resources/translations/en.json index 6dacb4375..1898b754e 100644 --- a/src/tagstudio/resources/translations/en.json +++ b/src/tagstudio/resources/translations/en.json @@ -316,6 +316,7 @@ "settings.dateformat.international": "International", "settings.dateformat.label": "Date Format", "settings.dateformat.system": "System", + "settings.edit_tag_on_create": "Edit Tag After Creation", "settings.filepath.label": "Filepath Visibility", "settings.filepath.option.full": "Show Full Paths", "settings.filepath.option.name": "Show Filenames Only", From d98beaf4448bf08505fd1d96b507547d9bd331da Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Sat, 18 Jul 2026 03:40:03 -0700 Subject: [PATCH 06/23] feat(ui): add edit tag setting to context menu, tweak translations --- .../qt/controllers/tag_suggest_box.py | 30 ++++++++--- src/tagstudio/qt/views/preview_panel_view.py | 6 +-- src/tagstudio/qt/views/suggest_box_view.py | 4 +- src/tagstudio/resources/translations/en.json | 51 ++++++++++--------- 4 files changed, 55 insertions(+), 36 deletions(-) diff --git a/src/tagstudio/qt/controllers/tag_suggest_box.py b/src/tagstudio/qt/controllers/tag_suggest_box.py index 0b4aac21c..622771d02 100644 --- a/src/tagstudio/qt/controllers/tag_suggest_box.py +++ b/src/tagstudio/qt/controllers/tag_suggest_box.py @@ -7,6 +7,7 @@ from warnings import catch_warnings import structlog +from PySide6.QtGui import QAction, Qt from PySide6.QtWidgets import QGraphicsOpacityEffect, QWidget from tagstudio.core.library.alchemy.enums import BrowsingState @@ -31,6 +32,29 @@ def __init__(self, driver: "QtDriver", view: SuggestBoxView | None = None): self._driver = driver self._lib = self._driver.lib + # Context Menu Actions + edit_tag_on_create_action = QAction(Translations["settings.edit_tag_on_create"], self) + edit_tag_on_create_action.setCheckable(True) + self.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) + self.addAction(edit_tag_on_create_action) + self.layout().search_field.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) + self.layout().search_field.addAction(edit_tag_on_create_action) + edit_tag_on_create_action.setChecked(self._driver.settings.edit_tag_on_create) + edit_tag_on_create_action.triggered.connect( + lambda checked: self.toggle_edit_on_tag_create(checked) + ) + + def search_for_tag(self, tag_id: int) -> None: + self._driver.main_window.search_field.setText(f"tag_id:{tag_id}") + self._driver.update_browsing_state( + BrowsingState.from_tag_id(tag_id, self._driver.browsing_history.current) + ) + + def toggle_edit_on_tag_create(self, checked: bool) -> None: + """Toggle the setting for opening the edit window after creating a tag..""" + self._driver.settings.edit_tag_on_create = checked + self._driver.settings.save() + @override def on_item_create(self) -> None: """Opens panel to create a new tag and optionally add it to an entry. @@ -143,12 +167,6 @@ def edit_item(self, edit_item_panel: PanelWidget) -> None: ) self.update_items(self._layout.search_field.text()) - def search_for_tag(self, tag_id: int) -> None: - self._driver.main_window.search_field.setText(f"tag_id:{tag_id}") - self._driver.update_browsing_state( - BrowsingState.from_tag_id(tag_id, self._driver.browsing_history.current) - ) - @override def get_item_widget(self, index: int, library: Library | None) -> TagWidget: """Gets the item widget at a specific index.""" diff --git a/src/tagstudio/qt/views/preview_panel_view.py b/src/tagstudio/qt/views/preview_panel_view.py index 7e0dbd457..448858838 100644 --- a/src/tagstudio/qt/views/preview_panel_view.py +++ b/src/tagstudio/qt/views/preview_panel_view.py @@ -47,11 +47,11 @@ def __init__(self, driver: "QtDriver") -> None: view=FieldTemplateSearchPanelView(is_field_template_chooser=True), ) - tag_placeholder_text = ( - f"{Translations['home.search_tags']} {Translations['home.search.how_to_exit']}" + tag_placeholder = " ".join( + [Translations["home.search_or_create_tags"], Translations["home.search.how_to_exit"]] ) self.tag_search_box = TagSuggestBox( - driver, view=SuggestBoxView(placeholder=tag_placeholder_text) + driver, view=SuggestBoxView(placeholder_text=tag_placeholder) ) self.tag_search_box.hide() diff --git a/src/tagstudio/qt/views/suggest_box_view.py b/src/tagstudio/qt/views/suggest_box_view.py index fc961a6d2..da07a9c59 100644 --- a/src/tagstudio/qt/views/suggest_box_view.py +++ b/src/tagstudio/qt/views/suggest_box_view.py @@ -27,7 +27,7 @@ class SuggestBoxView(QVBoxLayout): - def __init__(self, placeholder: str = "") -> None: + def __init__(self, placeholder_text: str = "") -> None: super().__init__() # Init layout self.setContentsMargins(0, 0, 0, 0) @@ -79,7 +79,7 @@ def __init__(self, placeholder: str = "") -> None: self.search_field.setStyleSheet(autofill_line_edit_style()) self.search_field.setObjectName("search_field") self.search_field.setMinimumHeight(28) - self.search_field.setPlaceholderText(placeholder) + self.search_field.setPlaceholderText(placeholder_text) self.scroll_area.setFocusProxy(self.search_field) # Finalize layout diff --git a/src/tagstudio/resources/translations/en.json b/src/tagstudio/resources/translations/en.json index 1898b754e..199aef6c0 100644 --- a/src/tagstudio/resources/translations/en.json +++ b/src/tagstudio/resources/translations/en.json @@ -30,9 +30,9 @@ "drop_import.description": "The following files match file paths that already exist in the library", "drop_import.duplicates_choice.plural": "The following {count} files match file paths that already exist in the library.", "drop_import.duplicates_choice.singular": "The following file matches a file path that already exists in the library.", - "drop_import.progress.label.initial": "Importing New Files...", - "drop_import.progress.label.plural": "Importing New Files...\n{count} Files Imported.{suffix}", - "drop_import.progress.label.singular": "Importing New Files...\n1 File imported.{suffix}", + "drop_import.progress.label.initial": "Importing New Files…", + "drop_import.progress.label.plural": "Importing New Files…\n{count} Files Imported.{suffix}", + "drop_import.progress.label.singular": "Importing New Files…\n1 File imported.{suffix}", "drop_import.progress.window_title": "Import Files", "drop_import.title": "Conflicting File(s)", "edit.color_manager": "Manage Tag Colors", @@ -40,26 +40,26 @@ "edit.paste_fields": "Paste Fields", "edit.tag_manager": "Manage Tags", "entries.duplicate.merge": "Merge Duplicate Entries", - "entries.duplicate.merge.label": "Merging Duplicate Entries...", + "entries.duplicate.merge.label": "Merging Duplicate Entries…", "entries.duplicate.refresh": "Refresh Duplicate Entries", "entries.duplicates.description": "Duplicate entries are defined as multiple entries which point to the same file on disk. Merging these will combine the tags and metadata from all duplicates into a single consolidated entry. These are not to be confused with \"duplicate files\", which are duplicates of your files themselves outside of TagStudio.", "entries.generic.refresh_alt": "&Refresh", "entries.generic.remove.removing": "Removing Entries", - "entries.generic.remove.removing_count": "Removing {count} Entries...", + "entries.generic.remove.removing_count": "Removing {count} Entries…", "entries.ignored.description": "File entries are considered to be \"ignored\" if they were added to the library before the user's ignore rules (via the '.ts_ignore' file) were updated to exclude it. Ignored files are kept in the library by default in order to prevent accidental data loss when updating ignore rules.", "entries.ignored.ignored_count": "Ignored Entries: {count}", "entries.ignored.remove": "Remove Ignored Entries", "entries.ignored.remove_alt": "Remo&ve Ignored Entries", - "entries.ignored.scanning": "Scanning Library for Ignored Entries...", + "entries.ignored.scanning": "Scanning Library for Ignored Entries…", "entries.ignored.title": "Fix Ignored Entries", "entries.mirror": "&Mirror", "entries.mirror.confirmation": "Are you sure you want to mirror the following {count} Entries?", - "entries.mirror.label": "Mirroring {idx}/{total} Entries...", + "entries.mirror.label": "Mirroring {idx}/{total} Entries…", "entries.mirror.title": "Mirroring Entries", "entries.mirror.window_title": "Mirror Entries", "entries.remove.plural.confirm": "Are you sure you want to remove these {count} entries from your library? No files on disk will be deleted.", "entries.remove.singular.confirm": "Are you sure you want to remove this entry from your library? No files on disk will be deleted.", - "entries.running.dialog.new_entries": "Adding {total} New File Entries...", + "entries.running.dialog.new_entries": "Adding {total} New File Entries…", "entries.running.dialog.title": "Adding New File Entries", "entries.tags": "Tags", "entries.unlinked.description": "Each library entry is linked to a file in one of your directories. If a file linked to an entry is moved or deleted outside of TagStudio, it is then considered unlinked.

Unlinked entries may be automatically relinked via searching your directories or deleted if desired.", @@ -68,7 +68,7 @@ "entries.unlinked.relink.title": "Relinking Entries", "entries.unlinked.remove": "Remove Unlinked Entries", "entries.unlinked.remove_alt": "Remo&ve Unlinked Entries", - "entries.unlinked.scanning": "Scanning Library for Unlinked Entries...", + "entries.unlinked.scanning": "Scanning Library for Unlinked Entries…", "entries.unlinked.search_and_relink": "&Search && Relink", "entries.unlinked.title": "Fix Unlinked Entries", "entries.unlinked.unlinked_count": "Unlinked Entries: {count}", @@ -161,10 +161,11 @@ "generic.yes": "Yes", "home.search": "Search", "home.search_entries": "Search Entries", - "home.search_field_templates": "Search Field Templates...", + "home.search_field_templates": "Search Field Templates…", "home.search_library": "Search Library", - "home.search_tags": "Search Tags...", - "home.search.how_to_exit": "(Esc/Enter to Exit)", + "home.search_or_create_tags": "Search or Create Tags…", + "home.search_tags": "Search Tags…", + "home.search.how_to_exit": "(Esc to Exit)", "home.search.view_limit": "View Limit:", "home.show_hidden_entries": "Show Hidden Entries", "home.thumbnail_size": "Thumbnail Size", @@ -174,8 +175,8 @@ "home.thumbnail_size.mini": "Mini Thumbnails", "home.thumbnail_size.small": "Small Thumbnails", "ignore.open_file": "Show \"{ts_ignore}\" File on Disk", - "json_migration.checking_for_parity": "Checking for Parity...", - "json_migration.creating_database_tables": "Creating SQL Database Tables...", + "json_migration.checking_for_parity": "Checking for Parity…", + "json_migration.creating_database_tables": "Creating SQL Database Tables…", "json_migration.description": "
Start and preview the results of the library migration process. The converted library will not be used unless you click \"Finish Migration\".

Library data should either have matching values or feature a \"Matched\" label. Values that do not match will be displayed in red and feature a \"(!)\" symbol next to them.
This process may take up to several minutes for larger libraries.
", "json_migration.discrepancies_found": "Library Discrepancies Found", "json_migration.discrepancies_found.description": "Discrepancies were found between the original and converted library formats. Please review and choose to whether continue with the migration or to cancel.", @@ -190,7 +191,7 @@ "json_migration.heading.paths": "Paths:", "json_migration.heading.shorthands": "Shorthands:", "json_migration.info.description": "Library save files created with TagStudio versions 9.4 and below will need to be migrated to the new v9.5+ format.

What you need to know:

What's changed: