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",