diff --git a/docs/usage.md b/docs/usage.md index 2901e107..cc8f81cb 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 00000000..099e4157 --- /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 5b868bec..6d236c21 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) - @typing.override + 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() + + 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 00000000..1a13d9c1 --- /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 00000000..da948988 --- /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 00000000..687e1708 --- /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 6a7d5555..31a9ca64 100644 --- a/src/tagstudio/qt/mixed/field_containers.py +++ b/src/tagstudio/qt/mixed/field_containers.py @@ -10,7 +10,6 @@ from warnings import catch_warnings import structlog from PySide6.QtCore import Qt -from PySide6.QtGui import QGuiApplication from PySide6.QtWidgets import ( QFrame, QHBoxLayout, @@ -21,7 +20,6 @@ from PySide6.QtWidgets import ( QWidget, ) -from tagstudio.core.enums import Theme from tagstudio.core.library.alchemy.fields import ( BaseField, BaseFieldTemplate, @@ -38,6 +36,7 @@ from tagstudio.qt.mixed.field_widget import FieldContainer 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 @@ class FieldContainers(QWidget): 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 @@ class FieldContainers(QWidget): # 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 @@ class FieldContainers(QWidget): 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 cf114f10..67112cb8 100644 --- a/src/tagstudio/qt/mixed/tag_widget.py +++ b/src/tagstudio/qt/mixed/tag_widget.py @@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, override 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 @@ class TagWidget(QWidget): # 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 @@ class TagWidget(QWidget): 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 120dab72..006b6bcf 100644 --- a/src/tagstudio/qt/models/palette.py +++ b/src/tagstudio/qt/models/palette.py @@ -7,12 +7,34 @@ from enum import IntEnum 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 f18ff1ac..ac555f92 100644 --- a/src/tagstudio/qt/ts_qt.py +++ b/src/tagstudio/qt/ts_qt.py @@ -51,8 +51,6 @@ from tagstudio.core.library.refresh import RefreshTracker 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.migration_modal import JsonMigrationModal 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 @@ class QtDriver(DriverMixin, QObject): 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 @@ class QtDriver(DriverMixin, QObject): 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 @@ class QtDriver(DriverMixin, QObject): 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 322fed8c..5607268b 100644 --- a/src/tagstudio/qt/views/main_window.py +++ b/src/tagstudio/qt/views/main_window.py @@ -182,10 +182,10 @@ class MainMenuBar(QMenuBar): 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 @@ class MainMenuBar(QMenuBar): # 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 @@ class MainWindow(QMainWindow): 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 7ebc8c56..d0f2292f 100644 --- a/src/tagstudio/qt/views/preview_panel_view.py +++ b/src/tagstudio/qt/views/preview_panel_view.py @@ -10,25 +10,23 @@ from pathlib import Path 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 @@ logger = structlog.get_logger(__name__) class PreviewPanelView(QWidget): lib: Library - _selected: list[int] def __init__(self, library: Library, driver: "QtDriver") -> None: @@ -46,18 +43,32 @@ class PreviewPanelView(QWidget): 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 @@ class PreviewPanelView(QWidget): 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 @@ class PreviewPanelView(QWidget): 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 @@ class PreviewPanelView(QWidget): 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 @@ class PreviewPanelView(QWidget): 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 @@ class PreviewPanelView(QWidget): 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 @@ class PreviewPanelView(QWidget): 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 @@ class PreviewPanelView(QWidget): @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 6a9e722b..ef496f6e 100644 --- a/src/tagstudio/qt/views/stylesheets/stylesheets.py +++ b/src/tagstudio/qt/views/stylesheets/stylesheets.py @@ -8,7 +8,7 @@ from PySide6.QtGui import QColor, QGuiApplication 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 00000000..9357b580 --- /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 e5c96462..a3097cb1 100644 --- a/src/tagstudio/qt/views/tag_box_view.py +++ b/src/tagstudio/qt/views/tag_box_view.py @@ -39,17 +39,12 @@ class TagBoxWidgetView(FieldWidget): 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 1b081703..be67f63f 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 @@ class TagSearchPanelView(SearchPanelView): 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 00000000..51601bf1 --- /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 0742b93d..6dacb437 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",