feat(ui): replace add tag modal with autocomplete search/create bar

This commit is contained in:
Travis Abendshien
2026-07-17 17:06:12 -07:00
parent 51a9c16f50
commit fdc01b7491
18 changed files with 1075 additions and 129 deletions
+1 -1
View File
@@ -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 <kbd>Ctrl</kbd>+<kbd>T</kbd>. 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 <kbd>Ctrl</kbd>+<kbd>N</kbd>. 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.
@@ -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)
@@ -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)
@@ -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)
@@ -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()
@@ -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()
+12 -11
View File
@@ -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]
+8 -6
View File
@@ -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)
+22
View File
@@ -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
+17 -8
View File
@@ -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):
+6 -4
View File
@@ -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(
+55 -62
View File
@@ -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
+159 -11
View File
@@ -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 {{
@@ -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)
-5
View File
@@ -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:
@@ -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
@@ -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)
+3 -2
View File
@@ -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",