diff --git a/docs/usage.md b/docs/usage.md index 2901e1074..cc8f81cba 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -40,7 +40,7 @@ Hover over the field and click the pencil icon. From there, add or edit text in ## Creating Tags -Create a new tag by accessing the "New Tag" option from the Edit menu or by pressing Ctrl+T. In the tag creation panel, enter a tag name, optional shorthand name, optional tag aliases, optional parent tags, and an optional color. +Create a new tag by accessing the "New Tag" option from the Edit menu or by pressing Ctrl+N. In the tag creation panel, enter a tag name, optional shorthand name, optional tag aliases, optional parent tags, and an optional color. - The tag **name** is the base name of the tag. **_This does NOT have to be unique!_** - The tag **shorthand** is a special type of alias that displays in situations where screen space is more valuable, notably with name disambiguation. diff --git a/src/tagstudio/qt/controllers/autofill_line_edit.py b/src/tagstudio/qt/controllers/autofill_line_edit.py new file mode 100644 index 000000000..2247dafdb --- /dev/null +++ b/src/tagstudio/qt/controllers/autofill_line_edit.py @@ -0,0 +1,61 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + +from typing import 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, +) + +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/edit_field_template_modal.py b/src/tagstudio/qt/controllers/edit_field_template_modal.py index 5ac7cf308..4a2a11748 100644 --- a/src/tagstudio/qt/controllers/edit_field_template_modal.py +++ b/src/tagstudio/qt/controllers/edit_field_template_modal.py @@ -73,8 +73,8 @@ def __on_name_changed(self): self.name_field.setStyleSheet(line_edit_style() if is_empty else "") - if self.panel_save_button is not None: - self.panel_save_button.setDisabled(is_empty) + if self.save_button is not None: + self.save_button.setDisabled(is_empty) def __on_type_changed(self, index: int): old_type = self.__field_type diff --git a/src/tagstudio/qt/controllers/field_suggest_box.py b/src/tagstudio/qt/controllers/field_suggest_box.py new file mode 100644 index 000000000..2a20ab765 --- /dev/null +++ b/src/tagstudio/qt/controllers/field_suggest_box.py @@ -0,0 +1,156 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + + +import typing +from typing import override +from warnings import catch_warnings + +import structlog +from PySide6.QtGui import QAction, Qt +from PySide6.QtWidgets import QWidget + +from tagstudio.core.library.alchemy.fields import BaseFieldTemplate +from tagstudio.core.library.alchemy.library import Library +from tagstudio.qt.controllers.edit_field_template_modal import EditFieldTemplateModal +from tagstudio.qt.controllers.field_template_widget_controller import FieldTemplateWidget +from tagstudio.qt.controllers.modal import Modal +from tagstudio.qt.controllers.modal_content import ModalContent +from tagstudio.qt.controllers.suggest_box import SuggestBox +from tagstudio.qt.controllers.underlined_widget import UnderlinedWidget +from tagstudio.qt.translations import Translations + +if typing.TYPE_CHECKING: + from tagstudio.qt.ts_qt import QtDriver + +logger = structlog.get_logger(__name__) + + +class FieldSuggestBox(SuggestBox[BaseFieldTemplate]): + def __init__(self, driver: "QtDriver", placeholder_text: str = ""): + super().__init__(driver, placeholder_text) + self._lib = self._driver.lib + + # Context Menu Actions + edit_field_on_add_action = QAction(Translations["settings.edit_field_on_add"], self) + edit_field_on_add_action.setCheckable(True) + self.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) + self.addAction(edit_field_on_add_action) + self.layout().search_field.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) + self.layout().search_field.addAction(edit_field_on_add_action) + edit_field_on_add_action.setChecked(self._driver.settings.edit_field_on_add) + edit_field_on_add_action.triggered.connect( + lambda checked: self.toggle_edit_on_field_add(checked) + ) + + def toggle_edit_on_field_add(self, checked: bool) -> None: + """Toggle the setting for opening the edit window after adding a field.""" + self._driver.settings.edit_field_on_add = checked + self._driver.settings.save() + + @override + def _on_item_create(self) -> None: + """Creates a new field template and adds it to the currently selected entries. + + Optionally opens up an edit panel after creation and before adding to entries. + Populates name field using current search query. + """ + # NOTE: Unlike tags, creating new field templates will ALWAYS spawn an edit window + # since the user needs to decide what type of field it should be before it's created. + query: str = self.layout().search_field.text() + panel = EditFieldTemplateModal() + modal = Modal( + panel, + Translations["field_template.new"], + Translations["field_template.new"], + is_savable=True, + ) + if query.strip(): + panel.name_field.setText(query) + + modal.saved.connect(lambda: self._create_item_from_modal(panel)) + modal.show() + + @override + def _on_item_edit(self, item: BaseFieldTemplate) -> None: + panel: EditFieldTemplateModal = EditFieldTemplateModal(item) + modal: Modal = Modal(panel, item.name, Translations["field_template.edit"], is_savable=True) + + modal.saved.connect(lambda: self._edit_item(panel)) + modal.show() + + @override + def _on_item_chosen(self, item: BaseFieldTemplate) -> None: + self.item_chosen.emit(item) + self.done.emit() + + @override + def _search_items(self, query: str) -> tuple[list[BaseFieldTemplate], list[BaseFieldTemplate]]: + if query != "": + return self._lib.search_field_templates(name=query, limit=0), [] + else: + return ([], []) + + @override + def _set_item_widget(self, item: BaseFieldTemplate | None, index: int) -> None: + """Set the field template of a field template widget at a specific index.""" + underlined_widget: UnderlinedWidget = self._get_item_widget(index, self._lib) + field_template_widget = underlined_widget.widget + assert isinstance(field_template_widget, FieldTemplateWidget) + field_template_widget.has_remove = False + field_template_widget.set_field_template(item) + underlined_widget.setHidden(item is None) + + if item is None: + return + + # TODO: Add tabbing to different items, and use underline to indicate which will be added + underlined_widget.toggle_underline(index != 0) + + # Disconnect previous callbacks + with catch_warnings(record=True): + field_template_widget.on_edit.disconnect() + field_template_widget.on_remove.disconnect() + field_template_widget.on_click.disconnect() + + # Connect callbacks + field_template_widget.on_edit.connect(lambda item_=item: self._on_item_edit(item_)) + field_template_widget.on_click.connect( + lambda checked=False, item_=item: self._on_item_chosen(item_) + ) + + @override + def _create_item_from_modal(self, edit_item_panel: ModalContent) -> None: + if isinstance(edit_item_panel, EditFieldTemplateModal): + template: BaseFieldTemplate = edit_item_panel.build_field_template() + self._lib.add_field_template(template) + self._on_item_chosen(template) + self._clear_search_query() + + edit_item_panel.hide() + self._on_search_query_changed(self.layout().search_field.text()) + + @override + def _edit_item(self, edit_item_panel: ModalContent) -> None: + if not isinstance(edit_item_panel, EditFieldTemplateModal): + return + + self._lib.update_field_template( + edit_item_panel.old_field_type, edit_item_panel.build_field_template() + ) + self._update_items(self.layout().search_field.text()) + + @override + def _get_item_widget(self, index: int, library: Library | None) -> UnderlinedWidget: + """Gets the item widget at a specific index.""" + # Create any new item widgets needed up to the given index + if self.layout().content_layout.count() <= index: + while self.layout().content_layout.count() <= index: + field_template_widget = FieldTemplateWidget() + widget = UnderlinedWidget(field_template_widget) + widget.setHidden(True) + self.layout().content_layout.addWidget(widget) + + widget_: QWidget = self.layout().content_layout.itemAt(index).widget() + assert isinstance(widget_, UnderlinedWidget) + return widget_ diff --git a/src/tagstudio/qt/controllers/field_template_search_panel_controller.py b/src/tagstudio/qt/controllers/field_template_search_panel_controller.py index 45c126acb..36d45e7ba 100644 --- a/src/tagstudio/qt/controllers/field_template_search_panel_controller.py +++ b/src/tagstudio/qt/controllers/field_template_search_panel_controller.py @@ -13,15 +13,16 @@ from tagstudio.core.library.alchemy.library import Library from tagstudio.qt.controllers.edit_field_template_modal import EditFieldTemplateModal from tagstudio.qt.controllers.field_template_widget_controller import FieldTemplateWidget +from tagstudio.qt.controllers.modal import Modal +from tagstudio.qt.controllers.modal_content import ModalContent from tagstudio.qt.controllers.search_panel_controller import SearchPanel from tagstudio.qt.translations import Translations from tagstudio.qt.views.field_template_search_panel_view import FieldTemplateSearchPanelView -from tagstudio.qt.views.panel_modal import PanelModal, PanelWidget logger = structlog.get_logger(__name__) -class FieldTemplateSearchModal(PanelModal): +class FieldTemplateSearchModal(Modal): def __init__( self, library: Library, @@ -33,11 +34,7 @@ def __init__( is_field_template_chooser, view=FieldTemplateSearchPanelView(is_field_template_chooser), ) - super().__init__( - self.search_panel, - Translations["field.add.plural"], - is_savable=has_save, - ) + super().__init__(self.search_panel, Translations["field.add.plural"], is_savable=has_save) class FieldTemplateSearchPanel(SearchPanel[BaseFieldTemplate]): @@ -76,7 +73,7 @@ def on_item_create(self, add_to_entry: bool = False) -> None: logger.info("[FieldTemplateSearch] Create and Add Field Template", name=query) panel: EditFieldTemplateModal = EditFieldTemplateModal() - modal: PanelModal = PanelModal( + modal: Modal = Modal( panel, Translations["field_template.new"], Translations["field_template.new"], @@ -93,7 +90,7 @@ def on_item_create(self, add_to_entry: bool = False) -> None: def on_item_edit(self, item: BaseFieldTemplate) -> None: panel: EditFieldTemplateModal = EditFieldTemplateModal(item) - modal: PanelModal = PanelModal( + modal: Modal = Modal( panel, item.name, Translations["field_template.edit"], @@ -157,7 +154,7 @@ def set_item_widget(self, item: BaseFieldTemplate | None, index: int) -> None: ) @override - def create_item(self, edit_item_panel: PanelWidget, choose_item: bool = False) -> None: + def create_item(self, edit_item_panel: ModalContent, choose_item: bool = False) -> None: if isinstance(edit_item_panel, EditFieldTemplateModal): template: BaseFieldTemplate = edit_item_panel.build_field_template() @@ -171,7 +168,7 @@ def create_item(self, edit_item_panel: PanelWidget, choose_item: bool = False) - self.on_search_query_changed(self.get_search_query()) @override - def edit_item(self, edit_item_panel: PanelWidget) -> None: + def edit_item(self, edit_item_panel: ModalContent) -> None: if not isinstance(edit_item_panel, EditFieldTemplateModal): return diff --git a/src/tagstudio/qt/controllers/modal.py b/src/tagstudio/qt/controllers/modal.py new file mode 100644 index 000000000..f5417bba8 --- /dev/null +++ b/src/tagstudio/qt/controllers/modal.py @@ -0,0 +1,85 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + + +import contextlib +from typing import Any, override + +import structlog +from PySide6 import QtGui +from PySide6.QtCore import Qt, Signal +from PySide6.QtWidgets import QWidget + +from tagstudio.qt.controllers.modal_content import ModalContent +from tagstudio.qt.views.modal_view import ModalView + +logger = structlog.get_logger(__name__) + + +class Modal(QWidget): + """A generic modal window widget with common signals and styling.""" + + done = Signal() + saved = Signal() + saved_data = Signal(type(Any)) + + def __init__( + self, + content_widget: ModalContent, + title: str = "", + window_title: str | None = None, + is_savable: bool = False, + inline_title: bool = True, + ): + super().__init__() + self.setWindowTitle(title if window_title is None else window_title) + self.setWindowModality(Qt.WindowModality.ApplicationModal) + self.setLayout( + ModalView( + content_widget=content_widget, + title=title, + is_savable=is_savable, + inline_title=inline_title, + ) + ) + + # [Done] + # - OR - + # [Cancel] [Save] + if not is_savable: + done_button = self.layout().content_widget.done_button + if done_button: + done_button.clicked.connect(self.hide) + done_button.clicked.connect(self.done.emit) + else: + cancel_button = self.layout().content_widget.cancel_button + if cancel_button: + cancel_button.clicked.connect(self.hide) + cancel_button.clicked.connect(content_widget.reset) + + save_button = self.layout().content_widget.save_button + if save_button: + save_button.clicked.connect(self.hide) + save_button.clicked.connect(self.saved.emit) + save_button.clicked.connect( + lambda: self.saved_data.emit(content_widget.saved_data()) + ) + + content_widget.parent_post_init() + + @override + def closeEvent(self, event: QtGui.QCloseEvent) -> None: + with contextlib.suppress(AttributeError): + cancel_button = self.layout().content_widget.cancel_button + if cancel_button: + cancel_button.click() + with contextlib.suppress(AttributeError): + done_button = self.layout().content_widget.done_button + if done_button: + done_button.click() + event.accept() + + @override + def layout(self) -> ModalView: + """Return the typed layout for this widget.""" + return super().layout() # pyright: ignore[reportReturnType] diff --git a/src/tagstudio/qt/controllers/modal_content.py b/src/tagstudio/qt/controllers/modal_content.py new file mode 100644 index 000000000..0a4348d91 --- /dev/null +++ b/src/tagstudio/qt/controllers/modal_content.py @@ -0,0 +1,47 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + + +from typing import Any, override + +import structlog +from PySide6 import QtCore, QtGui +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QPushButton, QWidget + +logger = structlog.get_logger(__name__) + + +class ModalContent(QWidget): + """Base class for widgets that go inside a Modal widget.""" + + save_button: QPushButton | None = None + cancel_button: QPushButton | None = None + done_button: QPushButton | None = None + + def __init__(self): + super().__init__() + + def saved_data(self) -> Any: # pyright: ignore[reportExplicitAny] + return None + + def reset(self) -> None: + pass + + def parent_post_init(self) -> None: + pass + + @override + def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: + if event.key() == QtCore.Qt.Key.Key_Escape: + if self.cancel_button: + self.cancel_button.click() + elif self.done_button: + self.done_button.click() + elif event.key() == Qt.Key.Key_Return or event.key() == Qt.Key.Key_Enter: + if self.save_button: + self.save_button.click() + elif self.done_button: + self.done_button.click() + else: # Other key presses + super().keyPressEvent(event) diff --git a/src/tagstudio/qt/controllers/preview_panel_controller.py b/src/tagstudio/qt/controllers/preview_panel_controller.py index 5b868bec4..2967f843b 100644 --- a/src/tagstudio/qt/controllers/preview_panel_controller.py +++ b/src/tagstudio/qt/controllers/preview_panel_controller.py @@ -3,63 +3,279 @@ import typing +from datetime import datetime as dt +from enum import IntEnum +from functools import partial +from pathlib import Path +from typing import override from warnings import catch_warnings -from tagstudio.core.library.alchemy.fields import BaseFieldTemplate -from tagstudio.core.library.alchemy.library import Library +import structlog +from PySide6 import QtCore +from PySide6.QtGui import QShortcut +from PySide6.QtWidgets import QWidget + +from tagstudio.core.library.alchemy.fields import ( + BaseField, + BaseFieldTemplate, + DatetimeField, + DatetimeFieldTemplate, + TextField, + TextFieldTemplate, +) +from tagstudio.core.library.alchemy.models import Entry 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.core.utils.types import unwrap +from tagstudio.qt.controllers.edit_text_controller import EditText +from tagstudio.qt.controllers.modal import Modal +from tagstudio.qt.mixed.datetime_picker import DatetimePicker +from tagstudio.qt.mixed.field_containers import FieldContainers +from tagstudio.qt.mixed.file_attributes import FileAttributeData +from tagstudio.qt.translations import FIELD_TYPE_KEYS, Translations 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 _ItemMode(IntEnum): + TAG = 1 + FIELD = 2 -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 +class PreviewPanel(QWidget): + def __init__(self, driver: "QtDriver") -> None: + super().__init__() + self._driver = driver + self._lib = self._driver.lib + self._selected: list[int] + self._current_stats: FileAttributeData | None = None + + self._open_tag_search_action = QShortcut( + QtCore.QKeyCombination( + QtCore.Qt.KeyboardModifier(QtCore.Qt.KeyboardModifier.ControlModifier), + QtCore.Qt.Key.Key_T, + ), + self, + ) + self._open_field_search_action = QShortcut( + QtCore.QKeyCombination( + QtCore.Qt.KeyboardModifier(QtCore.Qt.KeyboardModifier.ControlModifier), + QtCore.Qt.Key.Key_L, + ), + self, ) - self.__add_tag_modal.tsp.set_driver(driver) - self._thumb.check_ffmpeg.connect(self._toggle_ffmpeg_warning) - @typing.override - def _add_field_button_callback(self) -> None: - self.__add_field_modal.show() + self.setLayout(PreviewPanelView(driver=driver, pixel_ratio=self.devicePixelRatio())) + self._set_item_mode(None) + self._connect_callbacks() + + def _connect_callbacks(self) -> None: + # Tag Search + self.layout().add_tag_button.clicked.connect(lambda: self._set_item_mode(_ItemMode.TAG)) + self._open_tag_search_action.activated.connect(self._open_tag_search_callback) + self.layout().tag_search_box.done.connect(self._tag_added_callback) + self.layout().containers.on_tags_update.connect(self._update_added_callback) + + # Field Search + self.layout().add_field_button.clicked.connect(lambda: self._set_item_mode(_ItemMode.FIELD)) + self._open_field_search_action.activated.connect(self._open_field_search_callback) + self.layout().field_search_box.done.connect(self._field_added_callback) + + # Previews + self.layout().preview_thumb.stats_updated.connect(self._thumb_stats_updated_callback) + self.layout().preview_thumb.check_ffmpeg.connect(self._toggle_ffmpeg_warning) + + def _set_item_mode(self, mode: _ItemMode | None): + def hide_and_disable_buttons(): + self.layout().add_tag_button.setHidden(True) + self.layout().add_tag_button.setEnabled(False) + self.layout().add_field_button.setHidden(True) + self.layout().add_field_button.setEnabled(False) + + def restore_buttons(): + self.layout().add_tag_button.setHidden(False) + self.layout().add_tag_button.setEnabled(True) + self.layout().add_field_button.setHidden(False) + self.layout().add_field_button.setEnabled(True) + + if mode == _ItemMode.TAG: + self.layout().tag_search_box.added = self.layout().containers.tags + self.layout().field_search_box.hide_and_reset() + self.layout().tag_search_box.setHidden(False) + hide_and_disable_buttons() + elif mode == _ItemMode.FIELD: + self.layout().tag_search_box.hide_and_reset() + self.layout().field_search_box.setHidden(False) + hide_and_disable_buttons() + else: + self.layout().tag_search_box.hide_and_reset() + self.layout().field_search_box.hide_and_reset() + restore_buttons() + + def _open_tag_search_callback(self) -> None: + self.layout().add_tag_button.setFocus() + self.layout().add_tag_button.click() + + def _open_field_search_callback(self) -> None: + self.layout().add_field_button.setFocus() + self.layout().add_field_button.click() + + def _tag_added_callback(self): + self._set_item_mode(None) + self.layout().add_tag_button.setFocus() + + def _field_added_callback(self): + self._set_item_mode(None) + self.layout().add_field_button.setFocus() + + def _update_added_callback(self): + self.layout().tag_search_box.added = self.layout().containers.tags + + def _thumb_stats_updated_callback(self, filepath: Path, stats: FileAttributeData) -> None: + if len(self._selected) != 1: + return + + if filepath != self.layout().preview_thumb.current_file: + return + + if self._current_stats is None: + self._current_stats = FileAttributeData() - @typing.override - def _add_tag_button_callback(self) -> None: - self.__add_tag_modal.show() + 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.layout().file_attrs.update_stats(filepath, self._current_stats) - @typing.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.layout().field_search_box.item_chosen.disconnect() + self.layout().tag_search_box.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.layout().field_search_box.item_chosen.connect(self._add_field_to_selected) + self.layout().tag_search_box.item_chosen.connect(self._add_tag_to_selected) def _add_field_to_selected(self, template: BaseFieldTemplate) -> None: - self._containers.add_field_to_selected(template) + self.layout().containers.add_field_to_selected(template) + # TODO: Allow editing of fields across multiple entries at once. if len(self._selected) == 1: - self._containers.update_from_entry(self._selected[0]) + if self._driver.settings.edit_field_on_add: + entry = unwrap(self._lib.get_entry_full(self._selected[0])) + entry_field = None + if isinstance(template, TextFieldTemplate): + entry_field = entry.text_fields[-1] + elif isinstance(template, DatetimeFieldTemplate): + entry_field = entry.datetime_fields[-1] + if entry_field is not None: + self._edit_field(entry.id, entry_field) + + self.layout().containers.update_from_entry(self._selected[0]) + + def _edit_field(self, entry_id: int, field: BaseField) -> None: + # TODO: A lot of this code is similar to or straight up shared with FieldContainers. + # It's possible to reuse it later, after a FieldContainers refactor. + field_name_key: str = FIELD_TYPE_KEYS.get(field.class_name, "field_type.unknown") + + if type(field) is TextField: + edit_modal = Modal( + EditText(field.name, field.value, field.is_multiline), + window_title=f"{Translations['field.edit']} ({Translations[field_name_key]})", + is_savable=True, + inline_title=False, + ) + edit_modal.saved_data.connect( + partial(self.layout().containers.update_text_field_callback, field, entry_id) + ) + edit_modal.show() + elif type(field) is DatetimeField: + edit_modal = Modal( + DatetimePicker(self._driver, field.name, field.value or dt.now()), + window_title=f"{Translations['field.edit']} ({Translations[field_name_key]})", + is_savable=True, + inline_title=False, + ) + edit_modal.saved_data.connect( + partial(self.layout().containers.update_datetime_field_callback, field, entry_id) + ) + edit_modal.show() def _add_tag_to_selected(self, tag_id: int) -> None: - self._containers.add_tags_to_selected(tag_id) + self.layout().containers.add_tags_to_selected(tag_id) if len(self._selected) == 1: - self._containers.update_from_entry(self._selected[0]) + self.layout().containers.update_from_entry(self._selected[0]) def _toggle_ffmpeg_warning(self, enable_warning: bool = True) -> None: if enable_warning and (not FfmpegStatus.which() or not FfprobeStatus.which()): - self._ffmpeg_warning_widget.show() + self.layout().warning_banner.show() return - self._ffmpeg_warning_widget.hide() + self.layout().warning_banner.hide() + + def set_selection(self, selected: list[int], update_preview: bool = True) -> None: + """Render the panel widgets with the newest data from the Library. + + Args: + selected (list[int]): List of the IDs of the selected entries. + update_preview (bool): Should the file preview be updated? + (Only works with one or more items selected) + """ + self._selected = selected + self._set_item_mode(None) + try: + # No Items Selected + if len(selected) == 0: + self.layout().preview_thumb.hide_preview() + self._current_stats = None + self.layout().file_attrs.update_stats() + self.layout().file_attrs.update_date_label() + self.layout().containers.hide_containers() + self.layout().add_tag_button.setEnabled(False) + self.layout().add_field_button.setEnabled(False) + + # One Item Selected + elif len(selected) == 1: + entry_id = selected[0] + entry: Entry = unwrap(self._lib.get_entry(entry_id)) + + filepath: Path = unwrap(self._lib.library_dir) / entry.path + if filepath != self.layout().preview_thumb.current_file: + self._current_stats = None + + if update_preview: + stats: FileAttributeData = self.layout().preview_thumb.display_file(filepath) + self._current_stats = stats + self.layout().file_attrs.update_stats(filepath, stats) + self.layout().file_attrs.update_date_label(filepath) + self.layout().containers.update_from_entry(entry_id) + self._set_selection_callback() + + # Multiple Selected Items + elif len(selected) > 1: + # items: list[Entry] = [self.lib.get_entry_full(x) for x in self.driver.selected] + self.layout().preview_thumb.hide_preview() # TODO: Render mixed selection + self._current_stats = None + self.layout().file_attrs.update_multi_selection(len(selected)) + self.layout().file_attrs.update_date_label() + self.layout().containers.hide_containers() # TODO: Allow for mixed editing + self._set_selection_callback() + + except Exception as e: + logger.error("[Preview Panel] Error updating selection", error=e) + + def stop_media_playback(self) -> None: + """Stop any media playback in the preview panel.""" + self.layout().preview_thumb.media_player.stop() + + @property + def containers(self) -> FieldContainers: + return self.layout().containers + + @override + def layout(self) -> PreviewPanelView: + return super().layout() # pyright: ignore[reportReturnType] diff --git a/src/tagstudio/qt/controllers/return_button.py b/src/tagstudio/qt/controllers/return_button.py new file mode 100644 index 000000000..1a13d9c1e --- /dev/null +++ b/src/tagstudio/qt/controllers/return_button.py @@ -0,0 +1,27 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + + +import typing +from typing import override + +import structlog +from PySide6 import QtCore, QtGui +from PySide6.QtWidgets import QPushButton + +if typing.TYPE_CHECKING: + pass + +logger = structlog.get_logger(__name__) + + +class ReturnButton(QPushButton): + def __init__(self, *args, **kwargs) -> None: # pyright: ignore + super().__init__(*args, **kwargs) + + @override + def keyPressEvent(self, arg__1: QtGui.QKeyEvent) -> None: + if self.hasFocus() and arg__1.key() in {QtCore.Qt.Key.Key_Enter, QtCore.Qt.Key.Key_Return}: + self.click() + + super().keyPressEvent(arg__1) diff --git a/src/tagstudio/qt/controllers/search_panel_controller.py b/src/tagstudio/qt/controllers/search_panel_controller.py index 0b743931c..f26f3e33c 100644 --- a/src/tagstudio/qt/controllers/search_panel_controller.py +++ b/src/tagstudio/qt/controllers/search_panel_controller.py @@ -11,8 +11,8 @@ from PySide6.QtWidgets import QVBoxLayout, QWidget from tagstudio.core.library.alchemy.library import Library +from tagstudio.qt.controllers.modal_content import ModalContent from tagstudio.qt.translations import Translations -from tagstudio.qt.views.panel_modal import PanelWidget from tagstudio.qt.views.search_panel_view import SearchPanelView logger = structlog.get_logger(__name__) @@ -40,7 +40,7 @@ def _item_name(item: object) -> str: raise AttributeError() -class SearchPanel[T](PanelWidget): +class SearchPanel[T](ModalContent): item_chosen = Signal(int) def __init__( @@ -52,9 +52,9 @@ def __init__( 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.setLayout(QVBoxLayout(self)) + self.layout().setContentsMargins(0, 0, 0, 0) + self.layout().addWidget(self.view) self.view.connect_callbacks(self) self._driver: QtDriver | None = None self.exclude: list[int] = exclude or [] @@ -234,8 +234,8 @@ def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: # noqa N802 else: self.view.focus_search_box(select_all=True) - def create_item(self, edit_item_panel: PanelWidget, choose_item: bool = False) -> None: # pyright: ignore[reportUnusedParameter] + def create_item(self, edit_item_panel: ModalContent, choose_item: bool = False) -> None: # pyright: ignore[reportUnusedParameter] raise NotImplementedError() - def edit_item(self, edit_item_panel: PanelWidget) -> None: # pyright: ignore[reportUnusedParameter] + def edit_item(self, edit_item_panel: ModalContent) -> None: # pyright: ignore[reportUnusedParameter] raise NotImplementedError() diff --git a/src/tagstudio/qt/controllers/suggest_box.py b/src/tagstudio/qt/controllers/suggest_box.py new file mode 100644 index 000000000..34346e451 --- /dev/null +++ b/src/tagstudio/qt/controllers/suggest_box.py @@ -0,0 +1,229 @@ +# 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, QWidget + +from tagstudio.core.library.alchemy.library import Library +from tagstudio.qt.controllers.autofill_line_edit import QtCore, QtGui +from tagstudio.qt.controllers.modal_content import ModalContent +from tagstudio.qt.controllers.underlined_widget import UnderlinedWidget +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__) + +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](QWidget): + item_chosen = Signal(object) + done = Signal() + + def __init__(self, driver: "QtDriver", placeholder_text: str = "") -> None: + super().__init__() + self._driver = driver + self._limit = 5 + self._is_shift_held = False + self._search_results: list[T] = [] + self.added: list[int] = [] + self.excluded: list[int] = [] + + self.setLayout(SuggestBoxView(placeholder_text)) + self._connect_callbacks() + + def set_placeholder_text(self, text: str) -> None: + self.layout().search_field.setPlaceholderText(text) + + def hide_and_reset(self) -> None: + self.hide() + self.layout().search_field.setDisabled(True) + self._on_shift_held(held=False) + + def _connect_callbacks(self) -> None: + self.layout().search_field.textChanged.connect(self._on_search_query_changed) + self.layout().search_field.editingFinished.connect(self._editing_finished_callback) + self.layout().search_field.return_pressed.connect( + lambda: self._on_search_query_submitted(self.layout().search_field.text()) + ) + self.layout().search_field.shift_return_pressed.connect( + lambda: self._on_search_query_submitted( + self.layout().search_field.text(), always_create=True + ) + ) + + self.layout().search_field.shift_holding.connect(lambda held: self._on_shift_held(held)) + + def _on_shift_held(self, held: bool) -> None: + if held: + self._is_shift_held = True + opacity_effect = QGraphicsOpacityEffect(self) + opacity_effect.setOpacity(0.3) + if self.layout().content_layout.count() > 0: + underlined_widget = self.layout().content_layout.itemAt(0).widget() + assert isinstance(underlined_widget, UnderlinedWidget) + underlined_widget.widget.setGraphicsEffect(opacity_effect) + else: + self._is_shift_held = False + if self.layout().content_layout.count() > 0: + underlined_widget = self.layout().content_layout.itemAt(0).widget() + assert isinstance(underlined_widget, UnderlinedWidget) + underlined_widget.widget.setGraphicsEffect(None) # pyright: ignore[reportArgumentType] + + def _clear_search_query(self) -> None: + self.layout().search_field.setText("") + + def _get_item_widget(self, index: int, library: Library) -> Any: # pyright: ignore + 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.hide_and_reset() + return + elif not self.isHidden(): + self.layout().search_field.setFocus() + + # Create and add item if no search results + if (len(self._search_results) <= 0) or always_create: + self._on_item_create() + else: + self._on_item_chosen(self._search_results[0]) + + self._clear_search_query() + self._update_items() + + def _on_item_create(self) -> None: + raise NotImplementedError() + + def _on_item_edit(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.excluded + + def _update_items(self, query: str | None = None) -> None: + """Update the item list given a search query.""" + logger.info("[SearchPanel] Updating items", limit=self._limit) + + # 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.layout().content_layout.isEmpty(): + self.layout().scroll_area.setHidden(True) + self.layout().content_layout.setContentsMargins(0, 0, 0, 0) + self.layout().search_field.setStyleSheet(autofill_line_edit_style()) + else: + self.layout().scroll_area.setHidden(False) + self.layout().content_layout.setContentsMargins(6, 6, 6, 6) + self.layout().search_field.setStyleSheet(autofill_line_edit_top_style()) + + def _search_items(self, query: str) -> tuple[list[T], list[T]]: # pyright: ignore[reportUnusedParameter] + raise NotImplementedError() + + def _set_item_widget(self, item: T | None, index: int) -> None: # pyright: ignore[reportUnusedParameter] + raise NotImplementedError() + + def _editing_finished_callback(self) -> None: + if self.layout().search_field.text() == "": + self.done.emit() + self.hide_and_reset() + + def _create_item_from_modal(self, edit_item_panel: ModalContent) -> None: # pyright: ignore[reportUnusedParameter] + raise NotImplementedError() + + def _edit_item(self, edit_item_panel: ModalContent) -> None: # pyright: ignore[reportUnusedParameter] + raise NotImplementedError() + + @override + def showEvent(self, event: QShowEvent) -> None: + self._update_items() + self._on_shift_held(held=False) + self.layout().search_field.setDisabled(False) + self._clear_search_query() + return super().showEvent(event) + + @override + def layout(self) -> SuggestBoxView: + return super().layout() # pyright: ignore[reportReturnType] + + @override + def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: + # When Escape is pressed, focus back on the search box. + if event.key() in { + QtCore.Qt.Key.Key_Escape, + QtCore.Qt.Key.Key_Enter, + QtCore.Qt.Key.Key_Return, + }: + self.hide_and_reset() diff --git a/src/tagstudio/qt/controllers/tag_box_controller.py b/src/tagstudio/qt/controllers/tag_box_controller.py index 313f19cb1..11dc2a911 100644 --- a/src/tagstudio/qt/controllers/tag_box_controller.py +++ b/src/tagstudio/qt/controllers/tag_box_controller.py @@ -12,8 +12,8 @@ from tagstudio.core.library.alchemy.enums import BrowsingState from tagstudio.core.library.alchemy.models import Tag from tagstudio.core.utils.types import unwrap +from tagstudio.qt.controllers.modal import Modal from tagstudio.qt.mixed.build_tag import BuildTagPanel -from tagstudio.qt.views.panel_modal import PanelModal from tagstudio.qt.views.tag_box_view import TagBoxWidgetView if TYPE_CHECKING: @@ -74,7 +74,7 @@ def _on_remove(self, tag: Tag) -> None: def _on_edit(self, tag: Tag) -> None: build_tag_panel = BuildTagPanel(self.__driver.lib, tag=tag) - edit_modal = PanelModal( + edit_modal = Modal( build_tag_panel, self.__driver.lib.tag_display_name(tag), "Edit Tag", diff --git a/src/tagstudio/qt/controllers/tag_search_panel_controller.py b/src/tagstudio/qt/controllers/tag_search_panel_controller.py index 5a0e01e35..242e12412 100644 --- a/src/tagstudio/qt/controllers/tag_search_panel_controller.py +++ b/src/tagstudio/qt/controllers/tag_search_panel_controller.py @@ -12,18 +12,17 @@ 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.modal import Modal +from tagstudio.qt.controllers.modal_content import ModalContent from tagstudio.qt.controllers.search_panel_controller import SearchPanel 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_search_panel_view import TagSearchPanelView logger = structlog.get_logger(__name__) -class TagSearchModal(PanelModal): - tsp: "TagSearchPanel" - +class TagSearchModal(Modal): def __init__( self, library: Library, @@ -33,16 +32,9 @@ def __init__( has_save: bool = False, ): self.tsp = TagSearchPanel( - library, - exclude, - is_tag_chooser, - view=TagSearchPanelView(is_tag_chooser), - ) - super().__init__( - widget=self.tsp, - title=title, - is_savable=has_save, + library, exclude, is_tag_chooser, view=TagSearchPanelView(is_tag_chooser) ) + super().__init__(content_widget=self.tsp, title=title, is_savable=has_save) class TagSearchPanel(SearchPanel[Tag]): @@ -82,7 +74,7 @@ def on_item_create(self, add_to_entry: bool = False) -> None: query: str = self.get_search_query() panel: BuildTagPanel = BuildTagPanel(self.__lib) - modal: PanelModal = PanelModal( + modal: Modal = Modal( panel, Translations["tag.new"], Translations["tag.add"] if add_to_entry else Translations["tag.new"], @@ -101,7 +93,7 @@ def on_item_edit(self, item: Tag) -> None: 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_modal: Modal = Modal( edit_tag_panel, self.__lib.tag_display_name(item), Translations["tag.edit"], @@ -188,7 +180,7 @@ def set_item_widget(self, item: Tag | None, index: int) -> None: tag_widget.search_for_tag_action.setEnabled(False) @override - def create_item(self, edit_item_panel: PanelWidget, choose_item: bool = False) -> None: + def create_item(self, edit_item_panel: ModalContent, 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 @@ -206,7 +198,7 @@ def create_item(self, edit_item_panel: PanelWidget, choose_item: bool = False) - self.on_search_query_changed(self.get_search_query()) @override - def edit_item(self, edit_item_panel: PanelWidget) -> None: + def edit_item(self, edit_item_panel: ModalContent) -> None: # TODO: Move this to a top-level import from tagstudio.qt.mixed.build_tag import BuildTagPanel # here due to circular imports diff --git a/src/tagstudio/qt/controllers/tag_suggest_box.py b/src/tagstudio/qt/controllers/tag_suggest_box.py new file mode 100644 index 000000000..8ff0cd06f --- /dev/null +++ b/src/tagstudio/qt/controllers/tag_suggest_box.py @@ -0,0 +1,185 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + + +import typing +from typing import override +from warnings import catch_warnings + +import structlog +from PySide6.QtGui import QAction, Qt +from PySide6.QtWidgets import QGraphicsOpacityEffect, QWidget + +from tagstudio.core.library.alchemy.enums import BrowsingState +from tagstudio.core.library.alchemy.library import Library +from tagstudio.core.library.alchemy.models import Tag +from tagstudio.qt.controllers.modal import Modal +from tagstudio.qt.controllers.modal_content import ModalContent +from tagstudio.qt.controllers.suggest_box import SuggestBox +from tagstudio.qt.controllers.underlined_widget import UnderlinedWidget +from tagstudio.qt.mixed.build_tag import BuildTagPanel +from tagstudio.qt.mixed.tag_widget import TagWidget +from tagstudio.qt.translations import Translations + +if typing.TYPE_CHECKING: + from tagstudio.qt.ts_qt import QtDriver + +logger = structlog.get_logger(__name__) + + +class TagSuggestBox(SuggestBox[Tag]): + def __init__(self, driver: "QtDriver", placeholder_text: str = "") -> None: + super().__init__(driver, placeholder_text) + self._driver = driver + self._lib = self._driver.lib + + # Context Menu Actions + edit_tag_on_create_action = QAction(Translations["settings.edit_tag_on_create"], self) + edit_tag_on_create_action.setCheckable(True) + self.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) + self.addAction(edit_tag_on_create_action) + self.layout().search_field.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) + self.layout().search_field.addAction(edit_tag_on_create_action) + edit_tag_on_create_action.setChecked(self._driver.settings.edit_tag_on_create) + edit_tag_on_create_action.triggered.connect( + lambda checked: self.toggle_edit_on_tag_create(checked) + ) + + def _search_for_tag_callback(self, tag_id: int) -> None: + self._driver.main_window.search_field.setText(f"tag_id:{tag_id}") + self._driver.update_browsing_state( + BrowsingState.from_tag_id(tag_id, self._driver.browsing_history.current) + ) + + def toggle_edit_on_tag_create(self, checked: bool) -> None: + """Toggle the setting for opening the edit window after creating a tag.""" + self._driver.settings.edit_tag_on_create = checked + self._driver.settings.save() + + @override + def _on_item_create(self) -> None: + """Creates a new tag and adds it to the currently selected entries. + + Optionally opens up an edit panel after creation and before adding to entries. + Populates name field using current search query. + """ + query: str = self.layout().search_field.text() + + if self._driver.settings.edit_tag_on_create: + panel: BuildTagPanel = BuildTagPanel(self._lib) + modal: Modal = Modal( + panel, Translations["tag.new"], Translations["tag.new"], is_savable=True + ) + if query.strip(): + panel.name_field.setText(query) + + modal.saved.connect(lambda: self._create_item_from_modal(panel)) + modal.show() + else: + tag = Tag(name=query) + self._lib.add_tag(tag) + self._on_item_chosen(tag) + self._clear_search_query() + + @override + def _on_item_edit(self, item: Tag) -> None: + edit_tag_panel: BuildTagPanel = BuildTagPanel(self._lib, tag=item) + edit_tag_modal: Modal = Modal( + 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_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=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.""" + underlined_widget: UnderlinedWidget = self._get_item_widget(index, self._lib) + tag_widget = underlined_widget.widget + assert isinstance(tag_widget, TagWidget) + tag_widget.has_remove = False + tag_widget.set_tag(item) + underlined_widget.setHidden(item is None) + opacity_effect = QGraphicsOpacityEffect(self) + opacity_effect.setOpacity(0.3) + if item and item.id in self.added: + tag_widget.setGraphicsEffect(opacity_effect) + else: + tag_widget.setGraphicsEffect(None) # pyright: ignore[reportArgumentType] + + if item is None: + return + + # TODO: Add tabbing to different items, and use underline to indicate which will be added + underlined_widget.toggle_underline(index != 0) + + # Disconnect previous callbacks + with catch_warnings(record=True): + tag_widget.on_edit.disconnect() + 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.bg_button.clicked.connect( + lambda checked=False, tag=item: self._on_item_chosen(tag) + ) + tag_widget.search_for_tag_action.triggered.connect( + lambda checked=False, tag_id=item.id: self._search_for_tag_callback(tag_id) + ) + tag_widget.search_for_tag_action.setEnabled(True) + + @override + def _create_item_from_modal(self, edit_item_panel: ModalContent) -> None: + 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 + ) + self._on_item_chosen(tag) + self._clear_search_query() + + edit_item_panel.hide() + self._on_search_query_changed(self.layout().search_field.text()) + + @override + def _edit_item(self, edit_item_panel: ModalContent) -> None: + 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.layout().search_field.text()) + + @override + def _get_item_widget(self, index: int, library: Library | None) -> UnderlinedWidget: + """Gets the item widget at a specific index.""" + # Create any new item widgets needed up to the given index + if self.layout().content_layout.count() <= index: + while self.layout().content_layout.count() <= index: + tag_widget = TagWidget(tag=None, has_edit=True, has_remove=True, library=library) + tag_widget.on_remove.connect(self._update_items) + widget = UnderlinedWidget(tag_widget) + widget.setHidden(True) + self.layout().content_layout.addWidget(widget) + + widget_: QWidget = self.layout().content_layout.itemAt(index).widget() + assert isinstance(widget_, UnderlinedWidget) + return widget_ diff --git a/src/tagstudio/qt/controllers/underlined_widget.py b/src/tagstudio/qt/controllers/underlined_widget.py new file mode 100644 index 000000000..fe86de6d7 --- /dev/null +++ b/src/tagstudio/qt/controllers/underlined_widget.py @@ -0,0 +1,26 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + + +from typing import override + +from PySide6.QtWidgets import QWidget + +from tagstudio.qt.views.underlined_widget_view import UnderlinedWidgetView + + +class UnderlinedWidget(QWidget): + def __init__(self, widget: QWidget) -> None: + super().__init__() + self.setLayout(UnderlinedWidgetView(widget)) + + def toggle_underline(self, is_hidden: bool) -> None: + self.layout().underline.setHidden(is_hidden) + + @property + def widget(self) -> QWidget: + return self.layout().itemAt(0).widget() + + @override + def layout(self) -> UnderlinedWidgetView: + return super().layout() # pyright: ignore[reportReturnType] diff --git a/src/tagstudio/qt/global_settings.py b/src/tagstudio/qt/global_settings.py index 85fe1f362..0e9cda7b5 100644 --- a/src/tagstudio/qt/global_settings.py +++ b/src/tagstudio/qt/global_settings.py @@ -74,6 +74,8 @@ class GlobalSettings(BaseModel): infinite_scroll: bool = Field(default=True) show_filepath: ShowFilepathOption = Field(default=ShowFilepathOption.DEFAULT) tag_click_action: TagClickActionOption = Field(default=TagClickActionOption.DEFAULT) + edit_tag_on_create: bool = Field(default=False) + edit_field_on_add: bool = Field(default=True) theme: Theme = Field(default=Theme.SYSTEM) splash: Splash = Field(default=Splash.DEFAULT) windows_start_command: bool = Field(default=False) diff --git a/src/tagstudio/qt/mixed/add_field.py b/src/tagstudio/qt/mixed/add_field.py deleted file mode 100644 index 946f4d745..000000000 --- a/src/tagstudio/qt/mixed/add_field.py +++ /dev/null @@ -1,98 +0,0 @@ -# SPDX-FileCopyrightText: (c) TagStudio Contributors -# SPDX-License-Identifier: GPL-3.0-only - - -from typing import override - -import structlog -from PySide6 import QtCore, QtGui -from PySide6.QtCore import Qt, Signal -from PySide6.QtWidgets import ( - QHBoxLayout, - QLabel, - QListWidget, - QListWidgetItem, - QPushButton, - QVBoxLayout, - QWidget, -) - -from tagstudio.core.library.alchemy.library import Library -from tagstudio.qt.translations import FIELD_TYPE_KEYS, Translations -from tagstudio.qt.views.stylesheets.stylesheets import header - -logger = structlog.get_logger(__name__) - - -# NOTE: This class doesn't inherit from PanelWidget? Seems like it predates that system? -class AddFieldModal(QWidget): - done = Signal(list) - - def __init__(self, library: Library): - # [Done] - # - OR - - # [Cancel] [Save] - super().__init__() - self.lib = library - self.setWindowTitle(Translations["field.add"]) - self.setWindowModality(Qt.WindowModality.ApplicationModal) - self.setMinimumSize(400, 300) - self.root_layout = QVBoxLayout(self) - self.root_layout.setContentsMargins(6, 6, 6, 6) - - self.title_widget = QLabel(header(Translations["field.add"], 3)) - self.title_widget.setObjectName("fieldTitle") - self.title_widget.setWordWrap(True) - self.title_widget.setAlignment(Qt.AlignmentFlag.AlignCenter) - - self.list_widget = QListWidget() - - self.button_container = QWidget() - self.button_layout = QHBoxLayout(self.button_container) - self.button_layout.setContentsMargins(6, 6, 6, 6) - self.button_layout.addStretch(1) - - self.cancel_button = QPushButton(Translations["generic.cancel"]) - self.cancel_button.clicked.connect(self.hide) - self.button_layout.addWidget(self.cancel_button) - - self.save_button = QPushButton(Translations["generic.add"]) - self.save_button.setDefault(True) - self.save_button.clicked.connect(self.hide) - self.save_button.clicked.connect( - lambda: ( - # get userData for each selected item - self.done.emit(self.list_widget.selectedItems()) - ) - ) - self.button_layout.addWidget(self.save_button) - - self.root_layout.addWidget(self.title_widget) - self.root_layout.addWidget(self.list_widget) - self.root_layout.addStretch(1) - self.root_layout.addWidget(self.button_container) - - @override - def show(self): - self.list_widget.clear() - for field_template in self.lib.field_templates: - field_name_key: str = FIELD_TYPE_KEYS.get( - field_template.class_name, "field_type.unknown" - ) - item = QListWidgetItem(f"{field_template.name} ({Translations[field_name_key]})") - item.setData(Qt.ItemDataRole.UserRole, field_template) - self.list_widget.addItem(item) - self.list_widget.setFocus() - self.list_widget.setCurrentRow(0) - - super().show() - - @override - def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: # noqa N802 - if event.key() == QtCore.Qt.Key.Key_Escape: - self.cancel_button.click() - elif event.key() in (QtCore.Qt.Key.Key_Enter, QtCore.Qt.Key.Key_Return): - self.save_button.click() - else: # Other key presses - pass - return super().keyPressEvent(event) diff --git a/src/tagstudio/qt/mixed/build_color.py b/src/tagstudio/qt/mixed/build_color.py index 9791aaa78..b8590dfdd 100644 --- a/src/tagstudio/qt/mixed/build_color.py +++ b/src/tagstudio/qt/mixed/build_color.py @@ -24,10 +24,10 @@ from tagstudio.core.library.alchemy.library import Library, slugify from tagstudio.core.library.alchemy.models import TagColorGroup from tagstudio.core.utils.types import unwrap +from tagstudio.qt.controllers.modal_content import ModalContent from tagstudio.qt.mixed.tag_color_preview import TagColorPreview from tagstudio.qt.models.palette import ColorType, get_tag_color from tagstudio.qt.translations import Translations -from tagstudio.qt.views.panel_modal import PanelWidget from tagstudio.qt.views.stylesheets.stylesheets import ( checkbox_style, line_edit_style, @@ -37,7 +37,7 @@ logger = structlog.get_logger(__name__) -class BuildColorPanel(PanelWidget): +class BuildColorPanel(ModalContent): on_edit = Signal(TagColorGroup) def __init__(self, library: Library, color_group: TagColorGroup): @@ -254,8 +254,8 @@ def on_text_changed(self): self.slug_field.setText(slug) self.update_preview_text() - if self.panel_save_button is not None: - self.panel_save_button.setDisabled(is_name_empty) + if self.save_button is not None: + self.save_button.setDisabled(is_name_empty) def build_color(self) -> tuple[TagColorGroup, TagColorGroup]: name = self.name_field.text() diff --git a/src/tagstudio/qt/mixed/build_namespace.py b/src/tagstudio/qt/mixed/build_namespace.py index 36487a7b2..0fc8bc9d6 100644 --- a/src/tagstudio/qt/mixed/build_namespace.py +++ b/src/tagstudio/qt/mixed/build_namespace.py @@ -13,14 +13,14 @@ from tagstudio.core.constants import RESERVED_NAMESPACE_PREFIX from tagstudio.core.library.alchemy.library import Library, ReservedNamespaceError, slugify from tagstudio.core.library.alchemy.models import Namespace +from tagstudio.qt.controllers.modal_content import ModalContent from tagstudio.qt.translations import Translations -from tagstudio.qt.views.panel_modal import PanelWidget from tagstudio.qt.views.stylesheets.stylesheets import line_edit_style logger = structlog.get_logger(__name__) -class BuildNamespacePanel(PanelWidget): +class BuildNamespacePanel(ModalContent): on_edit = Signal(Namespace) def __init__(self, library: Library, namespace: Namespace | None = None): @@ -118,8 +118,8 @@ def on_text_changed(self): self.slug_field.setText(slug) - if self.panel_save_button is not None: - self.panel_save_button.setDisabled(is_name_empty) + if self.save_button is not None: + self.save_button.setDisabled(is_name_empty) def no_collide(self, slug: str) -> str: """Return a slug name that's verified not to collide with other known namespace slugs.""" diff --git a/src/tagstudio/qt/mixed/build_tag.py b/src/tagstudio/qt/mixed/build_tag.py index d590902bd..1a73976f1 100644 --- a/src/tagstudio/qt/mixed/build_tag.py +++ b/src/tagstudio/qt/mixed/build_tag.py @@ -28,12 +28,13 @@ from tagstudio.core.library.alchemy.library import Library from tagstudio.core.library.alchemy.models import Tag, TagAlias, TagColorGroup from tagstudio.core.utils.types import unwrap +from tagstudio.qt.controllers.modal import Modal +from tagstudio.qt.controllers.modal_content import ModalContent from tagstudio.qt.controllers.tag_search_panel_controller import TagSearchModal from tagstudio.qt.mixed.tag_color_preview import TagColorPreview from tagstudio.qt.mixed.tag_color_selection import TagColorSelection 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.stylesheets.stylesheets import ( checkbox_style, colored_radio_button_style, @@ -73,7 +74,7 @@ def keyPressEvent(self, arg__1: QKeyEvent): super().keyPressEvent(arg__1) -class BuildTagPanel(PanelWidget): +class BuildTagPanel(ModalContent): on_edit = Signal(Tag) def __init__(self, library: Library, tag: Tag | None = None) -> None: @@ -201,7 +202,7 @@ def __init__(self, library: Library, tag: Tag | None = None) -> None: self.color_button = TagColorPreview(self.lib, None) self.tag_color_selection = TagColorSelection(self.lib) chose_tag_color_title = Translations["tag.choose_color"] - self.choose_color_modal = PanelModal( + self.choose_color_modal = Modal( self.tag_color_selection, chose_tag_color_title, chose_tag_color_title ) self.choose_color_modal.done.connect( @@ -372,7 +373,7 @@ def update_parent_tag_callback(build_tag_panel: BuildTagPanel): def on_parent_tag_edit(tag: Tag) -> None: build_tag_panel = BuildTagPanel(self.lib, tag=tag) - edit_modal = PanelModal( + edit_modal = Modal( build_tag_panel, self.lib.tag_display_name(tag), "Edit Tag", @@ -422,7 +423,7 @@ def _set_aliases(self): while self.aliases_table.rowCount() > 0: self.aliases_table.removeRow(0) - last: QWidget | None = self.panel_save_button + last: QWidget | None = self.save_button aliases = list(self.aliases) alias_names = [a.name for a in aliases] sorted_aliases = sorted(aliases, key=lambda x: alias_names[aliases.index(x)]) @@ -493,8 +494,8 @@ def _on_name_change(self): is_empty = not self.name_field.text().strip() self.name_field.setStyleSheet(line_edit_style() if is_empty else "") - if self.panel_save_button is not None: - self.panel_save_button.setDisabled(is_empty) + if self.save_button is not None: + self.save_button.setDisabled(is_empty) def build_tag(self) -> Tag: tag = self.tag @@ -515,8 +516,8 @@ def parent_post_init(self): self.setTabOrder(self.shorthand_field, self.aliases_add_button) self.setTabOrder(self.aliases_add_button, self.parent_tags_add_button) self.setTabOrder(self.parent_tags_add_button, self.color_button) - self.setTabOrder(self.color_button, unwrap(self.panel_cancel_button)) - self.setTabOrder(unwrap(self.panel_cancel_button), unwrap(self.panel_save_button)) - self.setTabOrder(unwrap(self.panel_save_button), self.aliases_table.cellWidget(0, 1)) + self.setTabOrder(self.color_button, unwrap(self.cancel_button)) + self.setTabOrder(unwrap(self.cancel_button), unwrap(self.save_button)) + self.setTabOrder(unwrap(self.save_button), self.aliases_table.cellWidget(0, 1)) self.name_field.selectAll() self.name_field.setFocus() diff --git a/src/tagstudio/qt/mixed/color_box.py b/src/tagstudio/qt/mixed/color_box.py index 88771bbd1..ffa427d49 100644 --- a/src/tagstudio/qt/mixed/color_box.py +++ b/src/tagstudio/qt/mixed/color_box.py @@ -12,12 +12,12 @@ from tagstudio.core.constants import RESERVED_NAMESPACE_PREFIX from tagstudio.core.library.alchemy.models import TagColorGroup from tagstudio.core.utils.types import unwrap +from tagstudio.qt.controllers.modal import Modal from tagstudio.qt.mixed.build_color import BuildColorPanel from tagstudio.qt.mixed.field_widget import FieldWidget from tagstudio.qt.mixed.tag_color_label import TagColorLabel from tagstudio.qt.translations import Translations from tagstudio.qt.views.layouts.flow_layout import FlowLayout -from tagstudio.qt.views.panel_modal import PanelModal from tagstudio.qt.views.stylesheets.stylesheets import add_button_style if typing.TYPE_CHECKING: @@ -102,7 +102,7 @@ def set_colors(self, colors: Iterable[TagColorGroup]): def edit_color(self, color_group: TagColorGroup): build_color_panel = BuildColorPanel(self.lib, color_group) - self.edit_modal = PanelModal( + self.edit_modal = Modal( build_color_panel, "Edit Color", is_savable=True, diff --git a/src/tagstudio/qt/mixed/datetime_picker.py b/src/tagstudio/qt/mixed/datetime_picker.py index 1706eaa55..aa2a7a4ec 100644 --- a/src/tagstudio/qt/mixed/datetime_picker.py +++ b/src/tagstudio/qt/mixed/datetime_picker.py @@ -9,7 +9,7 @@ from PySide6.QtCore import QDateTime from PySide6.QtWidgets import QDateTimeEdit, QLineEdit, QVBoxLayout -from tagstudio.qt.views.panel_modal import PanelWidget +from tagstudio.qt.controllers.modal_content import ModalContent from tagstudio.qt.views.stylesheets.stylesheets import title_line_edit_style if typing.TYPE_CHECKING: @@ -39,7 +39,7 @@ def qdtf2dtf(dtf: str) -> str: return out -class DatetimePicker(PanelWidget): +class DatetimePicker(ModalContent): def __init__(self, driver: "QtDriver", name: str, datetime: dt | str): super().__init__() self.setMinimumSize(300, 60) diff --git a/src/tagstudio/qt/mixed/field_containers.py b/src/tagstudio/qt/mixed/field_containers.py index 6a7d5555d..1a6ae897d 100644 --- a/src/tagstudio/qt/mixed/field_containers.py +++ b/src/tagstudio/qt/mixed/field_containers.py @@ -9,8 +9,7 @@ from warnings import catch_warnings import structlog -from PySide6.QtCore import Qt -from PySide6.QtGui import QGuiApplication +from PySide6.QtCore import Qt, Signal from PySide6.QtWidgets import ( QFrame, QHBoxLayout, @@ -21,7 +20,6 @@ QWidget, ) -from tagstudio.core.enums import Theme from tagstudio.core.library.alchemy.fields import ( BaseField, BaseFieldTemplate, @@ -32,12 +30,13 @@ from tagstudio.core.library.alchemy.models import Entry, Tag from tagstudio.core.utils.types import unwrap from tagstudio.qt.controllers.edit_text_controller import EditText +from tagstudio.qt.controllers.modal import Modal from tagstudio.qt.controllers.tag_box_controller import TagBoxWidget from tagstudio.qt.mixed.datetime_picker import DatetimePicker 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 @@ -48,6 +47,8 @@ class FieldContainers(QWidget): """Widget for the tag and field containers displayed inside the Preview Panel.""" + on_tags_update = Signal() + def __init__(self, library: Library, driver: "QtDriver") -> None: super().__init__() @@ -58,18 +59,12 @@ def __init__(self, library: Library, driver: "QtDriver") -> None: self.common_fields: list = [] self.mixed_fields: list = [] 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._containers: list[FieldContainer] = [] self.scroll_layout = QVBoxLayout() self.scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop) self.scroll_layout.setContentsMargins(3, 3, 3, 3) - self.scroll_layout.setSpacing(0) + self.scroll_layout.setSpacing(6) scroll_container: QWidget = QWidget() scroll_container.setObjectName("entryScrollContainer") @@ -92,9 +87,7 @@ def __init__(self, library: Library, driver: "QtDriver") -> None: # background and NOT the scroll container background, so that the # rounded corners are maintained when scrolling. I was unable to # find the right trick to only select that particular element. - self.scroll_area.setStyleSheet( - f"QWidget#entryScrollContainer{{background:{self.panel_bg_color};border-radius:6px;}}" - ) + self.scroll_area.setStyleSheet(inset_container_style("entryScrollContainer")) self.scroll_area.setWidget(scroll_container) root_layout = QHBoxLayout(self) @@ -137,8 +130,8 @@ def update_granular( self.write_field_container(index, field, is_mixed=False) # Hide leftover container(s) - if len(self.containers) > container_len: - for i, c in enumerate(self.containers): + if len(self._containers) > container_len: + for i, c in enumerate(self._containers): if i > (container_len - 1): c.setHidden(True) @@ -157,7 +150,7 @@ def update_toggled_tag(self, tag_id: int, toggle_value: bool) -> None: def hide_containers(self) -> None: """Hide all field and tag containers.""" - for c in self.containers: + for c in self._containers: c.setHidden(True) def get_tag_categories(self, tags: set[Tag]) -> dict[Tag | None, set[Tag]]: @@ -251,6 +244,27 @@ def add_tags_to_selected(self, tag_ids: int | list[int]) -> None: ) self.driver.add_tags_to_selected_callback(tag_ids) + def update_text_field_callback( + self, field: TextField, entry_id: int, content: dict[str, str | bool] + ) -> None: + """Callback called when a text field has updated data.""" + self._update_text_field( + field, str(content["name"]), str(content["value"]), bool(content["is_multiline"]) + ) + self.update_from_entry(entry_id) + + def update_datetime_field_callback( + self, field: DatetimeField, entry_id: int, content: dict[str, str] + ) -> None: + """Callback called when a datetime field has updated data.""" + self.update_datetime_field(field, str(content["name"]), str(content["value"])) + self.update_from_entry(entry_id) + + def remove_field_callback(self, field: BaseField, entry_id: int) -> None: + """Callback called when a field needs to be removed from an entry.""" + self._remove_field(field) + self.update_from_entry(entry_id) + def write_field_container(self, index: int, field: BaseField, is_mixed: bool = False) -> None: """Update/Create data for a field FieldContainer. @@ -261,27 +275,6 @@ def write_field_container(self, index: int, field: BaseField, is_mixed: bool = F If True, field is not present in all selected items. """ - def update_text_field_callback( - field: TextField, entry_id: int, content: dict[str, str | bool] - ) -> None: - """Callback called when a text field has updated data.""" - self._update_text_field( - field, str(content["name"]), str(content["value"]), bool(content["is_multiline"]) - ) - self.update_from_entry(entry_id) - - def update_datetime_field_callback( - field: DatetimeField, entry_id: int, content: dict[str, str] - ) -> None: - """Callback called when a datetime field has updated data.""" - self.update_datetime_field(field, str(content["name"]), str(content["value"])) - self.update_from_entry(entry_id) - - def remove_field_callback(field: BaseField, entry_id: int) -> None: - """Callback called when a field needs to be removed from an entry.""" - self._remove_field(field) - self.update_from_entry(entry_id) - def write_text_container( container: FieldContainer, field: TextField, title: str, is_mixed: bool ): @@ -298,21 +291,21 @@ def write_text_container( container.set_inner_widget(inner_widget) if not is_mixed: - edit_modal = PanelModal( + edit_modal = Modal( EditText(field.name, field.value, field.is_multiline), window_title=f"{Translations['field.edit']} ({Translations[field_name_key]})", is_savable=True, inline_title=False, ) edit_modal.saved_data.connect( - partial(update_text_field_callback, field, self.top_entry_id) + partial(self.update_text_field_callback, field, self.top_entry_id) ) container.set_edit_callback(edit_modal.show) container.set_remove_callback( lambda: self.remove_message_box( prompt=self.remove_field_prompt(title), - callback=partial(remove_field_callback, field, self.top_entry_id), + callback=partial(self.remove_field_callback, field, self.top_entry_id), ) ) @@ -336,21 +329,21 @@ def write_datetime_container( container.set_inner_widget(inner_widget) if not is_mixed: - edit_modal = PanelModal( + edit_modal = Modal( DatetimePicker(self.driver, field.name, field.value or dt.now()), window_title=f"{Translations['field.edit']} ({Translations[field_name_key]})", is_savable=True, inline_title=False, ) edit_modal.saved_data.connect( - partial(update_datetime_field_callback, field, self.top_entry_id) + partial(self.update_datetime_field_callback, field, self.top_entry_id) ) container.set_edit_callback(edit_modal.show) container.set_remove_callback( lambda: self.remove_message_box( prompt=self.remove_field_prompt(field.name), - callback=partial(remove_field_callback, field, self.top_entry_id), + callback=partial(self.remove_field_callback, field, self.top_entry_id), ) ) @@ -361,7 +354,7 @@ def write_unknown_container(): container.set_remove_callback( lambda: self.remove_message_box( prompt=self.remove_field_prompt(field.name), - callback=partial(remove_field_callback, field, self.top_entry_id), + callback=partial(self.remove_field_callback, field, self.top_entry_id), ) ) @@ -373,12 +366,12 @@ def write_unknown_container(): ) # Create new containers if necessary - if len(self.containers) < (index + 1): + if len(self._containers) < (index + 1): container = FieldContainer() - self.containers.append(container) + self._containers.append(container) self.scroll_layout.addWidget(container) else: - container = self.containers[index] + container = self._containers[index] # Set field title field_name_key: str = FIELD_TYPE_KEYS.get(field.class_name, "field_type.unknown") @@ -407,12 +400,12 @@ def write_tag_container( If True, field is not present in all selected items. """ logger.info("[FieldContainers][write_tag_container]", index=index) - if len(self.containers) < (index + 1): + if len(self._containers) < (index + 1): container = FieldContainer() - self.containers.append(container) + self._containers.append(container) self.scroll_layout.addWidget(container) else: - container = self.containers[index] + container = self._containers[index] container.set_title(Translations["entries.tags"] if not category_tag else category_tag.name) @@ -430,7 +423,10 @@ def write_tag_container( inner_widget.set_tags(tags) inner_widget.on_update.connect( - lambda: self.update_from_entry(self.cached_entries[0].id, update_badges=True) + lambda: ( + self.update_from_entry(self.cached_entries[0].id, update_badges=True), + self.on_tags_update.emit(), + ) ) else: text = f"{Translations['field.mixed_data']}" @@ -480,3 +476,13 @@ def remove_message_box(self, prompt: str, callback: Callable[..., None]) -> None result = remove_mb.exec_() if result == QMessageBox.ButtonRole.ActionRole.value: callback() + + @property + def tags(self) -> list[int]: + if len(self.cached_entries) <= 0: + return [] + entry = self.cached_entries[0] + entry_ = self.lib.get_entry_full(entry.id, with_fields=False) + if not entry_: + return [] + return [tag.id for tag in entry_.tags] diff --git a/src/tagstudio/qt/mixed/field_widget.py b/src/tagstudio/qt/mixed/field_widget.py index 23f58a344..7108f7ae2 100644 --- a/src/tagstudio/qt/mixed/field_widget.py +++ b/src/tagstudio/qt/mixed/field_widget.py @@ -34,7 +34,9 @@ def __init__(self, title: str = "Field", inline: bool = True) -> None: self.copy_callback: Callable[[], None] | None = None self.edit_callback: Callable[[], None] | None = None self.remove_callback: Callable[[], None] | None = None - button_size = 24 + button_size = 22 + icon_margins = 4 + icon_size = button_size - icon_margins self.root_layout = QVBoxLayout(self) self.root_layout.setObjectName("baseLayout") @@ -42,7 +44,7 @@ def __init__(self, title: str = "Field", inline: bool = True) -> None: self.inner_layout = QVBoxLayout() self.inner_layout.setObjectName("innerLayout") - self.inner_layout.setContentsMargins(6, 0, 6, 6) + self.inner_layout.setContentsMargins(3, 0, 0, 3) self.inner_layout.setSpacing(0) self.field_container = QWidget() self.field_container.setObjectName("fieldContainer") @@ -82,7 +84,7 @@ def __init__(self, title: str = "Field", inline: bool = True) -> None: self.edit_button.setMaximumSize(button_size, button_size) self.edit_button.setFlat(True) self.edit_button.setIcon(QPixmap.fromImage(ImageQt.ImageQt(FieldContainer.edit_icon))) - self.edit_button.setIconSize(QSize(20, 20)) + self.edit_button.setIconSize(QSize(icon_size, icon_size)) self.edit_button.setCursor(Qt.CursorShape.PointingHandCursor) self.title_layout.addWidget(self.edit_button) self.edit_button.setHidden(True) @@ -93,7 +95,7 @@ def __init__(self, title: str = "Field", inline: bool = True) -> None: self.remove_button.setMaximumSize(button_size, button_size) self.remove_button.setFlat(True) self.remove_button.setIcon(QPixmap.fromImage(ImageQt.ImageQt(FieldContainer.trash_icon))) - self.remove_button.setIconSize(QSize(20, 20)) + self.remove_button.setIconSize(QSize(icon_size, icon_size)) self.remove_button.setCursor(Qt.CursorShape.PointingHandCursor) self.title_layout.addWidget(self.remove_button) self.remove_button.setHidden(True) diff --git a/src/tagstudio/qt/mixed/item_thumb.py b/src/tagstudio/qt/mixed/item_thumb.py index f39f37c7c..42e43b371 100644 --- a/src/tagstudio/qt/mixed/item_thumb.py +++ b/src/tagstudio/qt/mixed/item_thumb.py @@ -500,7 +500,7 @@ def toggle_item_tag( ): selected = self.driver._selected if len(selected) == 1 and entry_id in selected: - self.driver.main_window.preview_panel.field_containers_widget.update_toggled_tag( + self.driver.main_window.preview_panel.containers.update_toggled_tag( tag_id, toggle_value ) diff --git a/src/tagstudio/qt/mixed/settings_panel.py b/src/tagstudio/qt/mixed/settings_panel.py index 61bd93a79..c43f843c9 100644 --- a/src/tagstudio/qt/mixed/settings_panel.py +++ b/src/tagstudio/qt/mixed/settings_panel.py @@ -20,6 +20,8 @@ ) from tagstudio.core.enums import ShowFilepathOption, TagClickActionOption +from tagstudio.qt.controllers.modal import Modal +from tagstudio.qt.controllers.modal_content import ModalContent from tagstudio.qt.global_settings import ( DEFAULT_CACHED_THUMB_RES, DEFAULT_THUMB_CACHE_SIZE, @@ -30,7 +32,6 @@ Theme, ) from tagstudio.qt.translations import DEFAULT_TRANSLATION, LANGUAGES, Translations -from tagstudio.qt.views.panel_modal import PanelModal, PanelWidget if TYPE_CHECKING: from tagstudio.qt.ts_qt import QtDriver @@ -38,7 +39,7 @@ logger = structlog.get_logger(__name__) -class SettingsPanel(PanelWidget): +class SettingsPanel(ModalContent): driver: "QtDriver" filepath_option_map: dict[ShowFilepathOption, str] = { @@ -216,6 +217,20 @@ def on_page_size_changed(): Translations["settings.tag_click_action.label"], self.tag_click_action_combobox ) + # Open Edit Window When Creating a Tag + self.edit_tag_on_create_checkbox = QCheckBox() + self.edit_tag_on_create_checkbox.setChecked(self.driver.settings.edit_tag_on_create) + form_layout.addRow( + Translations["settings.edit_tag_on_create"], self.edit_tag_on_create_checkbox + ) + + # Open Edit Window When Adding a Field + self.edit_field_on_add_checkbox = QCheckBox() + self.edit_field_on_add_checkbox.setChecked(self.driver.settings.edit_field_on_add) + form_layout.addRow( + Translations["settings.edit_field_on_add"], self.edit_field_on_add_checkbox + ) + # TODO: Implement Library Settings def __build_library_settings(self): # pyright: ignore[reportUnusedFunction] form_layout = QFormLayout(self.library_settings_container) @@ -366,6 +381,8 @@ def get_settings(self) -> dict[str, Any]: # pyright: ignore[reportExplicitAny] "show_filepath": self.filepath_combobox.currentData(), "theme": self.theme_combobox.currentData(), "tag_click_action": self.tag_click_action_combobox.currentData(), + "edit_tag_on_create": self.edit_tag_on_create_checkbox.isChecked(), + "edit_field_on_add": self.edit_field_on_add_checkbox.isChecked(), "date_format": self.dateformat_combobox.currentData(), "hour_format": self.hourformat_checkbox.isChecked(), "zero_padding": self.zeropadding_checkbox.isChecked(), @@ -388,6 +405,8 @@ def update_settings(self, driver: "QtDriver"): driver.settings.show_filepath = settings["show_filepath"] driver.settings.theme = settings["theme"] driver.settings.tag_click_action = settings["tag_click_action"] + driver.settings.edit_tag_on_create = settings["edit_tag_on_create"] + driver.settings.edit_field_on_add = settings["edit_field_on_add"] driver.settings.date_format = settings["date_format"] driver.settings.hour_format = settings["hour_format"] driver.settings.zero_padding = settings["zero_padding"] @@ -409,15 +428,15 @@ def update_settings(self, driver: "QtDriver"): ) @classmethod - def build_modal(cls, driver: "QtDriver") -> PanelModal: + def build_modal(cls, driver: "QtDriver") -> Modal: settings_panel = cls(driver) - modal = PanelModal( - widget=settings_panel, + modal = Modal( + content_widget=settings_panel, window_title=Translations["settings.title"], is_savable=True, ) modal.saved.connect(lambda: settings_panel.update_settings(driver)) - modal.title_widget.setVisible(False) + modal.layout().title_label.setVisible(False) return modal diff --git a/src/tagstudio/qt/mixed/tag_color_manager.py b/src/tagstudio/qt/mixed/tag_color_manager.py index 99f5b47c4..20d789150 100644 --- a/src/tagstudio/qt/mixed/tag_color_manager.py +++ b/src/tagstudio/qt/mixed/tag_color_manager.py @@ -23,11 +23,11 @@ from tagstudio.core.constants import RESERVED_NAMESPACE_PREFIX from tagstudio.core.enums import Theme +from tagstudio.qt.controllers.modal import Modal from tagstudio.qt.mixed.build_namespace import BuildNamespacePanel from tagstudio.qt.mixed.color_box import ColorBoxWidget from tagstudio.qt.mixed.field_widget import FieldContainer from tagstudio.qt.translations import Translations -from tagstudio.qt.views.panel_modal import PanelModal from tagstudio.qt.views.stylesheets.stylesheets import header logger = structlog.get_logger(__name__) @@ -38,7 +38,7 @@ class TagColorManager(QWidget): - create_namespace_modal: PanelModal | None = None + create_namespace_modal: Modal | None = None def __init__( self, @@ -125,7 +125,7 @@ def setup_color_groups(self): self.setup_color_groups(), () if len(self.driver.selected) < 1 - else self.driver.main_window.preview_panel.field_containers_widget.update_from_entry( # noqa: E501 + else self.driver.main_window.preview_panel.containers.update_from_entry( # noqa: E501 self.driver.selected[0], update_badges=False ), ) @@ -142,7 +142,7 @@ def setup_color_groups(self): self.setup_color_groups(), () if len(self.driver.selected) < 1 - else self.driver.main_window.preview_panel.field_containers_widget.update_from_entry( # noqa: E501 + else self.driver.main_window.preview_panel.containers.update_from_entry( # noqa: E501 self.driver.selected[0], update_badges=False ), ), @@ -174,7 +174,7 @@ def reset(self): def create_namespace(self): build_namespace_panel = BuildNamespacePanel(self.lib) - self.create_namespace_modal = PanelModal( + self.create_namespace_modal = Modal( build_namespace_panel, Translations["namespace.create.title"], is_savable=True, diff --git a/src/tagstudio/qt/mixed/tag_color_selection.py b/src/tagstudio/qt/mixed/tag_color_selection.py index 35bc2c2e0..cbc823d79 100644 --- a/src/tagstudio/qt/mixed/tag_color_selection.py +++ b/src/tagstudio/qt/mixed/tag_color_selection.py @@ -20,21 +20,22 @@ from tagstudio.core.library.alchemy.enums import TagColorEnum from tagstudio.core.library.alchemy.library import Library from tagstudio.core.library.alchemy.models import TagColorGroup -from tagstudio.qt.mixed.tag_widget import ( +from tagstudio.qt.controllers.modal_content import ModalContent +from tagstudio.qt.models.palette import ColorType, get_tag_color +from tagstudio.qt.translations import Translations +from tagstudio.qt.views.layouts.flow_layout import FlowLayout +from tagstudio.qt.views.stylesheets.stylesheets import ( + color_swatch_style, get_tag_border_color, get_tag_highlight_color, get_tag_text_color, + header, ) -from tagstudio.qt.models.palette import ColorType, get_tag_color -from tagstudio.qt.translations import Translations -from tagstudio.qt.views.layouts.flow_layout import FlowLayout -from tagstudio.qt.views.panel_modal import PanelWidget -from tagstudio.qt.views.stylesheets.stylesheets import color_swatch_style, header logger = structlog.get_logger(__name__) -class TagColorSelection(PanelWidget): +class TagColorSelection(ModalContent): def __init__(self, library: Library): super().__init__() self.lib = library diff --git a/src/tagstudio/qt/mixed/tag_widget.py b/src/tagstudio/qt/mixed/tag_widget.py index cf114f100..2d702a4f2 100644 --- a/src/tagstudio/qt/mixed/tag_widget.py +++ b/src/tagstudio/qt/mixed/tag_widget.py @@ -8,7 +8,7 @@ import structlog from PySide6.QtCore import QEvent, Qt, Signal from PySide6.QtGui import QAction, QColor, QEnterEvent, QFontMetrics -from PySide6.QtWidgets import QHBoxLayout, QLineEdit, QPushButton, QVBoxLayout, QWidget +from PySide6.QtWidgets import QHBoxLayout, QLineEdit, QPushButton, QSizePolicy, QVBoxLayout, QWidget from tagstudio.core.library.alchemy.enums import TagColorEnum from tagstudio.core.library.alchemy.models import Tag @@ -148,23 +148,23 @@ def __init__( self.inner_layout = QHBoxLayout() self.inner_layout.setObjectName("innerLayout") self.inner_layout.setContentsMargins(0, 0, 0, 0) + self.inner_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) self._delete_button = QPushButton(self) self._delete_button.setFlat(True) self._delete_button.setText("–") self._delete_button.setHidden(True) - self._delete_button.setMinimumSize(22, 22) - self._delete_button.setMaximumSize(22, 22) + self._delete_button.setFixedSize(22, 22) self._delete_button.clicked.connect(self.on_remove.emit) self._delete_button.setHidden(True) self.inner_layout.addWidget(self._delete_button) - self.inner_layout.addStretch(1) self.bg_button.setLayout(self.inner_layout) self.bg_button.setMinimumSize(44, 22) - self.bg_button.setMinimumHeight(22) - self.bg_button.setMaximumHeight(22) + self.bg_button.setFixedHeight(22) + + self.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum) self.base_layout.addWidget(self.bg_button) diff --git a/src/tagstudio/qt/models/palette.py b/src/tagstudio/qt/models/palette.py index 120dab723..006b6bcf0 100644 --- a/src/tagstudio/qt/models/palette.py +++ b/src/tagstudio/qt/models/palette.py @@ -7,12 +7,34 @@ from typing import Any import structlog +from PySide6.QtGui import QPalette from tagstudio.core.library.alchemy.enums import TagColorEnum +from tagstudio.core.utils.singleton import Singleton logger = structlog.get_logger(__name__) +class Palette(metaclass=Singleton): + _palette: QPalette | None = None + _accent: str | None = None + + @staticmethod + def set_palette(palette: QPalette) -> None: + Palette._palette = palette + + @staticmethod + def accent() -> str: + if not Palette._palette: + logger.error("[Style] No QPalette set!") + return get_ui_color(ColorType.PRIMARY, UiColor.BLUE) + if not Palette._accent: + Palette._accent = ( + f"rgba{QPalette.color(Palette._palette, QPalette.ColorRole.Accent).toTuple()}" + ) + return Palette._accent + + class ColorType(IntEnum): PRIMARY = 0 TEXT = 1 diff --git a/src/tagstudio/qt/ts_qt.py b/src/tagstudio/qt/ts_qt.py index f18ff1acb..8a7d356da 100644 --- a/src/tagstudio/qt/ts_qt.py +++ b/src/tagstudio/qt/ts_qt.py @@ -51,8 +51,6 @@ from tagstudio.core.media_types import MediaCategories from tagstudio.core.query_lang.util import ParsingError from tagstudio.core.ts_core import TagStudioCore - -# This import has side-effect of importing PySide resources from tagstudio.core.utils.ffmpeg_status import FfmpegStatus, FfprobeStatus from tagstudio.core.utils.module_status import ModuleStatus from tagstudio.core.utils.ripgrep_status import RipgrepStatus @@ -63,6 +61,7 @@ from tagstudio.qt.controllers.fix_ignored_modal_controller import FixIgnoredEntriesModal from tagstudio.qt.controllers.ignore_modal_controller import IgnoreModal from tagstudio.qt.controllers.library_info_window_controller import LibraryInfoWindow +from tagstudio.qt.controllers.modal import Modal from tagstudio.qt.controllers.tag_search_panel_controller import TagSearchModal from tagstudio.qt.controllers.update_available_message_box import UpdateAvailableMessageBox from tagstudio.qt.global_settings import DEFAULT_GLOBAL_SETTINGS_PATH, GlobalSettings, Theme @@ -77,7 +76,7 @@ from tagstudio.qt.mixed.progress_bar import ProgressWidget from tagstudio.qt.mixed.settings_panel import SettingsPanel from tagstudio.qt.mixed.tag_color_manager import TagColorManager -from tagstudio.qt.models.palette import ColorType, UiColor, get_ui_color +from tagstudio.qt.models.palette import ColorType, Palette, UiColor, get_ui_color from tagstudio.qt.platform_strings import trash_term from tagstudio.qt.resource_manager import ResourceManager from tagstudio.qt.translations import Translations @@ -86,7 +85,6 @@ from tagstudio.qt.utils.function_iterator import FunctionIterator from tagstudio.qt.views.field_template_search_panel_view import FieldTemplateSearchPanelView from tagstudio.qt.views.main_window import MainWindow -from tagstudio.qt.views.panel_modal import PanelModal from tagstudio.qt.views.splash import SplashScreen from tagstudio.qt.views.stylesheets.stylesheets import header @@ -169,12 +167,12 @@ class QtDriver(DriverMixin, QObject): favorite_updated = Signal(bool) archived_updated = Signal(bool) - tag_manager_panel: PanelModal | None = None + tag_manager: Modal | None = None color_manager_panel: TagColorManager | None = None - field_template_manager_panel: PanelModal | None = None - ignore_modal: PanelModal | None = None - add_tag_modal: PanelModal | None = None - add_field_modal: PanelModal | None = None + field_template_manager: Modal | None = None + ignore_modal: Modal | None = None + add_tag_modal: Modal | None = None + add_field_modal: Modal | None = None folders_modal: FoldersToTagsModal about_modal: AboutModal unlinked_modal: FixUnlinkedEntriesModal @@ -274,7 +272,7 @@ def open_library_from_dialog(self): dir = QFileDialog.getExistingDirectory( parent=None, caption=Translations["window.title.open_create_library"], - dir="/", + dir=str(Path.home()), options=QFileDialog.Option.ShowDirsOnly, ) if dir not in (None, ""): @@ -303,19 +301,28 @@ def start(self) -> None: elif self.settings.theme == Theme.LIGHT: self.app.styleHints().setColorScheme(Qt.ColorScheme.Light) + pal: QPalette = self.app.palette() + # BUG: Changing the palette in any way here seems to affect the accent colors of certain + # widgets, like QLineEdit focused borders and QComboBox highlighted items and borders. + # Need to figure out the cause of this. if ( platform.system() == "Darwin" or platform.system() == "Windows" ) and QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark: - pal: QPalette = self.app.palette() pal.setColor(QPalette.ColorGroup.Normal, QPalette.ColorRole.Window, QColor("#1e1e1e")) pal.setColor(QPalette.ColorGroup.Normal, QPalette.ColorRole.Button, QColor("#1e1e1e")) + pal.setColor( + QPalette.ColorGroup.Inactive, QPalette.ColorRole.ToolTipBase, QColor("#1e1e1e") + ) + pal.setColor( + QPalette.ColorGroup.Inactive, QPalette.ColorRole.ToolTipText, QColor("#FFFFFF") + ) pal.setColor(QPalette.ColorGroup.Inactive, QPalette.ColorRole.Window, QColor("#232323")) pal.setColor(QPalette.ColorGroup.Inactive, QPalette.ColorRole.Button, QColor("#232323")) pal.setColor( QPalette.ColorGroup.Inactive, QPalette.ColorRole.ButtonText, QColor("#666666") ) - - self.app.setPalette(pal) + Palette.set_palette(pal) + self.app.setPalette(pal) # Handle OS signals self.setup_signals() @@ -358,14 +365,14 @@ def start(self) -> None: self.app.setDesktopFileName("tagstudio") # Initialize the Tag Manager panel - self.tag_manager_panel = TagSearchModal( + self.tag_manager = TagSearchModal( self.lib, title=Translations["tag_manager.title"], is_tag_chooser=False, ) - self.tag_manager_panel.tsp.set_driver(self) + self.tag_manager.tsp.set_driver(self) - self.tag_manager_panel.done.connect( + self.tag_manager.done.connect( lambda checked=False: self.main_window.preview_panel.set_selection( self.selected, update_preview=False ) @@ -375,8 +382,8 @@ def start(self) -> None: self.color_manager_panel = TagColorManager(self) # Initialize the Field Template Manager panel - self.field_template_manager_panel = PanelModal( - widget=FieldTemplateSearchPanel( + self.field_template_manager = Modal( + content_widget=FieldTemplateSearchPanel( self.lib, is_field_template_chooser=False, view=FieldTemplateSearchPanelView(is_field_template_chooser=False), @@ -384,7 +391,7 @@ def start(self) -> None: title=Translations["field_template_manager.title"], is_savable=False, ) - self.field_template_manager_panel.done.connect( + self.field_template_manager.done.connect( lambda checked=False: self.main_window.preview_panel.set_selection( self.selected, update_preview=False ) @@ -479,14 +486,14 @@ def set_open_last_loaded_on_startup(checked: bool): lambda f="": self.delete_files_callback(f) ) - self.main_window.menu_bar.tag_manager_action.triggered.connect(self.tag_manager_panel.show) + self.main_window.menu_bar.tag_manager_action.triggered.connect(self.tag_manager.show) self.main_window.menu_bar.color_manager_action.triggered.connect( self.color_manager_panel.show ) self.main_window.menu_bar.field_template_manager_action.triggered.connect( - self.field_template_manager_panel.show + self.field_template_manager.show ) # endregion @@ -627,8 +634,10 @@ def on_visible_changed(entry_id: int | None): if path_result.success and path_result.library_path: self.open_library(path_result.library_path) - self.check_for_update() + self.main_window.search_field.setFocus() + self.app.exec() + self.check_for_update() self.shutdown() def show_error_message(self, error_name: str, error_desc: str | None = None): @@ -724,7 +733,7 @@ def init_ignore_modal(self): self.ignore_modal = None panel = IgnoreModal(self.lib) - self.ignore_modal = PanelModal( + self.ignore_modal = Modal( panel, Translations["menu.edit.ignore_files"], is_savable=True, @@ -862,7 +871,7 @@ def emit_badge_signals(self, tag_ids: list[int] | set[int], emit_on_absent: bool def add_tag_action_callback(self): panel = BuildTagPanel(self.lib) - self.modal = PanelModal( + self.modal = Modal( panel, Translations["tag.new"], Translations["tag.create"], @@ -954,7 +963,7 @@ def delete_files_callback(self, origin_path: str | Path, origin_id: int | None = for i, tup in enumerate(pending): e_id, f = tup if (origin_path == f) or (not origin_path): - self.main_window.preview_panel.preview_thumb.media_player.stop() + self.main_window.preview_panel.stop_media_playback() msg = Translations.format( "status.deleting_file", i=i, count=len(pending), path=f diff --git a/src/tagstudio/qt/views/edit_field_template_modal_view.py b/src/tagstudio/qt/views/edit_field_template_modal_view.py index e4dafd673..944dffa06 100644 --- a/src/tagstudio/qt/views/edit_field_template_modal_view.py +++ b/src/tagstudio/qt/views/edit_field_template_modal_view.py @@ -15,14 +15,14 @@ ) from tagstudio.qt.controllers.clickable_label import ClickableLabel +from tagstudio.qt.controllers.modal_content import ModalContent from tagstudio.qt.translations import Translations -from tagstudio.qt.views.panel_modal import PanelWidget from tagstudio.qt.views.stylesheets.stylesheets import checkbox_style logger = structlog.get_logger(__name__) -class EditFieldTemplateModalView(PanelWidget): +class EditFieldTemplateModalView(ModalContent): def __init__(self) -> None: super().__init__() diff --git a/src/tagstudio/qt/views/edit_text_view.py b/src/tagstudio/qt/views/edit_text_view.py index 1ec7ed333..7fbc53e0e 100644 --- a/src/tagstudio/qt/views/edit_text_view.py +++ b/src/tagstudio/qt/views/edit_text_view.py @@ -14,12 +14,12 @@ ) from tagstudio.qt.controllers.clickable_label import ClickableLabel +from tagstudio.qt.controllers.modal_content import ModalContent from tagstudio.qt.translations import Translations -from tagstudio.qt.views.panel_modal import PanelWidget from tagstudio.qt.views.stylesheets.stylesheets import checkbox_style, title_line_edit_style -class EditTextView(PanelWidget): +class EditTextView(ModalContent): def __init__(self): super().__init__() self.setMinimumSize(480, 240) diff --git a/src/tagstudio/qt/views/field_template_widget_view.py b/src/tagstudio/qt/views/field_template_widget_view.py index 78efdb936..fe916c11d 100644 --- a/src/tagstudio/qt/views/field_template_widget_view.py +++ b/src/tagstudio/qt/views/field_template_widget_view.py @@ -2,8 +2,8 @@ # SPDX-License-Identifier: GPL-3.0-only from PySide6.QtCore import Signal -from PySide6.QtGui import QColor -from PySide6.QtWidgets import QHBoxLayout, QPushButton, QVBoxLayout, QWidget +from PySide6.QtGui import QColor, Qt +from PySide6.QtWidgets import QHBoxLayout, QPushButton, QSizePolicy, QVBoxLayout, QWidget from tagstudio.core.library.alchemy.enums import TagColorEnum from tagstudio.qt.models.palette import ColorType, get_tag_color @@ -12,8 +12,10 @@ get_tag_highlight_color, get_tag_text_color, list_button_style, + tag_remove_button_style, ) +# TODO: These colors and logic should be moved to and reworked in the stylesheets file. primary_color: QColor = QColor(get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT)) border_color: QColor = get_tag_border_color(primary_color) highlight_color: QColor = get_tag_highlight_color(primary_color) @@ -30,7 +32,6 @@ def __init__(self) -> None: self.__root_layout = QVBoxLayout(self) self.__root_layout.setObjectName("root_layout") - self.__root_layout.setContentsMargins(0, 0, 0, 0) # Background button @@ -47,6 +48,8 @@ def __init__(self) -> None: self.__inner_layout.setObjectName("inner_layout") self._bg_button.setLayout(self.__inner_layout) + self.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum) + self.__inner_layout.setContentsMargins(0, 0, 0, 0) # Remove button @@ -56,9 +59,12 @@ def __init__(self) -> None: self._delete_button.setHidden(True) self._delete_button.setMinimumSize(22, 22) self._delete_button.setMaximumSize(22, 22) + self._delete_button.setStyleSheet( + tag_remove_button_style(primary_color, text_color, border_color, highlight_color) + ) self.__inner_layout.addWidget(self._delete_button) - self.__inner_layout.addStretch(1) + self.__inner_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) self.__connect_callbacks() diff --git a/src/tagstudio/qt/views/ignore_modal_view.py b/src/tagstudio/qt/views/ignore_modal_view.py index 277f5eea6..dac325d41 100644 --- a/src/tagstudio/qt/views/ignore_modal_view.py +++ b/src/tagstudio/qt/views/ignore_modal_view.py @@ -14,13 +14,13 @@ from tagstudio.core.constants import IGNORE_NAME from tagstudio.core.library.alchemy.library import Library from tagstudio.core.library.alchemy.models import Tag +from tagstudio.qt.controllers.modal_content import ModalContent from tagstudio.qt.translations import Translations -from tagstudio.qt.views.panel_modal import PanelWidget logger = structlog.get_logger(__name__) -class IgnoreModalView(PanelWidget): +class IgnoreModalView(ModalContent): on_edit = Signal(Tag) def __init__(self, library: Library) -> None: diff --git a/src/tagstudio/qt/views/main_window.py b/src/tagstudio/qt/views/main_window.py index 322fed8c3..0a1082afc 100644 --- a/src/tagstudio/qt/views/main_window.py +++ b/src/tagstudio/qt/views/main_window.py @@ -5,10 +5,11 @@ import typing from collections.abc import Callable from pathlib import Path +from typing import override import structlog from PIL import Image, ImageQt -from PySide6 import QtCore +from PySide6 import QtCore, QtGui from PySide6.QtCore import QMetaObject, QSize, QStringListModel, Qt from PySide6.QtGui import QAction, QPixmap from PySide6.QtWidgets import ( @@ -182,10 +183,10 @@ def setup_edit_menu(self): self.new_tag_action.setShortcut( QtCore.QKeyCombination( QtCore.Qt.KeyboardModifier(QtCore.Qt.KeyboardModifier.ControlModifier), - QtCore.Qt.Key.Key_T, + QtCore.Qt.Key.Key_N, ) ) - self.new_tag_action.setToolTip("Ctrl+T") + self.new_tag_action.setToolTip("Ctrl+N") self.new_tag_action.setEnabled(False) self.edit_menu.addAction(self.new_tag_action) @@ -220,9 +221,8 @@ def setup_edit_menu(self): # Clear Selection self.clear_select_action = QAction(Translations["select.clear"], self) - self.clear_select_action.setShortcut(QtCore.Qt.Key.Key_Escape) - self.clear_select_action.setToolTip("Esc") self.clear_select_action.setEnabled(False) + self.clear_select_action.setToolTip("Esc") self.edit_menu.addAction(self.clear_select_action) # Copy Fields @@ -700,10 +700,12 @@ def setup_entry_list(self, driver: "QtDriver"): self.content_splitter.addWidget(self.entry_list_container) def setup_preview_panel(self, driver: "QtDriver"): - self.preview_panel = PreviewPanel(driver.lib, driver) + self.preview_panel = PreviewPanel(driver) self.content_splitter.addWidget(self.preview_panel) def setup_status_bar(self): + # 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( @@ -746,3 +748,9 @@ def thumb_size(self) -> int: def show_hidden_entries(self) -> bool: """Whether to show entries tagged with hidden tags.""" return self.show_hidden_entries_checkbox.isChecked() + + @override + def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: + if event.key() == QtCore.Qt.Key.Key_Escape: + self.menu_bar.clear_select_action.trigger() + return super().keyPressEvent(event) diff --git a/src/tagstudio/qt/views/modal_view.py b/src/tagstudio/qt/views/modal_view.py new file mode 100644 index 000000000..b9fc1aba0 --- /dev/null +++ b/src/tagstudio/qt/views/modal_view.py @@ -0,0 +1,64 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + + +import structlog +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget + +from tagstudio.qt.controllers.modal_content import ModalContent +from tagstudio.qt.translations import Translations +from tagstudio.qt.views.stylesheets.stylesheets import header + +logger = structlog.get_logger(__name__) + + +class ModalView(QVBoxLayout): + """A generic reusable modal panel widget.""" + + def __init__( + self, + content_widget: ModalContent, + title: str = "", + is_savable: bool = False, + inline_title: bool = True, + ): + super().__init__() + self.content_widget = content_widget + self.setContentsMargins(6, 6 if inline_title else 12, 6, 6) + + self.button_container = QWidget() + self.button_layout = QHBoxLayout(self.button_container) + self.button_layout.setContentsMargins(6, 6, 6, 6) + self.button_layout.addStretch(1) + + # [Done] + # - OR - + # [Cancel] [Save] + if not is_savable: + done_button = QPushButton(Translations["generic.done"]) + done_button.setAutoDefault(True) + self.content_widget.done_button = done_button + self.button_layout.addWidget(done_button) + else: + cancel_button = QPushButton(Translations["generic.cancel"]) + self.content_widget.cancel_button = cancel_button + self.button_layout.addWidget(cancel_button) + + save_button = QPushButton(Translations["generic.save"]) + save_button.setAutoDefault(True) + self.content_widget.save_button = save_button + self.button_layout.addWidget(save_button) + + if inline_title: + self.title_label = QLabel() + self.title_label.setObjectName("fieldTitle") + self.title_label.setWordWrap(True) + self.title_label.setText(header(title, 3)) + self.title_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.addWidget(self.title_label) + + self.addWidget(content_widget) + self.setStretch(1, 2) + self.addWidget(self.button_container) + content_widget.parent_post_init() diff --git a/src/tagstudio/qt/views/panel_modal.py b/src/tagstudio/qt/views/panel_modal.py deleted file mode 100644 index 87d476c75..000000000 --- a/src/tagstudio/qt/views/panel_modal.py +++ /dev/null @@ -1,127 +0,0 @@ -# SPDX-FileCopyrightText: (c) TagStudio Contributors -# SPDX-License-Identifier: GPL-3.0-only - - -import contextlib -from typing import Any, override - -import structlog -from PySide6 import QtCore, QtGui -from PySide6.QtCore import Qt, Signal -from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget - -from tagstudio.qt.translations import Translations - -logger = structlog.get_logger(__name__) - - -class PanelModal(QWidget): - """A generic reusable modal panel widget.""" - - done = Signal() - saved = Signal() - saved_data = Signal(type(Any)) - - def __init__( - self, - widget: "PanelWidget", - title: str = "", - window_title: str | None = None, - is_savable: bool = False, - inline_title: bool = True, - ): - # [Done] - # - OR - - # [Cancel] [Save] - super().__init__() - self.widget = widget - self.setWindowTitle(title if window_title is None else window_title) - self.setWindowModality(Qt.WindowModality.ApplicationModal) - self.root_layout = QVBoxLayout(self) - self.root_layout.setContentsMargins(6, 0 if inline_title else 12, 6, 6) - - self.button_container = QWidget() - self.button_layout = QHBoxLayout(self.button_container) - self.button_layout.setContentsMargins(6, 6, 6, 6) - self.button_layout.addStretch(1) - - if not is_savable: - self.done_button = QPushButton(Translations["generic.done"]) - self.done_button.setAutoDefault(True) - self.done_button.clicked.connect(self.hide) - self.done_button.clicked.connect(self.done.emit) - self.widget.panel_done_button = self.done_button - self.button_layout.addWidget(self.done_button) - else: - self.cancel_button = QPushButton(Translations["generic.cancel"]) - self.cancel_button.clicked.connect(self.hide) - self.cancel_button.clicked.connect(widget.reset) - self.widget.panel_cancel_button = self.cancel_button - self.button_layout.addWidget(self.cancel_button) - - self.save_button = QPushButton(Translations["generic.save"]) - self.save_button.setAutoDefault(True) - self.save_button.clicked.connect(self.hide) - self.save_button.clicked.connect(self.saved.emit) - self.save_button.clicked.connect(lambda: self.saved_data.emit(widget.saved_data())) - self.widget.panel_save_button = self.save_button - self.button_layout.addWidget(self.save_button) - - if inline_title: - self.title_widget = QLabel() - self.title_widget.setObjectName("fieldTitle") - self.title_widget.setWordWrap(True) - self.title_widget.setStyleSheet("font-weight:bold;font-size:14px;padding-top:6px") - self.title_widget.setText(title) - self.title_widget.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.root_layout.addWidget(self.title_widget) - - self.root_layout.addWidget(widget) - widget.parent_modal = self - self.root_layout.setStretch(1, 2) - self.root_layout.addWidget(self.button_container) - widget.parent_post_init() - - @override - def closeEvent(self, event: QtGui.QCloseEvent) -> None: - with contextlib.suppress(AttributeError): - self.cancel_button.click() - with contextlib.suppress(AttributeError): - self.done_button.click() - event.accept() - - -class PanelWidget(QWidget): - """Used for widgets that go in a modal panel, ex. for editing or searching.""" - - parent_modal: PanelModal | None = None - panel_save_button: QPushButton | None = None - panel_cancel_button: QPushButton | None = None - panel_done_button: QPushButton | None = None - - def __init__(self): - super().__init__() - - def saved_data(self) -> Any: # pyright: ignore[reportExplicitAny] - return None - - def reset(self) -> None: - pass - - def parent_post_init(self) -> None: - pass - - @override - def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: - if event.key() == QtCore.Qt.Key.Key_Escape: - if self.panel_cancel_button: - self.panel_cancel_button.click() - elif self.panel_done_button: - self.panel_done_button.click() - elif event.key() == Qt.Key.Key_Return or event.key() == Qt.Key.Key_Enter: - if self.panel_save_button: - self.panel_save_button.click() - elif self.panel_done_button: - self.panel_done_button.click() - else: # Other key presses - super().keyPressEvent(event) diff --git a/src/tagstudio/qt/views/preview_panel_view.py b/src/tagstudio/qt/views/preview_panel_view.py index 7ebc8c56e..e1daee1f3 100644 --- a/src/tagstudio/qt/views/preview_panel_view.py +++ b/src/tagstudio/qt/views/preview_panel_view.py @@ -3,29 +3,20 @@ import math -import traceback import typing -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_suggest_box import FieldSuggestBox from tagstudio.qt.controllers.preview_thumb_controller import PreviewThumb +from tagstudio.qt.controllers.return_button import ReturnButton +from tagstudio.qt.controllers.tag_suggest_box import TagSuggestBox from tagstudio.qt.mixed.field_containers import FieldContainers -from tagstudio.qt.mixed.file_attributes import FileAttributeData, FileAttributes +from tagstudio.qt.mixed.file_attributes import FileAttributes from tagstudio.qt.resource_manager import ResourceManager from tagstudio.qt.translations import Translations from tagstudio.qt.views.stylesheets.stylesheets import button_style, preview_warning_style @@ -36,33 +27,36 @@ logger = structlog.get_logger(__name__) -class PreviewPanelView(QWidget): - lib: Library - - _selected: list[int] - - def __init__(self, library: Library, driver: "QtDriver") -> None: +class PreviewPanelView(QVBoxLayout): + def __init__(self, driver: "QtDriver", pixel_ratio: float) -> None: super().__init__() - self.lib = library + self.setContentsMargins(0, 0, 0, 0) + self.setSpacing(6) rm = ResourceManager() - 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 + def ph_text(key: str) -> str: + return " ".join([Translations[key], Translations["home.search.how_to_exit"]]) + + # Search/Create Boxes + self.field_search_box = FieldSuggestBox(driver, ph_text("home.search_or_create_fields")) + self.tag_search_box = TagSuggestBox(driver, ph_text("home.search_or_create_tags")) + + self.preview_thumb = PreviewThumb(driver.lib, driver) + self.file_attrs = FileAttributes(driver.lib, driver) + self.containers = FieldContainers(driver.lib, driver) + # Visual Preview preview_section = QWidget() preview_layout = QVBoxLayout(preview_section) preview_layout.setContentsMargins(0, 0, 0, 0) preview_layout.setSpacing(6) - self._ffmpeg_warning_widget = QWidget() - self._ffmpeg_warning_widget.setObjectName("ffmpeg_widget") - ffmpeg_warning_layout = QHBoxLayout(self._ffmpeg_warning_widget) + # Warning Banner (Missing FFmpeg, etc.) + self.warning_banner = QWidget() + self.warning_banner.setObjectName("ffmpeg_widget") + ffmpeg_warning_layout = QHBoxLayout(self.warning_banner) ffmpeg_warning_layout.setContentsMargins(3, 3, 3, 3) - self._ffmpeg_warning_widget.setStyleSheet(preview_warning_style()) + self.warning_banner.setStyleSheet(preview_warning_style()) ffmpeg_warning_label = QLabel( Translations.format( "preview.missing_module.multimedia", @@ -75,16 +69,16 @@ def __init__(self, library: Library, driver: "QtDriver") -> None: ) warning_icon = QLabel() warning_icon_pixmap = rm.alert.scaled( - math.floor(20 * self.devicePixelRatio()), math.floor(20 * self.devicePixelRatio()) + math.floor(20 * pixel_ratio), math.floor(20 * pixel_ratio) ) - warning_icon_pixmap.setDevicePixelRatio(self.devicePixelRatio()) + warning_icon_pixmap.setDevicePixelRatio(pixel_ratio) warning_icon.setPixmap(warning_icon_pixmap) ffmpeg_warning_layout.addWidget(warning_icon) ffmpeg_warning_layout.addWidget(ffmpeg_warning_label) ffmpeg_warning_layout.setStretch(1, 1) + self.warning_banner.hide() - self._ffmpeg_warning_widget.hide() - + # File Information info_section = QWidget() info_layout = QVBoxLayout(info_section) info_layout.setContentsMargins(0, 0, 0, 0) @@ -94,154 +88,38 @@ def __init__(self, library: Library, driver: "QtDriver") -> None: splitter.setOrientation(Qt.Orientation.Vertical) splitter.setHandleWidth(12) + # Add Tag/Field Buttons add_buttons_container = QWidget() add_buttons_layout = QHBoxLayout(add_buttons_container) 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_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()) - - add_buttons_layout.addWidget(self.__add_tag_button) - add_buttons_layout.addWidget(self.__add_field_button) - - preview_layout.addWidget(self._thumb) - info_layout.addWidget(self._ffmpeg_warning_widget) - info_layout.addWidget(self._file_attrs) - info_layout.addWidget(self._containers) + 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 = 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.tag_search_box) + add_buttons_layout.addWidget(self.field_search_box) + + # Finalize Layout + preview_layout.addWidget(self.preview_thumb) + info_layout.addWidget(self.warning_banner) + info_layout.addWidget(self.file_attrs) + info_layout.addWidget(self.containers) splitter.addWidget(preview_section) splitter.addWidget(info_section) splitter.setStretchFactor(1, 2) - root_layout = QVBoxLayout(self) - root_layout.setContentsMargins(0, 0, 0, 0) - 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() - - def set_selection(self, selected: list[int], update_preview: bool = True) -> None: - """Render the panel widgets with the newest data from the Library. - - Args: - selected (list[int]): List of the IDs of the selected entries. - update_preview (bool): Should the file preview be updated? - (Only works with one or more items selected) - """ - self._selected = selected - try: - # No Items Selected - if len(selected) == 0: - self._thumb.hide_preview() - self.__current_stats = None - self._file_attrs.update_stats() - self._file_attrs.update_date_label() - self._containers.hide_containers() - - self.add_buttons_enabled = False - - # One Item Selected - elif len(selected) == 1: - entry_id = selected[0] - entry: Entry = unwrap(self.lib.get_entry(entry_id)) - - filepath: Path = unwrap(self.lib.library_dir) / entry.path - if filepath != self._thumb.current_file: - self.__current_stats = None - - if update_preview: - stats: FileAttributeData = self._thumb.display_file(filepath) - self.__current_stats = stats - self._file_attrs.update_stats(filepath, stats) - self._file_attrs.update_date_label(filepath) - self._containers.update_from_entry(entry_id) - - self._set_selection_callback() - - self.add_buttons_enabled = True - - # Multiple Selected Items - elif len(selected) > 1: - # items: list[Entry] = [self.lib.get_entry_full(x) for x in self.driver.selected] - self._thumb.hide_preview() # TODO: Render mixed selection - self.__current_stats = None - self._file_attrs.update_multi_selection(len(selected)) - self._file_attrs.update_date_label() - self._containers.hide_containers() # TODO: Allow for mixed editing - - self._set_selection_callback() - - self.add_buttons_enabled = True - - except Exception as e: - logger.error("[Preview Panel] Error updating selection", error=e) - traceback.print_exc() - - @property - def add_buttons_enabled(self) -> bool: # needed for the tests - 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) - - @property - def _file_attributes_widget(self) -> FileAttributes: # needed for the tests - """Getter for the file attributes widget.""" - return self._file_attrs - - @property - def field_containers_widget(self) -> FieldContainers: # needed for the tests - """Getter for the field containers widget.""" - return self._containers - - @property - def preview_thumb(self) -> PreviewThumb: - return self._thumb + self.addWidget(splitter) + self.addWidget(add_buttons_container) diff --git a/src/tagstudio/qt/views/preview_thumb_view.py b/src/tagstudio/qt/views/preview_thumb_view.py index b4847e61f..a13e70b83 100644 --- a/src/tagstudio/qt/views/preview_thumb_view.py +++ b/src/tagstudio/qt/views/preview_thumb_view.py @@ -160,6 +160,7 @@ def __stacked_page_setup(self, page: QWidget, widget: QWidget) -> None: layout.addWidget(widget) layout.setAlignment(widget, Qt.AlignmentFlag.AlignCenter) layout.setContentsMargins(0, 0, 0, 0) + widget.setCursor(Qt.CursorShape.PointingHandCursor) page.setLayout(layout) def __update_image_size(self, size: tuple[int, int]) -> None: diff --git a/src/tagstudio/qt/views/search_panel_view.py b/src/tagstudio/qt/views/search_panel_view.py index 175828afd..60ccd1e52 100644 --- a/src/tagstudio/qt/views/search_panel_view.py +++ b/src/tagstudio/qt/views/search_panel_view.py @@ -17,15 +17,15 @@ ) from tagstudio.core.library.alchemy.library import Library +from tagstudio.qt.controllers.modal_content import ModalContent from tagstudio.qt.translations import Translations -from tagstudio.qt.views.panel_modal import PanelWidget from tagstudio.qt.views.stylesheets.stylesheets import list_button_style if TYPE_CHECKING: from tagstudio.qt.controllers.search_panel_controller import SearchPanel -class SearchPanelView(PanelWidget): +class SearchPanelView(ModalContent): def __init__(self, is_chooser: bool) -> None: self.is_chooser: bool = is_chooser super().__init__() diff --git a/src/tagstudio/qt/views/stylesheets/stylesheets.py b/src/tagstudio/qt/views/stylesheets/stylesheets.py index 6a9e722bf..07b80f54a 100644 --- a/src/tagstudio/qt/views/stylesheets/stylesheets.py +++ b/src/tagstudio/qt/views/stylesheets/stylesheets.py @@ -8,7 +8,7 @@ from tagstudio.core.enums import Theme from tagstudio.core.library.alchemy.enums import TagColorEnum from tagstudio.core.library.alchemy.models import Tag -from tagstudio.qt.models.palette import ColorType, UiColor, get_tag_color, get_ui_color +from tagstudio.qt.models.palette import ColorType, Palette, UiColor, get_tag_color, get_ui_color # TODO: There's plenty of good opportunities here to consolidate similar styles. # Work should be done to more closely use Qt's theming systems rather than override them. @@ -53,18 +53,29 @@ def button_style() -> str: border-radius: 6px; font-weight: 500; text-align: center; + padding: 0px 12px; }} QPushButton::hover{{ background-color: {Theme.COLOR_HOVER.value}; - border-color: {get_ui_color(ColorType.BORDER, UiColor.THEME_DARK)}; border-style: solid; border-width: 2px; + border-color: {get_ui_color(ColorType.BORDER, UiColor.THEME_DARK)}; + padding: 0px 8px; }} QPushButton::pressed{{ - background-color: {Theme.COLOR_PRESSED.value}; - border-color: {get_ui_color(ColorType.LIGHT_ACCENT, UiColor.THEME_DARK)}; + outline: none; + background-color: palette(light); border-style: solid; border-width: 2px; + border-color: {get_ui_color(ColorType.BORDER, UiColor.THEME_DARK)}; + padding: 0px 8px; + }} + QPushButton::focus{{ + outline: none; + border: solid; + border-width: 2px; + border-color: {Palette.accent()}; + padding: 0px 8px; }} QPushButton::disabled{{ background-color: {Theme.COLOR_DISABLED_BG.value}; @@ -72,6 +83,40 @@ def button_style() -> str: """ +def line_edit_style_main() -> str: + """Style used for common QLineEdits.""" + bg_color = ( + Theme.COLOR_BG_DARK.value + if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark + else Theme.COLOR_BG_LIGHT.value + ) + + return f""" + QLineEdit{{ + background: {bg_color}; + border-radius: 6px; + font-weight: 500; + text-align: center; + padding: 0px 4px; + }} + QLineEdit::hover{{ + border-style: solid; + border-width: 2px; + border-color: {get_ui_color(ColorType.BORDER, UiColor.THEME_DARK)}; + padding: 0px 2px; + }} + QLineEdit::focus{{ + border-style: solid; + border-width: 2px; + border-color: {Palette.accent()}; + padding: 0px 2px; + }} + QLineEdit::disabled{{ + background-color: {Theme.COLOR_DISABLED_BG.value}; + }} +""" + + def checkbox_style() -> str: """Style used for QCheckBoxes.""" primary_color = QColor(get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT)) @@ -229,6 +274,7 @@ def line_edit_style() -> str: def list_button_style( color: QColor | None = None, border_style: str = "solid", + italic: bool = False, ) -> str: """Style used for special QPushButtons found in lists.""" if color is None: @@ -243,6 +289,7 @@ def list_button_style( background: rgba{color.toTuple()}; color: rgba{text_color.toTuple()}; font-weight: 600; + {"font: italic;" if italic else ""} border-color: rgba{border_color.toTuple()}; border-radius: 6px; border-style: {border_style}; @@ -310,9 +357,9 @@ def tag_style( border-radius: 6px; border-style: {border_style}; border-width: 2px; + font-size: 13px; padding-right: 4px; padding-left: 4px; - font-size: 13px }} QPushButton::hover{{ border-color: rgba{highlight_color.toTuple()}; @@ -323,12 +370,9 @@ def tag_style( border-color: rgba{primary_color.toTuple()}; }} QPushButton::focus{{ - padding-right: 0px; - padding-left: 0px; - outline-style: solid; - outline-width: 1px; - outline-radius: 4px; - outline-color: rgba{text_color.toTuple()}; + outline: none; + border-width: 3px; + border-color: rgba{text_color.toTuple()}; }} """ @@ -366,6 +410,13 @@ def tag_remove_button_style( """ +def widget_underline_style() -> str: + return f""" + background: {Palette.accent()}; + border-radius: 2px; + """ + + def title_line_edit_style() -> str: """Used to mimic an H3-like header style inside a QLineEdit.""" return """ @@ -374,6 +425,110 @@ def title_line_edit_style() -> str: """ +def inset_container_style(object_name: str = "") -> str: + """Used for darkened inset areas.""" + bg_color = ( + Theme.COLOR_BG_DARK.value + if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark + else Theme.COLOR_BG_LIGHT.value + ) + + return f""" + QWidget{"#" + object_name if object_name else ""}{{ + background: {bg_color}; + border-radius: 6px; + }} + """ + + +# TODO: Combine the autofill styles into one method? +def autofill_scroll_top_style(object_name: str = "") -> str: + """Used autofill lists positioned on top of line edits.""" + bg_color = ( + Theme.COLOR_BG_DARK.value + if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark + else Theme.COLOR_BG_LIGHT.value + ) + + return f""" + QWidget{"#" + object_name if object_name else ""}{{ + background: {bg_color}; + border-top-left-radius: 6px; + border-top-right-radius: 6px; + border: none; + }} + """ + + +def autofill_scroll_top_focus_style(object_name: str = "") -> str: + """Used autofill lists positioned on top of line edits.""" + bg_color = ( + Theme.COLOR_BG_DARK.value + if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark + else Theme.COLOR_BG_LIGHT.value + ) + + return f""" + QWidget{"#" + object_name if object_name else ""}{{ + background: {bg_color}; + border-top-left-radius: 6px; + border-top-right-radius: 6px; + border: solid; + border-width: 2px 2px 0px 2px; + border-color: {Palette.accent()}; + }} + """ + + +def autofill_line_edit_style() -> str: + """Used for QLineEdits.""" + bg_color = ( + Theme.COLOR_BG_DARK.value + if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark + else Theme.COLOR_BG_LIGHT.value + ) + + return f""" + QLineEdit{{ + background: {bg_color}; + border-radius: 6px; + padding: 3px 6px; + }} + QLineEdit::focus{{ + padding: 4px 4px; + border: solid; + border-width: 2px; + border-color: {Palette.accent()}; + }} + """ + + +def autofill_line_edit_top_style() -> str: + """Used for QLineEdits when there's a top autofill section present.""" + bg_color = ( + Theme.COLOR_BG_DARK.value + if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark + else Theme.COLOR_BG_LIGHT.value + ) + + return f""" + QLineEdit{{ + background: {bg_color}; + border-top-left-radius: 0px; + border-top-right-radius: 0px; + border-bottom-left-radius: 6px; + border-bottom-right-radius: 6px; + padding: 0px 0px 2px 6px; + }} + QLineEdit::focus{{ + padding: 4px 4px; + border: solid; + border-width: 0px 2px 2px 2px; + border-color: {Palette.accent()}; + }} + """ + + def preview_warning_style() -> str: return f""" QWidget#ffmpeg_widget {{ diff --git a/src/tagstudio/qt/views/suggest_box_view.py b/src/tagstudio/qt/views/suggest_box_view.py new file mode 100644 index 000000000..39184e61d --- /dev/null +++ b/src/tagstudio/qt/views/suggest_box_view.py @@ -0,0 +1,77 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + + +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, +) + +logger = structlog.get_logger(__name__) + + +class SuggestBoxView(QVBoxLayout): + def __init__(self, placeholder_text: str = "") -> None: + super().__init__() + self.setContentsMargins(0, 0, 0, 0) + self.setSpacing(0) + + # HACK: The transparent border allows for the focus border color to + # still show above the tags at the edges... sort of (overlaps on left when h-scrolling) + scroll_area_style = """ + QScrollArea{ + background: transparent; + border: solid; + border-color: transparent; + border-width: 0px 2px; + padding-left: -2px; + } + QScrollArea > QWidget > QWidget{ + background: transparent; + } + """ + + # Autocomplete ScrollArea + contents = QWidget() + self.content_layout = QHBoxLayout(contents) + self.content_layout.setSpacing(6) + self.content_layout.setAlignment(Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignLeft) + self.content_layout.setContentsMargins(0, 0, 0, 0) + scroll_area_container = QWidget() + scroll_area_container.setObjectName("container") + scroll_area_container_layout = QHBoxLayout(scroll_area_container) + scroll_area_container_layout.setContentsMargins(0, 0, 0, 0) + scroll_area_container_layout.setSpacing(0) + scroll_area_container.setStyleSheet(autofill_scroll_top_style("container")) + self.scroll_area = QScrollArea() + self.scroll_area.setStyleSheet(scroll_area_style) + scroll_area_container_layout.addWidget(self.scroll_area) + self.scroll_area.setWidget(contents) + search_bar_height = 28 + underline_padding = 7 + self.scroll_area.setMaximumHeight(search_bar_height + underline_padding) + self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.scroll_area.verticalScrollBar().setEnabled(False) + self.scroll_area.setContentsMargins(0, 0, 0, 0) + 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) + + # Search Field + self.search_field = AutofillLineEdit(scroll_area_container) + self.search_field.setStyleSheet(autofill_line_edit_style()) + self.search_field.setObjectName("search_field") + self.search_field.setMinimumHeight(28) + self.search_field.setPlaceholderText(placeholder_text) + self.scroll_area.setFocusProxy(self.search_field) + + # Finalize Layout + self.addWidget(scroll_area_container) + self.addWidget(self.search_field) diff --git a/src/tagstudio/qt/views/tag_box_view.py b/src/tagstudio/qt/views/tag_box_view.py index e5c96462d..a3097cb17 100644 --- a/src/tagstudio/qt/views/tag_box_view.py +++ b/src/tagstudio/qt/views/tag_box_view.py @@ -39,17 +39,12 @@ def set_tags(self, tags: Iterable[Tag]) -> None: for tag in tags_: tag_widget = TagWidget(tag, library=self.__lib, has_edit=True, has_remove=True) - tag_widget.on_click.connect(lambda t=tag: self._on_click(t)) - tag_widget.on_remove.connect(lambda t=tag: self._on_remove(t)) - tag_widget.on_edit.connect(lambda t=tag: self._on_edit(t)) - tag_widget.search_for_tag_action.triggered.connect( lambda checked=False, t=tag: self._on_search(t) ) - self.__root_layout.addWidget(tag_widget) def _on_click(self, tag: Tag) -> None: diff --git a/src/tagstudio/qt/views/tag_search_panel_view.py b/src/tagstudio/qt/views/tag_search_panel_view.py index 1b081703b..be67f63f1 100644 --- a/src/tagstudio/qt/views/tag_search_panel_view.py +++ b/src/tagstudio/qt/views/tag_search_panel_view.py @@ -1,6 +1,8 @@ # SPDX-FileCopyrightText: (c) TagStudio Contributors # SPDX-License-Identifier: GPL-3.0-only +from typing import override + from PySide6.QtWidgets import QWidget from tagstudio.core.library.alchemy.library import Library @@ -16,6 +18,7 @@ def __init__(self, is_tag_chooser: bool) -> None: self.search_field.setPlaceholderText(Translations["home.search_tags"]) self.create_button.setText(Translations["tag.create"]) + @override def get_item_widget(self, index: int, library: Library | None) -> TagWidget: """Gets the item widget at a specific index.""" # Create any new item widgets needed up to the given index diff --git a/src/tagstudio/qt/views/underlined_widget_view.py b/src/tagstudio/qt/views/underlined_widget_view.py new file mode 100644 index 000000000..029da7597 --- /dev/null +++ b/src/tagstudio/qt/views/underlined_widget_view.py @@ -0,0 +1,27 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QPushButton, QVBoxLayout, QWidget + +from tagstudio.qt.views.stylesheets.stylesheets import widget_underline_style + + +class UnderlinedWidgetView(QVBoxLayout): + def __init__(self, widget: QWidget) -> None: + super().__init__() + self.setContentsMargins(0, 0, 0, 0) + self.setSpacing(3) + self.setAlignment(Qt.AlignmentFlag.AlignTop) + + # HACK: I don't know why I can't just use a QFrame for the outline. + # The styling and sizing only seems to work if it's something like a QPushButton. + self.underline = QPushButton() + self.underline.setFlat(True) + self.underline.setDisabled(True) + self.underline.setMaximumHeight(4) + self.underline.setStyleSheet(widget_underline_style()) + + self.addWidget(widget) + self.addWidget(self.underline) diff --git a/src/tagstudio/resources/translations/en.json b/src/tagstudio/resources/translations/en.json index 0742b93dd..b303bd5bf 100644 --- a/src/tagstudio/resources/translations/en.json +++ b/src/tagstudio/resources/translations/en.json @@ -30,9 +30,9 @@ "drop_import.description": "The following files match file paths that already exist in the library", "drop_import.duplicates_choice.plural": "The following {count} files match file paths that already exist in the library.", "drop_import.duplicates_choice.singular": "The following file matches a file path that already exists in the library.", - "drop_import.progress.label.initial": "Importing New Files...", - "drop_import.progress.label.plural": "Importing New Files...\n{count} Files Imported.{suffix}", - "drop_import.progress.label.singular": "Importing New Files...\n1 File imported.{suffix}", + "drop_import.progress.label.initial": "Importing New Files…", + "drop_import.progress.label.plural": "Importing New Files…\n{count} Files Imported.{suffix}", + "drop_import.progress.label.singular": "Importing New Files…\n1 File imported.{suffix}", "drop_import.progress.window_title": "Import Files", "drop_import.title": "Conflicting File(s)", "edit.color_manager": "Manage Tag Colors", @@ -40,26 +40,26 @@ "edit.paste_fields": "Paste Fields", "edit.tag_manager": "Manage Tags", "entries.duplicate.merge": "Merge Duplicate Entries", - "entries.duplicate.merge.label": "Merging Duplicate Entries...", + "entries.duplicate.merge.label": "Merging Duplicate Entries…", "entries.duplicate.refresh": "Refresh Duplicate Entries", "entries.duplicates.description": "Duplicate entries are defined as multiple entries which point to the same file on disk. Merging these will combine the tags and metadata from all duplicates into a single consolidated entry. These are not to be confused with \"duplicate files\", which are duplicates of your files themselves outside of TagStudio.", "entries.generic.refresh_alt": "&Refresh", "entries.generic.remove.removing": "Removing Entries", - "entries.generic.remove.removing_count": "Removing {count} Entries...", + "entries.generic.remove.removing_count": "Removing {count} Entries…", "entries.ignored.description": "File entries are considered to be \"ignored\" if they were added to the library before the user's ignore rules (via the '.ts_ignore' file) were updated to exclude it. Ignored files are kept in the library by default in order to prevent accidental data loss when updating ignore rules.", "entries.ignored.ignored_count": "Ignored Entries: {count}", "entries.ignored.remove": "Remove Ignored Entries", "entries.ignored.remove_alt": "Remo&ve Ignored Entries", - "entries.ignored.scanning": "Scanning Library for Ignored Entries...", + "entries.ignored.scanning": "Scanning Library for Ignored Entries…", "entries.ignored.title": "Fix Ignored Entries", "entries.mirror": "&Mirror", "entries.mirror.confirmation": "Are you sure you want to mirror the following {count} Entries?", - "entries.mirror.label": "Mirroring {idx}/{total} Entries...", + "entries.mirror.label": "Mirroring {idx}/{total} Entries…", "entries.mirror.title": "Mirroring Entries", "entries.mirror.window_title": "Mirror Entries", "entries.remove.plural.confirm": "Are you sure you want to remove these {count} entries from your library? No files on disk will be deleted.", "entries.remove.singular.confirm": "Are you sure you want to remove this entry from your library? No files on disk will be deleted.", - "entries.running.dialog.new_entries": "Adding {total} New File Entries...", + "entries.running.dialog.new_entries": "Adding {total} New File Entries…", "entries.running.dialog.title": "Adding New File Entries", "entries.tags": "Tags", "entries.unlinked.description": "Each library entry is linked to a file in one of your directories. If a file linked to an entry is moved or deleted outside of TagStudio, it is then considered unlinked.

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

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

What you need to know:

What's changed: