diff --git a/docs/assets/add_fields.png b/docs/assets/add_fields.png new file mode 100644 index 000000000..2ba458a47 Binary files /dev/null and b/docs/assets/add_fields.png differ diff --git a/docs/assets/datetime_field_editor.png b/docs/assets/datetime_field_editor.png new file mode 100644 index 000000000..b786b98e9 Binary files /dev/null and b/docs/assets/datetime_field_editor.png differ diff --git a/docs/assets/field_template_editor.png b/docs/assets/field_template_editor.png new file mode 100644 index 000000000..5f4653455 Binary files /dev/null and b/docs/assets/field_template_editor.png differ diff --git a/docs/assets/field_template_manager.png b/docs/assets/field_template_manager.png new file mode 100644 index 000000000..82d73e1e1 Binary files /dev/null and b/docs/assets/field_template_manager.png differ diff --git a/docs/assets/fields_example.png b/docs/assets/fields_example.png new file mode 100644 index 000000000..1dcb2d960 Binary files /dev/null and b/docs/assets/fields_example.png differ diff --git a/docs/assets/text_field_editor.png b/docs/assets/text_field_editor.png new file mode 100644 index 000000000..2e27789a8 Binary files /dev/null and b/docs/assets/text_field_editor.png differ diff --git a/docs/fields.md b/docs/fields.md index 768e61a8c..304c71a1c 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 d0e4d6f3d..7b323c8c7 100644 --- a/src/tagstudio/core/constants.py +++ b/src/tagstudio/core/constants.py @@ -8,7 +8,10 @@ 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 @@ 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_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 eef19596d..8c28134d6 100644 --- a/src/tagstudio/core/library/alchemy/library.py +++ b/src/tagstudio/core/library/alchemy/library.py @@ -30,6 +30,7 @@ Engine, NullPool, ScalarResult, + Update, and_, asc, create_engine, @@ -1313,6 +1314,114 @@ def sort_key(text: str): 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 @@ def search_field_templates(self, name: str | None, limit: int = 100) -> list[Bas 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 @@ def remove_entry_field( 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 @@ def update_text_field( 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 @@ def update_datetime_field( 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 @@ def update_datetime_field( 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 f382d14e0..2eadda674 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 000000000..5ac7cf308 --- /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 000000000..3e98003e0 --- /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 f1bff0029..45c126acb 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 @@ def __init__( 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 @@ def __init__( 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 @@ def __init__( 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 @@ def set_item_widget(self, item: BaseFieldTemplate | None, index: int) -> None: 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 3a8a2aa0d..e23e9e665 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 @@ def __init__(self) -> None: 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 @@ def set_field_template(self, field_template: BaseFieldTemplate | None) -> None: 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 e841f61d5..cad8181c3 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.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 @@ def update_ignored_count(self): 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 426e1edd4..d0828dcb1 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.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 @@ def update_title(self): 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 f4a7e8e8b..53cc146fd 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.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 @@ def update_frame(self): # 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 @@ def update_frame(self): 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 287db4f89..d3852a805 100644 --- a/src/tagstudio/qt/controllers/preview_panel_controller.py +++ b/src/tagstudio/qt/controllers/preview_panel_controller.py @@ -42,11 +42,11 @@ def _set_selection_callback(self) -> None: 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 a90a9b782..0b743931c 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 @@ 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 @@ def get_search_query(self) -> str: 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 @@ def on_search_query_submitted(self, query: str) -> None: # 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: - raise NotImplementedError() - - def on_item_edit(self, item: T) -> None: + def on_item_create(self, add_to_entry: bool = False) -> None: # pyright: ignore[reportUnusedParameter] raise NotImplementedError() - def _on_item_remove(self, item: T) -> None: + def on_item_edit(self, item: T) -> None: # pyright: ignore[reportUnusedParameter] raise NotImplementedError() - def on_item_create_and_add(self) -> None: + def _on_item_remove(self, item: T) -> None: # pyright: ignore[reportUnusedParameter] 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 @@ def update_items(self, query: str | None = None) -> None: 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 @@ def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: # noqa N802 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 793512300..58e7919a6 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 @@ def _on_edit(self, tag: Tag) -> None: 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 161c7f083..ecd53496f 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 @@ def __init__( 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 @@ def __init__( 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 @@ def __init__( 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 @@ def on_item_edit(self, item: Tag) -> None: 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 @@ def _on_item_remove(self, item: Tag) -> None: 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 @@ def set_item_widget(self, item: Tag | None, index: int) -> None: 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 badb876b9..ca397750f 100644 --- a/src/tagstudio/qt/mixed/about_modal.py +++ b/src/tagstudio/qt/mixed/about_modal.py @@ -8,7 +8,7 @@ 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 @@ 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 @@ def __init__(self, config_path: Path | str): 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 @@ def __init__(self, config_path: Path | str): # 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 @@ def __init__(self, config_path: Path | str): # 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 @@ def __init__(self, config_path: Path | str): 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'

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

' + 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 653b9788c..946f4d745 100644 --- a/src/tagstudio/qt/mixed/add_field.py +++ b/src/tagstudio/qt/mixed/add_field.py @@ -19,6 +19,7 @@ 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 @@ def __init__(self, library: Library): 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 07b43d88e..9791aaa78 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.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 @@ def __init__(self, library: Library, color_group: TagColorGroup): 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 @@ def secondary_color_callback(self) -> None: 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 @@ def on_text_changed(self): 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 @@ def build_color(self) -> tuple[TagColorGroup, TagColorGroup]: ) 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 5b91a7001..36487a7b2 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 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 @@ def on_text_changed(self): 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 @@ def build_namespace(self) -> Namespace: 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 f42d14d60..4c81a9687 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 @@ 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_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 @@ def __init__(self, library: Library, tag: Tag | None = None) -> None: 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 @@ def __init__(self, library: Library, tag: Tag | None = None) -> None: 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 @@ def __init__(self, library: Library, tag: Tag | None = None) -> None: 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 @@ def __init__(self, library: Library, tag: Tag | None = None) -> None: 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 @@ def backspace(self): 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 @@ def add_alias_callback(self): 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 @@ def __build_row_item_widget(self, tag: Tag, parent_id: int, is_disambiguation: b 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 @@ def __build_row_item_widget(self, tag: Tag, parent_id: int, is_disambiguation: b 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 @@ def __build_row_item_widget(self, tag: Tag, parent_id: int, is_disambiguation: b 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 @@ def _set_aliases(self): 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 @@ def _set_aliases(self): 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 @@ def set_tag(self, tag: Tag): 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 @@ def build_tag(self) -> Tag: 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 3a031f2ac..88771bbd1 100644 --- a/src/tagstudio/qt/mixed/color_box.py +++ b/src/tagstudio/qt/mixed/color_box.py @@ -10,16 +10,15 @@ 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 @@ def __init__( 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 @@ def set_colors(self, colors: Iterable[TagColorGroup]): 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 @@ def edit_color(self, color_group: TagColorGroup): 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 318c4768a..1706eaa55 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 @@ def __init__(self, driver: "QtDriver", datetime: dt | str): 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 0c2500eb6..6a7d5555d 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.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 @@ 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 @@ def __init__(self, library: Library, driver: "QtDriver") -> None: 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 @@ def update_granular( # 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,129 +237,91 @@ def add_field_to_selected( ) 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. """ - logger.info( - "[FieldContainers][write_container]", - index=index, - name=field.name, - type=field.class_name, - ) - if len(self.containers) < (index + 1): - container = FieldContainer() - self.containers.append(container) - self.scroll_layout.addWidget(container) - else: - container = self.containers[index] - - # Set field title - 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: + 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) - 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 "" + text = (field.value or "").replace("\r", "\n") else: - text = "Mixed Data" # TODO: Localize this + text = f"{Translations['field.mixed_data']}" - inner_widget = TextWidget(title, text) + inner_widget = TextContainerWidget(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), - ) - ), + 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) ) - if "pytest" in sys.modules: - # for better testability - container.modal = modal # pyright: ignore[reportAttributeAccessIssue] - container.set_edit_callback(modal.show) + container.set_edit_callback(edit_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), - ), + callback=partial(remove_field_callback, field, self.top_entry_id), ) ) - # Multiline Text - elif type(field) is TextField and field.is_multiline: + def write_datetime_container( + container: FieldContainer, field: DatetimeField, title: str, is_mixed: bool + ): 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), - ), - ) - ) - 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( @@ -363,67 +329,82 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False) ) except (ValueError, AssertionError): text = str(field.value) + else: + text = f"{Translations['field.mixed_data']}" - inner_widget = TextWidget(title, text) - container.set_inner_widget(inner_widget) + inner_widget = TextContainerWidget(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), - ) - ), + 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(modal.show) + container.set_edit_callback(edit_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), - ), + callback=partial(remove_field_callback, field, self.top_entry_id), ) ) - else: - text = "Mixed Data" # TODO: Localize this - inner_widget = TextWidget(title, text) - container.set_inner_widget(inner_widget) - else: - logger.warning( - "[FieldContainers][write_container] Unknown Field", field=field - ) # TODO: Localize this + + def write_unknown_container(): container.set_title(field.name) - container.set_inline(False) - inner_widget = TextWidget(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=lambda: ( - self.remove_field(field), - self.update_from_entry(self.cached_entries[0].id), - ), + 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) + self.scroll_layout.addWidget(container) + else: + container = self.containers[index] + + # Set field title + field_name_key: str = FIELD_TYPE_KEYS.get(field.class_name, "field_type.unknown") + title = f"{field.name} ({Translations[field_name_key]})" + + # Write containers + if type(field) is TextField: + write_text_container(container, field, title, is_mixed) + elif type(field) is DatetimeField: + write_datetime_container(container, field, title, is_mixed) + else: + 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 @@ def write_tag_container( 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 @@ def write_tag_container( 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 @@ def write_tag_container( 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 @@ def remove_field(self, field: BaseField) -> None: 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 dc99771a5..23f58a344 100644 --- a/src/tagstudio/qt/mixed/field_widget.py +++ b/src/tagstudio/qt/mixed/field_widget.py @@ -12,9 +12,9 @@ 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 @@ def __init__(self, title: str = "Field", inline: bool = True) -> None: 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 @@ def get_inner_widget(self) -> QWidget | None: 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 38dfd3f09..3193aee08 100644 --- a/src/tagstudio/qt/mixed/file_attributes.py +++ b/src/tagstudio/qt/mixed/file_attributes.py @@ -11,13 +11,12 @@ 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.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 @@ def __init__(self, library: Library, driver: "QtDriver"): 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 @@ def __init__(self, library: Library, driver: "QtDriver"): 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 c7c3165bb..9b102dfa9 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.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 @@ def __init__(self, library: "Library", driver: "QtDriver"): 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 @@ def refresh_dupes(self): 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 59474c1f9..f96bf2d08 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.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 @@ def update_unlinked_count(self): 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 45cdbc0a3..0f9742e25 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.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 @@ def __init__(self, library: "Library", driver: "QtDriver"): 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 50cc4fae5..d07678a4a 100644 --- a/src/tagstudio/qt/mixed/landing.py +++ b/src/tagstudio/qt/mixed/landing.py @@ -11,10 +11,10 @@ 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 0859b0ec5..7f21f825e 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.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 @@ def migration_progress(self, skip_ui: bool = False): 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 @@ def migration_progress(self, skip_ui: bool = False): 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 @@ def migration_iterator(self): ) 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 @@ def color_value_conditional(self, old_value: int | str, new_value: int | str) -> 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 fe76f83d0..edb7a9f4c 100644 --- a/src/tagstudio/qt/mixed/settings_panel.py +++ b/src/tagstudio/qt/mixed/settings_panel.py @@ -357,9 +357,9 @@ def build_modal(cls, driver: "QtDriver") -> PanelModal: 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 8052f59d8..90d797b78 100644 --- a/src/tagstudio/qt/mixed/tag_color_label.py +++ b/src/tagstudio/qt/mixed/tag_color_label.py @@ -11,12 +11,14 @@ 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 @@ def set_color(self, color: TagColorGroup | None) -> None: 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 @@ def _get_primary_color(self, color: TagColorGroup) -> QColor: 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 92c3b42b1..99f5b47c4 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.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 @@ def __init__( 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 @@ def create_namespace(self): 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 @@ def create_namespace(self): 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 @@ def delete_namespace_dialog(self, prompt: str, callback: Callable) -> None: 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 c73571d4a..965a823ab 100644 --- a/src/tagstudio/qt/mixed/tag_color_preview.py +++ b/src/tagstudio/qt/mixed/tag_color_preview.py @@ -11,9 +11,14 @@ 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 @@ def set_tag_color_group(self, color_group: TagColorGroup | None): 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 @@ def set_tag_color_group(self, color_group: TagColorGroup | None): 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 a58d3c06f..35bc2c2e0 100644 --- a/src/tagstudio/qt/mixed/tag_color_selection.py +++ b/src/tagstudio/qt/mixed/tag_color_selection.py @@ -20,11 +20,16 @@ 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 @@ def __init__(self, library: Library): 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 @@ def __init__(self, library: Library): 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 @@ def __init__(self, library: Library): 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 @@ def add_no_color_widget(self): 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 e69de29bb..000000000 diff --git a/src/tagstudio/qt/mixed/tag_widget.py b/src/tagstudio/qt/mixed/tag_widget.py index 5907fd181..c17cf196e 100644 --- a/src/tagstudio/qt/mixed/tag_widget.py +++ b/src/tagstudio/qt/mixed/tag_widget.py @@ -15,6 +15,14 @@ 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 @@ def __init__( 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 @@ def set_tag(self, tag: Tag | None) -> None: 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 @@ def set_tag(self, tag: Tag | None) -> None: 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 @@ def set_has_remove(self, has_remove: bool): @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 3d958e3cb..879a01216 100644 --- a/src/tagstudio/qt/mixed/text_field.py +++ b/src/tagstudio/qt/mixed/text_field.py @@ -10,8 +10,8 @@ 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 44946a3f8..60f0dab18 100644 --- a/src/tagstudio/qt/translations.py +++ b/src/tagstudio/qt/translations.py @@ -83,7 +83,7 @@ def change_language(self, lang: str): 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 @@ def __format(self, text: str, **kwargs) -> str: 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 ed6dcf58e..ad9aaadcf 100644 --- a/src/tagstudio/qt/ts_qt.py +++ b/src/tagstudio/qt/ts_qt.py @@ -24,7 +24,7 @@ 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 @@ 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.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.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 @@ def start(self) -> None: 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 @@ def start(self) -> None: 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 @@ def init_ignore_modal(self): 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 @@ def add_tag_action_callback(self): panel, Translations["tag.new"], Translations["tag.add"], - has_save=True, + is_savable=True, ) self.modal.saved.connect( @@ -1013,9 +1015,8 @@ def delete_file_confirmation(self, count: int, filename: Path | None = None) -> 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 @@ def delete_file_confirmation(self, count: int, filename: Path | None = None) -> "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 @@ def delete_file_confirmation(self, count: int, filename: Path | None = None) -> 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 @@ def add_new_files_callback(self): 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 000000000..e4dafd673 --- /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 fd60ba21e..000000000 --- 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 d7ce6d0d8..000000000 --- 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 000000000..1ec7ed333 --- /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 0c8ef6fc6..4b6218a9a 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 @@ def __init__(self, is_field_template_chooser: bool) -> None: 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 f4073ff5c..78efdb936 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.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 @@ def __init__(self) -> None: 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 @@ def __init__(self) -> None: 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.__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.__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 978525511..2ad72dda2 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 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 @@ def __init__(self, library: "Library", driver: "QtDriver"): 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 @@ def __init__(self, library: "Library", driver: "QtDriver"): 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 4fc94bfab..322fed8c3 100644 --- a/src/tagstudio/qt/views/main_window.py +++ b/src/tagstudio/qt/views/main_window.py @@ -10,7 +10,7 @@ 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 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 @@ def setup_extra_input_bar(self): 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 @@ def setup_extra_input_bar(self): 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 b754c118e..87d476c75 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 @@ class PanelModal(QWidget): + """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 @@ def __init__( 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 @@ def __init__( 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 save_callback is not None: - widget.done.connect(lambda: save_callback(widget.get_content())) + if inline_title: + self.title_widget = QLabel() + self.title_widget.setObjectName("fieldTitle") + self.title_widget.setWordWrap(True) + self.title_widget.setStyleSheet("font-weight:bold;font-size:14px;padding-top:6px") + self.title_widget.setText(title) + self.title_widget.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.root_layout.addWidget(self.title_widget) - self.root_layout.addWidget(self.title_widget) self.root_layout.addWidget(widget) widget.parent_modal = self self.root_layout.setStretch(1, 2) @@ -100,9 +84,9 @@ def __init__( @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 @@ def closeEvent(self, event: QtGui.QCloseEvent) -> None: 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 @@ def reset(self) -> None: 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 81ad91fb9..266e06fe0 100644 --- a/src/tagstudio/qt/views/preview_panel_view.py +++ b/src/tagstudio/qt/views/preview_panel_view.py @@ -16,45 +16,20 @@ 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 @@ def __init__(self, library: Library, driver: "QtDriver") -> None: 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 @@ def __init__(self, library: Library, driver: "QtDriver") -> None: 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 @@ def set_selection(self, selected: list[int], update_preview: bool = True) -> Non 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 @@ def set_selection(self, selected: list[int], update_preview: bool = True) -> Non 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 @@ def set_selection(self, selected: list[int], update_preview: bool = True) -> Non 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 @@ def _file_attributes_widget(self) -> FileAttributes: # needed for the tests @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 d6c032f7f..381ae10bf 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.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 50fd527b9..175828afd 100644 --- a/src/tagstudio/qt/views/search_panel_view.py +++ b/src/tagstudio/qt/views/search_panel_view.py @@ -16,46 +16,14 @@ 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 @@ def __init__(self, is_chooser: bool) -> None: 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 @@ def scroll_layout(self) -> QVBoxLayout: 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 @@ def connect_callbacks(self, controller: "SearchPanel[Any]") -> None: ) 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 @@ def clear_search_query(self) -> None: 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 000000000..542ebd3fa --- /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 80b279895..9582cf33b 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 b624356e0..5206f36c3 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 17f0d990d..4cca5d8fb 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))