From 1b0bbba08052e03d06703478604c33ff612a55f1 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Sun, 28 Jun 2026 01:45:32 -0700 Subject: [PATCH] feat: add field template editor, editable field names (#1396) * feat: add basic field template editor * fix: fix various issues with adding templates, reduce reused code * feat: add field name editing on entries * ui: add multiline checkbox to field template editor * refactor: move stylesheets to central file * fix(ui): fix untranslated key * docs: update field documentation --- docs/assets/add_fields.png | Bin 0 -> 32424 bytes docs/assets/datetime_field_editor.png | Bin 0 -> 29713 bytes docs/assets/field_template_editor.png | Bin 0 -> 17760 bytes docs/assets/field_template_manager.png | Bin 0 -> 36271 bytes docs/assets/fields_example.png | Bin 0 -> 24880 bytes docs/assets/text_field_editor.png | Bin 0 -> 18100 bytes docs/fields.md | 51 ++- src/tagstudio/core/constants.py | 8 +- src/tagstudio/core/library/alchemy/library.py | 123 ++++- .../{views => controllers}/clickable_label.py | 6 +- .../controllers/edit_field_template_modal.py | 115 +++++ .../qt/controllers/edit_text_controller.py | 67 +++ .../field_template_search_panel_controller.py | 114 +++-- .../field_template_widget_controller.py | 28 ++ .../fix_ignored_modal_controller.py | 3 +- .../library_info_window_controller.py | 3 +- .../qt/controllers/paged_panel_controller.py | 5 +- .../controllers/preview_panel_controller.py | 8 +- .../qt/controllers/search_panel_controller.py | 42 +- .../qt/controllers/tag_box_controller.py | 23 +- .../tag_search_panel_controller.py | 90 ++-- src/tagstudio/qt/mixed/about_modal.py | 68 ++- src/tagstudio/qt/mixed/add_field.py | 4 +- src/tagstudio/qt/mixed/build_color.py | 147 +----- src/tagstudio/qt/mixed/build_namespace.py | 16 +- src/tagstudio/qt/mixed/build_tag.py | 157 ++----- src/tagstudio/qt/mixed/color_box.py | 35 +- src/tagstudio/qt/mixed/datetime_picker.py | 33 +- src/tagstudio/qt/mixed/field_containers.py | 315 ++++++------- src/tagstudio/qt/mixed/field_widget.py | 21 +- src/tagstudio/qt/mixed/file_attributes.py | 28 +- src/tagstudio/qt/mixed/fix_dupe_files.py | 14 +- src/tagstudio/qt/mixed/fix_unlinked.py | 3 +- src/tagstudio/qt/mixed/folders_to_tags.py | 6 +- src/tagstudio/qt/mixed/landing.py | 2 +- src/tagstudio/qt/mixed/migration_modal.py | 9 +- src/tagstudio/qt/mixed/settings_panel.py | 4 +- src/tagstudio/qt/mixed/tag_color_label.py | 79 +--- src/tagstudio/qt/mixed/tag_color_manager.py | 13 +- src/tagstudio/qt/mixed/tag_color_preview.py | 37 +- src/tagstudio/qt/mixed/tag_color_selection.py | 91 +--- src/tagstudio/qt/mixed/tag_database.py | 0 src/tagstudio/qt/mixed/tag_widget.py | 134 ++---- src/tagstudio/qt/mixed/text_field.py | 4 +- src/tagstudio/qt/translations.py | 6 +- src/tagstudio/qt/ts_qt.py | 49 +- .../views/edit_field_template_modal_view.py | 91 ++++ src/tagstudio/qt/views/edit_text_box_modal.py | 25 - .../qt/views/edit_text_line_modal.py | 33 -- src/tagstudio/qt/views/edit_text_view.py | 60 +++ .../views/field_template_search_panel_view.py | 3 + .../qt/views/field_template_widget_view.py | 60 +-- .../qt/views/library_info_window_view.py | 5 +- src/tagstudio/qt/views/main_window.py | 40 +- src/tagstudio/qt/views/panel_modal.py | 72 ++- src/tagstudio/qt/views/preview_panel_view.py | 43 +- src/tagstudio/qt/views/preview_thumb_view.py | 2 +- src/tagstudio/qt/views/search_panel_view.py | 44 +- .../rounded_pixmap_style.py | 0 .../qt/views/stylesheets/stylesheets.py | 431 ++++++++++++++++++ src/tagstudio/resources/translations/en.json | 13 +- tests/qt/test_build_tag_panel.py | 2 +- tests/test_library.py | 8 +- 63 files changed, 1623 insertions(+), 1270 deletions(-) create mode 100644 docs/assets/add_fields.png create mode 100644 docs/assets/datetime_field_editor.png create mode 100644 docs/assets/field_template_editor.png create mode 100644 docs/assets/field_template_manager.png create mode 100644 docs/assets/fields_example.png create mode 100644 docs/assets/text_field_editor.png rename src/tagstudio/qt/{views => controllers}/clickable_label.py (70%) create mode 100644 src/tagstudio/qt/controllers/edit_field_template_modal.py create mode 100644 src/tagstudio/qt/controllers/edit_text_controller.py delete mode 100644 src/tagstudio/qt/mixed/tag_database.py create mode 100644 src/tagstudio/qt/views/edit_field_template_modal_view.py delete mode 100644 src/tagstudio/qt/views/edit_text_box_modal.py delete mode 100644 src/tagstudio/qt/views/edit_text_line_modal.py create mode 100644 src/tagstudio/qt/views/edit_text_view.py rename src/tagstudio/qt/views/{styles => stylesheets}/rounded_pixmap_style.py (100%) create mode 100644 src/tagstudio/qt/views/stylesheets/stylesheets.py diff --git a/docs/assets/add_fields.png b/docs/assets/add_fields.png new file mode 100644 index 0000000000000000000000000000000000000000..2ba458a47a8f144a0468e9300d264fbcb28a2eec GIT binary patch literal 32424 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�w+?)-xpr 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 literal 0 HcmV?d00001 diff --git a/docs/assets/datetime_field_editor.png b/docs/assets/datetime_field_editor.png new file mode 100644 index 0000000000000000000000000000000000000000..b786b98e93ea5b35d087d7c15f767ea3b5c82474 GIT binary patch literal 29713 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 literal 0 HcmV?d00001 diff --git a/docs/assets/field_template_editor.png b/docs/assets/field_template_editor.png new file mode 100644 index 0000000000000000000000000000000000000000..5f4653455223986f0c6bedab81a576fef398dc3d GIT binary patch literal 17760 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 V%c9T?yCnRhJIg>Pdd9MC{}1jU{h0s& literal 0 HcmV?d00001 diff --git a/docs/assets/fields_example.png b/docs/assets/fields_example.png new file mode 100644 index 0000000000000000000000000000000000000000..1dcb2d9607444ee1349cd9adcd01188e360c88b3 GIT binary patch literal 24880 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 CH6juK literal 0 HcmV?d00001 diff --git a/docs/fields.md b/docs/fields.md index 768e61a8..304c71a1 100644 --- a/docs/fields.md +++ b/docs/fields.md @@ -8,24 +8,53 @@ icon: material/text-box # :material-text-box: Fields -Fields are additional types of metadata that you can attach to [file entries](./entries.md). Like [tags](tags.md), fields are not stored inside files themselves nor in sidecar files, but rather inside the respective TagStudio [library](./index.md) save file. +Fields are extra pieces of information you can add to [file entries](./entries.md), similar to how [tags](tags.md) are added to entries. Fields are useful for storing information that doesn't nessisarily need to be a tag, such as titles, comments, notes, specific dates or times, etc. -## Field Types +To add a field to an entry, click the "Add Field" button in the preview panel. From there you can search and/or select a [field template](#field-templates) to choose from, or create a new one from the search bar. Alternatively you can create new field templates from **Edit -> Manage Field Templates**. -### Text Line +
+ ![Fields Example](assets/fields_example.png) +
Example of tags and various fields on a file entry.
+
-A string of text, displayed as a single line. +## :material-text-box-plus-outline: Field Templates -- e.g: Title, Author, Artist, URL, etc. +Field templates are handy templates to use when adding fields to entries that contain preconfigured options but no actual data. When you add a field to an entry from the "Add Field" button, you choose from a template to add and then fill in the information afterwards. TagStudio includes a handful of field templates to start you off with, but you're free to modify or delete them, or simply create your own. -### Text Box +Field templates can be viewed, created, and deleted from the **Edit -> Manage Field Templates** window. You can also edit field templates from the "Add Field" menu, and create new ones on the fly from the search bar. Note that you can not currently delete field templates from the "Add Field" menu, just like tags. -A long string of text displayed as a box of text. +
+ ![Field Template Manager](assets/field_template_manager.png) +
Field Template Manager from Edit -> Manage Field Templates.
+
-- e.g: Description, Notes, etc. +
+ ![Field Template Editor](assets/field_template_editor.png) +
The field template editor, shown creating a new "Citations" field.
+
-### Datetime +## :material-format-list-bulleted-type: Field Types -A date and time value. +Fields come in a variety of types that are better suited for different types of information, and may provide additional options unique to those types. Single lines are good for fields like titles, while multiline blocks are good for things like comments and notes. -- e.g: Date Published, Date Taken, etc. +### :material-text-box: Text + +Text fields contain a piece of text with the option to display it either a single line or a multiline body of text. + +| Option | Value | Description | +| --------- | ---------- | ------------------------------------------------------------------------ | +| Multiline | True/False | Indicates if the text should be displayed on multiple lines or just one. | + +
+ ![Text Field Editor](assets/text_field_editor.png) +
The text field editor, editing a "Comments" field on an entry.
+
+ +### :material-calendar-month: Datetime + +Datetime fields contain a date and time value. Dates are formatted using the format specified in your application settings. + +
+ ![Datetime Field Editor](assets/datetime_field_editor.png) +
The datetime field editor, expanded to show the date picker.
+
diff --git a/src/tagstudio/core/constants.py b/src/tagstudio/core/constants.py index d0e4d6f3..7b323c8c 100644 --- a/src/tagstudio/core/constants.py +++ b/src/tagstudio/core/constants.py @@ -8,7 +8,10 @@ COPYRIGHT_YEARS: str = "2021-2026" COPYRIGHT: str = f"© {COPYRIGHT_YEARS} Travis Abendshien & TagStudio Contributors" COPYRIGHT_COMPACT: str = f"© {COPYRIGHT_YEARS} Travis Abendshien\n& TagStudio Contributors" +GITHUB_REPO_URL = "https://github.com/TagStudioDev/TagStudio" GITHUB_RELEASE_URL = "https://github.com/TagStudioDev/TagStudio/releases/latest" +DOCS_URL = "https://docs.tagstud.io" +DISCORD_URL = "https://discord.com/invite/hRNnVKhF2G" # The folder & file names where TagStudio keeps its data relative to a library. TS_FOLDER_NAME: str = ".TagStudio" @@ -17,9 +20,7 @@ COLLAGE_FOLDER_NAME: str = "collages" IGNORE_NAME: str = ".ts_ignore" THUMB_CACHE_NAME: str = "thumbs" -FONT_SAMPLE_TEXT: str = ( - """ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!?@$%(){}[]""" -) +FONT_SAMPLE_TEXT: str = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!?@$%(){}[]" FONT_SAMPLE_SIZES: list[int] = [10, 15, 20] # NOTE: These were the field IDs used for the "Tags", "Content Tags", and "Meta Tags" fields inside @@ -31,5 +32,4 @@ TAG_FAVORITE = 1 TAG_META = 2 RESERVED_TAG_START = 0 RESERVED_TAG_END = 999 - RESERVED_NAMESPACE_PREFIX = "tagstudio" diff --git a/src/tagstudio/core/library/alchemy/library.py b/src/tagstudio/core/library/alchemy/library.py index eef19596..8c28134d 100644 --- a/src/tagstudio/core/library/alchemy/library.py +++ b/src/tagstudio/core/library/alchemy/library.py @@ -30,6 +30,7 @@ from sqlalchemy import ( Engine, NullPool, ScalarResult, + Update, and_, asc, create_engine, @@ -1313,6 +1314,114 @@ class Library: return direct_tags, descendant_tags + def add_field_template(self, field_template: BaseFieldTemplate) -> BaseFieldTemplate | None: + """Add a new field template to the library.""" + if not (isinstance(field_template, (TextFieldTemplate, DatetimeFieldTemplate))): + logger.error("[Library] BaseFieldTemplate attempted to be added to the library.") + return None + + with Session(self.engine) as session: + try: + session.add(field_template) + session.flush() + make_transient(field_template) + session.commit() + return field_template + except IntegrityError as e: + logger.error(e) + session.rollback() + return None + + def update_field_template(self, old_field_type: str, field_template: BaseFieldTemplate) -> bool: + """Update a field template in the library. + + old_field_class:str + field_template: BaseFieldTemplate + """ + with Session(self.engine) as session: + logger.warning(f"Updating old type {old_field_type} to new {field_template.class_name}") + is_same_type: bool = old_field_type == field_template.class_name + try: + update_stmt: Update | None = None + # If the template is changing type, remove the old one and add the updated + # template to the proper table. + if not is_same_type: + old_template: BaseFieldTemplate | None = None + if old_field_type == "TextFieldTemplate": + old_template = session.scalar( + select(TextFieldTemplate) + .where(TextFieldTemplate.id == field_template.id) + .limit(1) + ) + elif old_field_type == "DatetimeFieldTemplate": + old_template = session.scalar( + select(DatetimeFieldTemplate) + .where(DatetimeFieldTemplate.id == field_template.id) + .limit(1) + ) + if old_template is None: + logger.error("[Library] old_template is None") + return False + session.delete(old_template) + session.flush() + field_template.id = None # The id should not transfer between tables + session.add(field_template) + session.commit() + # Otherwise, update the existing template in-place + elif isinstance(field_template, TextFieldTemplate): + update_stmt = ( + update(TextFieldTemplate) + .where(TextFieldTemplate.id == field_template.id) + .values(name=field_template.name, is_multiline=field_template.is_multiline) + ) + elif isinstance(field_template, DatetimeFieldTemplate): + update_stmt = ( + update(DatetimeFieldTemplate) + .where(DatetimeFieldTemplate.id == field_template.id) + .values(name=field_template.name) + ) + if is_same_type: + if update_stmt is None: + return False + session.execute(update_stmt) + session.commit() + + except IntegrityError as e: + logger.error(e) + session.rollback() + return False + + return True + + def remove_field_template(self, field_template: BaseFieldTemplate) -> bool: + """Remove a field template from the library.""" + with Session(self.engine) as session: + try: + session_item: BaseFieldTemplate | None = None + if isinstance(field_template, TextFieldTemplate): + session_item = session.scalar( + select(TextFieldTemplate) + .where(TextFieldTemplate.id == field_template.id) + .limit(1) + ) + elif isinstance(field_template, DatetimeFieldTemplate): + session_item = session.scalar( + select(DatetimeFieldTemplate) + .where(DatetimeFieldTemplate.id == field_template.id) + .limit(1) + ) + + if session_item is not None: + session.delete(session_item) + session.commit() + + except IntegrityError as e: + logger.error(e) + session.rollback() + return False + + return True + def search_field_templates(self, name: str | None, limit: int = 100) -> list[BaseFieldTemplate]: """Return field template rows matching the query, detached from the session.""" if limit <= 0: @@ -1320,7 +1429,7 @@ class Library: search_query: str = name.lower() if name else "" - def sort_key(template: BaseFieldTemplate) -> tuple: + def sort_key(template: BaseFieldTemplate) -> tuple[str] | tuple[bool, int, str]: text = template.name.lower() if not search_query: return (text,) @@ -1431,7 +1540,12 @@ class Library: session.commit() def update_text_field( - self, entry_ids: list[int] | int, field: TextField, value: str, is_multiline: bool + self, + entry_ids: list[int] | int, + field: TextField, + name: str, + value: str, + is_multiline: bool, ): """Update a TextField field on one or more Entries.""" if isinstance(entry_ids, int): @@ -1443,7 +1557,7 @@ class Library: update_stmt = ( update(field_type) .where(and_(field_type.id == field.id, field_type.entry_id.in_(entry_ids))) - .values(value=value, is_multiline=is_multiline) + .values(name=name, value=value, is_multiline=is_multiline) ) session.execute(update_stmt) @@ -1453,6 +1567,7 @@ class Library: self, entry_ids: list[int] | int, field: DatetimeField, + name: str, value: datetime, ): """Update a DatetimeField field on one or more Entries.""" @@ -1465,7 +1580,7 @@ class Library: update_stmt = ( update(field_type) .where(and_(field_type.id == field.id, field_type.entry_id.in_(entry_ids))) - .values(value=value) + .values(name=name, value=value) ) session.execute(update_stmt) diff --git a/src/tagstudio/qt/views/clickable_label.py b/src/tagstudio/qt/controllers/clickable_label.py similarity index 70% rename from src/tagstudio/qt/views/clickable_label.py rename to src/tagstudio/qt/controllers/clickable_label.py index f382d14e..2eadda67 100644 --- a/src/tagstudio/qt/views/clickable_label.py +++ b/src/tagstudio/qt/controllers/clickable_label.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: GPL-3.0-only -from typing import override +from typing import Any, override from PySide6.QtCore import Signal from PySide6.QtGui import QMouseEvent @@ -14,8 +14,8 @@ class ClickableLabel(QLabel): clicked = Signal() - def __init__(self): - super().__init__() + def __init__(self, *args: Any, **kwarg: Any): # pyright: ignore[reportExplicitAny] + super().__init__(*args, **kwarg) @override def mousePressEvent(self, ev: QMouseEvent): diff --git a/src/tagstudio/qt/controllers/edit_field_template_modal.py b/src/tagstudio/qt/controllers/edit_field_template_modal.py new file mode 100644 index 00000000..5ac7cf30 --- /dev/null +++ b/src/tagstudio/qt/controllers/edit_field_template_modal.py @@ -0,0 +1,115 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + + +import structlog + +from tagstudio.core.library.alchemy.fields import ( + BaseFieldTemplate, + DatetimeFieldTemplate, + TextFieldTemplate, +) +from tagstudio.qt.translations import Translations +from tagstudio.qt.views.edit_field_template_modal_view import EditFieldTemplateModalView +from tagstudio.qt.views.stylesheets.stylesheets import line_edit_style + +logger = structlog.get_logger(__name__) + + +class EditFieldTemplateModal(EditFieldTemplateModalView): + field_type_map: dict[str, str] = { + "TextFieldTemplate": Translations["field_type.text"], + "DatetimeFieldTemplate": Translations["field_type.datetime"], + } + DEFAULT_TYPE_INDEX = 0 + + def __init__(self, field_template: BaseFieldTemplate | None = None) -> None: + super().__init__() + self.__field_id: int | None = field_template.id if field_template else None + self.__field_name: str = "" + self.__field_type: str | None = field_template.class_name if field_template else None + self.old_field_type: str = "" + + for k, v in EditFieldTemplateModal.field_type_map.items(): + self._type_combobox.addItem(v, k) + + self.__connect_callbacks() + self.set_field_template(field_template) + self.__on_type_changed(EditFieldTemplateModal.DEFAULT_TYPE_INDEX) + + def __connect_callbacks(self) -> None: + self.name_field.textChanged.connect(self.__on_name_changed) + self._type_combobox.currentIndexChanged.connect(self.__on_type_changed) + + def set_field_template(self, field_template: BaseFieldTemplate | None = None) -> None: + """Populate the modal with pre-existing field template values, or fallback to defaults.""" + logger.info("[EditFieldTemplate] Setting Field Template", field_template=field_template) + + # Indicates a new template, set default values + if field_template is None: + self.__field_name = Translations["field_template.new"] + self.__field_type = list(EditFieldTemplateModal.field_type_map.keys())[ + EditFieldTemplateModal.DEFAULT_TYPE_INDEX + ] + return + # Populate common values for any field type + else: + self.__field_name = field_template.name + self.__field_type = field_template.class_name + self.old_field_type = field_template.class_name # Only set on init + + # Update widgets + self.name_field.setText(self.__field_name) + self._type_combobox.setCurrentIndex( + list(EditFieldTemplateModal.field_type_map.keys()).index(field_template.class_name) + ) + + # Populate values for specific field types + if isinstance(field_template, TextFieldTemplate): + self._multiline_checkbox.setChecked(field_template.is_multiline) + + def __on_name_changed(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) + + def __on_type_changed(self, index: int): + old_type = self.__field_type + self.__field_type = list(EditFieldTemplateModal.field_type_map.keys())[index] + + if old_type == self.__field_type: + logger.info(f"old type {old_type}, new type {self.__field_type}") + return + + if old_type == "TextFieldTemplate": + self._text_field_attributes_widget.hide() + # NOTE: Future options specific to other type will go here. + + if self.__field_type == "TextFieldTemplate": + self._text_field_attributes_widget.show() + + def build_field_template(self) -> BaseFieldTemplate: + if self.__field_type == "TextFieldTemplate": + return TextFieldTemplate( + id=self.__field_id, + name=self.name_field.text(), + is_multiline=self._multiline_checkbox.isChecked(), + ) + elif self.__field_type == "DatetimeFieldTemplate": + return DatetimeFieldTemplate( + id=self.__field_id, + name=self.name_field.text(), + ) + else: + logger.warning( + "[EditFieldTemplateModal] Unknown field, falling back to TextFieldTemplate", + field_type=self.__field_type, + example=TextFieldTemplate, + ) + return TextFieldTemplate( + name=self.name_field.text(), + is_multiline=self._multiline_checkbox.isChecked(), + ) diff --git a/src/tagstudio/qt/controllers/edit_text_controller.py b/src/tagstudio/qt/controllers/edit_text_controller.py new file mode 100644 index 00000000..3e98003e --- /dev/null +++ b/src/tagstudio/qt/controllers/edit_text_controller.py @@ -0,0 +1,67 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + + +from typing import override + +import structlog + +from tagstudio.qt.views.edit_text_view import EditTextView + +logger = structlog.get_logger(__name__) + + +class EditText(EditTextView): + def __init__(self, name: str, text: str | None, is_multiline: bool = False): + super().__init__() + self.name_field.setText(name) + + self.text = text + self.is_multiline: bool = is_multiline + + self.multiline_checkbox.setChecked(is_multiline) + self.multiline_checkbox.clicked.connect(lambda checked: self.on_multiline_checked(checked)) + + if self.is_multiline: + self.text_line.hide() + self.text_line_stretch.hide() + self.text_box.setPlainText(self.text or "") + else: + self.text_box.hide() + self.text_line.setText(self.text or "") + + def on_multiline_checked(self, checked: bool): + was_multiline = self.is_multiline + self.is_multiline = checked + + if was_multiline: + self.text = self.text_box.toPlainText() + self.text_box.hide() + self.text_line.setText(self.text) + self.text_line.show() + self.text_line_stretch.show() + else: + self.text = self.text_line.text() + self.text_line.hide() + self.text_line_stretch.hide() + self.text_box.setPlainText(self.text) + self.text_box.show() + + @override + def parent_post_init(self): + if self.is_multiline: + self.text_box.setFocus() + else: + self.text_line.setFocus() + + @override + def saved_data(self) -> dict[str, str | bool]: + return { + "name": self.name_field.text(), + "value": self.text_box.toPlainText() if self.is_multiline else self.text_line.text(), + "is_multiline": self.is_multiline, + } + + @override + def reset(self): + self.text_box.setPlainText(self.text or "") 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 f1bff002..45c126ac 100644 --- a/src/tagstudio/qt/controllers/field_template_search_panel_controller.py +++ b/src/tagstudio/qt/controllers/field_template_search_panel_controller.py @@ -2,13 +2,16 @@ # SPDX-License-Identifier: GPL-3.0-only +from typing import override from warnings import catch_warnings import structlog from PySide6.QtCore import Signal +from PySide6.QtWidgets import QMessageBox 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.search_panel_controller import SearchPanel from tagstudio.qt.translations import Translations @@ -23,9 +26,7 @@ class FieldTemplateSearchModal(PanelModal): self, library: Library, is_field_template_chooser: bool = True, - done_callback=None, - save_callback=None, - has_save=False, + has_save: bool = False, ) -> None: self.search_panel: FieldTemplateSearchPanel = FieldTemplateSearchPanel( library, @@ -35,9 +36,7 @@ class FieldTemplateSearchModal(PanelModal): super().__init__( self.search_panel, Translations["field.add.plural"], - done_callback=done_callback, - save_callback=save_callback, - has_save=has_save, + is_savable=has_save, ) @@ -60,34 +59,79 @@ class FieldTemplateSearchPanel(SearchPanel[BaseFieldTemplate]): self._unlimited_limit_item_label = Translations["field_template.all_field_templates"] self._create_and_add_button_label_key = "field_template.create_add" + @override def _get_max_limit(self) -> int: return len(self.__lib.field_templates) - def on_item_create(self) -> None: - # TODO: Allow creation of field templates - pass + @override + def on_item_create(self, add_to_entry: bool = False) -> None: + """Opens panel to create a new field template and optionally add it to an entry. + Populates name field using current search query. + + Args: + add_to_entry (bool): Should this item be added to currently selected entries? + """ + query: str = self.get_search_query() + logger.info("[FieldTemplateSearch] Create and Add Field Template", name=query) + + panel: EditFieldTemplateModal = EditFieldTemplateModal() + modal: PanelModal = PanelModal( + 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(panel, choose_item=add_to_entry)) + modal.show() + + @override def on_item_edit(self, item: BaseFieldTemplate) -> None: - # TODO: Allow creation of field templates - pass + panel: EditFieldTemplateModal = EditFieldTemplateModal(item) + modal: PanelModal = PanelModal( + panel, + item.name, + Translations["field_template.edit"], + is_savable=True, + ) + + modal.saved.connect(lambda: self.edit_item(panel)) + modal.show() + + @override def _on_item_remove(self, item: BaseFieldTemplate) -> None: if self.is_chooser: return - # TODO: Allow creation of field templates - pass + message_box = QMessageBox( + QMessageBox.Icon.Question, + Translations["field_template.delete"], + Translations.format("field_template.confirm_delete", field_template_name=item.name), + QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel, + ) - def on_item_create_and_add(self) -> None: - # TODO: Allow creation of field templates - pass + result = message_box.exec() + if result != QMessageBox.StandardButton.Ok: + return + + self.__lib.remove_field_template(item) + self.update_items(self.get_search_query()) + + @override def _on_item_chosen(self, item: BaseFieldTemplate) -> None: self.field_template_chosen.emit(item) + @override def search_items(self, query: str) -> tuple[list[BaseFieldTemplate], list[BaseFieldTemplate]]: return self.__lib.search_field_templates(name=query, limit=self._get_limit()[1]), [] + @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.""" field_template_widget: FieldTemplateWidget = self.get_item_widget(index, self.__lib) @@ -97,25 +141,41 @@ class FieldTemplateSearchPanel(SearchPanel[BaseFieldTemplate]): if item is None: return - # field_template_widget.has_remove = not self.is_chooser + field_template_widget.has_remove = not self.is_chooser # Disconnect previous callbacks with catch_warnings(record=True): - # tag_widget.on_edit.disconnect() - # tag_widget.on_remove.disconnect() + field_template_widget.on_edit.disconnect() + field_template_widget.on_remove.disconnect() field_template_widget.on_click.disconnect() # Connect callbacks - # tag_widget.on_edit.connect(lambda edit_tag=item: self.on_item_edit(edit_tag)) - # tag_widget.on_remove.connect(lambda remove_tag=item: self._on_item_remove(remove_tag)) + field_template_widget.on_edit.connect(lambda item_=item: self.on_item_edit(item_)) + field_template_widget.on_remove.connect(lambda item_=item: self._on_item_remove(item_)) field_template_widget.on_click.connect( - lambda checked=False, tag=item: self._on_item_chosen(tag) + lambda checked=False, item_=item: self._on_item_chosen(item_) ) - def create_item(self, build_item_modal: PanelModal, choose_item: bool = False) -> None: - # TODO: Allow creation of field templates - pass + @override + def create_item(self, edit_item_panel: PanelWidget, choose_item: bool = False) -> None: + if isinstance(edit_item_panel, EditFieldTemplateModal): + template: BaseFieldTemplate = edit_item_panel.build_field_template() + self.__lib.add_field_template(template) + + if choose_item: + self._on_item_chosen(template) + self.clear_search_query() + + edit_item_panel.hide() + self.on_search_query_changed(self.get_search_query()) + + @override def edit_item(self, edit_item_panel: PanelWidget) -> None: - # TODO: Allow creation of field templates - pass + 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.search_field.text()) diff --git a/src/tagstudio/qt/controllers/field_template_widget_controller.py b/src/tagstudio/qt/controllers/field_template_widget_controller.py index 3a8a2aa0..e23e9e66 100644 --- a/src/tagstudio/qt/controllers/field_template_widget_controller.py +++ b/src/tagstudio/qt/controllers/field_template_widget_controller.py @@ -1,6 +1,11 @@ # SPDX-FileCopyrightText: (c) TagStudio Contributors # SPDX-License-Identifier: GPL-3.0-only +from typing import override + +from PySide6.QtCore import QEvent, Qt +from PySide6.QtGui import QAction, QEnterEvent + from tagstudio.core.library.alchemy.fields import BaseFieldTemplate from tagstudio.qt.translations import FIELD_TYPE_KEYS, Translations from tagstudio.qt.views.field_template_widget_view import FieldTemplateWidgetView @@ -11,6 +16,15 @@ class FieldTemplateWidget(FieldTemplateWidgetView): super().__init__() self.__field_template: BaseFieldTemplate | None = None + self.has_remove: bool = False + + # Add actions + edit_action = QAction(self) + edit_action.setText(Translations["generic.edit"]) + edit_action.triggered.connect(self.on_edit.emit) + self.addAction(edit_action) + + self.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) def set_field_template(self, field_template: BaseFieldTemplate | None) -> None: self.__field_template = field_template @@ -20,3 +34,17 @@ class FieldTemplateWidget(FieldTemplateWidgetView): field_name_key: str = FIELD_TYPE_KEYS.get(field_template.class_name, "field_type.unknown") self._bg_button.setText(f"{field_template.name} ({Translations[field_name_key]})") + + @override + def enterEvent(self, event: QEnterEvent) -> None: + if self.has_remove: + self._delete_button.setHidden(False) + self.update() + return super().enterEvent(event) + + @override + def leaveEvent(self, event: QEvent) -> None: + if self.has_remove: + self._delete_button.setHidden(True) + self.update() + return super().leaveEvent(event) diff --git a/src/tagstudio/qt/controllers/fix_ignored_modal_controller.py b/src/tagstudio/qt/controllers/fix_ignored_modal_controller.py index e841f61d..cad8181c 100644 --- a/src/tagstudio/qt/controllers/fix_ignored_modal_controller.py +++ b/src/tagstudio/qt/controllers/fix_ignored_modal_controller.py @@ -13,6 +13,7 @@ from tagstudio.qt.mixed.progress_bar import ProgressWidget from tagstudio.qt.mixed.remove_ignored_modal import RemoveIgnoredModal from tagstudio.qt.translations import Translations from tagstudio.qt.views.fix_ignored_modal_view import FixIgnoredEntriesModalView +from tagstudio.qt.views.stylesheets.stylesheets import header # Only import for type checking/autocompletion, will not be imported at runtime. if TYPE_CHECKING: @@ -78,7 +79,7 @@ class FixIgnoredEntriesModal(FixIgnoredEntriesModalView): count_text: str = Translations.format( "entries.ignored.ignored_count", count=count if count >= 0 else "—" ) - self.ignored_count_label.setText(f"

{count_text}

") + self.ignored_count_label.setText(header(count_text, 3)) def update_driver_widgets(self): if ( diff --git a/src/tagstudio/qt/controllers/library_info_window_controller.py b/src/tagstudio/qt/controllers/library_info_window_controller.py index 426e1edd..d0828dcb 100644 --- a/src/tagstudio/qt/controllers/library_info_window_controller.py +++ b/src/tagstudio/qt/controllers/library_info_window_controller.py @@ -22,6 +22,7 @@ from tagstudio.core.utils.types import unwrap from tagstudio.qt.translations import Translations from tagstudio.qt.utils import file_opener from tagstudio.qt.views.library_info_window_view import LibraryInfoWindowView +from tagstudio.qt.views.stylesheets.stylesheets import header # Only import for type checking/autocompletion, will not be imported at runtime. if TYPE_CHECKING: @@ -61,7 +62,7 @@ class LibraryInfoWindow(LibraryInfoWindowView): title: str = Translations.format( "library_info.title", library_dir=self.lib.library_dir.stem ) - self.title_label.setText(f"

{title}

") + self.title_label.setText(header(title, 2)) def update_stats(self): self.entry_count_label.setText(f"{self.lib.entries_count}") diff --git a/src/tagstudio/qt/controllers/paged_panel_controller.py b/src/tagstudio/qt/controllers/paged_panel_controller.py index f4a7e8e8..53cc146f 100644 --- a/src/tagstudio/qt/controllers/paged_panel_controller.py +++ b/src/tagstudio/qt/controllers/paged_panel_controller.py @@ -10,6 +10,7 @@ from PySide6.QtCore import Qt from PySide6.QtWidgets import QHBoxLayout, QLabel, QVBoxLayout, QWidget from tagstudio.qt.controllers.paged_panel_state import PagedPanelState +from tagstudio.qt.views.stylesheets.stylesheets import header logger = structlog.get_logger(__name__) @@ -89,7 +90,7 @@ class PagedPanel(QWidget): # Update Title self.setWindowTitle(frame.title) - self.title_label.setText(f"

{frame.title}

") + self.title_label.setText(header(frame.title, 1)) # Update Body Widget if self.body_layout.itemAt(0): @@ -107,7 +108,7 @@ class PagedPanel(QWidget): if isinstance(item, QWidget): self.button_nav_layout.addWidget(item) item.setHidden(False) - elif isinstance(item, int): + elif isinstance(item, int): # pyright: ignore[reportUnnecessaryIsInstance] self.button_nav_layout.addStretch(item) @override diff --git a/src/tagstudio/qt/controllers/preview_panel_controller.py b/src/tagstudio/qt/controllers/preview_panel_controller.py index 287db4f8..d3852a80 100644 --- a/src/tagstudio/qt/controllers/preview_panel_controller.py +++ b/src/tagstudio/qt/controllers/preview_panel_controller.py @@ -42,11 +42,11 @@ class PreviewPanel(PreviewPanelView): self.__add_tag_modal.tsp.item_chosen.connect(self._add_tag_to_selected) def _add_field_to_selected(self, template: BaseFieldTemplate) -> None: - self._fields.add_field_to_selected(template) + self._containers.add_field_to_selected(template) if len(self._selected) == 1: - self._fields.update_from_entry(self._selected[0]) + self._containers.update_from_entry(self._selected[0]) def _add_tag_to_selected(self, tag_id: int) -> None: - self._fields.add_tags_to_selected(tag_id) + self._containers.add_tags_to_selected(tag_id) if len(self._selected) == 1: - self._fields.update_from_entry(self._selected[0]) + self._containers.update_from_entry(self._selected[0]) diff --git a/src/tagstudio/qt/controllers/search_panel_controller.py b/src/tagstudio/qt/controllers/search_panel_controller.py index a90a9b78..0b743931 100644 --- a/src/tagstudio/qt/controllers/search_panel_controller.py +++ b/src/tagstudio/qt/controllers/search_panel_controller.py @@ -2,16 +2,17 @@ # SPDX-License-Identifier: GPL-3.0-only -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, override import structlog from PySide6 import QtCore, QtGui from PySide6.QtCore import Signal from PySide6.QtGui import QShowEvent -from PySide6.QtWidgets import QVBoxLayout +from PySide6.QtWidgets import QVBoxLayout, QWidget +from tagstudio.core.library.alchemy.library import Library from tagstudio.qt.translations import Translations -from tagstudio.qt.views.panel_modal import PanelModal, PanelWidget +from tagstudio.qt.views.panel_modal import PanelWidget from tagstudio.qt.views.search_panel_view import SearchPanelView logger = structlog.get_logger(__name__) @@ -22,7 +23,7 @@ if TYPE_CHECKING: def _item_id(item: object) -> int: - item_id: Any = getattr(item, "id") # noqa: B009 + item_id: Any = getattr(item, "id") # noqa: B009 # pyright: ignore[reportExplicitAny] if isinstance(item_id, int): return item_id @@ -31,7 +32,7 @@ def _item_id(item: object) -> int: def _item_name(item: object) -> str: - item_name: Any = getattr(item, "name") # noqa: B009 + item_name: Any = getattr(item, "name") # noqa: B009 # pyright: ignore[reportExplicitAny] if isinstance(item_name, str): return item_name @@ -93,15 +94,13 @@ class SearchPanel[T](PanelWidget): def clear_search_query(self) -> None: self.view.clear_search_query() - def get_item_widget(self, index: int, library: Any): + def get_item_widget(self, index: int, library: Library): return self.view.get_item_widget(index, library) def set_driver(self, driver: "QtDriver") -> None: self._driver = driver def on_limit_changed(self, index: int) -> None: - logger.info("[SearchPanel] Updating limit") - # Method was called outside the limit_combobox callback if index != self.view.get_limit_index(): self.view.set_limit_index(index) @@ -130,33 +129,30 @@ class SearchPanel[T](PanelWidget): # Focus search field if no query if not query: self.search_field.setFocus() - parent = self.parentWidget() - if parent is not None: + parent: QWidget | None = self.parentWidget() + if parent is not None: # pyright: ignore[reportUnnecessaryComparison] parent.hide() return # Create and add item if no search results if len(self._search_results) <= 0: - self.on_item_create_and_add() + self.on_item_create(add_to_entry=True) elif self.is_chooser: self._on_item_chosen(self._search_results[0]) self.clear_search_query() self.update_items() - def on_item_create(self) -> None: + def on_item_create(self, add_to_entry: bool = False) -> None: # pyright: ignore[reportUnusedParameter] raise NotImplementedError() - def on_item_edit(self, item: T) -> None: + def on_item_edit(self, item: T) -> None: # pyright: ignore[reportUnusedParameter] raise NotImplementedError() - def _on_item_remove(self, item: T) -> None: + def _on_item_remove(self, item: T) -> None: # pyright: ignore[reportUnusedParameter] raise NotImplementedError() - def on_item_create_and_add(self) -> None: - raise NotImplementedError() - - def _on_item_chosen(self, item: T) -> None: + def _on_item_chosen(self, item: T) -> None: # pyright: ignore[reportUnusedParameter] raise NotImplementedError() def _is_excluded(self, item: T) -> bool: @@ -215,18 +211,20 @@ class SearchPanel[T](PanelWidget): if query and query.strip(): self.view.add_create_and_add_button() - def search_items(self, query: str) -> tuple[list[T], list[T]]: + 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: + def set_item_widget(self, item: T | None, index: int) -> None: # pyright: ignore[reportUnusedParameter] raise NotImplementedError() + @override def showEvent(self, event: QShowEvent) -> None: # noqa N802 self.update_items() self.view.scroll_to(0) self.view.clear_search_query() return super().showEvent(event) + @override def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: # noqa N802 # When Escape is pressed, focus back on the search box. # If focus is already on the search box, close the modal. @@ -236,8 +234,8 @@ class SearchPanel[T](PanelWidget): else: self.view.focus_search_box(select_all=True) - def create_item(self, build_item_modal: PanelModal, choose_item: bool = False) -> None: + def create_item(self, edit_item_panel: PanelWidget, choose_item: bool = False) -> None: # pyright: ignore[reportUnusedParameter] raise NotImplementedError() - def edit_item(self, edit_item_panel: PanelWidget) -> None: + def edit_item(self, edit_item_panel: PanelWidget) -> None: # pyright: ignore[reportUnusedParameter] raise NotImplementedError() diff --git a/src/tagstudio/qt/controllers/tag_box_controller.py b/src/tagstudio/qt/controllers/tag_box_controller.py index 79351230..58e7919a 100644 --- a/src/tagstudio/qt/controllers/tag_box_controller.py +++ b/src/tagstudio/qt/controllers/tag_box_controller.py @@ -2,6 +2,7 @@ # SPDX-License-Identifier: GPL-3.0-only +from functools import partial from typing import TYPE_CHECKING, override import structlog @@ -77,20 +78,20 @@ class TagBoxWidget(TagBoxWidgetView): build_tag_panel, self.__driver.lib.tag_display_name(tag), "Edit Tag", - done_callback=self.on_update.emit, - has_save=True, - ) - # TODO - this was update_tag() - edit_modal.saved.connect( - lambda: self.__driver.lib.update_tag( - build_tag_panel.build_tag(), - parent_ids=set(build_tag_panel.parent_ids), - alias_names=set(build_tag_panel.alias_names), - alias_ids=set(build_tag_panel.alias_ids), - ) + is_savable=True, ) + edit_modal.saved.connect(partial(self._update_tag_callback, build_tag_panel)) edit_modal.show() + def _update_tag_callback(self, build_tag_panel: BuildTagPanel): + self.__driver.lib.update_tag( + build_tag_panel.build_tag(), + parent_ids=set(build_tag_panel.parent_ids), + alias_names=set(build_tag_panel.alias_names), + alias_ids=set(build_tag_panel.alias_ids), + ) + self.on_update.emit() + @override def _on_search(self, tag: Tag) -> None: self.__driver.main_window.search_field.setText(f"tag_id:{tag.id}") diff --git a/src/tagstudio/qt/controllers/tag_search_panel_controller.py b/src/tagstudio/qt/controllers/tag_search_panel_controller.py index 161c7f08..ecd53496 100644 --- a/src/tagstudio/qt/controllers/tag_search_panel_controller.py +++ b/src/tagstudio/qt/controllers/tag_search_panel_controller.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: GPL-3.0-only -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, override from warnings import catch_warnings import structlog @@ -33,9 +33,7 @@ class TagSearchModal(PanelModal): library: Library, exclude: list[int] | None = None, is_tag_chooser: bool = True, - done_callback=None, - save_callback=None, - has_save=False, + has_save: bool = False, ): self.tsp = TagSearchPanel( library, @@ -46,9 +44,7 @@ class TagSearchModal(PanelModal): super().__init__( self.tsp, Translations["tag.add.plural"], - done_callback=done_callback, - save_callback=save_callback, - has_save=has_save, + is_savable=has_save, ) @@ -70,28 +66,39 @@ class TagSearchPanel(SearchPanel[Tag]): self._unlimited_limit_item_label = Translations["tag.all_tags"] self._create_and_add_button_label_key = "tag.create_add" + @override def _get_max_limit(self) -> int: return len(self.__lib.tags) - def on_item_create(self) -> None: + @override + def on_item_create(self, add_to_entry: bool = False) -> None: + """Opens panel to create a new tag and optionally add it to an entry. + + Populates name field using current search query. + + Args: + add_to_entry (bool): Should this item be added to currently selected entries? + """ # TODO: Move this to a top-level import from tagstudio.qt.mixed.build_tag import BuildTagPanel # here due to circular imports query: str = self.get_search_query() - build_tag_panel: BuildTagPanel = BuildTagPanel(self.__lib) - build_tag_modal: PanelModal = PanelModal( - build_tag_panel, + panel: BuildTagPanel = BuildTagPanel(self.__lib) + modal: PanelModal = PanelModal( + panel, Translations["tag.new"], - has_save=True, + Translations["tag.add"] if add_to_entry else Translations["tag.new"], + is_savable=True, ) if query.strip(): - build_tag_panel.name_field.setText(query) + panel.name_field.setText(query) - build_tag_modal.saved.connect(lambda: self.create_item(build_tag_modal)) - build_tag_modal.show() + modal.saved.connect(lambda: self.create_item(panel, choose_item=add_to_entry)) + modal.show() + @override def on_item_edit(self, item: Tag) -> None: # TODO: Move this to a top-level import from tagstudio.qt.mixed.build_tag import BuildTagPanel # here due to circular imports @@ -101,12 +108,13 @@ class TagSearchPanel(SearchPanel[Tag]): edit_tag_panel, self.__lib.tag_display_name(item), Translations["tag.edit"], - has_save=True, + is_savable=True, ) edit_tag_modal.saved.connect(lambda: self.edit_item(edit_tag_panel)) edit_tag_modal.show() + @override def _on_item_remove(self, item: Tag) -> None: if self.is_chooser: return @@ -115,49 +123,29 @@ class TagSearchPanel(SearchPanel[Tag]): return message_box = QMessageBox( - QMessageBox.Question, # type: ignore + QMessageBox.Icon.Question, Translations["tag.remove"], Translations.format("tag.confirm_delete", tag_name=self.__lib.tag_display_name(item)), - QMessageBox.Ok | QMessageBox.Cancel, # type: ignore + QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel, ) result = message_box.exec() - if result != QMessageBox.Ok: # type: ignore + if result != QMessageBox.StandardButton.Ok: return self.__lib.remove_tag(item.id) self.update_items(self.get_search_query()) - def on_item_create_and_add(self) -> None: - """Opens "Create Tag" panel to create and add a new tag with given name.""" - # TODO: Move this to a top-level import - from tagstudio.qt.mixed.build_tag import BuildTagPanel # here due to circular imports - - query: str = self.get_search_query() - - logger.info("Create and Add Tag", name=query) - - build_tag_panel: BuildTagPanel = BuildTagPanel(self.__lib) - build_tag_modal: PanelModal = PanelModal( - build_tag_panel, - Translations["tag.new"], - Translations["tag.add"], - has_save=True, - ) - - if query.strip(): - build_tag_panel.name_field.setText(query) - - build_tag_modal.saved.connect(lambda: self.create_item(build_tag_modal, choose_item=True)) - build_tag_modal.show() - + @override def _on_item_chosen(self, item: Tag) -> None: self.item_chosen.emit(item.id) + @override def search_items(self, query: str) -> tuple[list[Tag], list[Tag]]: return self.__lib.search_tags(name=query, limit=self._get_limit()[1]) + @override def set_item_widget(self, item: Tag | None, index: int) -> None: """Set the tag of a tag widget at a specific index.""" tag_widget: TagWidget = self.get_item_widget(index, self.__lib) @@ -195,39 +183,41 @@ class TagSearchPanel(SearchPanel[Tag]): else: tag_widget.search_for_tag_action.setEnabled(False) - def create_item(self, build_item_modal: PanelModal, choose_item: bool = False) -> None: + @override + def create_item(self, edit_item_panel: PanelWidget, choose_item: bool = False) -> None: # TODO: Move this to a top-level import from tagstudio.qt.mixed.build_tag import BuildTagPanel # here due to circular imports - if isinstance(build_item_modal.widget, BuildTagPanel): - tag: Tag = build_item_modal.widget.build_tag() + if isinstance(edit_item_panel, BuildTagPanel): + tag: Tag = edit_item_panel.build_tag() self.__lib.add_tag( tag, - parent_ids=build_item_modal.widget.parent_ids, - alias_names=build_item_modal.widget.alias_names, - alias_ids=build_item_modal.widget.alias_ids, + parent_ids=edit_item_panel.parent_ids, + alias_names=edit_item_panel.alias_names, + alias_ids=edit_item_panel.alias_ids, ) if choose_item: self._on_item_chosen(tag) self.clear_search_query() - build_item_modal.hide() + edit_item_panel.hide() self.on_search_query_changed(self.get_search_query()) + @override def edit_item(self, edit_item_panel: PanelWidget) -> None: # TODO: Move this to a top-level import from tagstudio.qt.mixed.build_tag import BuildTagPanel # here due to circular imports if not isinstance(edit_item_panel, BuildTagPanel): return + self.__lib.update_tag( tag=edit_item_panel.build_tag(), parent_ids=edit_item_panel.parent_ids, alias_names=edit_item_panel.alias_names, alias_ids=edit_item_panel.alias_ids, ) - self.update_items(self.search_field.text()) def search_for_tag(self, tag_id: int) -> None: diff --git a/src/tagstudio/qt/mixed/about_modal.py b/src/tagstudio/qt/mixed/about_modal.py index badb876b..ca397750 100644 --- a/src/tagstudio/qt/mixed/about_modal.py +++ b/src/tagstudio/qt/mixed/about_modal.py @@ -8,7 +8,7 @@ from shutil import which from PIL import ImageQt from PySide6.QtCore import QSize, Qt -from PySide6.QtGui import QGuiApplication, QPalette, QPixmap +from PySide6.QtGui import QPalette, QPixmap from PySide6.QtWidgets import ( QFormLayout, QHBoxLayout, @@ -19,16 +19,23 @@ from PySide6.QtWidgets import ( QWidget, ) -from tagstudio.core.constants import COPYRIGHT, VERSION, VERSION_BRANCH -from tagstudio.core.enums import Theme +from tagstudio.core.constants import ( + COPYRIGHT, + DISCORD_URL, + DOCS_URL, + GITHUB_REPO_URL, + VERSION, + VERSION_BRANCH, +) from tagstudio.core.ts_core import TagStudioCore from tagstudio.core.utils.types import unwrap +from tagstudio.qt.controllers.clickable_label import ClickableLabel from tagstudio.qt.models.palette import ColorType, UiColor, get_ui_color from tagstudio.qt.previews.vendored import ffmpeg from tagstudio.qt.resource_manager import ResourceManager from tagstudio.qt.translations import Translations from tagstudio.qt.utils.file_opener import open_file -from tagstudio.qt.views.clickable_label import ClickableLabel +from tagstudio.qt.views.stylesheets.stylesheets import form_content_style class AboutModal(QWidget): @@ -42,18 +49,6 @@ class AboutModal(QWidget): self.rm: ResourceManager = ResourceManager() pixel_ratio = self.devicePixelRatio() - - # TODO: There should be a global button theme somewhere. - self.form_content_style = ( - f"background-color:{ - Theme.COLOR_BG.value - if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark - else Theme.COLOR_BG_LIGHT.value - };" - "border-radius:3px;" - "font-weight: 500;" - "padding: 2px;" - ) self.setStyleSheet("QLabel {color: white}") self.setWindowModality(Qt.WindowModality.ApplicationModal) @@ -84,7 +79,6 @@ class AboutModal(QWidget): # Version -------------------------------------------------------------- self.version_label = QLabel(f"

{AboutModal.VERSION_STR}

") self.version_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - # self.version_label.setStyleSheet("QLabel {color: #9782ff}") # Copyright ------------------------------------------------------------ self.copyright_label = QLabel(COPYRIGHT) @@ -128,54 +122,55 @@ class AboutModal(QWidget): # Version version_title = QLabel(Translations["about.version"]) - most_recent_release = unwrap(TagStudioCore.get_most_recent_release_version(), "UNKNOWN") - version_content_style = self.form_content_style - if most_recent_release == VERSION: + latest_version = unwrap(TagStudioCore.get_most_recent_release_version(), "?") + version_content_style = form_content_style() + if latest_version == VERSION: version_content = QLabel(f"{VERSION}") else: - version_content = QLabel(f"{VERSION} (Latest Release: {most_recent_release})") - version_content_style += "color: #d9534f;" + version_content = QLabel( + Translations.format( + "about.version.latest", built_version=VERSION, latest_version=latest_version + ) + ) + version_content_style += f"color: {red};" version_content.setStyleSheet(version_content_style) version_content.setMaximumWidth(version_content.sizeHint().width()) self.system_info_layout.addRow(version_title, version_content) # Config Path config_path_title = QLabel(f"{Translations['about.config_path']}") - config_path_content = ClickableLabel() - config_path_content.setText(f"{config_path}") # TODO: Pass in constructor after #1386 + config_path_content = ClickableLabel(f"{config_path}") config_path_content.clicked.connect(lambda: open_file(config_path, file_manager=True)) config_path_content.setCursor(Qt.CursorShape.PointingHandCursor) - config_path_content.setStyleSheet(self.form_content_style) config_path_content.setWordWrap(True) + config_path_content.setStyleSheet(form_content_style()) self.system_info_layout.addRow(config_path_title, config_path_content) # TODO: Add row for "App Cache Path" (currently that TagStudio.ini file) # FFmpeg Status ffmpeg_path_title = QLabel("FFmpeg") - ffmpeg_path_content = ClickableLabel() - ffmpeg_path_content.setText(f"{ffmpeg_status}") # TODO: Pass in constructor after #1386 + ffmpeg_path_content = ClickableLabel(f"{ffmpeg_status}") ffmpeg_location = which(ffmpeg._get_ffmpeg_location()) # pyright: ignore[reportPrivateUsage] if ffmpeg_location: ffmpeg_path_content.clicked.connect( lambda: open_file(ffmpeg_location, file_manager=True) ) ffmpeg_path_content.setCursor(Qt.CursorShape.PointingHandCursor) - ffmpeg_path_content.setStyleSheet(self.form_content_style) ffmpeg_path_content.setMaximumWidth(ffmpeg_path_content.sizeHint().width()) + ffmpeg_path_content.setStyleSheet(form_content_style()) self.system_info_layout.addRow(ffmpeg_path_title, ffmpeg_path_content) # FFprobe Status ffprobe_path_title = QLabel("FFprobe") - ffprobe_path_content = ClickableLabel() - ffprobe_path_content.setText(f"{ffprobe_status}") # TODO: Pass in constructor after #1386 + ffprobe_path_content = ClickableLabel(f"{ffprobe_status}") ffprobe_location = which(ffmpeg._get_ffprobe_location()) # pyright: ignore[reportPrivateUsage] if ffprobe_location: ffprobe_path_content.clicked.connect( lambda: open_file(ffprobe_location, file_manager=True) ) ffprobe_path_content.setCursor(Qt.CursorShape.PointingHandCursor) - ffprobe_path_content.setStyleSheet(self.form_content_style) + ffprobe_path_content.setStyleSheet(form_content_style()) ffprobe_path_content.setMaximumWidth(ffprobe_path_content.sizeHint().width()) self.system_info_layout.addRow(ffprobe_path_title, ffprobe_path_content) @@ -190,19 +185,16 @@ class AboutModal(QWidget): lambda: open_file(ripgrep_location, file_manager=True) ) ripgrep_path_content.setCursor(Qt.CursorShape.PointingHandCursor) - ripgrep_path_content.setStyleSheet(self.form_content_style) + ripgrep_path_content.setStyleSheet(form_content_style()) ripgrep_path_content.setMaximumWidth(ripgrep_path_content.sizeHint().width()) self.system_info_layout.addRow(ripgrep_path_title, ripgrep_path_content) # Links ---------------------------------------------------------------- - repo_link = "https://github.com/TagStudioDev/TagStudio" - docs_link = "https://docs.tagstud.io" - discord_link = "https://discord.com/invite/hRNnVKhF2G" self.links_label = QLabel( - f'
' + f'

GitHub | ' + f'{Translations["about.documentation"]} | ' + f'Discord

' ) self.links_label.setStyleSheet("QLabel {color: #809782ff}") self.links_label.setWordWrap(True) diff --git a/src/tagstudio/qt/mixed/add_field.py b/src/tagstudio/qt/mixed/add_field.py index 653b9788..946f4d74 100644 --- a/src/tagstudio/qt/mixed/add_field.py +++ b/src/tagstudio/qt/mixed/add_field.py @@ -19,6 +19,7 @@ from PySide6.QtWidgets import ( 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__) @@ -39,10 +40,9 @@ class AddFieldModal(QWidget): self.root_layout = QVBoxLayout(self) self.root_layout.setContentsMargins(6, 6, 6, 6) - self.title_widget = QLabel(Translations["field.add"]) + self.title_widget = QLabel(header(Translations["field.add"], 3)) 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.setAlignment(Qt.AlignmentFlag.AlignCenter) self.list_widget = QListWidget() diff --git a/src/tagstudio/qt/mixed/build_color.py b/src/tagstudio/qt/mixed/build_color.py index 07b43d88..9791aaa7 100644 --- a/src/tagstudio/qt/mixed/build_color.py +++ b/src/tagstudio/qt/mixed/build_color.py @@ -3,6 +3,7 @@ import contextlib +from typing import override import structlog from PySide6.QtCore import Qt, Signal @@ -24,14 +25,14 @@ 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.mixed.tag_color_preview import TagColorPreview -from tagstudio.qt.mixed.tag_widget import ( - get_border_color, - get_highlight_color, - get_text_color, -) -from tagstudio.qt.models.palette import ColorType, UiColor, get_tag_color, get_ui_color +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, + list_button_style, +) logger = structlog.get_logger(__name__) @@ -129,43 +130,12 @@ class BuildColorPanel(PanelWidget): color=QColor(unwrap(self.preview_button.tag_color_group).secondary) if unwrap(self.preview_button.tag_color_group).secondary else None, - color_border=checked, ) ) self.border_layout.addWidget(self.border_checkbox) self.border_label = QLabel(Translations["color.color_border"]) self.border_layout.addWidget(self.border_label) - - primary_color = QColor(get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT)) - border_color = get_border_color(primary_color) - highlight_color = get_highlight_color(primary_color) - text_color: QColor = get_text_color(primary_color, highlight_color) - self.border_checkbox.setStyleSheet( - f"QCheckBox{{" - f"background: rgba{primary_color.toTuple()};" - f"color: rgba{text_color.toTuple()};" - f"border-color: rgba{border_color.toTuple()};" - f"border-radius: 6px;" - f"border-style:solid;" - f"border-width: 2px;" - f"}}" - f"QCheckBox::indicator{{" - f"width: 10px;" - f"height: 10px;" - f"border-radius: 2px;" - f"margin: 4px;" - f"}}" - f"QCheckBox::indicator:checked{{" - f"background: rgba{text_color.toTuple()};" - f"}}" - f"QCheckBox::hover{{" - f"border-color: rgba{highlight_color.toTuple()};" - f"}}" - f"QCheckBox::focus{{" - f"border-color: rgba{highlight_color.toTuple()};" - f"outline:none;" - f"}}" - ) + self.border_checkbox.setStyleSheet(checkbox_style()) # Add Widgets to Layout ================================================ self.root_layout.addWidget(self.preview_widget) @@ -222,89 +192,20 @@ class BuildColorPanel(PanelWidget): def update_primary(self, color: QColor): logger.info("[BuildColorPanel] Updating Primary", primary_color=color) - highlight_color = get_highlight_color(color) - text_color = get_text_color(color, highlight_color) - border_color = get_border_color(color) - hex_code = color.name().upper() + self.primary_button.setText(hex_code) - self.primary_button.setStyleSheet( - f"QPushButton{{" - f"background: rgba{color.toTuple()};" - f"color: rgba{text_color.toTuple()};" - f"font-weight: 600;" - f"border-color: rgba{border_color.toTuple()};" - f"border-radius: 6px;" - f"border-style:solid;" - f"border-width: 2px;" - f"padding-right: 4px;" - f"padding-bottom: 1px;" - f"padding-left: 4px;" - f"font-size: 13px" - f"}}" - f"QPushButton::hover{{" - f"border-color: rgba{highlight_color.toTuple()};" - f"}}" - f"QPushButton::pressed{{" - f"background: rgba{highlight_color.toTuple()};" - f"color: rgba{color.toTuple()};" - f"border-color: rgba{color.toTuple()};" - f"}}" - f"QPushButton::focus{{" - f"padding-right: 0px;" - f"padding-left: 0px;" - f"outline-style: solid;" - f"outline-width: 1px;" - f"outline-radius: 4px;" - f"outline-color: rgba{text_color.toTuple()};" - f"}}" - ) + self.primary_button.setStyleSheet(list_button_style(color)) self.preview_button.set_tag_color_group(self.build_color()[1]) - def update_secondary(self, color: QColor | None = None, color_border: bool = False): + def update_secondary(self, color: QColor | None = None): logger.info("[BuildColorPanel] Updating Secondary", color=color) color_ = color or QColor(get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT)) - - highlight_color = get_highlight_color(color_) - text_color = get_text_color(color_, highlight_color) - border_color = get_border_color(color_) - hex_code = "" if not color else color.name().upper() - self.secondary_button.setText( - Translations["color.title.no_color"] if not color else hex_code - ) - self.secondary_button.setStyleSheet( - f"QPushButton{{" - f"background: rgba{color_.toTuple()};" - f"color: rgba{text_color.toTuple()};" - f"font-weight: 600;" - f"border-color: rgba{border_color.toTuple()};" - f"border-radius: 6px;" - f"border-style:solid;" - f"border-width: 2px;" - f"padding-right: 4px;" - f"padding-bottom: 1px;" - f"padding-left: 4px;" - f"font-size: 13px" - f"}}" - f"QPushButton::hover{{" - f"border-color: rgba{highlight_color.toTuple()};" - f"}}" - f"QPushButton::pressed{{" - f"background: rgba{highlight_color.toTuple()};" - f"color: rgba{color_.toTuple()};" - f"border-color: rgba{color_.toTuple()};" - f"}}" - f"QPushButton::focus{{" - f"padding-right: 0px;" - f"padding-left: 0px;" - f"outline-style: solid;" - f"outline-width: 1px;" - f"outline-radius: 4px;" - f"outline-color: rgba{text_color.toTuple()};" - f"}}" - ) + + self.secondary_button.setText(hex_code if color else Translations["color.title.no_color"]) + self.secondary_button.setStyleSheet(list_button_style(color_)) self.preview_button.set_tag_color_group(self.build_color()[1]) def update_known_colors(self): @@ -346,17 +247,9 @@ class BuildColorPanel(PanelWidget): is_slug_empty = not slug is_invalid = False - self.name_field.setStyleSheet( - f"border: 1px solid {get_ui_color(ColorType.PRIMARY, UiColor.RED)}; border-radius: 2px" - if is_name_empty - else "" - ) + self.name_field.setStyleSheet(line_edit_style() if is_name_empty else "") - self.slug_field.setStyleSheet( - f"border: 1px solid {get_ui_color(ColorType.PRIMARY, UiColor.RED)}; border-radius: 2px" - if is_slug_empty or is_invalid - else "" - ) + self.slug_field.setStyleSheet(line_edit_style() if is_slug_empty or is_invalid else "") self.slug_field.setText(slug) self.update_preview_text() @@ -393,13 +286,7 @@ class BuildColorPanel(PanelWidget): ) return (self.color_group, new_color) + @override def parent_post_init(self): - # self.setTabOrder(self.name_field, self.shorthand_field) - # 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, self.panel_cancel_button) - # self.setTabOrder(self.panel_cancel_button, self.panel_save_button) - # self.setTabOrder(self.panel_save_button, self.aliases_table.cellWidget(0, 1)) self.name_field.selectAll() self.name_field.setFocus() diff --git a/src/tagstudio/qt/mixed/build_namespace.py b/src/tagstudio/qt/mixed/build_namespace.py index 5b91a700..36487a7b 100644 --- a/src/tagstudio/qt/mixed/build_namespace.py +++ b/src/tagstudio/qt/mixed/build_namespace.py @@ -3,6 +3,7 @@ import contextlib +from typing import override from uuid import uuid4 import structlog @@ -12,9 +13,9 @@ from PySide6.QtWidgets import QLabel, QLineEdit, QVBoxLayout, QWidget 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.models.palette import ColorType, UiColor, get_ui_color 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__) @@ -111,17 +112,9 @@ class BuildNamespacePanel(PanelWidget): is_slug_empty = not slug is_invalid = False - self.name_field.setStyleSheet( - f"border: 1px solid {get_ui_color(ColorType.PRIMARY, UiColor.RED)}; border-radius: 2px" - if is_name_empty - else "" - ) + self.name_field.setStyleSheet(line_edit_style() if is_name_empty else "") - self.slug_field.setStyleSheet( - f"border: 1px solid {get_ui_color(ColorType.PRIMARY, UiColor.RED)}; border-radius: 2px" - if is_slug_empty or is_invalid - else "" - ) + self.slug_field.setStyleSheet(line_edit_style() if is_slug_empty or is_invalid else "") self.slug_field.setText(slug) @@ -156,6 +149,7 @@ class BuildNamespacePanel(PanelWidget): logger.info("[BuildNamespacePanel] Built Namespace", slug=slug, name=name) return namespace + @override def parent_post_init(self): self.setTabOrder(self.name_field, self.slug_field) self.name_field.selectAll() diff --git a/src/tagstudio/qt/mixed/build_tag.py b/src/tagstudio/qt/mixed/build_tag.py index f42d14d6..4c81a968 100644 --- a/src/tagstudio/qt/mixed/build_tag.py +++ b/src/tagstudio/qt/mixed/build_tag.py @@ -3,6 +3,7 @@ import sys +from collections.abc import Callable from typing import cast, override import structlog @@ -24,7 +25,6 @@ from PySide6.QtWidgets import ( QWidget, ) -from tagstudio.core.library.alchemy.enums import TagColorEnum from tagstudio.core.library.alchemy.library import Library from tagstudio.core.library.alchemy.models import Tag, TagColorGroup from tagstudio.core.utils.types import unwrap @@ -33,27 +33,38 @@ 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, - get_border_color, - get_highlight_color, - get_primary_color, - get_text_color, + get_tag_border_color, + get_tag_highlight_color, + get_tag_primary_color, + get_tag_text_color, ) -from tagstudio.qt.models.palette import ColorType, UiColor, get_tag_color, get_ui_color 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, + header, + line_edit_style, +) from tagstudio.qt.views.tag_search_panel_view import TagSearchPanelView logger = structlog.get_logger(__name__) class CustomTableItem(QLineEdit): - def __init__(self, text, on_return, on_backspace, parent=None): + def __init__( + self, + text: str, + on_return: Callable[..., None], + on_backspace: Callable[..., None], + parent: QWidget | None = None, + ): super().__init__(parent) self.setText(text) - self.on_return = on_return - self.on_backspace = on_backspace + self.on_return: Callable[..., None] = on_return + self.on_backspace: Callable[..., None] = on_backspace - def set_id(self, id): + def set_id(self, id: int): self.id = id @override @@ -194,9 +205,9 @@ class BuildTagPanel(PanelWidget): self.tag_color_selection, chose_tag_color_title, chose_tag_color_title, - done_callback=lambda: self.choose_color_callback( - self.tag_color_selection.selected_color - ), + ) + self.choose_color_modal.done.connect( + lambda: self.choose_color_callback(self.tag_color_selection.selected_color) ) self.color_button.button.clicked.connect(self.choose_color_modal.show) self.color_layout.addWidget(self.color_button) @@ -211,38 +222,7 @@ class BuildTagPanel(PanelWidget): self.cat_title = QLabel(Translations["tag.is_category"]) self.cat_checkbox = QCheckBox() self.cat_checkbox.setFixedSize(22, 22) - - primary_color = QColor(get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT)) - border_color = get_border_color(primary_color) - highlight_color = get_highlight_color(primary_color) - text_color: QColor = get_text_color(primary_color, highlight_color) - - self.cat_checkbox.setStyleSheet( - f"QCheckBox{{" - f"background: rgba{primary_color.toTuple()};" - f"color: rgba{text_color.toTuple()};" - f"border-color: rgba{border_color.toTuple()};" - f"border-radius: 6px;" - f"border-style:solid;" - f"border-width: 2px;" - f"}}" - f"QCheckBox::indicator{{" - f"width: 10px;" - f"height: 10px;" - f"border-radius: 2px;" - f"margin: 4px;" - f"}}" - f"QCheckBox::indicator:checked{{" - f"background: rgba{text_color.toTuple()};" - f"}}" - f"QCheckBox::hover{{" - f"border-color: rgba{highlight_color.toTuple()};" - f"}}" - f"QCheckBox::focus{{" - f"border-color: rgba{highlight_color.toTuple()};" - f"outline:none;" - f"}}" - ) + self.cat_checkbox.setStyleSheet(checkbox_style()) self.cat_layout.addWidget(self.cat_checkbox) self.cat_layout.addWidget(self.cat_title) @@ -256,33 +236,7 @@ class BuildTagPanel(PanelWidget): self.hidden_title = QLabel(Translations["tag.is_hidden"]) self.hidden_checkbox = QCheckBox() self.hidden_checkbox.setFixedSize(22, 22) - - self.hidden_checkbox.setStyleSheet( - f"QCheckBox{{" - f"background: rgba{primary_color.toTuple()};" - f"color: rgba{text_color.toTuple()};" - f"border-color: rgba{border_color.toTuple()};" - f"border-radius: 6px;" - f"border-style:solid;" - f"border-width: 2px;" - f"}}" - f"QCheckBox::indicator{{" - f"width: 10px;" - f"height: 10px;" - f"border-radius: 2px;" - f"margin: 4px;" - f"}}" - f"QCheckBox::indicator:checked{{" - f"background: rgba{text_color.toTuple()};" - f"}}" - f"QCheckBox::hover{{" - f"border-color: rgba{highlight_color.toTuple()};" - f"}}" - f"QCheckBox::focus{{" - f"border-color: rgba{highlight_color.toTuple()};" - f"outline:none;" - f"}}" - ) + self.hidden_checkbox.setStyleSheet(checkbox_style()) self.hidden_layout.addWidget(self.hidden_checkbox) self.hidden_layout.addWidget(self.hidden_title) @@ -294,14 +248,14 @@ class BuildTagPanel(PanelWidget): self.root_layout.addWidget(self.aliases_add_button) self.root_layout.addWidget(self.parent_tags_widget) self.root_layout.addWidget(self.color_widget) - self.root_layout.addWidget(QLabel("

Properties

")) + self.root_layout.addWidget(QLabel(header(Translations["tag.properties"], 3))) self.root_layout.addWidget(self.cat_widget) self.root_layout.addWidget(self.hidden_widget) self.parent_ids: set[int] = set() self.alias_ids: list[int] = [] self.alias_names: list[str] = [] - self.new_alias_names: dict = {} + self.new_alias_names: dict[int, str] = {} self.new_item_id = sys.maxsize self.set_tag(tag or Tag(name=Translations["tag.new"])) @@ -317,7 +271,7 @@ class BuildTagPanel(PanelWidget): item = self.aliases_table.cellWidget(i, 1) if ( isinstance(item, CustomTableItem) - and cast(CustomTableItem, item).id == cast(CustomTableItem, focused_widget).id + and item.id == cast(CustomTableItem, focused_widget).id ): cast(QPushButton, self.aliases_table.cellWidget(i, 0)).click() remove_row = i @@ -359,7 +313,7 @@ class BuildTagPanel(PanelWidget): item = self.aliases_table.cellWidget(row, 1) item.setFocus() - def remove_alias_callback(self, alias_name: str, alias_id: int): + def remove_alias_callback(self, alias_id: int): logger.info("remove_alias_callback") self.alias_ids.remove(alias_id) @@ -407,13 +361,13 @@ class BuildTagPanel(PanelWidget): row.setSpacing(3) # Init Colors - primary_color = get_primary_color(tag) + primary_color = get_tag_primary_color(tag) border_color = ( - get_border_color(primary_color) + get_tag_border_color(primary_color) if not (tag.color and tag.color.secondary and tag.color.color_border) else (QColor(tag.color.secondary)) ) - highlight_color = get_highlight_color( + highlight_color = get_tag_highlight_color( primary_color if not (tag.color and tag.color.secondary) else QColor(tag.color.secondary) @@ -422,7 +376,7 @@ class BuildTagPanel(PanelWidget): if tag.color and tag.color.secondary: text_color = QColor(tag.color.secondary) else: - text_color = get_text_color(primary_color, highlight_color) + text_color = get_tag_text_color(primary_color, highlight_color) # Add Tag Widget tag_widget = TagWidget( @@ -445,35 +399,7 @@ class BuildTagPanel(PanelWidget): disam_button.setFixedSize(22, 22) disam_button.setToolTip(Translations["tag.disambiguation.tooltip"]) disam_button.setStyleSheet( - f"QRadioButton{{" - f"background: rgba{primary_color.toTuple()};" - f"color: rgba{text_color.toTuple()};" - f"border-color: rgba{border_color.toTuple()};" - f"border-radius: 6px;" - f"border-style:solid;" - f"border-width: 2px;" - f"}}" - f"QRadioButton::indicator{{" - f"width: 10px;" - f"height: 10px;" - f"border-radius: 2px;" - f"margin: 4px;" - f"}}" - f"QRadioButton::indicator:checked{{" - f"background: rgba{text_color.toTuple()};" - f"}}" - f"QRadioButton::hover{{" - f"border-color: rgba{highlight_color.toTuple()};" - f"}}" - f"QRadioButton::pressed{{" - f"background: rgba{border_color.toTuple()};" - f"color: rgba{primary_color.toTuple()};" - f"border-color: rgba{primary_color.toTuple()};" - f"}}" - f"QRadioButton::focus{{" - f"border-color: rgba{highlight_color.toTuple()};" - f"outline:none;" - f"}}" + colored_radio_button_style(primary_color, text_color, border_color, highlight_color) ) self.disam_button_group.addButton(disam_button) @@ -530,7 +456,7 @@ class BuildTagPanel(PanelWidget): for alias_id in self.alias_ids: alias = self.lib.get_alias(self.tag.id, alias_id) - alias_name = alias.name if alias else self.new_alias_names[alias_id] + alias_name: str = alias.name if alias else self.new_alias_names[alias_id] # handel when an alias name changes if alias_id in self.new_alias_names: @@ -539,9 +465,7 @@ class BuildTagPanel(PanelWidget): self.alias_names.append(alias_name) remove_btn = QPushButton("-") - remove_btn.clicked.connect( - lambda a=alias_name, id=alias_id: self.remove_alias_callback(a, id) - ) + remove_btn.clicked.connect(lambda id=alias_id: self.remove_alias_callback(id)) row = self.aliases_table.rowCount() new_item = CustomTableItem(alias_name, self.enter, self.backspace) @@ -595,11 +519,7 @@ class BuildTagPanel(PanelWidget): def on_name_changed(self): is_empty = not self.name_field.text().strip() - self.name_field.setStyleSheet( - f"border: 1px solid {get_ui_color(ColorType.PRIMARY, UiColor.RED)}; border-radius: 2px" - if is_empty - else "" - ) + 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) @@ -619,6 +539,7 @@ class BuildTagPanel(PanelWidget): logger.info("built tag", tag=tag) return tag + @override def parent_post_init(self): self.setTabOrder(self.name_field, self.shorthand_field) self.setTabOrder(self.shorthand_field, self.aliases_add_button) diff --git a/src/tagstudio/qt/mixed/color_box.py b/src/tagstudio/qt/mixed/color_box.py index 3a031f2a..88771bbd 100644 --- a/src/tagstudio/qt/mixed/color_box.py +++ b/src/tagstudio/qt/mixed/color_box.py @@ -10,16 +10,15 @@ from PySide6.QtCore import Signal from PySide6.QtWidgets import QMessageBox, QPushButton from tagstudio.core.constants import RESERVED_NAMESPACE_PREFIX -from tagstudio.core.library.alchemy.enums import TagColorEnum from tagstudio.core.library.alchemy.models import TagColorGroup from tagstudio.core.utils.types import unwrap 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.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 PanelModal +from tagstudio.qt.views.stylesheets.stylesheets import add_button_style if typing.TYPE_CHECKING: from tagstudio.core.library.alchemy.library import Library @@ -43,34 +42,6 @@ class ColorBoxWidget(FieldWidget): title = "" if not self.lib.engine else self.lib.get_namespace_name(group) super().__init__(title) - self.add_button_stylesheet = ( - f"QPushButton{{" - f"background: {get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT)};" - f"color: {get_tag_color(ColorType.TEXT, TagColorEnum.DEFAULT)};" - f"font-weight: 600;" - f"border-color:{get_tag_color(ColorType.BORDER, TagColorEnum.DEFAULT)};" - f"border-radius: 6px;" - f"border-style:solid;" - f"border-width: 2px;" - f"padding-right: 4px;" - f"padding-bottom: 2px;" - f"padding-left: 4px;" - f"font-size: 15px" - f"}}" - f"QPushButton::hover{{" - f"border-color:{get_tag_color(ColorType.LIGHT_ACCENT, TagColorEnum.DEFAULT)};" - f"}}" - f"QPushButton::pressed{{" - f"background: {get_tag_color(ColorType.LIGHT_ACCENT, TagColorEnum.DEFAULT)};" - f"color: {get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT)};" - f"border-color: {get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT)};" - f"}}" - f"QPushButton::focus{{" - f"border-color: {get_tag_color(ColorType.LIGHT_ACCENT, TagColorEnum.DEFAULT)};" - f"outline:none;" - f"}}" - ) - self.setObjectName("colorBox") self.base_layout = FlowLayout() self.base_layout.enable_grid_optimizations(value=True) @@ -114,7 +85,7 @@ class ColorBoxWidget(FieldWidget): add_button.setText("+") add_button.setFlat(True) add_button.setFixedSize(22, 22) - add_button.setStyleSheet(self.add_button_stylesheet) + add_button.setStyleSheet(add_button_style()) add_button.clicked.connect( lambda: self.edit_color( TagColorGroup( @@ -134,7 +105,7 @@ class ColorBoxWidget(FieldWidget): self.edit_modal = PanelModal( build_color_panel, "Edit Color", - has_save=True, + is_savable=True, ) self.edit_modal.saved.connect( diff --git a/src/tagstudio/qt/mixed/datetime_picker.py b/src/tagstudio/qt/mixed/datetime_picker.py index 318c4768..1706eaa5 100644 --- a/src/tagstudio/qt/mixed/datetime_picker.py +++ b/src/tagstudio/qt/mixed/datetime_picker.py @@ -3,14 +3,14 @@ import typing -from collections.abc import Callable from datetime import datetime as dt -from typing import cast +from typing import cast, override from PySide6.QtCore import QDateTime -from PySide6.QtWidgets import QDateTimeEdit, QVBoxLayout +from PySide6.QtWidgets import QDateTimeEdit, QLineEdit, QVBoxLayout from tagstudio.qt.views.panel_modal import PanelWidget +from tagstudio.qt.views.stylesheets.stylesheets import title_line_edit_style if typing.TYPE_CHECKING: from tagstudio.qt.ts_qt import QtDriver @@ -40,11 +40,16 @@ def qdtf2dtf(dtf: str) -> str: class DatetimePicker(PanelWidget): - def __init__(self, driver: "QtDriver", datetime: dt | str): + def __init__(self, driver: "QtDriver", name: str, datetime: dt | str): super().__init__() + self.setMinimumSize(300, 60) self.root_layout = QVBoxLayout(self) self.root_layout.setContentsMargins(6, 0, 6, 0) + self.name_field = QLineEdit() + self.name_field.setStyleSheet(title_line_edit_style()) + self.name_field.setText(name) + if isinstance(datetime, str): datetime = DatetimePicker.string2dt(datetime) self.datetime_edit = QDateTimeEdit() @@ -55,20 +60,24 @@ class DatetimePicker(PanelWidget): self.datetime_edit.setDisplayFormat(qdtf2dtf(driver.settings.datetime_format)) self.initial_value = datetime + self.root_layout.addWidget(self.name_field) self.root_layout.addWidget(self.datetime_edit) - def get_content(self): - return DatetimePicker.dt2string(DatetimePicker.qdt2dt(self.datetime_edit.dateTime())) + @override + def saved_data(self) -> dict[str, str]: + return { + "name": self.name_field.text(), + "value": DatetimePicker.dt2string(DatetimePicker.qdt2dt(self.datetime_edit.dateTime())), + } + @override + def parent_post_init(self): + self.datetime_edit.setFocus() + + @override def reset(self): self.datetime_edit.setDateTime(DatetimePicker.dt2qdt(self.initial_value)) - def add_callback(self, callback: Callable, event: str = "returnPressed"): - if event == "returnPressed": - pass - else: - raise ValueError(f"unknown event type: {event}") - @staticmethod def qdt2dt(qdt: QDateTime) -> dt: return cast(dt, qdt.toPython()) diff --git a/src/tagstudio/qt/mixed/field_containers.py b/src/tagstudio/qt/mixed/field_containers.py index 0c2500eb..6a7d5555 100644 --- a/src/tagstudio/qt/mixed/field_containers.py +++ b/src/tagstudio/qt/mixed/field_containers.py @@ -2,10 +2,10 @@ # SPDX-License-Identifier: GPL-3.0-only -import sys import typing from collections.abc import Callable from datetime import datetime as dt +from functools import partial from warnings import catch_warnings import structlog @@ -31,13 +31,12 @@ from tagstudio.core.library.alchemy.fields import ( from tagstudio.core.library.alchemy.library import Library from tagstudio.core.library.alchemy.models import Entry, Tag from tagstudio.core.utils.types import unwrap +from tagstudio.qt.controllers.edit_text_controller import EditText 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 TextWidget +from tagstudio.qt.mixed.text_field import TextContainerWidget from tagstudio.qt.translations import FIELD_TYPE_KEYS, Translations -from tagstudio.qt.views.edit_text_box_modal import EditTextBox -from tagstudio.qt.views.edit_text_line_modal import EditTextLine from tagstudio.qt.views.panel_modal import PanelModal if typing.TYPE_CHECKING: @@ -47,7 +46,7 @@ logger = structlog.get_logger(__name__) class FieldContainers(QWidget): - """The Preview Panel Widget.""" + """Widget for the tag and field containers displayed inside the Preview Panel.""" def __init__(self, library: Library, driver: "QtDriver") -> None: super().__init__() @@ -102,6 +101,11 @@ class FieldContainers(QWidget): root_layout.setContentsMargins(0, 0, 0, 0) root_layout.addWidget(self.scroll_area) + @property + def top_entry_id(self) -> int: + """Get the topmost entry ID in the (cached) selected entries.""" + return self.cached_entries[0].id + def update_from_entry(self, entry_id: int, update_badges: bool = True) -> None: """Update tags and fields from a single Entry source.""" logger.warning("[FieldContainers] Updating Selection", entry_id=entry_id) @@ -130,7 +134,7 @@ class FieldContainers(QWidget): # Write field container(s) for index, field in enumerate(entry_fields, start=container_index): - self.write_container(index, field, is_mixed=False) + self.write_field_container(index, field, is_mixed=False) # Hide leftover container(s) if len(self.containers) > container_len: @@ -233,36 +237,142 @@ class FieldContainers(QWidget): ) self.lib.add_field_to_entries(entry_id, field_template.to_field()) - def add_tags_to_selected(self, tags: int | list[int]) -> None: + def add_tags_to_selected(self, tag_ids: int | list[int]) -> None: """Add list of tags to one or more selected items. Uses the current driver selection, NOT the field containers cache. """ - if isinstance(tags, int): - tags = [tags] + if isinstance(tag_ids, int): + tag_ids = [tag_ids] logger.info( "[FieldContainers][add_tags_to_selected]", selected=self.driver.selected, - tags=tags, + tag_ids=tag_ids, ) - self.driver.add_tags_to_selected_callback(tags) + self.driver.add_tags_to_selected_callback(tag_ids) - def write_container(self, index: int, field: BaseField, is_mixed: bool = False) -> None: - """Update/Create data for a FieldContainer. + def write_field_container(self, index: int, field: BaseField, is_mixed: bool = False) -> None: + """Update/Create data for a field FieldContainer. Args: index(int): The container index. - field(BaseField): The type of field to write to. + field(BaseField): The field to write in this container. is_mixed(bool): Relevant when multiple items are selected. - - If True, field is not present in all selected items. + 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 + ): + container.set_title(field.name) + + # Normalize line endings in any text content. + if not is_mixed: + assert isinstance(field.value, str | type(None)) + text = (field.value or "").replace("\r", "\n") + else: + text = f"{Translations['field.mixed_data']}" + + inner_widget = TextContainerWidget(title, text) + container.set_inner_widget(inner_widget) + + if not is_mixed: + edit_modal = PanelModal( + 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) + ) + + 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), + ) + ) + + def write_datetime_container( + container: FieldContainer, field: DatetimeField, title: str, is_mixed: bool + ): + container.set_title(field.name) + + if not is_mixed: + try: + assert field.value is not None + text = self.driver.settings.format_datetime( + DatetimePicker.string2dt(field.value) + ) + except (ValueError, AssertionError): + text = str(field.value) + else: + text = f"{Translations['field.mixed_data']}" + + inner_widget = TextContainerWidget(title, text) + container.set_inner_widget(inner_widget) + + if not is_mixed: + edit_modal = PanelModal( + 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) + ) + + 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), + ) + ) + + def write_unknown_container(): + container.set_title(field.name) + inner_widget = TextContainerWidget(title, field.name) + container.set_inner_widget(inner_widget) + 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), + ) + ) + logger.info( "[FieldContainers][write_container]", index=index, name=field.name, type=field.class_name, ) + + # Create new containers if necessary if len(self.containers) < (index + 1): container = FieldContainer() self.containers.append(container) @@ -274,156 +384,27 @@ class FieldContainers(QWidget): field_name_key: str = FIELD_TYPE_KEYS.get(field.class_name, "field_type.unknown") title = f"{field.name} ({Translations[field_name_key]})" - # Single-line Text - if type(field) is TextField and not field.is_multiline: - container.set_title(field.name) - container.set_inline(False) - - # Normalize line endings in any text content. - if not is_mixed: - assert isinstance(field.value, str | type(None)) - text = field.value or "" - else: - text = "Mixed Data" # TODO: Localize this - - inner_widget = TextWidget(title, text) - container.set_inner_widget(inner_widget) - if not is_mixed: - modal = PanelModal( - EditTextLine(field.value), - title=title, - window_title=f"Edit {field.name}", # TODO: Localize this - save_callback=( # pyright: ignore[reportArgumentType] - lambda content: ( - self.update_text_field(field, content, is_multiline=False), - self.update_from_entry(self.cached_entries[0].id), - ) - ), - ) - if "pytest" in sys.modules: - # for better testability - container.modal = modal # pyright: ignore[reportAttributeAccessIssue] - - container.set_edit_callback(modal.show) - container.set_remove_callback( - lambda: self.remove_message_box( - prompt=self.remove_field_prompt(title), - callback=lambda: ( - self.remove_field(field), - self.update_from_entry(self.cached_entries[0].id), - ), - ) - ) - - # Multiline Text - elif type(field) is TextField and field.is_multiline: - container.set_title(field.name) - container.set_inline(False) - # Normalize line endings in any text content. - if not is_mixed: - assert isinstance(field.value, str | type(None)) - text = (field.value or "").replace("\r", "\n") - else: - text = "Mixed Data" # TODO: Localize this - inner_widget = TextWidget(title, text) - container.set_inner_widget(inner_widget) - if not is_mixed: - modal = PanelModal( - EditTextBox(field.value), - title=title, - window_title=f"Edit {field.name}", # TODO: Localize this - save_callback=( # pyright: ignore[reportArgumentType] - lambda content: ( - self.update_text_field(field, content, is_multiline=True), - self.update_from_entry(self.cached_entries[0].id), - ) - ), - ) - container.set_edit_callback(modal.show) - container.set_remove_callback( - lambda: self.remove_message_box( - prompt=self.remove_field_prompt(field.name), - callback=lambda: ( - self.remove_field(field), - self.update_from_entry(self.cached_entries[0].id), - ), - ) - ) - + # Write containers + if type(field) is TextField: + write_text_container(container, field, title, is_mixed) elif type(field) is DatetimeField: - logger.info("[FieldContainers][write_container] Datetime Field", field=field) - if not is_mixed: - container.set_title(field.name) - container.set_inline(False) - - try: - assert field.value is not None - text = self.driver.settings.format_datetime( - DatetimePicker.string2dt(field.value) - ) - except (ValueError, AssertionError): - text = str(field.value) - - inner_widget = TextWidget(title, text) - container.set_inner_widget(inner_widget) - - modal = PanelModal( - DatetimePicker(self.driver, field.value or dt.now()), - title=f"Edit {field.name}", - save_callback=( # pyright: ignore[reportArgumentType] - lambda content: ( - self.update_datetime_field(field, content), - self.update_from_entry(self.cached_entries[0].id), - ) - ), - ) - - container.set_edit_callback(modal.show) - container.set_remove_callback( - lambda: self.remove_message_box( - prompt=self.remove_field_prompt(field.name), - callback=lambda: ( - self.remove_field(field), - self.update_from_entry(self.cached_entries[0].id), - ), - ) - ) - else: - text = "Mixed Data" # TODO: Localize this - inner_widget = TextWidget(title, text) - container.set_inner_widget(inner_widget) + write_datetime_container(container, field, title, is_mixed) else: - logger.warning( - "[FieldContainers][write_container] Unknown Field", field=field - ) # TODO: Localize this - container.set_title(field.name) - container.set_inline(False) - inner_widget = TextWidget(title, field.name) - container.set_inner_widget(inner_widget) - container.set_remove_callback( - lambda: self.remove_message_box( - prompt=self.remove_field_prompt(field.name), - callback=lambda: ( - self.remove_field(field), - self.update_from_entry(self.cached_entries[0].id), - ), - ) - ) + write_unknown_container() container.setHidden(False) def write_tag_container( self, index: int, tags: set[Tag], category_tag: Tag | None = None, is_mixed: bool = False ) -> None: - """Update/Create tag data for a FieldContainer. + """Update/Create tag data for a tag FieldContainer. Args: index(int): The container index. tags(set[Tag]): The list of tags for this container. category_tag(Tag|None): The category tag this container represents. is_mixed(bool): Relevant when multiple items are selected. - - If True, field is not present in all selected items. + If True, field is not present in all selected items. """ logger.info("[FieldContainers][write_tag_container]", index=index) if len(self.containers) < (index + 1): @@ -433,10 +414,7 @@ class FieldContainers(QWidget): else: container = self.containers[index] - container.set_title( - "Tags" if not category_tag else category_tag.name - ) # TODO: Localize this - container.set_inline(False) + container.set_title(Translations["entries.tags"] if not category_tag else category_tag.name) if not is_mixed: inner_widget = container.get_inner_widget() @@ -446,10 +424,7 @@ class FieldContainers(QWidget): inner_widget.on_update.disconnect() else: - inner_widget = TagBoxWidget( - "Tags", # TODO: Localize this - self.driver, - ) + inner_widget = TagBoxWidget(Translations["entries.tags"], self.driver) container.set_inner_widget(inner_widget) inner_widget.set_entries([e.id for e in self.cached_entries]) inner_widget.set_tags(tags) @@ -458,15 +433,15 @@ class FieldContainers(QWidget): lambda: self.update_from_entry(self.cached_entries[0].id, update_badges=True) ) else: - text = "Mixed Data" - inner_widget = TextWidget("Mixed Tags", text) + text = f"{Translations['field.mixed_data']}" + inner_widget = TextContainerWidget("Mixed Tags", text) # NOTE: Unlocalized but unused container.set_inner_widget(inner_widget) container.set_edit_callback() container.set_remove_callback() container.setHidden(False) - def remove_field(self, field: BaseField) -> None: + def _remove_field(self, field: BaseField) -> None: """Remove a field from all selected Entries.""" logger.info( "[FieldContainers] Removing Field", @@ -476,24 +451,26 @@ class FieldContainers(QWidget): entry_ids = [e.id for e in self.cached_entries] self.lib.remove_entry_field(field, entry_ids) - def update_text_field(self, field: TextField, value: str, is_multiline: bool) -> None: + def _update_text_field( + self, field: TextField, name: str, value: str, is_multiline: bool + ) -> None: """Update a text field across selected entries.""" entry_ids = [e.id for e in self.cached_entries] assert entry_ids, "No entries selected" - self.lib.update_text_field(entry_ids, field, value, is_multiline) + self.lib.update_text_field(entry_ids, field, name, value, is_multiline) - def update_datetime_field(self, field: DatetimeField, value: str) -> None: + def update_datetime_field(self, field: DatetimeField, name: str, value: str) -> None: """Update a datetime field across selected entries.""" entry_ids = [e.id for e in self.cached_entries] assert entry_ids, "No entries selected" - self.lib.update_datetime_field(entry_ids, field, dt.fromisoformat(value)) + self.lib.update_datetime_field(entry_ids, field, name, dt.fromisoformat(value)) - def remove_message_box(self, prompt: str, callback: Callable) -> None: + def remove_message_box(self, prompt: str, callback: Callable[..., None]) -> None: remove_mb = QMessageBox() remove_mb.setText(prompt) - remove_mb.setWindowTitle("Remove Field") # TODO: Localize + remove_mb.setWindowTitle(Translations["Remove Field"]) remove_mb.setIcon(QMessageBox.Icon.Warning) cancel_button = remove_mb.addButton( Translations["generic.cancel_alt"], QMessageBox.ButtonRole.DestructiveRole diff --git a/src/tagstudio/qt/mixed/field_widget.py b/src/tagstudio/qt/mixed/field_widget.py index dc99771a..23f58a34 100644 --- a/src/tagstudio/qt/mixed/field_widget.py +++ b/src/tagstudio/qt/mixed/field_widget.py @@ -12,9 +12,9 @@ from PySide6.QtCore import QEvent, QSize, Qt from PySide6.QtGui import QEnterEvent, QPixmap, QResizeEvent from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget -from tagstudio.core.enums import Theme from tagstudio.qt.helpers.color_overlay import auto_theme_overlay from tagstudio.qt.resource_manager import ResourceManager +from tagstudio.qt.views.stylesheets.stylesheets import container_style, header logger = structlog.get_logger(__name__) @@ -26,23 +26,11 @@ class FieldContainer(QWidget): trash_icon = auto_theme_overlay(rm.trash, inverse=True) # TODO: There should be a global button theme somewhere. - container_style = ( - f"QWidget#fieldContainer{{" - "border-radius:4px;" - f"}}" - f"QWidget#fieldContainer::hover{{" - f"background-color:{Theme.COLOR_HOVER.value};" - f"}}" - f"QWidget#fieldContainer::pressed{{" - f"background-color:{Theme.COLOR_PRESSED.value};" - f"}}" - ) def __init__(self, title: str = "Field", inline: bool = True) -> None: super().__init__() self.setObjectName("fieldContainer") self.title: str = title - self.inline: bool = inline self.copy_callback: Callable[[], None] | None = None self.edit_callback: Callable[[], None] | None = None self.remove_callback: Callable[[], None] | None = None @@ -119,7 +107,7 @@ class FieldContainer(QWidget): self.inner_layout.addWidget(self.field) self.set_title(title) - self.setStyleSheet(FieldContainer.container_style) + self.setStyleSheet(container_style()) def set_copy_callback(self, callback: Callable[[], None] | None = None) -> None: with catch_warnings(record=True): @@ -159,12 +147,9 @@ class FieldContainer(QWidget): return None def set_title(self, title: str) -> None: - self.title = self.title = f"

{title}

" + self.title = header(title, 4) self.title_widget.setText(self.title) - def set_inline(self, inline: bool) -> None: - self.inline = inline - @override def enterEvent(self, event: QEnterEvent) -> None: # NOTE: You could pass the hover event to the FieldWidget if needed. diff --git a/src/tagstudio/qt/mixed/file_attributes.py b/src/tagstudio/qt/mixed/file_attributes.py index 38dfd3f0..3193aee0 100644 --- a/src/tagstudio/qt/mixed/file_attributes.py +++ b/src/tagstudio/qt/mixed/file_attributes.py @@ -11,13 +11,12 @@ from datetime import timedelta from pathlib import Path import structlog -from humanfriendly import format_size +from humanfriendly import format_size # pyright: ignore[reportUnknownVariableType] from PIL import ImageFont from PySide6.QtCore import Qt -from PySide6.QtGui import QGuiApplication from PySide6.QtWidgets import QLabel, QVBoxLayout, QWidget -from tagstudio.core.enums import ShowFilepathOption, Theme +from tagstudio.core.enums import ShowFilepathOption from tagstudio.core.library.alchemy.library import Library from tagstudio.core.library.ignore import Ignore from tagstudio.core.media_types import MediaCategories @@ -25,6 +24,7 @@ from tagstudio.core.utils.types import unwrap from tagstudio.qt.models.palette import ColorType, UiColor, get_ui_color from tagstudio.qt.translations import Translations from tagstudio.qt.utils.file_opener import FileOpenerHelper, FileOpenerLabel +from tagstudio.qt.views.stylesheets.stylesheets import properties_style if typing.TYPE_CHECKING: from tagstudio.qt.ts_qt import QtDriver @@ -48,26 +48,8 @@ class FileAttributes(QWidget): root_layout.setContentsMargins(0, 0, 0, 0) root_layout.setSpacing(0) - label_bg_color = ( - Theme.COLOR_BG_DARK.value - if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark - else Theme.COLOR_DARK_LABEL.value - ) - - self.date_style = "font-size:12px;" + self.date_style = "font-size: 12px;" self.file_label_style = "font-size: 12px" - self.properties_style = ( - f"background-color:{label_bg_color};" - "color:#FFFFFF;" - "font-family:Oxanium;" - "font-weight:bold;" - "font-size:12px;" - "border-radius:3px;" - "padding-top: 4px;" - "padding-right: 1px;" - "padding-bottom: 1px;" - "padding-left: 1px;" - ) self.file_label = FileOpenerLabel() self.file_label.setObjectName("filenameLabel") @@ -93,7 +75,7 @@ class FileAttributes(QWidget): self.dimensions_label = QLabel() self.dimensions_label.setObjectName("dimensionsLabel") self.dimensions_label.setWordWrap(True) - self.dimensions_label.setStyleSheet(self.properties_style) + self.dimensions_label.setStyleSheet(properties_style()) self.dimensions_label.setHidden(True) self.date_container = QWidget() diff --git a/src/tagstudio/qt/mixed/fix_dupe_files.py b/src/tagstudio/qt/mixed/fix_dupe_files.py index c7c3165b..9b102dfa 100644 --- a/src/tagstudio/qt/mixed/fix_dupe_files.py +++ b/src/tagstudio/qt/mixed/fix_dupe_files.py @@ -19,6 +19,7 @@ from tagstudio.core.library.alchemy.library import Library from tagstudio.core.library.alchemy.registries.dupe_files_registry import DupeFilesRegistry from tagstudio.qt.mixed.mirror_entries_modal import MirrorEntriesModal from tagstudio.qt.translations import Translations +from tagstudio.qt.views.stylesheets.stylesheets import header # Only import for type checking/autocompletion, will not be imported at runtime. if TYPE_CHECKING: @@ -49,7 +50,6 @@ class FixDupeFilesModal(QWidget): self.dupe_count = QLabel() self.dupe_count.setObjectName("dupeCountLabel") - self.dupe_count.setStyleSheet("font-weight:bold;font-size:14px;") self.dupe_count.setAlignment(Qt.AlignmentFlag.AlignCenter) self.file_label = QLabel(Translations["file.duplicates.dupeguru.no_file"]) @@ -119,13 +119,19 @@ class FixDupeFilesModal(QWidget): def set_dupe_count(self, count: int): if count < 0: self.mirror_button.setDisabled(True) - self.dupe_count.setText(Translations["file.duplicates.matches_uninitialized"]) + self.dupe_count.setText( + header(Translations["file.duplicates.matches_uninitialized"], 4) + ) elif count == 0: self.mirror_button.setDisabled(True) - self.dupe_count.setText(Translations.format("file.duplicates.matches", count=count)) + self.dupe_count.setText( + header(Translations.format("file.duplicates.matches", count=count), 4) + ) else: self.mirror_button.setDisabled(False) - self.dupe_count.setText(Translations.format("file.duplicates.matches", count=count)) + self.dupe_count.setText( + header(Translations.format("file.duplicates.matches", count=count), 4) + ) @override def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: # noqa N802 diff --git a/src/tagstudio/qt/mixed/fix_unlinked.py b/src/tagstudio/qt/mixed/fix_unlinked.py index 59474c1f..f96bf2d0 100644 --- a/src/tagstudio/qt/mixed/fix_unlinked.py +++ b/src/tagstudio/qt/mixed/fix_unlinked.py @@ -15,6 +15,7 @@ from tagstudio.qt.mixed.progress_bar import ProgressWidget from tagstudio.qt.mixed.relink_entries_modal import RelinkUnlinkedEntries from tagstudio.qt.mixed.remove_unlinked_modal import RemoveUnlinkedEntriesModal from tagstudio.qt.translations import Translations +from tagstudio.qt.views.stylesheets.stylesheets import header # Only import for type checking/autocompletion, will not be imported at runtime. if TYPE_CHECKING: @@ -148,7 +149,7 @@ class FixUnlinkedEntriesModal(QWidget): count_text: str = Translations.format( "entries.unlinked.unlinked_count", count=count if count >= 0 else "—" ) - self.unlinked_count_label.setText(f"

{count_text}

") + self.unlinked_count_label.setText(header(count_text, 3)) @override def showEvent(self, event: QtGui.QShowEvent) -> None: diff --git a/src/tagstudio/qt/mixed/folders_to_tags.py b/src/tagstudio/qt/mixed/folders_to_tags.py index 45cdbc0a..0f9742e2 100644 --- a/src/tagstudio/qt/mixed/folders_to_tags.py +++ b/src/tagstudio/qt/mixed/folders_to_tags.py @@ -29,6 +29,7 @@ from tagstudio.core.utils.types import unwrap 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 header if TYPE_CHECKING: from tagstudio.qt.ts_qt import QtDriver @@ -56,7 +57,7 @@ def add_folders_to_tree(library: Library, tree: BranchData, items: tuple[str, .. return branch -@deprecated("Will be replaced with upcoming 'Macros' feature before v9.6") +@deprecated("Will be replaced with upcoming 'Macros' feature.") def folders_to_tags(library: Library): logger.info("Converting folders to Tags") tree = BranchData() @@ -177,10 +178,9 @@ class FoldersToTagsModal(QWidget): self.root_layout = QVBoxLayout(self) self.root_layout.setContentsMargins(6, 6, 6, 6) - self.title_widget = QLabel(Translations["folders_to_tags.title"]) + self.title_widget = QLabel(header(Translations["folders_to_tags.title"], 3)) self.title_widget.setObjectName("title") self.title_widget.setWordWrap(True) - self.title_widget.setStyleSheet("font-weight:bold;font-size:14px;padding-top: 6px") self.title_widget.setAlignment(Qt.AlignmentFlag.AlignCenter) self.desc_widget = QLabel() diff --git a/src/tagstudio/qt/mixed/landing.py b/src/tagstudio/qt/mixed/landing.py index 50cc4fae..d07678a4 100644 --- a/src/tagstudio/qt/mixed/landing.py +++ b/src/tagstudio/qt/mixed/landing.py @@ -11,10 +11,10 @@ from PySide6.QtCore import QEasingCurve, QPoint, QPropertyAnimation, Qt from PySide6.QtGui import QPixmap from PySide6.QtWidgets import QLabel, QPushButton, QVBoxLayout, QWidget +from tagstudio.qt.controllers.clickable_label import ClickableLabel from tagstudio.qt.helpers.color_overlay import auto_theme_overlay from tagstudio.qt.resource_manager import ResourceManager from tagstudio.qt.translations import Translations -from tagstudio.qt.views.clickable_label import ClickableLabel # Only import for type checking/autocompletion, will not be imported at runtime. if typing.TYPE_CHECKING: diff --git a/src/tagstudio/qt/mixed/migration_modal.py b/src/tagstudio/qt/mixed/migration_modal.py index 0859b0ec..7f21f825 100644 --- a/src/tagstudio/qt/mixed/migration_modal.py +++ b/src/tagstudio/qt/mixed/migration_modal.py @@ -48,6 +48,7 @@ from tagstudio.qt.utils.custom_runnable import CustomRunnable from tagstudio.qt.utils.function_iterator import FunctionIterator from tagstudio.qt.views.paged_body_wrapper import PagedBodyWrapper from tagstudio.qt.views.qbutton_wrapper import QPushButtonWrapper +from tagstudio.qt.views.stylesheets.stylesheets import header logger = structlog.get_logger(__name__) @@ -364,7 +365,7 @@ class JsonMigrationModal(QObject): iterator = FunctionIterator(self.migration_iterator) iterator.value.connect( lambda x: ( - pb.setLabelText(f"

{x}

"), + pb.setLabelText(header(x, 4)), self.update_sql_value_ui(show_msg_box=False) if x == Translations["json_migration.checking_for_parity"] else (), @@ -386,7 +387,7 @@ class JsonMigrationModal(QObject): QThreadPool.globalInstance().start(r) except Exception as e: logger.error("[MigrationModal][Iterator] Error:", error=e) - pb.setLabelText(f"

{type(e).__name__}

") + pb.setLabelText(header(type(e).__name__, 4)) pb.setMinimum(1) pb.setValue(1) @@ -410,7 +411,7 @@ class JsonMigrationModal(QObject): ) self.sql_lib.migrate_json_to_sqlite(self.json_lib) yield Translations["json_migration.checking_for_parity"] - check_set = set() + check_set: set[bool] = set() check_set.add(self.check_field_parity()) check_set.add(self.check_path_parity()) check_set.add(self.check_name_parity()) @@ -522,7 +523,7 @@ class JsonMigrationModal(QObject): def assert_ignore_parity(self) -> None: compiled_pats = fnmatch.compile( ignore_to_glob( - Ignore._load_ignore_file( + Ignore._load_ignore_file( # pyright: ignore[reportPrivateUsage] unwrap(self.json_lib.library_dir) / TS_FOLDER_NAME / IGNORE_NAME ) ), diff --git a/src/tagstudio/qt/mixed/settings_panel.py b/src/tagstudio/qt/mixed/settings_panel.py index fe76f83d..edb7a9f4 100644 --- a/src/tagstudio/qt/mixed/settings_panel.py +++ b/src/tagstudio/qt/mixed/settings_panel.py @@ -357,9 +357,9 @@ class SettingsPanel(PanelWidget): modal = PanelModal( widget=settings_panel, window_title=Translations["settings.title"], - done_callback=lambda: settings_panel.update_settings(driver), - has_save=True, + is_savable=True, ) + modal.done.connect(lambda: settings_panel.update_settings(driver)) modal.title_widget.setVisible(False) return modal diff --git a/src/tagstudio/qt/mixed/tag_color_label.py b/src/tagstudio/qt/mixed/tag_color_label.py index 8052f59d..90d797b7 100644 --- a/src/tagstudio/qt/mixed/tag_color_label.py +++ b/src/tagstudio/qt/mixed/tag_color_label.py @@ -11,12 +11,14 @@ from PySide6.QtWidgets import QHBoxLayout, QPushButton, QVBoxLayout, QWidget from tagstudio.core.library.alchemy.models import TagColorGroup from tagstudio.qt.helpers.escape_text import escape_text -from tagstudio.qt.mixed.tag_widget import ( - get_border_color, - get_highlight_color, - get_text_color, -) from tagstudio.qt.translations import Translations +from tagstudio.qt.views.stylesheets.stylesheets import ( + get_tag_border_color, + get_tag_highlight_color, + get_tag_text_color, + tag_remove_button_style, + tag_style, +) logger = structlog.get_logger(__name__) @@ -101,76 +103,25 @@ class TagColorLabel(QWidget): primary_color = self._get_primary_color(color) border_color = ( - get_border_color(primary_color) + get_tag_border_color(primary_color) if not (color and color.secondary and color.color_border) else (QColor(color.secondary)) ) - highlight_color = get_highlight_color( + highlight_color = get_tag_highlight_color( primary_color if not (color and color.secondary) else QColor(color.secondary) ) text_color: QColor if color and color.secondary: text_color = QColor(color.secondary) else: - text_color = get_text_color(primary_color, highlight_color) + text_color = get_tag_text_color(primary_color, highlight_color) self.bg_button.setStyleSheet( - f"QPushButton{{" - f"background: rgba{primary_color.toTuple()};" - f"color: rgba{text_color.toTuple()};" - f"font-weight: 600;" - f"border-color: rgba{border_color.toTuple()};" - f"border-radius: 6px;" - f"border-style:solid;" - f"border-width: 2px;" - f"padding-right: 4px;" - f"padding-left: 4px;" - f"font-size: 13px" - f"}}" - f"QPushButton::hover{{" - f"border-color: rgba{highlight_color.toTuple()};" - f"}}" - f"QPushButton::pressed{{" - f"background: rgba{highlight_color.toTuple()};" - f"color: rgba{primary_color.toTuple()};" - f"border-color: rgba{primary_color.toTuple()};" - f"}}" - f"QPushButton::focus{{" - f"padding-right: 0px;" - f"padding-left: 0px;" - f"outline-style: solid;" - f"outline-width: 1px;" - f"outline-radius: 4px;" - f"outline-color: rgba{text_color.toTuple()};" - f"}}" + tag_style(primary_color, text_color, border_color, highlight_color) ) self.remove_button.setStyleSheet( - f"QPushButton{{" - f"color: rgba{primary_color.toTuple()};" - f"background: rgba{text_color.toTuple()};" - f"font-weight: 800;" - f"border-radius: 5px;" - f"border-width: 4;" - f"border-color: rgba(0,0,0,0);" - f"padding-bottom: 4px;" - f"font-size: 14px" - f"}}" - f"QPushButton::hover{{" - f"background: rgba{primary_color.toTuple()};" - f"color: rgba{text_color.toTuple()};" - f"border-color: rgba{highlight_color.toTuple()};" - f"border-width: 2;" - f"border-radius: 6px;" - f"}}" - f"QPushButton::pressed{{" - f"background: rgba{border_color.toTuple()};" - f"color: rgba{highlight_color.toTuple()};" - f"}}" - f"QPushButton::focus{{" - f"background: rgba{border_color.toTuple()};" - f"outline:none;" - f"}}" + tag_remove_button_style(primary_color, text_color, border_color, highlight_color) ) self.bg_button.setText(escape_text(color.name)) @@ -183,13 +134,15 @@ class TagColorLabel(QWidget): def set_has_remove(self, has_remove: bool): self.has_remove = has_remove - def enterEvent(self, event: QEnterEvent) -> None: # noqa: N802 + @typing.override + def enterEvent(self, event: QEnterEvent) -> None: if self.has_remove: self.remove_button.setHidden(False) self.update() return super().enterEvent(event) - def leaveEvent(self, event: QEvent) -> None: # noqa: N802 + @typing.override + def leaveEvent(self, event: QEvent) -> None: if self.has_remove: self.remove_button.setHidden(True) self.update() diff --git a/src/tagstudio/qt/mixed/tag_color_manager.py b/src/tagstudio/qt/mixed/tag_color_manager.py index 92c3b42b..99f5b47c 100644 --- a/src/tagstudio/qt/mixed/tag_color_manager.py +++ b/src/tagstudio/qt/mixed/tag_color_manager.py @@ -3,7 +3,7 @@ from collections.abc import Callable -from typing import TYPE_CHECKING, override +from typing import TYPE_CHECKING, Any, override import structlog from PySide6 import QtCore, QtGui @@ -28,6 +28,7 @@ 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__) @@ -62,7 +63,7 @@ class TagColorManager(QWidget): self.title_label = QLabel() self.title_label.setObjectName("titleLabel") self.title_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.title_label.setText(f"

{Translations['color_manager.title']}

") + self.title_label.setText(header(Translations["color_manager.title"], 3)) self.scroll_layout = QVBoxLayout() self.scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop) @@ -176,7 +177,7 @@ class TagColorManager(QWidget): self.create_namespace_modal = PanelModal( build_namespace_panel, Translations["namespace.create.title"], - has_save=True, + is_savable=True, ) self.create_namespace_modal.saved.connect( @@ -189,7 +190,7 @@ class TagColorManager(QWidget): self.create_namespace_modal.show() - def delete_namespace_dialog(self, prompt: str, callback: Callable) -> None: + def delete_namespace_dialog(self, prompt: str, callback: Callable[..., Any]) -> None: # pyright: ignore[reportExplicitAny] message_box = QMessageBox() message_box.setText(prompt) message_box.setWindowTitle(Translations["color.namespace.delete.title"]) @@ -207,13 +208,13 @@ class TagColorManager(QWidget): callback() @override - def showEvent(self, event: QtGui.QShowEvent) -> None: # noqa N802 + def showEvent(self, event: QtGui.QShowEvent) -> None: if not self.is_initialized: self.setup_color_groups() return super().showEvent(event) @override - def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: # noqa N802 + def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: if event.key() == QtCore.Qt.Key.Key_Escape: # noqa SIM114 self.done_button.click() elif event.key() == Qt.Key.Key_Return or event.key() == Qt.Key.Key_Enter: diff --git a/src/tagstudio/qt/mixed/tag_color_preview.py b/src/tagstudio/qt/mixed/tag_color_preview.py index c73571d4..965a823a 100644 --- a/src/tagstudio/qt/mixed/tag_color_preview.py +++ b/src/tagstudio/qt/mixed/tag_color_preview.py @@ -11,9 +11,14 @@ from PySide6.QtWidgets import QPushButton, QVBoxLayout, QWidget from tagstudio.core.library.alchemy.enums import TagColorEnum from tagstudio.core.library.alchemy.models import TagColorGroup -from tagstudio.qt.mixed.tag_widget import get_border_color, get_highlight_color, get_text_color from tagstudio.qt.models.palette import ColorType, get_tag_color from tagstudio.qt.translations import Translations +from tagstudio.qt.views.stylesheets.stylesheets import ( + get_tag_border_color, + get_tag_highlight_color, + get_tag_text_color, + tag_style, +) if typing.TYPE_CHECKING: from tagstudio.core.library.alchemy.library import Library @@ -66,11 +71,11 @@ class TagColorPreview(QWidget): primary_color = self._get_primary_color(color_group) border_color = ( - get_border_color(primary_color) + get_tag_border_color(primary_color) if not (color_group and color_group.secondary and color_group.color_border) else (QColor(color_group.secondary)) ) - highlight_color = get_highlight_color( + highlight_color = get_tag_highlight_color( primary_color if not (color_group and color_group.secondary) else QColor(color_group.secondary) @@ -79,32 +84,10 @@ class TagColorPreview(QWidget): if color_group and color_group.secondary: text_color = QColor(color_group.secondary) else: - text_color = get_text_color(primary_color, highlight_color) + text_color = get_tag_text_color(primary_color, highlight_color) self.button.setStyleSheet( - f"QPushButton{{" - f"background: rgba{primary_color.toTuple()};" - f"color: rgba{text_color.toTuple()};" - f"font-weight: 600;" - f"border-color: rgba{border_color.toTuple()};" - f"border-radius: 6px;" - f"border-style:solid;" - f"border-width: 2px;" - f"padding-right: 8px;" - f"padding-left: 8px;" - f"font-size: 14px" - f"}}" - f"QPushButton::hover{{" - f"border-color: rgba{highlight_color.toTuple()};" - f"}}" - f"QPushButton::focus{{" - f"padding-right: 0px;" - f"padding-left: 0px;" - f"outline-style: solid;" - f"outline-width: 1px;" - f"outline-radius: 4px;" - f"outline-color: rgba{text_color.toTuple()};" - f"}}" + tag_style(primary_color, text_color, border_color, highlight_color) ) # Add back the padding if the hint is generated while the button has focus (no padding) self.button.setMinimumWidth( diff --git a/src/tagstudio/qt/mixed/tag_color_selection.py b/src/tagstudio/qt/mixed/tag_color_selection.py index a58d3c06..35bc2c2e 100644 --- a/src/tagstudio/qt/mixed/tag_color_selection.py +++ b/src/tagstudio/qt/mixed/tag_color_selection.py @@ -20,11 +20,16 @@ from PySide6.QtWidgets import ( 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 get_border_color, get_highlight_color, get_text_color +from tagstudio.qt.mixed.tag_widget import ( + get_tag_border_color, + get_tag_highlight_color, + get_tag_text_color, +) 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__) @@ -67,9 +72,7 @@ class TagColorSelection(PanelWidget): self.scroll_layout.addSpacerItem(QSpacerItem(1, 6)) for group, colors in tag_color_groups.items(): display_name: str = self.lib.get_namespace_name(group) - self.scroll_layout.addWidget( - QLabel(f"

{display_name if display_name else group}

") - ) + self.scroll_layout.addWidget(QLabel(header(display_name if display_name else group, 4))) color_box_widget = QWidget() color_group_layout = FlowLayout() color_group_layout.setSpacing(4) @@ -79,54 +82,31 @@ class TagColorSelection(PanelWidget): for color in colors: primary_color = self._get_primary_color(color) border_color = ( - get_border_color(primary_color) + get_tag_border_color(primary_color) if not (color and color.secondary and color.color_border) else (QColor(color.secondary)) ) - highlight_color = get_highlight_color( + highlight_color = get_tag_highlight_color( primary_color if not (color and color.secondary) else QColor(color.secondary) ) text_color: QColor if color and color.secondary: text_color = QColor(color.secondary) else: - text_color = get_text_color(primary_color, highlight_color) + text_color = get_tag_text_color(primary_color, highlight_color) radio_button = QRadioButton() radio_button.setObjectName(f"{color.namespace}.{color.slug}") radio_button.setToolTip(color.name) radio_button.setFixedSize(24, 24) - bottom_color: str = ( - f"border-bottom-color: rgba{text_color.toTuple()};" if color.secondary else "" - ) radio_button.setStyleSheet( - f"QRadioButton{{" - f"background: rgba{primary_color.toTuple()};" - f"color: rgba{text_color.toTuple()};" - f"border-color: rgba{border_color.toTuple()};" - f"{bottom_color}" - f"border-radius: 3px;" - f"border-style:solid;" - f"border-width: 2px;" - f"}}" - f"QRadioButton::indicator{{" - f"width: 12px;" - f"height: 12px;" - f"border-radius: 1px;" - f"margin: 4px;" - f"}}" - f"QRadioButton::indicator:checked{{" - f"background: rgba{text_color.toTuple()};" - f"}}" - f"QRadioButton::hover{{" - f"border-color: rgba{highlight_color.toTuple()};" - f"}}" - f"QRadioButton::focus{{" - f"outline-style: solid;" - f"outline-width: 2px;" - f"outline-radius: 3px;" - f"outline-color: rgba{highlight_color.toTuple()};" - f"}}" + color_swatch_style( + primary_color, + text_color, + border_color, + highlight_color, + text_color if color.secondary else None, + ) ) radio_button.clicked.connect(lambda checked=False, x=color: self.select_color(x)) color_group_layout.addWidget(radio_button) @@ -136,7 +116,7 @@ class TagColorSelection(PanelWidget): def add_no_color_widget(self): no_color_str: str = Translations["color.title.no_color"] - self.scroll_layout.addWidget(QLabel(f"

{no_color_str}

")) + self.scroll_layout.addWidget(QLabel(header(no_color_str, 4))) color_box_widget = QWidget() color_group_layout = FlowLayout() color_group_layout.setSpacing(4) @@ -145,45 +125,20 @@ class TagColorSelection(PanelWidget): color_box_widget.setLayout(color_group_layout) color = None primary_color = self._get_primary_color(color) - border_color = get_border_color(primary_color) - highlight_color = get_highlight_color(primary_color) + border_color = get_tag_border_color(primary_color) + highlight_color = get_tag_highlight_color(primary_color) text_color: QColor if color and color.secondary and color.color_border: text_color = QColor(color.secondary) else: - text_color = get_text_color(primary_color, highlight_color) + text_color = get_tag_text_color(primary_color, highlight_color) radio_button = QRadioButton() - radio_button.setObjectName("None") # NOTE: Internal use, no translation needed. + radio_button.setObjectName("None") radio_button.setToolTip(no_color_str) radio_button.setFixedSize(24, 24) radio_button.setStyleSheet( - f"QRadioButton{{" - f"background: rgba{primary_color.toTuple()};" - f"color: rgba{text_color.toTuple()};" - f"border-color: rgba{border_color.toTuple()};" - f"border-radius: 3px;" - f"border-style:solid;" - f"border-width: 2px;" - f"}}" - f"QRadioButton::indicator{{" - f"width: 12px;" - f"height: 12px;" - f"border-radius: 1px;" - f"margin: 4px;" - f"}}" - f"QRadioButton::indicator:checked{{" - f"background: rgba{text_color.toTuple()};" - f"}}" - f"QRadioButton::hover{{" - f"border-color: rgba{highlight_color.toTuple()};" - f"}}" - f"QRadioButton::focus{{" - f"outline-style: solid;" - f"outline-width: 2px;" - f"outline-radius: 3px;" - f"outline-color: rgba{highlight_color.toTuple()};" - f"}}" + color_swatch_style(primary_color, text_color, border_color, highlight_color) ) radio_button.clicked.connect(lambda checked=False, x=color: self.select_color(x)) color_group_layout.addWidget(radio_button) diff --git a/src/tagstudio/qt/mixed/tag_database.py b/src/tagstudio/qt/mixed/tag_database.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/tagstudio/qt/mixed/tag_widget.py b/src/tagstudio/qt/mixed/tag_widget.py index 5907fd18..c17cf196 100644 --- a/src/tagstudio/qt/mixed/tag_widget.py +++ b/src/tagstudio/qt/mixed/tag_widget.py @@ -15,6 +15,14 @@ from tagstudio.core.library.alchemy.models import Tag from tagstudio.qt.helpers.escape_text import escape_text from tagstudio.qt.models.palette import ColorType, get_tag_color from tagstudio.qt.translations import Translations +from tagstudio.qt.views.stylesheets.stylesheets import ( + get_tag_border_color, + get_tag_highlight_color, + get_tag_primary_color, + get_tag_text_color, + tag_remove_button_style, + tag_style, +) logger = structlog.get_logger(__name__) @@ -156,15 +164,15 @@ class TagWidget(QWidget): self.inner_layout.setObjectName("innerLayout") self.inner_layout.setContentsMargins(0, 0, 0, 0) - self.remove_button = QPushButton(self) - self.remove_button.setFlat(True) - self.remove_button.setText("–") - self.remove_button.setHidden(True) - self.remove_button.setMinimumSize(22, 22) - self.remove_button.setMaximumSize(22, 22) - self.remove_button.clicked.connect(self.on_remove.emit) - self.remove_button.setHidden(True) - self.inner_layout.addWidget(self.remove_button) + 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.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) @@ -188,13 +196,13 @@ class TagWidget(QWidget): if not tag: return - primary_color = get_primary_color(tag) + primary_color = get_tag_primary_color(tag) border_color = ( - get_border_color(primary_color) + get_tag_border_color(primary_color) if not (tag.color and tag.color.secondary and tag.color.color_border) else (QColor(tag.color.secondary)) ) - highlight_color = get_highlight_color( + highlight_color = get_tag_highlight_color( primary_color if not (tag.color and tag.color.secondary) else QColor(tag.color.secondary) @@ -203,65 +211,14 @@ class TagWidget(QWidget): if tag.color and tag.color.secondary: text_color = QColor(tag.color.secondary) else: - text_color = get_text_color(primary_color, highlight_color) + text_color = get_tag_text_color(primary_color, highlight_color) self.bg_button.setStyleSheet( - f"QPushButton{{" - f"background: rgba{primary_color.toTuple()};" - f"color: rgba{text_color.toTuple()};" - f"font-weight: 600;" - f"border-color: rgba{border_color.toTuple()};" - f"border-radius: 6px;" - f"border-style:solid;" - f"border-width: 2px;" - f"padding-right: 4px;" - f"padding-left: 4px;" - f"font-size: 13px" - f"}}" - f"QPushButton::hover{{" - f"border-color: rgba{highlight_color.toTuple()};" - f"}}" - f"QPushButton::pressed{{" - f"background: rgba{highlight_color.toTuple()};" - f"color: rgba{primary_color.toTuple()};" - f"border-color: rgba{primary_color.toTuple()};" - f"}}" - f"QPushButton::focus{{" - f"padding-right: 0px;" - f"padding-left: 0px;" - f"outline-style: solid;" - f"outline-width: 1px;" - f"outline-radius: 4px;" - f"outline-color: rgba{text_color.toTuple()};" - f"}}" + tag_style(primary_color, text_color, border_color, highlight_color) ) - self.remove_button.setStyleSheet( - f"QPushButton{{" - f"color: rgba{primary_color.toTuple()};" - f"background: rgba{text_color.toTuple()};" - f"font-weight: 800;" - f"border-radius: 5px;" - f"border-width: 4;" - f"border-color: rgba(0,0,0,0);" - f"padding-bottom: 4px;" - f"font-size: 14px" - f"}}" - f"QPushButton::hover{{" - f"background: rgba{primary_color.toTuple()};" - f"color: rgba{text_color.toTuple()};" - f"border-color: rgba{highlight_color.toTuple()};" - f"border-width: 2;" - f"border-radius: 6px;" - f"}}" - f"QPushButton::pressed{{" - f"background: rgba{border_color.toTuple()};" - f"color: rgba{highlight_color.toTuple()};" - f"}}" - f"QPushButton::focus{{" - f"background: rgba{border_color.toTuple()};" - f"outline:none;" - f"}}" + self._delete_button.setStyleSheet( + tag_remove_button_style(primary_color, text_color, border_color, highlight_color) ) if self.lib: @@ -275,52 +232,13 @@ class TagWidget(QWidget): @override def enterEvent(self, event: QEnterEvent) -> None: if self.has_remove: - self.remove_button.setHidden(False) + self._delete_button.setHidden(False) self.update() return super().enterEvent(event) @override def leaveEvent(self, event: QEvent) -> None: if self.has_remove: - self.remove_button.setHidden(True) + self._delete_button.setHidden(True) self.update() return super().leaveEvent(event) - - -def get_primary_color(tag: Tag) -> QColor: - primary_color = QColor( - get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT) - if not tag.color - else tag.color.primary - ) - - return primary_color - - -def get_border_color(primary_color: QColor) -> QColor: - border_color: QColor = QColor(primary_color) - border_color.setRed(min(border_color.red() + 20, 255)) - border_color.setGreen(min(border_color.green() + 20, 255)) - border_color.setBlue(min(border_color.blue() + 20, 255)) - - return border_color - - -def get_highlight_color(primary_color: QColor) -> QColor: - highlight_color: QColor = QColor(primary_color) - highlight_color = highlight_color.toHsl() - highlight_color.setHsl(highlight_color.hue(), min(highlight_color.saturation(), 200), 225, 255) - highlight_color = highlight_color.toRgb() - - return highlight_color - - -def get_text_color(primary_color: QColor, highlight_color: QColor) -> QColor: - # logger.info("[TagWidget] Evaluating tag text color", lightness=primary_color.lightness()) - if primary_color.lightness() > 120: - text_color = QColor(primary_color) - text_color = text_color.toHsl() - text_color.setHsl(text_color.hue(), text_color.saturation(), 50, 255) - return text_color.toRgb() - else: - return highlight_color diff --git a/src/tagstudio/qt/mixed/text_field.py b/src/tagstudio/qt/mixed/text_field.py index 3d958e3c..879a0121 100644 --- a/src/tagstudio/qt/mixed/text_field.py +++ b/src/tagstudio/qt/mixed/text_field.py @@ -10,8 +10,8 @@ from PySide6.QtWidgets import QHBoxLayout, QLabel from tagstudio.qt.mixed.field_widget import FieldWidget -class TextWidget(FieldWidget): - def __init__(self, title, text: str) -> None: +class TextContainerWidget(FieldWidget): + def __init__(self, title: str, text: str) -> None: super().__init__(title) self.setObjectName("textBox") self.base_layout = QHBoxLayout() diff --git a/src/tagstudio/qt/translations.py b/src/tagstudio/qt/translations.py index 44946a3f..60f0dab1 100644 --- a/src/tagstudio/qt/translations.py +++ b/src/tagstudio/qt/translations.py @@ -83,7 +83,7 @@ class Translator: for k, v in self._strings.items(): self._strings[k] = remove_mnemonic_marker(v) - def __format(self, text: str, **kwargs) -> str: + def __format(self, text: str, **kwargs: ...) -> str: try: return text.format(**kwargs) except (KeyError, ValueError): @@ -93,11 +93,11 @@ class Translator: kwargs=kwargs, language=self.__lang, ) - params: defaultdict[str, Any] = defaultdict(lambda: "{unknown_key}") + params: defaultdict[str, Any] = defaultdict(lambda: "{unknown_key}") # pyright: ignore[reportExplicitAny] params.update(kwargs) return text.format_map(params) - def format(self, key: str, **kwargs) -> str: + def format(self, key: str, **kwargs: ...) -> str: return self.__format(self[key], **kwargs) def __getitem__(self, key: str) -> str: diff --git a/src/tagstudio/qt/ts_qt.py b/src/tagstudio/qt/ts_qt.py index ed6dcf58..ad9aaadc 100644 --- a/src/tagstudio/qt/ts_qt.py +++ b/src/tagstudio/qt/ts_qt.py @@ -24,7 +24,7 @@ from typing import TypeVar from warnings import catch_warnings import structlog -from humanfriendly import format_size, format_timespan +from humanfriendly import format_size, format_timespan # pyright: ignore[reportUnknownVariableType] from PySide6.QtCore import QObject, QSettings, Qt, QThread, QThreadPool, QTimer, Signal from PySide6.QtGui import ( QColor, @@ -45,7 +45,8 @@ from PySide6.QtWidgets import ( QScrollArea, ) -import tagstudio.qt.resources_rc # noqa: F401 +# This import has side-effect of importing PySide resources +import tagstudio.qt.resources_rc # noqa: F401 # pyright: ignore[reportUnusedImport] from tagstudio.core.constants import TAG_ARCHIVED, TAG_FAVORITE, VERSION, VERSION_BRANCH from tagstudio.core.driver import DriverMixin from tagstudio.core.enums import MacroID, SettingItems, ShowFilepathOption @@ -64,11 +65,7 @@ from tagstudio.core.utils.str_formatting import is_version_outdated from tagstudio.core.utils.types import unwrap from tagstudio.qt.cache_manager import CacheManager from tagstudio.qt.controllers.ffmpeg_missing_message_box import FfmpegMissingMessageBox -from tagstudio.qt.controllers.field_template_search_panel_controller import ( - FieldTemplateSearchPanel, -) - -# this import has side-effect of import PySide resources +from tagstudio.qt.controllers.field_template_search_panel_controller import FieldTemplateSearchPanel 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 @@ -102,6 +99,7 @@ from tagstudio.qt.views.field_template_search_panel_view import FieldTemplateSea 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 from tagstudio.qt.views.tag_search_panel_view import TagSearchPanelView BADGE_TAGS = { @@ -376,10 +374,12 @@ class QtDriver(DriverMixin, QObject): view=TagSearchPanelView(is_tag_chooser=False), ), title=Translations["tag_manager.title"], - done_callback=lambda checked=False: self.main_window.preview_panel.set_selection( + is_savable=False, + ) + self.tag_manager_panel.done.connect( + lambda checked=False: self.main_window.preview_panel.set_selection( self.selected, update_preview=False - ), - has_save=False, + ) ) # Initialize the Color Group Manager panel @@ -393,10 +393,12 @@ class QtDriver(DriverMixin, QObject): view=FieldTemplateSearchPanelView(is_field_template_chooser=False), ), title=Translations["field_template_manager.title"], - done_callback=lambda checked=False: self.main_window.preview_panel.set_selection( + is_savable=False, + ) + self.field_template_manager_panel.done.connect( + lambda checked=False: self.main_window.preview_panel.set_selection( self.selected, update_preview=False - ), - has_save=False, + ) ) # Initialize the Tag Search panel @@ -741,7 +743,7 @@ class QtDriver(DriverMixin, QObject): self.ignore_modal = PanelModal( panel, Translations["menu.edit.ignore_files"], - has_save=True, + is_savable=True, ) self.ignore_modal.saved.connect(panel.save) self.main_window.menu_bar.ignore_modal_action.triggered.connect(self.ignore_modal.show) @@ -880,7 +882,7 @@ class QtDriver(DriverMixin, QObject): panel, Translations["tag.new"], Translations["tag.add"], - has_save=True, + is_savable=True, ) self.modal.saved.connect( @@ -1013,9 +1015,8 @@ class QtDriver(DriverMixin, QObject): perm_warning_msg = Translations.format( "trash.dialog.permanent_delete_warning", trash_term=trash_term() ) - perm_warning: str = ( - f"

" - f"{perm_warning_msg}

" + perm_warning: str = header( + perm_warning_msg, 4, get_ui_color(ColorType.PRIMARY, UiColor.RED) ) msg = QMessageBox() @@ -1032,8 +1033,8 @@ class QtDriver(DriverMixin, QObject): "trash.dialog.move.confirmation.singular", trash_term=trash_term() ) msg.setText( - f"

{msg_text}

" - f"

{Translations['trash.dialog.disambiguation_warning.singular']}

" + f"{header(msg_text, 3)}" + f"{header(Translations['trash.dialog.disambiguation_warning.singular'], 4)}" f"{filename if filename else ''}" f"{perm_warning}
" ) @@ -1044,8 +1045,8 @@ class QtDriver(DriverMixin, QObject): trash_term=trash_term(), ) msg.setText( - f"

{msg_text}

" - f"

{Translations['trash.dialog.disambiguation_warning.plural']}

" + f"{header(msg_text, 3)}" + f"{header(Translations['trash.dialog.disambiguation_warning.plural'], 4)}" f"{perm_warning}
" ) @@ -1068,9 +1069,7 @@ class QtDriver(DriverMixin, QObject): pw.update_label(Translations["library.refresh.scanning_preparing"]) pw.show() - iterator = FunctionIterator( - lambda lib=unwrap(self.lib.library_dir): tracker.refresh_dir(lib) # noqa: B008 - ) + iterator = FunctionIterator(lambda lib=self.lib.library_dir: tracker.refresh_dir(lib)) iterator.value.connect( lambda x: ( pw.update_progress(x + 1), diff --git a/src/tagstudio/qt/views/edit_field_template_modal_view.py b/src/tagstudio/qt/views/edit_field_template_modal_view.py new file mode 100644 index 00000000..e4dafd67 --- /dev/null +++ b/src/tagstudio/qt/views/edit_field_template_modal_view.py @@ -0,0 +1,91 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + + +import structlog +from PySide6.QtCore import Qt +from PySide6.QtWidgets import ( + QCheckBox, + QComboBox, + QHBoxLayout, + QLabel, + QLineEdit, + QVBoxLayout, + QWidget, +) + +from tagstudio.qt.controllers.clickable_label import ClickableLabel +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): + def __init__(self) -> None: + super().__init__() + + # Layout Init + self.setMinimumSize(460, 200) + self.root_layout = QVBoxLayout(self) + self.root_layout.setContentsMargins(6, 0, 6, 0) + self.root_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + + # Field Name + self._name_widget = QWidget() + self._name_layout = QVBoxLayout(self._name_widget) + self._name_layout.setStretch(1, 1) + self._name_layout.setContentsMargins(0, 0, 0, 0) + self._name_layout.setSpacing(0) + self._name_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) + self._name_title = QLabel(Translations["field.name"]) + self._name_layout.addWidget(self._name_title) + self.name_field = QLineEdit() + self.name_field.setFixedHeight(24) + self.name_field.setPlaceholderText(Translations["field.field_name_required"]) + self._name_layout.addWidget(self.name_field) + + # Field Type + self._type_widget = QWidget() + self._type_layout = QVBoxLayout(self._type_widget) + self._type_layout.setStretch(1, 1) + self._type_layout.setContentsMargins(0, 0, 0, 0) + self._type_layout.setSpacing(0) + self._type_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) + self._type_title = QLabel(Translations["field.type"]) + self._type_layout.addWidget(self._type_title) + self._type_combobox = QComboBox() + self._type_combobox.setMinimumWidth(120) + self._type_layout.addWidget(self._type_combobox) + + # Text Field Attributes -------------------------------------------------------------------- + self._text_field_attributes_widget = QWidget() + self._text_field_attributes_layout = QHBoxLayout(self._text_field_attributes_widget) + self._text_field_attributes_layout.setStretch(1, 1) + self._text_field_attributes_layout.setContentsMargins(0, 0, 0, 0) + self._text_field_attributes_layout.setSpacing(6) + + # Is Multiline + self._multiline_widget = QWidget() + self._multiline_layout = QHBoxLayout(self._multiline_widget) + self._multiline_layout.setStretch(1, 1) + self._multiline_layout.setContentsMargins(0, 0, 0, 0) + self._multiline_layout.setSpacing(6) + self._multiline_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) + self._multiline_title = ClickableLabel(Translations["field.text.is_multiline"]) + self._multiline_checkbox = QCheckBox() + self._multiline_checkbox.setFixedSize(22, 22) + self._multiline_checkbox.setStyleSheet(checkbox_style()) + self._multiline_title.clicked.connect(self._multiline_checkbox.click) + self._multiline_layout.addWidget(self._multiline_checkbox) + self._multiline_layout.addWidget(self._multiline_title) + self._text_field_attributes_layout.addWidget(self._multiline_widget) + + # NOTE: Future options specific to other type will go in their own sections, + # following the pattern with text fields above. + + # Add Widgets to Layout ==================================================================== + self.root_layout.addWidget(self._name_widget) + self.root_layout.addWidget(self._type_widget) + self.root_layout.addWidget(self._text_field_attributes_widget) diff --git a/src/tagstudio/qt/views/edit_text_box_modal.py b/src/tagstudio/qt/views/edit_text_box_modal.py deleted file mode 100644 index fd60ba21..00000000 --- a/src/tagstudio/qt/views/edit_text_box_modal.py +++ /dev/null @@ -1,25 +0,0 @@ -# SPDX-FileCopyrightText: (c) TagStudio Contributors -# SPDX-License-Identifier: GPL-3.0-only - - -from PySide6.QtWidgets import QPlainTextEdit, QVBoxLayout - -from tagstudio.qt.views.panel_modal import PanelWidget - - -class EditTextBox(PanelWidget): - def __init__(self, text): - super().__init__() - self.setMinimumSize(480, 480) - self.root_layout = QVBoxLayout(self) - self.root_layout.setContentsMargins(6, 0, 6, 0) - self.text = text - self.text_edit = QPlainTextEdit() - self.text_edit.setPlainText(text) - self.root_layout.addWidget(self.text_edit) - - def get_content(self) -> str: - return self.text_edit.toPlainText() - - def reset(self): - self.text_edit.setPlainText(self.text) diff --git a/src/tagstudio/qt/views/edit_text_line_modal.py b/src/tagstudio/qt/views/edit_text_line_modal.py deleted file mode 100644 index d7ce6d0d..00000000 --- a/src/tagstudio/qt/views/edit_text_line_modal.py +++ /dev/null @@ -1,33 +0,0 @@ -# SPDX-FileCopyrightText: (c) TagStudio Contributors -# SPDX-License-Identifier: GPL-3.0-only - - -from collections.abc import Callable - -from PySide6.QtWidgets import QLineEdit, QVBoxLayout - -from tagstudio.qt.views.panel_modal import PanelWidget - - -class EditTextLine(PanelWidget): - def __init__(self, text): - super().__init__() - self.setMinimumWidth(480) - self.root_layout = QVBoxLayout(self) - self.root_layout.setContentsMargins(6, 0, 6, 0) - self.text = text - self.text_edit = QLineEdit() - self.text_edit.setText(text) - self.root_layout.addWidget(self.text_edit) - - def get_content(self) -> str: - return self.text_edit.text() - - def reset(self): - self.text_edit.setText(self.text) - - def add_callback(self, callback: Callable, event: str = "returnPressed"): - if event == "returnPressed": - self.text_edit.returnPressed.connect(callback) - else: - raise ValueError(f"unknown event type: {event}") diff --git a/src/tagstudio/qt/views/edit_text_view.py b/src/tagstudio/qt/views/edit_text_view.py new file mode 100644 index 00000000..1ec7ed33 --- /dev/null +++ b/src/tagstudio/qt/views/edit_text_view.py @@ -0,0 +1,60 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + + +from PySide6.QtGui import Qt +from PySide6.QtWidgets import ( + QCheckBox, + QHBoxLayout, + QLineEdit, + QPlainTextEdit, + QSizePolicy, + QVBoxLayout, + QWidget, +) + +from tagstudio.qt.controllers.clickable_label import ClickableLabel +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): + def __init__(self): + super().__init__() + self.setMinimumSize(480, 240) + self.root_layout = QVBoxLayout(self) + self.root_layout.setContentsMargins(6, 0, 6, 0) + + self.name_field = QLineEdit() + self.name_field.setStyleSheet(title_line_edit_style()) + + self.text_box = QPlainTextEdit() + self.text_line = QLineEdit() + self.text_line_stretch = QWidget() + self.text_line_stretch.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding + ) + + # Is Multiline + self.multiline_widget = QWidget() + self.multiline_layout = QHBoxLayout(self.multiline_widget) + self.multiline_layout.setStretch(1, 1) + self.multiline_layout.setContentsMargins(0, 0, 0, 0) + self.multiline_layout.setSpacing(6) + self.multiline_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) + self.multiline_title = ClickableLabel(Translations["field.text.is_multiline"]) + self.multiline_checkbox = QCheckBox() + self.multiline_checkbox.setFixedSize(22, 22) + self.multiline_checkbox.setStyleSheet(checkbox_style()) + self.multiline_title.clicked.connect(self.multiline_checkbox.click) + self.multiline_layout.addWidget(self.multiline_checkbox) + self.multiline_layout.addWidget(self.multiline_title) + + self.root_layout.addWidget(self.name_field) + self.root_layout.addWidget(self.text_box) + self.root_layout.setStretch(2, 1) + self.root_layout.addWidget(self.text_line) + self.root_layout.addWidget(self.text_line_stretch) + self.root_layout.setStretch(4, 1) + self.root_layout.addWidget(self.multiline_widget) diff --git a/src/tagstudio/qt/views/field_template_search_panel_view.py b/src/tagstudio/qt/views/field_template_search_panel_view.py index 0c8ef6fc..4b6218a9 100644 --- a/src/tagstudio/qt/views/field_template_search_panel_view.py +++ b/src/tagstudio/qt/views/field_template_search_panel_view.py @@ -1,6 +1,8 @@ # SPDX-FileCopyrightText: (c) TagStudio Contributors # SPDX-License-Identifier: GPL-3.0-only +from typing import override + from PySide6.QtWidgets import QWidget from tagstudio.core.library.alchemy.library import Library @@ -16,6 +18,7 @@ class FieldTemplateSearchPanelView(SearchPanelView): self.search_field.setPlaceholderText(Translations["home.search_field_templates"]) self.create_button.setText(Translations["field_template.create"]) + @override def get_item_widget(self, index: int, library: Library | None) -> FieldTemplateWidget: """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/field_template_widget_view.py b/src/tagstudio/qt/views/field_template_widget_view.py index f4073ff5..78efdb93 100644 --- a/src/tagstudio/qt/views/field_template_widget_view.py +++ b/src/tagstudio/qt/views/field_template_widget_view.py @@ -5,41 +5,19 @@ from PySide6.QtCore import Signal from PySide6.QtGui import QColor from PySide6.QtWidgets import QHBoxLayout, QPushButton, QVBoxLayout, QWidget -from tagstudio.core.enums import Theme from tagstudio.core.library.alchemy.enums import TagColorEnum -from tagstudio.qt.mixed.tag_widget import get_border_color, get_highlight_color, get_text_color -from tagstudio.qt.models.palette import ColorType, UiColor, get_tag_color, get_ui_color +from tagstudio.qt.models.palette import ColorType, get_tag_color +from tagstudio.qt.views.stylesheets.stylesheets import ( + get_tag_border_color, + get_tag_highlight_color, + get_tag_text_color, + list_button_style, +) primary_color: QColor = QColor(get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT)) -border_color: QColor = get_border_color(primary_color) -highlight_color: QColor = get_highlight_color(primary_color) -text_color: QColor = get_text_color(primary_color, highlight_color) - -FIELD_TEMPLATE_BUTTON_STYLESHEET = f""" - QPushButton{{ - background-color: {Theme.COLOR_BG.value}; - font-weight: 600; - border-radius: 6px; - padding-right: 4px; - padding-left: 4px; - font-size: 13px; - text-align: center; - }} - - QPushButton::hover{{ - background-color: {Theme.COLOR_HOVER.value}; - border-color: {get_ui_color(ColorType.BORDER, UiColor.THEME_DARK)}; - border-style: solid; - border-width: 2px; - }} - - QPushButton::pressed{{ - background-color: {Theme.COLOR_PRESSED.value}; - border-color: {get_ui_color(ColorType.LIGHT_ACCENT, UiColor.THEME_DARK)}; - border-style: solid; - border-width: 2px; - }} -""" +border_color: QColor = get_tag_border_color(primary_color) +highlight_color: QColor = get_tag_highlight_color(primary_color) +text_color: QColor = get_tag_text_color(primary_color, highlight_color) class FieldTemplateWidgetView(QWidget): @@ -63,7 +41,7 @@ class FieldTemplateWidgetView(QWidget): self._bg_button.setMinimumSize(44, 22) self._bg_button.setMinimumHeight(22) self._bg_button.setMaximumHeight(22) - self._bg_button.setStyleSheet(FIELD_TEMPLATE_BUTTON_STYLESHEET) + self._bg_button.setStyleSheet(list_button_style()) self.__inner_layout = QHBoxLayout() self.__inner_layout.setObjectName("inner_layout") @@ -72,18 +50,18 @@ class FieldTemplateWidgetView(QWidget): self.__inner_layout.setContentsMargins(0, 0, 0, 0) # Remove button - self.__remove_button = QPushButton(self) - self.__remove_button.setFlat(True) - self.__remove_button.setText("–") - self.__remove_button.setHidden(True) - self.__remove_button.setMinimumSize(22, 22) - self.__remove_button.setMaximumSize(22, 22) + 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.__inner_layout.addWidget(self.__remove_button) + self.__inner_layout.addWidget(self._delete_button) self.__inner_layout.addStretch(1) self.__connect_callbacks() def __connect_callbacks(self) -> None: self._bg_button.clicked.connect(self.on_click.emit) - self.__remove_button.clicked.connect(self.on_remove.emit) + self._delete_button.clicked.connect(self.on_remove.emit) diff --git a/src/tagstudio/qt/views/library_info_window_view.py b/src/tagstudio/qt/views/library_info_window_view.py index 97852551..2ad72dda 100644 --- a/src/tagstudio/qt/views/library_info_window_view.py +++ b/src/tagstudio/qt/views/library_info_window_view.py @@ -24,6 +24,7 @@ from PySide6.QtWidgets import ( from tagstudio.qt.helpers.color_overlay import auto_theme_overlay from tagstudio.qt.platform_strings import open_file_str from tagstudio.qt.translations import Translations +from tagstudio.qt.views.stylesheets.stylesheets import header # Only import for type checking/autocompletion, will not be imported at runtime. if TYPE_CHECKING: @@ -65,7 +66,7 @@ class LibraryInfoWindowView(QWidget): self.stats_layout.setContentsMargins(0, 0, 0, 0) self.stats_layout.setSpacing(12) - self.stats_label = QLabel(f"

{Translations['library_info.stats']}

") + self.stats_label = QLabel(header(Translations["library_info.stats"], 3)) self.stats_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.stats_grid: QWidget = QWidget() @@ -223,7 +224,7 @@ class LibraryInfoWindowView(QWidget): self.cleanup_layout.setContentsMargins(0, 0, 0, 0) self.cleanup_layout.setSpacing(12) - self.cleanup_label = QLabel(f"

{Translations['library_info.cleanup']}

") + self.cleanup_label = QLabel(header(Translations["library_info.cleanup"], 3)) self.cleanup_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.cleanup_grid: QWidget = QWidget() diff --git a/src/tagstudio/qt/views/main_window.py b/src/tagstudio/qt/views/main_window.py index 4fc94bfa..322fed8c 100644 --- a/src/tagstudio/qt/views/main_window.py +++ b/src/tagstudio/qt/views/main_window.py @@ -10,7 +10,7 @@ import structlog from PIL import Image, ImageQt from PySide6 import QtCore from PySide6.QtCore import QMetaObject, QSize, QStringListModel, Qt -from PySide6.QtGui import QAction, QColor, QPixmap +from PySide6.QtGui import QAction, QPixmap from PySide6.QtWidgets import ( QCheckBox, QComboBox, @@ -35,18 +35,17 @@ from PySide6.QtWidgets import ( ) from tagstudio.core.enums import ShowFilepathOption -from tagstudio.core.library.alchemy.enums import SortingModeEnum, TagColorEnum +from tagstudio.core.library.alchemy.enums import SortingModeEnum from tagstudio.qt.controllers.preview_panel_controller import PreviewPanel from tagstudio.qt.helpers.color_overlay import auto_theme_overlay from tagstudio.qt.mixed.landing import LandingWidget from tagstudio.qt.mixed.pagination import Pagination -from tagstudio.qt.mixed.tag_widget import get_border_color, get_highlight_color, get_text_color from tagstudio.qt.mnemonics import assign_mnemonics -from tagstudio.qt.models.palette import ColorType, get_tag_color from tagstudio.qt.platform_strings import trash_term from tagstudio.qt.resource_manager import ResourceManager from tagstudio.qt.thumb_grid_layout import ThumbGridLayout from tagstudio.qt.translations import Translations +from tagstudio.qt.views.stylesheets.stylesheets import checkbox_style # Only import for type checking/autocompletion, will not be imported at runtime. if typing.TYPE_CHECKING: @@ -589,11 +588,6 @@ class MainWindow(QMainWindow): self.extra_input_layout = QHBoxLayout() self.extra_input_layout.setObjectName("extra_input_layout") - primary_color = QColor(get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT)) - border_color = get_border_color(primary_color) - highlight_color = get_highlight_color(primary_color) - text_color: QColor = get_text_color(primary_color, highlight_color) - ## Show hidden entries checkbox self.show_hidden_entries_widget = QWidget() self.show_hidden_entries_layout = QHBoxLayout(self.show_hidden_entries_widget) @@ -604,33 +598,7 @@ class MainWindow(QMainWindow): self.show_hidden_entries_title = QLabel(Translations["home.show_hidden_entries"]) self.show_hidden_entries_checkbox = QCheckBox() self.show_hidden_entries_checkbox.setFixedSize(22, 22) - - self.show_hidden_entries_checkbox.setStyleSheet( - f"QCheckBox{{" - f"background: rgba{primary_color.toTuple()};" - f"color: rgba{text_color.toTuple()};" - f"border-color: rgba{border_color.toTuple()};" - f"border-radius: 6px;" - f"border-style:solid;" - f"border-width: 2px;" - f"}}" - f"QCheckBox::indicator{{" - f"width: 10px;" - f"height: 10px;" - f"border-radius: 2px;" - f"margin: 4px;" - f"}}" - f"QCheckBox::indicator:checked{{" - f"background: rgba{text_color.toTuple()};" - f"}}" - f"QCheckBox::hover{{" - f"border-color: rgba{highlight_color.toTuple()};" - f"}}" - f"QCheckBox::focus{{" - f"border-color: rgba{highlight_color.toTuple()};" - f"outline:none;" - f"}}" - ) + self.show_hidden_entries_checkbox.setStyleSheet(checkbox_style()) self.show_hidden_entries_checkbox.setChecked(False) # Default: No diff --git a/src/tagstudio/qt/views/panel_modal.py b/src/tagstudio/qt/views/panel_modal.py index b754c118..87d476c7 100644 --- a/src/tagstudio/qt/views/panel_modal.py +++ b/src/tagstudio/qt/views/panel_modal.py @@ -2,8 +2,8 @@ # SPDX-License-Identifier: GPL-3.0-only -from collections.abc import Callable -from typing import override +import contextlib +from typing import Any, override import structlog from PySide6 import QtCore, QtGui @@ -16,18 +16,19 @@ logger = structlog.get_logger(__name__) class PanelModal(QWidget): - saved = Signal() + """A generic reusable modal panel widget.""" + + done = Signal() + saved = Signal() + saved_data = Signal(type(Any)) - # TODO: Separate callbacks from the buttons you want, and just generally - # figure out what you want from this. def __init__( self, widget: "PanelWidget", title: str = "", window_title: str | None = None, - done_callback: Callable[[], None] | None = None, - save_callback: Callable[[str], None] | None = None, - has_save: bool = False, + is_savable: bool = False, + inline_title: bool = True, ): # [Done] # - OR - @@ -37,37 +38,24 @@ class PanelModal(QWidget): 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, 6, 6) - - 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.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) - # self.cancel_button = QPushButton() - # self.cancel_button.setText('Cancel') - - if not (save_callback or has_save): + if not is_savable: self.done_button = QPushButton(Translations["generic.done"]) self.done_button.setAutoDefault(True) self.done_button.clicked.connect(self.hide) - if done_callback: - self.done_button.clicked.connect(done_callback) + self.done_button.clicked.connect(self.done.emit) self.widget.panel_done_button = self.done_button self.button_layout.addWidget(self.done_button) - - if save_callback or has_save: + else: self.cancel_button = QPushButton(Translations["generic.cancel"]) self.cancel_button.clicked.connect(self.hide) self.cancel_button.clicked.connect(widget.reset) - # self.cancel_button.clicked.connect(cancel_callback) self.widget.panel_cancel_button = self.cancel_button self.button_layout.addWidget(self.cancel_button) @@ -75,23 +63,19 @@ class PanelModal(QWidget): 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 - - if done_callback: - self.save_button.clicked.connect(done_callback) - - if save_callback: - self.save_button.clicked.connect(lambda: save_callback(widget.get_content())) - self.button_layout.addWidget(self.save_button) - # trigger save button actions when pressing enter in the widget - self.widget.add_callback(lambda: self.save_button.click()) + 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) - if save_callback is not None: - widget.done.connect(lambda: save_callback(widget.get_content())) - - self.root_layout.addWidget(self.title_widget) self.root_layout.addWidget(widget) widget.parent_modal = self self.root_layout.setStretch(1, 2) @@ -100,9 +84,9 @@ class PanelModal(QWidget): @override def closeEvent(self, event: QtGui.QCloseEvent) -> None: - if self.cancel_button: + with contextlib.suppress(AttributeError): self.cancel_button.click() - elif self.done_button: + with contextlib.suppress(AttributeError): self.done_button.click() event.accept() @@ -110,7 +94,6 @@ class PanelModal(QWidget): class PanelWidget(QWidget): """Used for widgets that go in a modal panel, ex. for editing or searching.""" - done = Signal() parent_modal: PanelModal | None = None panel_save_button: QPushButton | None = None panel_cancel_button: QPushButton | None = None @@ -119,8 +102,8 @@ class PanelWidget(QWidget): def __init__(self): super().__init__() - def get_content(self) -> str: - return "" + def saved_data(self) -> Any: # pyright: ignore[reportExplicitAny] + return None def reset(self) -> None: pass @@ -128,9 +111,6 @@ class PanelWidget(QWidget): def parent_post_init(self) -> None: pass - def add_callback(self, callback: Callable[[], None], event: str = "returnPressed"): - logger.warning(f"[PanelModal] add_callback not implemented for {self.__class__.__name__}") - @override def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: if event.key() == QtCore.Qt.Key.Key_Escape: diff --git a/src/tagstudio/qt/views/preview_panel_view.py b/src/tagstudio/qt/views/preview_panel_view.py index 81ad91fb..266e06fe 100644 --- a/src/tagstudio/qt/views/preview_panel_view.py +++ b/src/tagstudio/qt/views/preview_panel_view.py @@ -16,45 +16,20 @@ from PySide6.QtWidgets import ( QWidget, ) -from tagstudio.core.enums import Theme 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.preview_thumb_controller import PreviewThumb from tagstudio.qt.mixed.field_containers import FieldContainers from tagstudio.qt.mixed.file_attributes import FileAttributeData, FileAttributes -from tagstudio.qt.models.palette import ColorType, UiColor, get_ui_color from tagstudio.qt.translations import Translations +from tagstudio.qt.views.stylesheets.stylesheets import button_style if typing.TYPE_CHECKING: from tagstudio.qt.ts_qt import QtDriver logger = structlog.get_logger(__name__) -BUTTON_STYLE: str = f""" - QPushButton{{ - background-color: {Theme.COLOR_BG.value}; - border-radius: 6px; - font-weight: 500; - text-align: center; - }} - QPushButton::hover{{ - background-color: {Theme.COLOR_HOVER.value}; - border-color: {get_ui_color(ColorType.BORDER, UiColor.THEME_DARK)}; - border-style: solid; - border-width: 2px; - }} - QPushButton::pressed{{ - background-color: {Theme.COLOR_PRESSED.value}; - border-color: {get_ui_color(ColorType.LIGHT_ACCENT, UiColor.THEME_DARK)}; - border-style: solid; - border-width: 2px; - }} - QPushButton::disabled{{ - background-color: {Theme.COLOR_DISABLED_BG.value}; - }} -""" - class PreviewPanelView(QWidget): lib: Library @@ -67,7 +42,7 @@ class PreviewPanelView(QWidget): self.__thumb = PreviewThumb(self.lib, driver) self.__file_attrs = FileAttributes(self.lib, driver) - self._fields = FieldContainers( + self._containers = FieldContainers( self.lib, driver ) # TODO: this should be name mangled, but is still needed on the controller side atm @@ -94,20 +69,20 @@ class PreviewPanelView(QWidget): self.__add_tag_button.setEnabled(False) self.__add_tag_button.setCursor(Qt.CursorShape.PointingHandCursor) self.__add_tag_button.setMinimumHeight(28) - self.__add_tag_button.setStyleSheet(BUTTON_STYLE) + self.__add_tag_button.setStyleSheet(button_style()) self.__add_field_button = QPushButton(Translations["field.add"]) self.__add_field_button.setEnabled(False) self.__add_field_button.setCursor(Qt.CursorShape.PointingHandCursor) self.__add_field_button.setMinimumHeight(28) - self.__add_field_button.setStyleSheet(BUTTON_STYLE) + self.__add_field_button.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.__file_attrs) - info_layout.addWidget(self._fields) + info_layout.addWidget(self._containers) splitter.addWidget(preview_section) splitter.addWidget(info_section) @@ -148,7 +123,7 @@ class PreviewPanelView(QWidget): self.__thumb.hide_preview() self.__file_attrs.update_stats() self.__file_attrs.update_date_label() - self._fields.hide_containers() + self._containers.hide_containers() self.add_buttons_enabled = False @@ -163,7 +138,7 @@ class PreviewPanelView(QWidget): stats: FileAttributeData = self.__thumb.display_file(filepath) self.__file_attrs.update_stats(filepath, stats) self.__file_attrs.update_date_label(filepath) - self._fields.update_from_entry(entry_id) + self._containers.update_from_entry(entry_id) self._set_selection_callback() @@ -175,7 +150,7 @@ class PreviewPanelView(QWidget): self.__thumb.hide_preview() # TODO: Render mixed selection self.__file_attrs.update_multi_selection(len(selected)) self.__file_attrs.update_date_label() - self._fields.hide_containers() # TODO: Allow for mixed editing + self._containers.hide_containers() # TODO: Allow for mixed editing self._set_selection_callback() @@ -205,7 +180,7 @@ class PreviewPanelView(QWidget): @property def field_containers_widget(self) -> FieldContainers: # needed for the tests """Getter for the field containers widget.""" - return self._fields + return self._containers @property def preview_thumb(self) -> PreviewThumb: diff --git a/src/tagstudio/qt/views/preview_thumb_view.py b/src/tagstudio/qt/views/preview_thumb_view.py index d6c032f7..381ae10b 100644 --- a/src/tagstudio/qt/views/preview_thumb_view.py +++ b/src/tagstudio/qt/views/preview_thumb_view.py @@ -19,7 +19,7 @@ from tagstudio.qt.mixed.media_player import MediaPlayer from tagstudio.qt.platform_strings import open_file_str, trash_term from tagstudio.qt.previews.renderer import ThumbRenderer from tagstudio.qt.translations import Translations -from tagstudio.qt.views.styles.rounded_pixmap_style import RoundedPixmapStyle +from tagstudio.qt.views.stylesheets.rounded_pixmap_style import RoundedPixmapStyle if TYPE_CHECKING: from tagstudio.qt.ts_qt import QtDriver diff --git a/src/tagstudio/qt/views/search_panel_view.py b/src/tagstudio/qt/views/search_panel_view.py index 50fd527b..175828af 100644 --- a/src/tagstudio/qt/views/search_panel_view.py +++ b/src/tagstudio/qt/views/search_panel_view.py @@ -16,46 +16,14 @@ from PySide6.QtWidgets import ( QWidget, ) -from tagstudio.core.library.alchemy.enums import TagColorEnum from tagstudio.core.library.alchemy.library import Library -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 list_button_style if TYPE_CHECKING: from tagstudio.qt.controllers.search_panel_controller import SearchPanel -CREATE_BUTTON_STYLESHEET: str = f""" - QPushButton{{ - background: {get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT)}; - color: {get_tag_color(ColorType.TEXT, TagColorEnum.DEFAULT)}; - font-weight: 600; - border-color:{get_tag_color(ColorType.BORDER, TagColorEnum.DEFAULT)}; - border-radius: 6px; - border-style: dashed; - border-width: 2px; - padding-right: 4px; - padding-bottom: 1px; - padding-left: 4px; - font-size: 13px - }} - - QPushButton::hover{{ - border-color: {get_tag_color(ColorType.LIGHT_ACCENT, TagColorEnum.DEFAULT)}; - }} - - QPushButton::pressed{{ - background: {get_tag_color(ColorType.LIGHT_ACCENT, TagColorEnum.DEFAULT)}; - color: {get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT)}; - border-color: {get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT)}; - }} - - QPushButton::focus{{ - border-color: {get_tag_color(ColorType.LIGHT_ACCENT, TagColorEnum.DEFAULT)}; - outline: none; - }} -""" - class SearchPanelView(PanelWidget): def __init__(self, is_chooser: bool) -> None: @@ -120,7 +88,7 @@ class SearchPanelView(PanelWidget): self.create_and_add_button = QPushButton() self.create_and_add_button.setFlat(True) self.create_and_add_button.setMinimumSize(22, 22) - self.create_and_add_button.setStyleSheet(CREATE_BUTTON_STYLESHEET) + self.create_and_add_button.setStyleSheet(list_button_style(border_style="dashed")) @property def scroll_layout(self) -> QVBoxLayout: @@ -130,7 +98,7 @@ class SearchPanelView(PanelWidget): def scroll_area(self) -> QScrollArea: return self.__scroll_area - def connect_callbacks(self, controller: "SearchPanel[Any]") -> None: + def connect_callbacks(self, controller: "SearchPanel[Any]") -> None: # pyright: ignore[reportExplicitAny] self.limit_combobox.currentIndexChanged.connect(controller.on_limit_changed) self.search_field.textChanged.connect(controller.on_search_query_changed) @@ -139,7 +107,9 @@ class SearchPanelView(PanelWidget): ) self.create_button.clicked.connect(controller.on_item_create) - self.create_and_add_button.clicked.connect(controller.on_item_create_and_add) + self.create_and_add_button.clicked.connect( + lambda: controller.on_item_create(add_to_entry=True) + ) def set_limit_items(self, limit_items: list[tuple[str, int]]) -> None: # Remove existing limit items @@ -171,7 +141,7 @@ class SearchPanelView(PanelWidget): def scroll_to(self, position: int) -> None: self.__scroll_area.verticalScrollBar().setValue(position) - def get_item_widget(self, index: int, library: Library | None) -> Any: + def get_item_widget(self, index: int, library: Library | None) -> Any: # pyright: ignore[reportUnusedParameter, reportExplicitAny] raise NotImplementedError() def add_create_and_add_button(self) -> None: diff --git a/src/tagstudio/qt/views/styles/rounded_pixmap_style.py b/src/tagstudio/qt/views/stylesheets/rounded_pixmap_style.py similarity index 100% rename from src/tagstudio/qt/views/styles/rounded_pixmap_style.py rename to src/tagstudio/qt/views/stylesheets/rounded_pixmap_style.py diff --git a/src/tagstudio/qt/views/stylesheets/stylesheets.py b/src/tagstudio/qt/views/stylesheets/stylesheets.py new file mode 100644 index 00000000..542ebd3f --- /dev/null +++ b/src/tagstudio/qt/views/stylesheets/stylesheets.py @@ -0,0 +1,431 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + + +from PySide6.QtCore import Qt +from PySide6.QtGui import QColor, QGuiApplication + +from tagstudio.core.enums import Theme +from tagstudio.core.library.alchemy.enums import TagColorEnum +from tagstudio.core.library.alchemy.models import Tag +from tagstudio.qt.models.palette import ColorType, UiColor, get_tag_color, get_ui_color + +# 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. + + +def add_button_style() -> str: + """Style used for tag-like "Add" buttons [+].""" + return f""" + QPushButton{{ + background: {get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT)}; + color: {get_tag_color(ColorType.TEXT, TagColorEnum.DEFAULT)}; + font-weight: 600; + border-color: {get_tag_color(ColorType.BORDER, TagColorEnum.DEFAULT)}; + border-radius: 6px; + border-style: solid; + border-width: 2px; + padding-right: 4px; + padding-bottom: 2px; + padding-left: 4px; + font-size: 15px + }} + QPushButton::hover{{ + border-color: {get_tag_color(ColorType.LIGHT_ACCENT, TagColorEnum.DEFAULT)}; + }} + QPushButton::pressed{{ + background: {get_tag_color(ColorType.LIGHT_ACCENT, TagColorEnum.DEFAULT)}; + color: {get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT)}; + border-color: {get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT)}; + }} + QPushButton::focus{{ + border-color: {get_tag_color(ColorType.LIGHT_ACCENT, TagColorEnum.DEFAULT)}; + outline: none; + }} + """ + + +def button_style() -> str: + """Style used for common QPushButtons.""" + return f""" + QPushButton{{ + background-color: {Theme.COLOR_BG.value}; + border-radius: 6px; + font-weight: 500; + text-align: center; + }} + QPushButton::hover{{ + background-color: {Theme.COLOR_HOVER.value}; + border-color: {get_ui_color(ColorType.BORDER, UiColor.THEME_DARK)}; + border-style: solid; + border-width: 2px; + }} + QPushButton::pressed{{ + background-color: {Theme.COLOR_PRESSED.value}; + border-color: {get_ui_color(ColorType.LIGHT_ACCENT, UiColor.THEME_DARK)}; + border-style: solid; + border-width: 2px; + }} + QPushButton::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)) + border_color = get_tag_border_color(primary_color) + highlight_color = get_tag_highlight_color(primary_color) + text_color: QColor = get_tag_text_color(primary_color, highlight_color) + return f""" + QCheckBox{{ + background: rgba{primary_color.toTuple()}; + color: rgba{text_color.toTuple()}; + border-color: rgba{border_color.toTuple()}; + border-radius: 6px; + border-style: solid; + border-width: 2px; + }} + QCheckBox::indicator{{ + width: 10px; + height: 10px; + border-radius: 2px; + margin: 4px; + }} + QCheckBox::indicator:checked{{ + background: rgba{text_color.toTuple()}; + }} + QCheckBox::hover{{ + border-color: rgba{highlight_color.toTuple()}; + }} + QCheckBox::focus{{ + border-color: rgba{highlight_color.toTuple()}; + outline: none; + }} + """ + + +def colored_radio_button_style( + primary_color: QColor, + text_color: QColor, + border_color: QColor, + highlight_color: QColor, +) -> str: + return f""" + QRadioButton{{ + background: rgba{primary_color.toTuple()}; + color: rgba{text_color.toTuple()}; + border-color: rgba{border_color.toTuple()}; + border-radius: 6px; + border-style: solid; + border-width: 2px; + }} + QRadioButton::indicator{{ + width: 10px; + height: 10px; + border-radius: 2px; + margin: 4px; + }} + QRadioButton::indicator:checked{{ + background: rgba{text_color.toTuple()}; + }} + QRadioButton::hover{{ + border-color: rgba{highlight_color.toTuple()}; + }} + QRadioButton::pressed{{ + background: rgba{border_color.toTuple()}; + color: rgba{primary_color.toTuple()}; + border-color: rgba{primary_color.toTuple()}; + }} + QRadioButton::focus{{ + border-color: rgba{highlight_color.toTuple()}; + outline: none; + }} + """ + + +def color_swatch_style( + primary_color: QColor, + text_color: QColor, + border_color: QColor, + highlight_color: QColor, + bottom_color: QColor | None = None, +) -> str: + """A style used for color swatches (aka special QRadioButtons).""" + bottom_color_str: str = ( + f"border-bottom-color: rgba{bottom_color.toTuple()};" if bottom_color else "" + ) + + return f""" + QRadioButton{{ + background: rgba{primary_color.toTuple()}; + color: rgba{text_color.toTuple()}; + border-color: rgba{border_color.toTuple()}; + {bottom_color_str} + border-radius: 3px; + border-style: solid; + border-width: 2px; + }} + QRadioButton::indicator{{ + width: 12px; + height: 12px; + border-radius: 1px; + margin: 4px; + }} + QRadioButton::indicator:checked{{ + background: rgba{text_color.toTuple()}; + }} + QRadioButton::hover{{ + border-color: rgba{highlight_color.toTuple()}; + }} + QRadioButton::focus{{ + outline-style: solid; + outline-width: 2px; + outline-radius: 3px; + outline-color: rgba{highlight_color.toTuple()}; + }} + """ + + +def container_style() -> str: + """Style used for field containers.""" + return f""" + QWidget#fieldContainer{{ + border-radius: 4px; + }} + QWidget#fieldContainer::hover{{ + background-color: {Theme.COLOR_HOVER.value}; + }} + QWidget#fieldContainer::pressed{{ + background-color: {Theme.COLOR_PRESSED.value}; + }} + """ + + +def form_content_style() -> str: + return f""" + background-color: { + Theme.COLOR_BG.value + if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark + else Theme.COLOR_BG_LIGHT.value + }; + border-radius: 3px; + font-weight: 500; + padding: 1px; + """ + + +def line_edit_style() -> str: + """Style used for QLineEdits.""" + return f""" + border: 1px solid {get_ui_color(ColorType.PRIMARY, UiColor.RED)}; + border-radius: 2px + """ + + +def list_button_style( + color: QColor | None = None, + border_style: str = "solid", +) -> str: + """Style used for special QPushButtons found in lists.""" + if color is None: + color = QColor(get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT)) + + highlight_color = get_tag_highlight_color(color) + text_color = get_tag_text_color(color, highlight_color) + border_color = get_tag_border_color(color) + + return f""" + QPushButton{{ + background: rgba{color.toTuple()}; + color: rgba{text_color.toTuple()}; + font-weight: 600; + border-color: rgba{border_color.toTuple()}; + border-radius: 6px; + border-style: {border_style}; + border-width: 2px; + padding-right: 4px; + padding-bottom: 1px; + padding-left: 4px; + font-size: 13px + }} + QPushButton::hover{{ + border-color: rgba{highlight_color.toTuple()}; + }} + QPushButton::pressed{{ + background: rgba{highlight_color.toTuple()}; + color: rgba{color.toTuple()}; + border-color: rgba{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()}; + }} + """ + + +def properties_style() -> str: + """Style used for small labels such as file properties.""" + label_bg_color = ( + Theme.COLOR_BG_DARK.value + if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark + else Theme.COLOR_DARK_LABEL.value + ) + + return f""" + background-color: {label_bg_color}; + color: #FFFFFF; + font-family: Oxanium; + font-weight: bold; + font-size: 12px; + border-radius: 3px; + padding-top: 4px; + padding-right: 1px; + padding-bottom: 1px; + padding-left: 1px; + """ + + +def tag_style( + primary_color: QColor, + text_color: QColor, + border_color: QColor, + highlight_color: QColor, + border_style: str = "solid", +) -> str: + """Style used for TagWidgets.""" + return f""" + QPushButton{{ + background: rgba{primary_color.toTuple()}; + color: rgba{text_color.toTuple()}; + font-weight: 600; + border-color: rgba{border_color.toTuple()}; + border-radius: 6px; + border-style: {border_style}; + border-width: 2px; + padding-right: 4px; + padding-left: 4px; + font-size: 13px + }} + QPushButton::hover{{ + border-color: rgba{highlight_color.toTuple()}; + }} + QPushButton::pressed{{ + background: rgba{highlight_color.toTuple()}; + color: rgba{primary_color.toTuple()}; + 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()}; + }} + """ + + +def tag_remove_button_style( + primary_color: QColor, text_color: QColor, border_color: QColor, highlight_color: QColor +) -> str: + """Style used for "Remove" buttons on TagWidgets [-].""" + return f""" + QPushButton{{ + color: rgba{primary_color.toTuple()}; + background: rgba{text_color.toTuple()}; + font-weight: 800; + border-radius: 5px; + border-width: 4; + border-color: rgba(0,0,0,0); + padding-bottom: 4px; + font-size: 14px + }} + QPushButton::hover{{ + background: rgba{primary_color.toTuple()}; + color: rgba{text_color.toTuple()}; + border-color: rgba{highlight_color.toTuple()}; + border-width: 2; + border-radius: 6px; + }} + QPushButton::pressed{{ + background: rgba{border_color.toTuple()}; + color: rgba{highlight_color.toTuple()}; + }} + QPushButton::focus{{ + background: rgba{border_color.toTuple()}; + outline: none; + }} + """ + + +def title_line_edit_style() -> str: + """Used to mimic an H3-like header style inside a QLineEdit.""" + return """ + font-weight: bold; + font-size: 16px; + """ + + +def header(string: str, level: int, color: str | None = None) -> str: + """Wrap a string in HTML header tags. + + Args: + string (str): The string to format. + level (int): A value between 1 and 6 denoting the header level. + For example, "1" will create

tags, "6" will create

tags, etc. + color: Optional color string to pass as an inline HTML style. + """ + if level < 1: + level = 1 + elif level > 6: + level = 6 + + style_tag: str = "" + if color is not None: + style_tag = f" style='color: {color}'" + + return f"{string}" + + +def get_tag_primary_color(tag: Tag) -> QColor: + primary_color = QColor( + get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT) + if not tag.color + else tag.color.primary + ) + + return primary_color + + +def get_tag_border_color(primary_color: QColor) -> QColor: + border_color: QColor = QColor(primary_color) + border_color.setRed(min(border_color.red() + 20, 255)) + border_color.setGreen(min(border_color.green() + 20, 255)) + border_color.setBlue(min(border_color.blue() + 20, 255)) + + return border_color + + +def get_tag_highlight_color(primary_color: QColor) -> QColor: + highlight_color: QColor = QColor(primary_color) + highlight_color = highlight_color.toHsl() + highlight_color.setHsl(highlight_color.hue(), min(highlight_color.saturation(), 200), 225, 255) + highlight_color = highlight_color.toRgb() + + return highlight_color + + +def get_tag_text_color(primary_color: QColor, highlight_color: QColor) -> QColor: + if primary_color.lightness() > 120: + text_color = QColor(primary_color) + text_color = text_color.toHsl() + text_color.setHsl(text_color.hue(), text_color.saturation(), 50, 255) + return text_color.toRgb() + else: + return highlight_color diff --git a/src/tagstudio/resources/translations/en.json b/src/tagstudio/resources/translations/en.json index 80b27989..9582cf33 100644 --- a/src/tagstudio/resources/translations/en.json +++ b/src/tagstudio/resources/translations/en.json @@ -1,11 +1,12 @@ { - "about.config_path": "Config Path", "about.app_cache_path": "App Cache Path", + "about.config_path": "Config Path", "about.description": "TagStudio is a photo and file organization application with an underlying tag-based system that focuses on giving freedom and flexibility to the user. No proprietary programs or formats, no sea of sidecar files, and no complete upheaval of your filesystem structure.", "about.documentation": "Documentation", "about.module.found": "Found", "about.title": "About TagStudio", "about.version": "Version", + "about.version.latest": "{built_version} (Latest Release: {latest_version})", "about.website": "Website", "app.git": "Git Commit", "app.pre_release": "Pre-Release", @@ -35,7 +36,6 @@ "drop_import.title": "Conflicting File(s)", "edit.color_manager": "Manage Tag Colors", "edit.copy_fields": "Copy Fields", - "edit.field_template_manager": "Manage Field Templates", "edit.paste_fields": "Paste Fields", "edit.tag_manager": "Manage Tags", "entries.duplicate.merge": "Merge Duplicate Entries", @@ -75,8 +75,12 @@ "ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}
{ffprobe}: {ffprobe_status}", "field_template_manager.title": "Library Field Templates", "field_template.all_field_templates": "All Field Templates", + "field_template.confirm_delete": "Are you sure you want to delete the field template \"{field_template_name}\"?", "field_template.create": "Create Field Template", "field_template.create_add": "Create && Add \"{query}\"", + "field_template.delete": "Delete Field Template", + "field_template.edit": "Edit Field Template", + "field_template.new": "New Field Template", "field_type.datetime": "Datetime", "field_type.text": "Text", "field_type.unknown": "Unknown Type", @@ -85,9 +89,13 @@ "field.confirm_remove": "Are you sure you want to remove this \"{name}\" field?", "field.copy": "Copy Field", "field.edit": "Edit Field", + "field.field_name_required": "Field Name (Required)", "field.mixed_data": "Mixed Data", + "field.name": "Name", "field.paste": "Paste Field", "field.remove": "Remove Field", + "field.text.is_multiline": "Multiline", + "field.type": "Type", "file.date_added": "Date Added", "file.date_created": "Date Created", "file.date_modified": "Date Modified", @@ -347,6 +355,7 @@ "tag.parent_tags": "Parent Tags", "tag.parent_tags.add": "Add Parent Tag(s)", "tag.parent_tags.description": "This tag can be treated as a substitute for any of these Parent Tags in searches.", + "tag.properties": "Properties", "tag.remove": "Remove Tag", "tag.search_for_tag": "Search for Tag", "tag.shorthand": "Shorthand", diff --git a/tests/qt/test_build_tag_panel.py b/tests/qt/test_build_tag_panel.py index b624356e..5206f36c 100644 --- a/tests/qt/test_build_tag_panel.py +++ b/tests/qt/test_build_tag_panel.py @@ -80,7 +80,7 @@ def test_build_tag_panel_remove_alias_callback( alias: TagAlias = unwrap(library.get_alias(tag.id, tag.alias_ids[0])) - panel.remove_alias_callback(alias.name, alias.id) + panel.remove_alias_callback(alias.id) assert len(panel.alias_ids) == 1 assert len(panel.alias_names) == 1 diff --git a/tests/test_library.py b/tests/test_library.py index 17f0d990..4cca5d8f 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -225,7 +225,9 @@ def test_remove_text_field_entry_with_multiple_fields(library: Library, entry_fu def test_update_entry_field(library: Library, entry_full: Entry): title_field = entry_full.text_fields[0] - library.update_text_field(entry_full.id, title_field, "new value", title_field.is_multiline) + library.update_text_field( + entry_full.id, title_field, title_field.name, "new value", title_field.is_multiline + ) entry = next(library.all_entries(with_joins=True)) assert entry.text_fields[0].value == "new value" @@ -241,7 +243,9 @@ def test_update_entry_with_multiple_identical_text_fields(library: Library, entr library.add_field_to_entries(entry_full.id, field=empty_title) # update one of the fields - library.update_text_field(entry_full.id, title_field, "new value", title_field.is_multiline) + library.update_text_field( + entry_full.id, title_field, title_field.name, "new value", title_field.is_multiline + ) # Then only one should be updated entry = next(library.all_entries(with_joins=True))

GitHub | ' - f'{Translations["about.documentation"]} | ' - f'Discord