From 28de21ade757aa5b80e87f77a459f4a3af21ffe0 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Mon, 17 Feb 2025 14:36:34 -0800 Subject: [PATCH] feat(ui)!: user-created tag colors (#801) * feat: custom tag colors * ui: minor ui polish * ui: add confirmation for deleting colors * ui: match tag_color_preview focused style * ui: reduce spacing between color swatch groups * ui!: change default behavior of secondary color The secondary color now acts as only the text color by default, with the new `color_border` bool serving to optionally restore the previous text + colored border behavior. * ui: adjust focused tag/color button styles * fix: avoid namespace collision * fix: make reserved namespace check case-insensitive * ui: add namespace description + prompt * fix: don't reset tag color if none are chosen * refactor(ui): use form layout for build_color * fix(ui): dynamically scale field title widget * feat(ui): add additional tag shade colors Add "burgundy", "dark-teal", and "dark_lavender" tag colors. * fix: don't check for self in collision checks * fix: update tag references on color update * fix(ui): stop fields widgets expanding indefinitely --- tagstudio/resources/translations/en.json | 27 +- tagstudio/src/core/constants.py | 2 + tagstudio/src/core/enums.py | 2 +- .../library/alchemy/default_color_groups.py | 35 +- tagstudio/src/core/library/alchemy/library.py | 284 +++++++++++- tagstudio/src/core/library/alchemy/models.py | 3 + tagstudio/src/qt/modals/build_color.py | 413 ++++++++++++++++++ tagstudio/src/qt/modals/build_namespace.py | 176 ++++++++ tagstudio/src/qt/modals/build_tag.py | 12 +- tagstudio/src/qt/modals/tag_color_manager.py | 226 ++++++++++ .../src/qt/modals/tag_color_selection.py | 79 +++- tagstudio/src/qt/modals/tag_database.py | 2 +- tagstudio/src/qt/modals/tag_search.py | 4 +- tagstudio/src/qt/ts_qt.py | 23 +- tagstudio/src/qt/widgets/color_box.py | 166 +++++++ tagstudio/src/qt/widgets/fields.py | 11 +- tagstudio/src/qt/widgets/tag.py | 12 +- tagstudio/src/qt/widgets/tag_color_label.py | 201 +++++++++ tagstudio/src/qt/widgets/tag_color_preview.py | 101 ++--- 19 files changed, 1678 insertions(+), 101 deletions(-) create mode 100644 tagstudio/src/qt/modals/build_color.py create mode 100644 tagstudio/src/qt/modals/build_namespace.py create mode 100644 tagstudio/src/qt/modals/tag_color_manager.py create mode 100644 tagstudio/src/qt/widgets/color_box.py create mode 100644 tagstudio/src/qt/widgets/tag_color_label.py diff --git a/tagstudio/resources/translations/en.json b/tagstudio/resources/translations/en.json index 7608d3ca..0962a594 100644 --- a/tagstudio/resources/translations/en.json +++ b/tagstudio/resources/translations/en.json @@ -2,6 +2,19 @@ "app.git": "Git Commit", "app.pre_release": "Pre-Release", "app.title": "{base_title} - Library '{library_dir}'", + "color_manager.title": "Manage Tag Colors", + "color.color_border": "Use Secondary Color for Border", + "color.confirm_delete": "Are you sure you want to delete the color \"{color_name}\"?", + "color.delete": "Delete Tag", + "color.import_pack": "Import Color Pack", + "color.name": "Name", + "color.namespace.delete.prompt": "Are you sure you want to delete this color namespace? This will delete ALL colors in the namespace along with it!", + "color.namespace.delete.title": "Delete Color Namespace", + "color.new": "New Color", + "color.placeholder": "Color", + "color.primary_required": "Primary Color (Required)", + "color.primary": "Primary Color", + "color.secondary": "Secondary Color", "color.title.no_color": "No Color", "drop_import.description": "The following files match file paths that already exist in the library", "drop_import.duplicates_choice.plural": "The following {count} files match file paths that already exist in the library.", @@ -11,6 +24,7 @@ "drop_import.progress.label.singular": "Importing New Files...\n1 File imported.{suffix}", "drop_import.progress.window_title": "Import Files", "drop_import.title": "Conflicting File(s)", + "edit.color_manager": "Manage Tag Colors", "edit.tag_manager": "Manage Tags", "entries.duplicate.merge.label": "Merging Duplicate Entries...", "entries.duplicate.merge": "Merge Duplicate Entries", @@ -96,6 +110,7 @@ "generic.recent_libraries": "Recent Libraries", "generic.rename_alt": "&Rename", "generic.rename": "Rename", + "generic.reset": "Reset", "generic.save": "Save", "generic.skip_alt": "&Skip", "generic.skip": "Skip", @@ -143,6 +158,10 @@ "json_migration.title.old_lib": "

v9.4 Library

", "json_migration.title": "Save Format Migration: \"{path}\"", "landing.open_create_library": "Open/Create Library {shortcut}", + "library_object.name_required": "Name (Required)", + "library_object.name": "Name", + "library_object.slug_required": "ID Slug (Required)", + "library_object.slug": "ID Slug", "library.field.add": "Add Field", "library.field.confirm_remove": "Are you sure you want to remove this \"{name}\" field?", "library.field.mixed_data": "Mixed Data", @@ -175,8 +194,8 @@ "menu.file.save_backup": "&Save Library Backup", "menu.file.save_library": "Save Library", "menu.file": "&File", - "menu.help": "&Help", "menu.help.about": "About", + "menu.help": "&Help", "menu.macros.folders_to_tags": "Folders to Tags", "menu.macros": "&Macros", "menu.select": "Select", @@ -185,6 +204,11 @@ "menu.tools": "&Tools", "menu.view": "&View", "menu.window": "Window", + "namespace.create.description_color": "Tag colors use namespaces as color palette groups. All custom colors must be under a namespace group first.", + "namespace.create.description": "Namespaces are used by TagStudio to separate groups of items such as tags and colors in a way that makes them easy to export and share. Namespaces starting with \"tagstudio\" are reserved by TagStudio for internal use.", + "namespace.create.title": "Create Namespace", + "namespace.new.button": "New Namespace", + "namespace.new.prompt": "Create a New Namespace to Start Adding Custom Colors!", "preview.no_selection": "No Items Selected", "select.add_tag_to_selected": "Add Tag to Selected", "select.all": "Select All", @@ -228,6 +252,7 @@ "tag.create": "Create Tag", "tag.disambiguation.tooltip": "Use this tag for disambiguation", "tag.edit": "Edit Tag", + "tag.is_category": "Is Category", "tag.name": "Name", "tag.new": "New Tag", "tag.parent_tags.add": "Add Parent Tag(s)", diff --git a/tagstudio/src/core/constants.py b/tagstudio/src/core/constants.py index fbc63b06..f984b768 100644 --- a/tagstudio/src/core/constants.py +++ b/tagstudio/src/core/constants.py @@ -25,3 +25,5 @@ TAG_FAVORITE = 1 TAG_META = 2 RESERVED_TAG_START = 0 RESERVED_TAG_END = 999 + +RESERVED_NAMESPACE_PREFIX = "tagstudio" diff --git a/tagstudio/src/core/enums.py b/tagstudio/src/core/enums.py index 6cdedbcf..ea68838b 100644 --- a/tagstudio/src/core/enums.py +++ b/tagstudio/src/core/enums.py @@ -71,4 +71,4 @@ class LibraryPrefs(DefaultEnum): IS_EXCLUDE_LIST = True EXTENSION_LIST: list[str] = [".json", ".xmp", ".aae"] PAGE_SIZE: int = 500 - DB_VERSION: int = 7 + DB_VERSION: int = 8 diff --git a/tagstudio/src/core/library/alchemy/default_color_groups.py b/tagstudio/src/core/library/alchemy/default_color_groups.py index d489dae1..ccc5a649 100644 --- a/tagstudio/src/core/library/alchemy/default_color_groups.py +++ b/tagstudio/src/core/library/alchemy/default_color_groups.py @@ -307,6 +307,12 @@ def pastels() -> list[TagColorGroup]: def shades() -> list[TagColorGroup]: + burgundy = TagColorGroup( + slug="burgundy", + namespace="tagstudio-shades", + name="Burgundy", + primary="#6E1C24", + ) auburn = TagColorGroup( slug="auburn", namespace="tagstudio-shades", @@ -319,19 +325,31 @@ def shades() -> list[TagColorGroup]: name="Olive", primary="#4C652E", ) + dark_teal = TagColorGroup( + slug="dark-teal", + namespace="tagstudio-shades", + name="Dark Teal", + primary="#1F5E47", + ) navy = TagColorGroup( slug="navy", namespace="tagstudio-shades", name="Navy", primary="#104B98", ) + dark_lavender = TagColorGroup( + slug="dark_lavender", + namespace="tagstudio-shades", + name="Dark Lavender", + primary="#3D3B6C", + ) berry = TagColorGroup( slug="berry", namespace="tagstudio-shades", name="Berry", primary="#9F2AA7", ) - return [auburn, olive, navy, berry] + return [burgundy, auburn, olive, dark_teal, navy, dark_lavender, berry] def earth_tones() -> list[TagColorGroup]: @@ -421,6 +439,7 @@ def neon() -> list[TagColorGroup]: name="Neon Red", primary="#180607", secondary="#E22C3C", + color_border=True, ) neon_red_orange = TagColorGroup( slug="neon-red-orange", @@ -428,6 +447,7 @@ def neon() -> list[TagColorGroup]: name="Neon Red Orange", primary="#220905", secondary="#E83726", + color_border=True, ) neon_orange = TagColorGroup( slug="neon-orange", @@ -435,6 +455,7 @@ def neon() -> list[TagColorGroup]: name="Neon Orange", primary="#1F0D05", secondary="#ED6022", + color_border=True, ) neon_amber = TagColorGroup( slug="neon-amber", @@ -442,6 +463,7 @@ def neon() -> list[TagColorGroup]: name="Neon Amber", primary="#251507", secondary="#FA9A2C", + color_border=True, ) neon_yellow = TagColorGroup( slug="neon-yellow", @@ -449,6 +471,7 @@ def neon() -> list[TagColorGroup]: name="Neon Yellow", primary="#2B1C0B", secondary="#FFD63D", + color_border=True, ) neon_lime = TagColorGroup( slug="neon-lime", @@ -456,6 +479,7 @@ def neon() -> list[TagColorGroup]: name="Neon Lime", primary="#1B220C", secondary="#92E649", + color_border=True, ) neon_green = TagColorGroup( slug="neon-green", @@ -463,6 +487,7 @@ def neon() -> list[TagColorGroup]: name="Neon Green", primary="#091610", secondary="#45D649", + color_border=True, ) neon_teal = TagColorGroup( slug="neon-teal", @@ -470,6 +495,7 @@ def neon() -> list[TagColorGroup]: name="Neon Teal", primary="#09191D", secondary="#22D589", + color_border=True, ) neon_cyan = TagColorGroup( slug="neon-cyan", @@ -477,6 +503,7 @@ def neon() -> list[TagColorGroup]: name="Neon Cyan", primary="#0B191C", secondary="#3DDBDB", + color_border=True, ) neon_blue = TagColorGroup( slug="neon-blue", @@ -484,6 +511,7 @@ def neon() -> list[TagColorGroup]: name="Neon Blue", primary="#09101C", secondary="#3B87F0", + color_border=True, ) neon_indigo = TagColorGroup( slug="neon-indigo", @@ -491,6 +519,7 @@ def neon() -> list[TagColorGroup]: name="Neon Indigo", primary="#150B24", secondary="#874FF5", + color_border=True, ) neon_purple = TagColorGroup( slug="neon-purple", @@ -498,6 +527,7 @@ def neon() -> list[TagColorGroup]: name="Neon Purple", primary="#1E0B26", secondary="#BB4FF0", + color_border=True, ) neon_magenta = TagColorGroup( slug="neon-magenta", @@ -505,6 +535,7 @@ def neon() -> list[TagColorGroup]: name="Neon Magenta", primary="#220A13", secondary="#F64680", + color_border=True, ) neon_pink = TagColorGroup( slug="neon-pink", @@ -512,6 +543,7 @@ def neon() -> list[TagColorGroup]: name="Neon Pink", primary="#210E15", secondary="#FF62AF", + color_border=True, ) neon_white = TagColorGroup( slug="neon-white", @@ -519,6 +551,7 @@ def neon() -> list[TagColorGroup]: name="Neon White", primary="#131315", secondary="#F2F1F8", + color_border=True, ) return [ neon_red, diff --git a/tagstudio/src/core/library/alchemy/library.py b/tagstudio/src/core/library/alchemy/library.py index 9ea9de52..703a9fb4 100644 --- a/tagstudio/src/core/library/alchemy/library.py +++ b/tagstudio/src/core/library/alchemy/library.py @@ -49,6 +49,7 @@ from src.qt.translations import Translations from ...constants import ( BACKUP_FOLDER_NAME, LEGACY_TAG_FIELD_IDS, + RESERVED_NAMESPACE_PREFIX, RESERVED_TAG_END, RESERVED_TAG_START, TAG_ARCHIVED, @@ -89,7 +90,16 @@ SELECT * FROM ChildTags; """) # noqa: E501 -def slugify(input_string: str) -> str: +class ReservedNamespaceError(Exception): + """Raise during an unauthorized attempt to create or modify a reserved namespace value. + + Reserved namespace prefix: "tagstudio". + """ + + pass + + +def slugify(input_string: str, allow_reserved: bool = False) -> str: # Convert to lowercase and normalize unicode characters slug = unicodedata.normalize("NFKD", input_string.lower()) @@ -99,6 +109,9 @@ def slugify(input_string: str) -> str: # Replace spaces with hyphens slug = re.sub(r"[-\s]+", "-", slug) + if not allow_reserved and slug.startswith(RESERVED_NAMESPACE_PREFIX): + raise ReservedNamespaceError + return slug @@ -179,7 +192,7 @@ class Library: library_dir: Path | None = None storage_path: Path | str | None - engine: Engine | None + engine: Engine | None = None folder: Folder | None included_files: set[Path] = set() @@ -391,12 +404,13 @@ class Library: tag_colors += default_color_groups.grayscale() tag_colors += default_color_groups.earth_tones() tag_colors += default_color_groups.neon() - try: - session.add_all(tag_colors) - session.commit() - except IntegrityError as e: - logger.error("[Library] Couldn't add default tag colors", error=e) - session.rollback() + if is_new: + try: + session.add_all(tag_colors) + session.commit() + except IntegrityError as e: + logger.error("[Library] Couldn't add default tag colors", error=e) + session.rollback() # Add default tags. if is_new: @@ -446,14 +460,14 @@ class Library: # Apply any post-SQL migration patches. if not is_new: - # NOTE: DB_VERSION 6 was first used in v9.5.0-pr1 if db_version == 6: self.apply_db6_patches(session) - else: - pass + if db_version >= 6 and db_version < 8: + self.apply_db7_patches(session) # Update DB_VERSION - self.set_prefs(LibraryPrefs.DB_VERSION, LibraryPrefs.DB_VERSION.default) + if LibraryPrefs.DB_VERSION.default > db_version: + self.set_prefs(LibraryPrefs.DB_VERSION, LibraryPrefs.DB_VERSION.default) # everything is fine, set the library path self.library_dir = library_dir @@ -462,9 +476,9 @@ class Library: def apply_db6_patches(self, session: Session): """Apply migration patches to a library with DB_VERSION 6. - DB_VERSION 6 was first used in v9.5.0-pr1. + DB_VERSION 6 was only used in v9.5.0-pr1. """ - logger.info("[Library] Applying patches to DB_VERSION: 6 library...") + logger.info("[Library][Migration] Applying patches to DB_VERSION: 6 library...") with session: # Repair "Description" fields with a TEXT_LINE key instead of a TEXT_BOX key. desc_stmd = ( @@ -487,6 +501,75 @@ class Library: session.commit() + def apply_db7_patches(self, session: Session): + """Apply migration patches to a library with DB_VERSION 7 or earlier. + + DB_VERSION 7 was used from v9.5.0-pr2 to v9.5.0-pr3. + """ + # TODO: Use Alembic for this part instead + # Add the missing color_border column to the TagColorGroups table. + color_border_stmt = text( + "ALTER TABLE tag_colors ADD COLUMN color_border BOOLEAN DEFAULT FALSE NOT NULL" + ) + try: + session.execute(color_border_stmt) + session.commit() + logger.info("[Library][Migration] Added color_border column to tag_colors table") + except Exception as e: + logger.error( + "[Library][Migration] Could not create color_border column in tag_colors table!", + error=e, + ) + session.rollback() + + tag_colors: list[TagColorGroup] = default_color_groups.standard() + tag_colors += default_color_groups.pastels() + tag_colors += default_color_groups.shades() + tag_colors += default_color_groups.grayscale() + tag_colors += default_color_groups.earth_tones() + # tag_colors += default_color_groups.neon() # NOTE: Neon is handled separately + + # Add any new default colors introduced in DB_VERSION 8 + for color in tag_colors: + try: + session.add(color) + logger.info( + "[Library][Migration] Migrated tag color to DB_VERSION 8+", + color_name=color.name, + ) + session.commit() + except IntegrityError: + session.rollback() + + # Update Neon colors to use the the color_border property + for color in default_color_groups.neon(): + try: + neon_stmt = ( + update(TagColorGroup) + .where( + and_( + TagColorGroup.namespace == color.namespace, + TagColorGroup.slug == color.slug, + ) + ) + .values( + slug=color.slug, + namespace=color.namespace, + name=color.name, + primary=color.primary, + secondary=color.secondary, + color_border=color.color_border, + ) + ) + session.execute(neon_stmt) + session.commit() + except IntegrityError as e: + logger.error( + "[Library] Could not migrate Neon colors to DB_VERSION 8+!", + error=e, + ) + session.rollback() + @property def default_fields(self) -> list[BaseField]: with Session(self.engine) as session: @@ -1088,6 +1171,80 @@ class Library: session.commit() return tags + def add_namespace(self, namespace: Namespace) -> bool: + """Add a namespace value to the library. + + Args: + namespace(str): The namespace slug. No special characters + """ + with Session(self.engine) as session: + if not namespace.namespace: + logger.warning("[LIBRARY][add_namespace] Namespace slug must not be empty") + return False + + slug = namespace.namespace + try: + slug = slugify(namespace.namespace) + except ReservedNamespaceError: + logger.error( + f"[LIBRARY][add_namespace] Will not add a namespace with the reserved prefix:" + f"{RESERVED_NAMESPACE_PREFIX}", + namespace=namespace, + ) + logger.error("Should not see me") + + namespace_obj = Namespace( + namespace=slug, + name=namespace.name, + ) + + try: + session.add(namespace_obj) + session.commit() + return True + except IntegrityError: + session.rollback() + logger.error("IntegrityError") + return False + + def delete_namespace(self, namespace: Namespace | str): + """Delete a namespace and any connected data from the library.""" + if isinstance(namespace, str): + if namespace.startswith(RESERVED_NAMESPACE_PREFIX): + raise ReservedNamespaceError + else: + if namespace.namespace.startswith(RESERVED_NAMESPACE_PREFIX): + raise ReservedNamespaceError + + with Session(self.engine, expire_on_commit=False) as session: + try: + namespace_: Namespace | None = None + if isinstance(namespace, str): + namespace_ = session.scalar( + select(Namespace).where(Namespace.namespace == namespace) + ) + else: + namespace_ = namespace + + if not namespace_: + raise Exception + session.delete(namespace_) + session.flush() + + colors = session.scalars( + select(TagColorGroup).where(TagColorGroup.namespace == namespace_.namespace) + ) + for color in colors: + session.delete(color) + session.flush() + + session.commit() + + except IntegrityError as e: + logger.error(e) + session.rollback() + return None + def add_tag( self, tag: Tag, @@ -1165,6 +1322,33 @@ class Library: session.rollback() return False + def add_color(self, color_group: TagColorGroup) -> TagColorGroup | None: + with Session(self.engine, expire_on_commit=False) as session: + try: + session.add(color_group) + session.commit() + session.expunge(color_group) + return color_group + + except IntegrityError as e: + logger.error( + "[Library] Could not add color, trying to update existing value instead.", + error=e, + ) + session.rollback() + return None + + def delete_color(self, color: TagColorGroup): + with Session(self.engine, expire_on_commit=False) as session: + try: + session.delete(color) + session.commit() + + except IntegrityError as e: + logger.error(e) + session.rollback() + return None + def save_library_backup_to_disk(self) -> Path: assert isinstance(self.library_dir, Path) makedirs(str(self.library_dir / TS_FOLDER_NAME / BACKUP_FOLDER_NAME), exist_ok=True) @@ -1280,6 +1464,55 @@ class Library: """Edit a Tag in the Library.""" self.add_tag(tag, parent_ids, alias_names, alias_ids) + def update_color(self, old_color_group: TagColorGroup, new_color_group: TagColorGroup) -> None: + """Update a TagColorGroup in the Library. If it doesn't already exist, create it.""" + with Session(self.engine) as session: + existing_color = session.scalar( + select(TagColorGroup).where( + and_( + TagColorGroup.namespace == old_color_group.namespace, + TagColorGroup.slug == old_color_group.slug, + ) + ) + ) + if existing_color: + update_color_stmt = ( + update(TagColorGroup) + .where( + and_( + TagColorGroup.namespace == old_color_group.namespace, + TagColorGroup.slug == old_color_group.slug, + ) + ) + .values( + slug=new_color_group.slug, + namespace=new_color_group.namespace, + name=new_color_group.name, + primary=new_color_group.primary, + secondary=new_color_group.secondary, + color_border=new_color_group.color_border, + ) + ) + session.execute(update_color_stmt) + session.flush() + update_tags_stmt = ( + update(Tag) + .where( + and_( + Tag.color_namespace == old_color_group.namespace, + Tag.color_slug == old_color_group.slug, + ) + ) + .values( + color_namespace=new_color_group.namespace, + color_slug=new_color_group.slug, + ) + ) + session.execute(update_tags_stmt) + session.commit() + else: + self.add_color(new_color_group) + def update_aliases(self, tag, alias_ids, alias_names, session): prev_aliases = session.scalars(select(TagAlias).where(TagAlias.tag_id == tag.id)).all() @@ -1379,7 +1612,28 @@ class Library: color_groups[color.namespace] = [] color_groups[color.namespace].append(color) session.expunge(color) - return color_groups + + # Add empty namespaces that are available for use. + empty_namespaces = session.scalars( + select(Namespace) + .where(Namespace.namespace.not_in(color_groups.keys())) + .order_by(asc(Namespace.namespace)) + ) + for en in empty_namespaces: + if not color_groups.get(en.namespace): + color_groups[en.namespace] = [] + session.expunge(en) + + return dict( + sorted(color_groups.items(), key=lambda kv: self.get_namespace_name(kv[0]).lower()) + ) + + @property + def namespaces(self) -> list[Namespace]: + """Return every Namespace in the library.""" + with Session(self.engine) as session: + namespaces = session.scalars(select(Namespace).order_by(asc(Namespace.name))) + return list(namespaces) def get_namespace_name(self, namespace: str) -> str: with Session(self.engine) as session: diff --git a/tagstudio/src/core/library/alchemy/models.py b/tagstudio/src/core/library/alchemy/models.py index e4d1edc1..0e77161d 100644 --- a/tagstudio/src/core/library/alchemy/models.py +++ b/tagstudio/src/core/library/alchemy/models.py @@ -63,6 +63,7 @@ class TagColorGroup(Base): name: Mapped[str] = mapped_column() primary: Mapped[str] = mapped_column(nullable=False) secondary: Mapped[str | None] + color_border: Mapped[bool] = mapped_column(nullable=False, default=False) # TODO: Determine if slug and namespace can be optional and generated/added here if needed. def __init__( @@ -72,6 +73,7 @@ class TagColorGroup(Base): name: str, primary: str, secondary: str | None = None, + color_border: bool = False, ): self.slug = slug self.namespace = namespace @@ -79,6 +81,7 @@ class TagColorGroup(Base): self.primary = primary if secondary: self.secondary = secondary + self.color_border = color_border super().__init__() diff --git a/tagstudio/src/qt/modals/build_color.py b/tagstudio/src/qt/modals/build_color.py new file mode 100644 index 00000000..a5c88b41 --- /dev/null +++ b/tagstudio/src/qt/modals/build_color.py @@ -0,0 +1,413 @@ +# Copyright (C) 2025 Travis Abendshien (CyanVoxel). +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + + +import structlog +from PySide6.QtCore import Qt, Signal +from PySide6.QtGui import QColor +from PySide6.QtWidgets import ( + QCheckBox, + QColorDialog, + QFormLayout, + QHBoxLayout, + QLabel, + QLineEdit, + QPushButton, + QVBoxLayout, + QWidget, +) +from src.core import palette +from src.core.library import Library +from src.core.library.alchemy.enums import TagColorEnum +from src.core.library.alchemy.library import slugify +from src.core.library.alchemy.models import TagColorGroup +from src.core.palette import ColorType, UiColor, get_tag_color, get_ui_color +from src.qt.translations import Translations +from src.qt.widgets.panel import PanelWidget +from src.qt.widgets.tag import ( + get_border_color, + get_highlight_color, + get_text_color, +) +from src.qt.widgets.tag_color_preview import TagColorPreview + +logger = structlog.get_logger(__name__) + + +class BuildColorPanel(PanelWidget): + on_edit = Signal(TagColorGroup) + + def __init__(self, library: Library, color_group: TagColorGroup): + super().__init__() + self.lib = library + self.color_group: TagColorGroup = color_group + self.tag_color_namespace: str | None + self.tag_color_slug: str | None + self.disambiguation_id: int | None + + self.known_colors: set[str] + self.update_known_colors() + + self.setMinimumSize(340, 240) + self.root_layout = QVBoxLayout(self) + self.root_layout.setContentsMargins(6, 0, 6, 0) + self.root_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + + self.form_container = QWidget() + self.form_layout = QFormLayout(self.form_container) + self.form_layout.setLabelAlignment(Qt.AlignmentFlag.AlignRight) + self.form_layout.setFormAlignment(Qt.AlignmentFlag.AlignLeft) + + # Preview Tag ---------------------------------------------------------- + self.preview_widget = QWidget() + self.preview_layout = QVBoxLayout(self.preview_widget) + self.preview_layout.setStretch(1, 1) + self.preview_layout.setContentsMargins(0, 0, 0, 6) + self.preview_layout.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.preview_button = TagColorPreview(self.lib, None) + self.preview_button.setEnabled(False) + self.preview_layout.addWidget(self.preview_button) + + # Name ----------------------------------------------------------------- + self.name_title = QLabel() + Translations.translate_qobject(self.name_title, "library_object.name") + self.name_field = QLineEdit() + self.name_field.setFixedHeight(24) + self.name_field.textChanged.connect(self.on_text_changed) + Translations.translate_with_setter( + self.name_field.setPlaceholderText, "library_object.name_required" + ) + self.form_layout.addRow(self.name_title, self.name_field) + + # Slug ----------------------------------------------------------------- + self.slug_title = QLabel() + Translations.translate_qobject(self.slug_title, "library_object.slug") + self.slug_field = QLineEdit() + self.slug_field.setEnabled(False) + self.slug_field.setFixedHeight(24) + Translations.translate_with_setter( + self.slug_field.setPlaceholderText, "library_object.slug_required" + ) + self.form_layout.addRow(self.slug_title, self.slug_field) + + # Primary -------------------------------------------------------------- + self.primary_title = QLabel() + Translations.translate_qobject(self.primary_title, "color.primary") + self.primary_button = QPushButton() + self.primary_button.setMinimumSize(44, 22) + self.primary_button.setMaximumHeight(22) + self.edit_primary_modal = QColorDialog() + self.primary_button.clicked.connect(self.primary_color_callback) + self.form_layout.addRow(self.primary_title, self.primary_button) + + # Secondary ------------------------------------------------------------ + self.secondary_widget = QWidget() + self.secondary_layout = QHBoxLayout(self.secondary_widget) + self.secondary_layout.setContentsMargins(0, 0, 0, 0) + self.secondary_layout.setSpacing(6) + self.secondary_title = QLabel() + Translations.translate_qobject(self.secondary_title, "color.secondary") + self.secondary_button = QPushButton() + self.secondary_button.setMinimumSize(44, 22) + self.secondary_button.setMaximumHeight(22) + self.edit_secondary_modal = QColorDialog() + self.secondary_button.clicked.connect(self.secondary_color_callback) + self.secondary_layout.addWidget(self.secondary_button) + + self.secondary_reset_button = QPushButton() + Translations.translate_qobject(self.secondary_reset_button, "generic.reset") + self.secondary_reset_button.clicked.connect(self.update_secondary) + self.secondary_layout.addWidget(self.secondary_reset_button) + self.secondary_layout.setStretch(0, 3) + self.secondary_layout.setStretch(1, 1) + self.form_layout.addRow(self.secondary_title, self.secondary_widget) + + # Color Border --------------------------------------------------------- + self.border_widget = QWidget() + self.border_layout = QHBoxLayout(self.border_widget) + self.border_layout.setStretch(1, 1) + self.border_layout.setContentsMargins(0, 0, 0, 0) + self.border_layout.setSpacing(6) + self.border_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) + self.border_checkbox = QCheckBox() + self.border_checkbox.setFixedSize(22, 22) + self.border_checkbox.clicked.connect( + lambda checked: self.update_secondary( + color=QColor(self.preview_button.tag_color_group.secondary) + if self.preview_button.tag_color_group.secondary + else None, + color_border=checked, + ) + ) + self.border_layout.addWidget(self.border_checkbox) + self.border_label = QLabel() + Translations.translate_qobject(self.border_label, "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"}}" + ) + + # Add Widgets to Layout ================================================ + self.root_layout.addWidget(self.preview_widget) + self.root_layout.addWidget(self.form_container) + self.root_layout.addWidget(self.border_widget) + + self.set_color(color_group or TagColorGroup("", "", Translations["color.new"], "")) + self.update_primary(QColor(color_group.primary)) + self.update_secondary(None if not color_group.secondary else QColor(color_group.secondary)) + self.on_text_changed() + + def set_color(self, color_group: TagColorGroup): + logger.info("[BuildColorPanel] Setting Color", color=color_group) + self.color_group = color_group + + self.preview_button.set_tag_color_group(color_group) + self.name_field.setText(color_group.name) + self.primary_button.setText(color_group.primary) + self.edit_primary_modal.setCurrentColor(color_group.primary) + self.secondary_button.setText( + Translations["color.title.no_color"] + if not color_group.secondary + else str(color_group.secondary) + ) + self.edit_secondary_modal.setCurrentColor(color_group.secondary or QColor(0, 0, 0, 255)) + self.border_checkbox.setChecked(color_group.color_border) + + def primary_color_callback(self) -> None: + initial = ( + self.primary_button.text() + if self.primary_button.text().startswith("#") + else self.color_group.primary + ) + color = self.edit_primary_modal.getColor(initial=initial) + if color.isValid(): + self.update_primary(color) + self.preview_button.set_tag_color_group(self.build_color()[1]) + else: + logger.info("[BuildColorPanel] Primary color selection was cancelled!") + + def secondary_color_callback(self) -> None: + initial = ( + self.secondary_button.text() + if self.secondary_button.text().startswith("#") + else (self.color_group.secondary or QColor()) + ) + color = self.edit_secondary_modal.getColor(initial=initial) + if color.isValid(): + self.update_secondary(color) + self.preview_button.set_tag_color_group(self.build_color()[1]) + else: + logger.info("[BuildColorPanel] Secondary color selection was cancelled!") + + 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.preview_button.set_tag_color_group(self.build_color()[1]) + + def update_secondary(self, color: QColor | None = None, color_border: bool = False): + logger.info("[BuildColorPanel] Updating Secondary", color=color) + + color_ = color or QColor(palette.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.preview_button.set_tag_color_group(self.build_color()[1]) + + def update_known_colors(self): + groups = self.lib.tag_color_groups + colors = groups.get(self.color_group.namespace, []) + self.known_colors = {c.slug for c in colors} + self.known_colors = self.known_colors.difference(self.color_group.slug) + + def update_preview_text(self): + self.preview_button.button.setText( + f"{self.name_field.text().strip() or Translations["color.placeholder"]} " + f"({self.lib.get_namespace_name(self.color_group.namespace)})" + ) + self.preview_button.button.setMaximumWidth(self.preview_button.button.sizeHint().width()) + + def no_collide(self, slug: str) -> str: + """Return a slug name that's verified not to collide with other known color slugs.""" + if slug and slug in self.known_colors: + split_slug: list[str] = slug.rsplit("-", 1) + suffix: str = "" + if len(split_slug) > 1: + suffix = split_slug[1] + + if suffix: + try: + suffix_num: int = int(suffix) + return self.no_collide(f"{split_slug[0]}-{suffix_num+1}") + except ValueError: + return self.no_collide(f"{slug}-2") + else: + return self.no_collide(f"{slug}-2") + return slug + + def on_text_changed(self): + slug = self.no_collide(slugify(self.name_field.text().strip(), allow_reserved=True)) + + is_name_empty = not self.name_field.text().strip() + 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.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.setText(slug) + self.update_preview_text() + + if self.panel_save_button is not None: + self.panel_save_button.setDisabled(is_name_empty) + + def build_color(self) -> tuple[TagColorGroup, TagColorGroup]: + name = self.name_field.text() + slug = self.slug_field.text() + primary: str = self.primary_button.text() + secondary: str | None = ( + self.secondary_button.text() if self.secondary_button.text().startswith("#") else None + ) + color_border: bool = self.border_checkbox.isChecked() + + new_color = TagColorGroup( + slug=slug, + namespace=self.color_group.namespace, + name=name, + primary=primary, + secondary=secondary, + color_border=color_border, + ) + + logger.info( + "[BuildColorPanel] Built Color", + slug=new_color.slug, + namespace=new_color.namespace, + name=new_color.name, + primary=new_color.primary, + secondary=new_color.secondary, + color_border=new_color.color_border, + ) + return (self.color_group, new_color) + + 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/tagstudio/src/qt/modals/build_namespace.py b/tagstudio/src/qt/modals/build_namespace.py new file mode 100644 index 00000000..555b7816 --- /dev/null +++ b/tagstudio/src/qt/modals/build_namespace.py @@ -0,0 +1,176 @@ +# Copyright (C) 2025 Travis Abendshien (CyanVoxel). +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + + +from uuid import uuid4 + +import structlog +from PySide6.QtCore import Qt, Signal +from PySide6.QtWidgets import ( + QLabel, + QLineEdit, + QVBoxLayout, + QWidget, +) +from src.core.constants import RESERVED_NAMESPACE_PREFIX +from src.core.library import Library +from src.core.library.alchemy.library import ReservedNamespaceError, slugify +from src.core.library.alchemy.models import Namespace +from src.core.palette import ColorType, UiColor, get_ui_color +from src.qt.translations import Translations +from src.qt.widgets.panel import PanelWidget + +logger = structlog.get_logger(__name__) + + +class BuildNamespacePanel(PanelWidget): + on_edit = Signal(Namespace) + + def __init__(self, library: Library, namespace: Namespace | None = None): + super().__init__() + self.lib = library + self.namespace: Namespace | None = namespace + + self.known_namespaces: set[str] + self.update_known_namespaces() + + self.setMinimumSize(360, 260) + self.root_layout = QVBoxLayout(self) + self.root_layout.setContentsMargins(6, 0, 6, 0) + self.root_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + + # 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.translate_qobject(self.name_title, "library_object.name") + self.name_layout.addWidget(self.name_title) + self.name_field = QLineEdit() + self.name_field.setFixedHeight(24) + self.name_field.textChanged.connect(self.on_text_changed) + Translations.translate_with_setter( + self.name_field.setPlaceholderText, "library_object.name_required" + ) + self.name_layout.addWidget(self.name_field) + + # Slug ----------------------------------------------------------------- + self.slug_widget = QWidget() + self.slug_layout = QVBoxLayout(self.slug_widget) + self.slug_layout.setStretch(1, 1) + self.slug_layout.setContentsMargins(0, 0, 0, 0) + self.slug_layout.setSpacing(0) + self.slug_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) + self.slug_title = QLabel() + Translations.translate_qobject(self.slug_title, "library_object.slug") + self.slug_layout.addWidget(self.slug_title) + self.slug_field = QLineEdit() + self.slug_field.setFixedHeight(24) + self.slug_field.setEnabled(False) + Translations.translate_with_setter( + self.slug_field.setPlaceholderText, "library_object.slug_required" + ) + self.slug_layout.addWidget(self.slug_field) + + # Description ---------------------------------------------------------- + self.desc_label = QLabel() + self.desc_label.setWordWrap(True) + Translations.translate_with_setter(self.desc_label.setText, "namespace.create.description") + self.desc_color_label = QLabel() + self.desc_color_label.setWordWrap(True) + Translations.translate_with_setter( + self.desc_color_label.setText, "namespace.create.description_color" + ) + + # Add Widgets to Layout ================================================ + self.root_layout.addWidget(self.name_widget) + self.root_layout.addWidget(self.slug_widget) + self.root_layout.addSpacing(12) + self.root_layout.addWidget(self.desc_label) + self.root_layout.addSpacing(6) + self.root_layout.addWidget(self.desc_color_label) + + self.set_namespace(namespace) + + def set_namespace(self, namespace: Namespace | None): + logger.info("[BuildNamespacePanel] Setting Namespace", namespace=namespace) + self.namespace = namespace + + if namespace: + self.name_field.setText(namespace.name) + self.slug_field.setText(namespace.namespace) + else: + self.name_field.setText("User Colors") + + def update_known_namespaces(self): + namespaces = self.lib.namespaces + self.known_namespaces = {n.namespace for n in namespaces} + if self.namespace: + self.known_namespaces = self.known_namespaces.difference(self.namespace.namespace) + + def on_text_changed(self): + slug = "" + try: + slug = self.no_collide(slugify(self.name_field.text().strip())) + except ReservedNamespaceError: + raw_name = self.name_field.text().strip().lower() + raw_name = raw_name.replace(RESERVED_NAMESPACE_PREFIX, str(uuid4()).split("-", 1)[0]) + slug = self.no_collide(slugify(raw_name)) + + is_name_empty = not self.name_field.text().strip() + 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.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.setText(slug) + + if self.panel_save_button is not None: + self.panel_save_button.setDisabled(is_name_empty) + + def no_collide(self, slug: str) -> str: + """Return a slug name that's verified not to collide with other known namespace slugs.""" + if slug and slug in self.known_namespaces: + split_slug: list[str] = slug.rsplit("-", 1) + suffix: str = "" + if len(split_slug) > 1: + suffix = split_slug[1] + + if suffix: + try: + suffix_num: int = int(suffix) + return self.no_collide(f"{split_slug[0]}-{suffix_num+1}") + except ValueError: + return self.no_collide(f"{slug}-2") + else: + return self.no_collide(f"{slug}-2") + return slug + + def build_namespace(self) -> Namespace: + name = self.name_field.text() + slug_raw = self.slug_field.text() + slug = slugify(slug_raw) + + namespace = Namespace(namespace=slug, name=name) + + logger.info("[BuildNamespacePanel] Built Namespace", slug=slug, name=name) + return namespace + + def parent_post_init(self): + self.setTabOrder(self.name_field, self.slug_field) + self.name_field.selectAll() + self.name_field.setFocus() diff --git a/tagstudio/src/qt/modals/build_tag.py b/tagstudio/src/qt/modals/build_tag.py index 755ba4fa..2732a43a 100644 --- a/tagstudio/src/qt/modals/build_tag.py +++ b/tagstudio/src/qt/modals/build_tag.py @@ -189,11 +189,11 @@ class BuildTagPanel(PanelWidget): self.color_layout.addWidget(self.color_title) self.color_button: TagColorPreview try: - self.color_button = TagColorPreview(tag.color) + self.color_button = TagColorPreview(self.lib, tag.color) except Exception as e: # TODO: Investigate why this happens during tests logger.error("[BuildTag] Could not access Tag member attributes", error=e) - self.color_button = TagColorPreview(None) + self.color_button = TagColorPreview(self.lib, None) self.tag_color_selection = TagColorSelection(self.lib) chose_tag_color_title = Translations.translate_formatted("tag.choose_color") self.choose_color_modal = PanelModal( @@ -215,7 +215,7 @@ class BuildTagPanel(PanelWidget): self.cat_layout.setSpacing(6) self.cat_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) self.cat_title = QLabel() - self.cat_title.setText("Is Category") + Translations.translate_qobject(self.cat_title, "tag.is_category") self.cat_checkbox = QCheckBox() self.cat_checkbox.setFixedSize(22, 22) @@ -245,6 +245,10 @@ class BuildTagPanel(PanelWidget): 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_layout.addWidget(self.cat_checkbox) self.cat_layout.addWidget(self.cat_title) @@ -372,7 +376,7 @@ class BuildTagPanel(PanelWidget): primary_color = get_primary_color(tag) border_color = ( get_border_color(primary_color) - if not (tag.color and tag.color.secondary) + if not (tag.color and tag.color.secondary and tag.color.color_border) else (QColor(tag.color.secondary)) ) highlight_color = get_highlight_color( diff --git a/tagstudio/src/qt/modals/tag_color_manager.py b/tagstudio/src/qt/modals/tag_color_manager.py new file mode 100644 index 00000000..9b5f6b94 --- /dev/null +++ b/tagstudio/src/qt/modals/tag_color_manager.py @@ -0,0 +1,226 @@ +# Copyright (C) 2025 Travis Abendshien (CyanVoxel). +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + +from typing import TYPE_CHECKING, Callable, override + +import structlog +from PySide6 import QtCore, QtGui +from PySide6.QtCore import Qt +from PySide6.QtGui import QGuiApplication +from PySide6.QtWidgets import ( + QFrame, + QHBoxLayout, + QLabel, + QMessageBox, + QPushButton, + QScrollArea, + QSizePolicy, + QVBoxLayout, + QWidget, +) +from src.core.constants import RESERVED_NAMESPACE_PREFIX +from src.core.enums import Theme +from src.qt.modals.build_namespace import BuildNamespacePanel +from src.qt.translations import Translations +from src.qt.widgets.color_box import ColorBoxWidget +from src.qt.widgets.fields import FieldContainer +from src.qt.widgets.panel import PanelModal + +logger = structlog.get_logger(__name__) + +# Only import for type checking/autocompletion, will not be imported at runtime. +if TYPE_CHECKING: + from src.qt.ts_qt import QtDriver + + +class TagColorManager(QWidget): + create_namespace_modal: PanelModal | None = None + + def __init__( + self, + driver: "QtDriver", + ): + super().__init__() + self.driver = driver + self.lib = driver.lib + Translations.translate_with_setter(self.setWindowTitle, "color_manager.title") + self.setWindowModality(Qt.WindowModality.ApplicationModal) + self.setMinimumSize(800, 600) + self.is_initialized = False + self.root_layout = QVBoxLayout(self) + self.root_layout.setContentsMargins(6, 6, 6, 6) + + panel_bg_color = ( + Theme.COLOR_BG_DARK.value + if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark + else Theme.COLOR_BG_LIGHT.value + ) + + 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.scroll_layout = QVBoxLayout() + self.scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + self.scroll_layout.setContentsMargins(3, 3, 3, 3) + self.scroll_layout.setSpacing(0) + + scroll_container: QWidget = QWidget() + scroll_container.setObjectName("entryScrollContainer") + scroll_container.setLayout(self.scroll_layout) + + self.scroll_area = QScrollArea() + self.scroll_area.setObjectName("entryScrollArea") + self.scroll_area.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignCenter) + self.scroll_area.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) + self.scroll_area.setWidgetResizable(True) + self.scroll_area.setFrameShadow(QFrame.Shadow.Plain) + self.scroll_area.setFrameShape(QFrame.Shape.NoFrame) + + self.scroll_area.setStyleSheet( + "QWidget#entryScrollContainer{" f"background:{panel_bg_color};" "border-radius:6px;" "}" + ) + self.scroll_area.setWidget(scroll_container) + + self.setup_color_groups() + + self.button_container = QWidget() + self.button_layout = QHBoxLayout(self.button_container) + self.button_layout.setContentsMargins(6, 6, 6, 6) + + self.new_namespace_button = QPushButton() + Translations.translate_qobject(self.new_namespace_button, "namespace.new.button") + self.new_namespace_button.clicked.connect(self.create_namespace) + self.button_layout.addWidget(self.new_namespace_button) + + # self.import_pack_button = QPushButton() + # Translations.translate_qobject(self.import_pack_button, "color.import_pack") + # self.button_layout.addWidget(self.import_pack_button) + + self.button_layout.addStretch(1) + + self.done_button = QPushButton() + Translations.translate_qobject(self.done_button, "generic.done_alt") + self.done_button.clicked.connect(self.hide) + self.button_layout.addWidget(self.done_button) + + self.root_layout.addWidget(self.title_label) + self.root_layout.addWidget(self.scroll_area) + self.root_layout.addWidget(self.button_container) + logger.info(self.root_layout.dumpObjectTree()) + + def setup_color_groups(self): + all_default = True + if self.driver.lib.engine: + for group, colors in self.driver.lib.tag_color_groups.items(): + if not group.startswith(RESERVED_NAMESPACE_PREFIX): + all_default = False + color_box = ColorBoxWidget(group, colors, self.driver.lib) + color_box.updated.connect( + lambda: ( + self.reset(), + self.setup_color_groups(), + () + if len(self.driver.selected) < 1 + else self.driver.preview_panel.fields.update_from_entry( + self.driver.selected[0], update_badges=False + ), + ) + ) + field_container = FieldContainer(self.driver.lib.get_namespace_name(group)) + field_container.set_inner_widget(color_box) + if not group.startswith(RESERVED_NAMESPACE_PREFIX): + field_container.set_remove_callback( + lambda checked=False, g=group: self.delete_namespace_dialog( + prompt=Translations["color.namespace.delete.prompt"], + callback=lambda namespace=g: ( + self.lib.delete_namespace(namespace), + self.reset(), + self.setup_color_groups(), + () + if len(self.driver.selected) < 1 + else self.driver.preview_panel.fields.update_from_entry( + self.driver.selected[0], update_badges=False + ), + ), + ) + ) + + self.scroll_layout.addWidget(field_container) + + if all_default: + ns_container = QWidget() + ns_layout = QHBoxLayout(ns_container) + ns_layout.setAlignment(Qt.AlignmentFlag.AlignCenter) + ns_layout.setContentsMargins(0, 18, 0, 18) + namespace_prompt = QPushButton() + Translations.translate_qobject(namespace_prompt, "namespace.new.prompt") + namespace_prompt.setFixedSize(namespace_prompt.sizeHint().width() + 8, 24) + namespace_prompt.clicked.connect(self.create_namespace) + ns_layout.addWidget(namespace_prompt) + self.scroll_layout.addWidget(ns_container) + + self.is_initialized = True + + def reset(self): + while self.scroll_layout.count(): + widget = self.scroll_layout.itemAt(0).widget() + self.scroll_layout.removeWidget(widget) + widget.deleteLater() + self.is_initialized = False + + def create_namespace(self): + build_namespace_panel = BuildNamespacePanel(self.lib) + + self.create_namespace_modal = PanelModal( + build_namespace_panel, + Translations["namespace.create.title"], + Translations["namespace.create.title"], + has_save=True, + ) + + self.create_namespace_modal.saved.connect( + lambda: ( + self.lib.add_namespace(build_namespace_panel.build_namespace()), + self.reset(), + self.setup_color_groups(), + ) + ) + + self.create_namespace_modal.show() + + def delete_namespace_dialog(self, prompt: str, callback: Callable) -> None: + message_box = QMessageBox() + message_box.setText(prompt) + Translations.translate_with_setter( + message_box.setWindowTitle, "color.namespace.delete.title" + ) + message_box.setIcon(QMessageBox.Icon.Warning) + cancel_button = message_box.addButton( + Translations["generic.cancel_alt"], QMessageBox.ButtonRole.RejectRole + ) + message_box.addButton( + Translations["generic.delete_alt"], QMessageBox.ButtonRole.DestructiveRole + ) + message_box.setEscapeButton(cancel_button) + result = message_box.exec_() + if result != QMessageBox.ButtonRole.ActionRole.value: + return + callback() + + @override + def showEvent(self, event: QtGui.QShowEvent) -> None: # noqa N802 + if not self.is_initialized: + self.setup_color_groups() + return super().showEvent(event) + + @override + def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: # noqa N802 + 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: + self.done_button.click() + return super().keyPressEvent(event) diff --git a/tagstudio/src/qt/modals/tag_color_selection.py b/tagstudio/src/qt/modals/tag_color_selection.py index 98cd9fab..f0f1585b 100644 --- a/tagstudio/src/qt/modals/tag_color_selection.py +++ b/tagstudio/src/qt/modals/tag_color_selection.py @@ -8,21 +8,25 @@ from PySide6.QtCore import Qt from PySide6.QtGui import QColor from PySide6.QtWidgets import ( QButtonGroup, + QFrame, QLabel, QRadioButton, + QScrollArea, + QSizePolicy, QSpacerItem, QVBoxLayout, QWidget, ) from src.core.library import Library +from src.core.library.alchemy.enums import TagColorEnum from src.core.library.alchemy.models import TagColorGroup +from src.core.palette import ColorType, get_tag_color from src.qt.flowlayout import FlowLayout from src.qt.translations import Translations from src.qt.widgets.panel import PanelWidget -from src.qt.widgets.tag_color_preview import ( +from src.qt.widgets.tag import ( get_border_color, get_highlight_color, - get_primary_color, get_text_color, ) @@ -37,19 +41,37 @@ class TagColorSelection(PanelWidget): self.setMinimumSize(308, 540) self.root_layout = QVBoxLayout(self) - self.root_layout.setContentsMargins(6, 0, 6, 0) - self.root_layout.setSpacing(6) - self.root_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + self.root_layout.setContentsMargins(0, 0, 0, 0) + self.root_layout.setSpacing(0) + + self.scroll_layout = QVBoxLayout() + self.scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + self.scroll_layout.setContentsMargins(6, 0, 6, 0) + self.scroll_layout.setSpacing(3) + + scroll_container: QWidget = QWidget() + scroll_container.setObjectName("entryScrollContainer") + scroll_container.setLayout(self.scroll_layout) + + self.scroll_area = QScrollArea() + self.scroll_area.setObjectName("entryScrollArea") + self.scroll_area.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) + self.scroll_area.setWidgetResizable(True) + self.scroll_area.setFrameShadow(QFrame.Shadow.Plain) + self.scroll_area.setFrameShape(QFrame.Shape.NoFrame) + self.scroll_area.setWidget(scroll_container) + self.root_layout.addWidget(self.scroll_area) # Add Widgets to Layout ================================================ tag_color_groups = self.lib.tag_color_groups self.button_group = QButtonGroup(self) self.add_no_color_widget() - self.root_layout.addSpacerItem(QSpacerItem(1, 12)) + 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.root_layout.addWidget( + self.scroll_layout.addWidget( QLabel(f"

{display_name if display_name else group}

") ) color_box_widget = QWidget() @@ -59,10 +81,10 @@ class TagColorSelection(PanelWidget): color_group_layout.setContentsMargins(0, 0, 0, 0) color_box_widget.setLayout(color_group_layout) for color in colors: - primary_color = get_primary_color(color) + primary_color = self._get_primary_color(color) border_color = ( get_border_color(primary_color) - if not (color and color.secondary) + if not (color and color.secondary and color.color_border) else (QColor(color.secondary)) ) highlight_color = get_highlight_color( @@ -78,11 +100,15 @@ class TagColorSelection(PanelWidget): 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;" @@ -99,16 +125,22 @@ class TagColorSelection(PanelWidget): 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"}}" ) radio_button.clicked.connect(lambda checked=False, x=color: self.select_color(x)) color_group_layout.addWidget(radio_button) self.button_group.addButton(radio_button) - self.root_layout.addWidget(color_box_widget) - self.root_layout.addSpacerItem(QSpacerItem(1, 12)) + self.scroll_layout.addWidget(color_box_widget) + self.scroll_layout.addSpacerItem(QSpacerItem(1, 6)) def add_no_color_widget(self): no_color_str: str = Translations.translate_formatted("color.title.no_color") - self.root_layout.addWidget(QLabel(f"

{no_color_str}

")) + self.scroll_layout.addWidget(QLabel(f"

{no_color_str}

")) color_box_widget = QWidget() color_group_layout = FlowLayout() color_group_layout.setSpacing(4) @@ -116,11 +148,11 @@ class TagColorSelection(PanelWidget): color_group_layout.setContentsMargins(0, 0, 0, 0) color_box_widget.setLayout(color_group_layout) color = None - primary_color = get_primary_color(color) + primary_color = self._get_primary_color(color) border_color = get_border_color(primary_color) highlight_color = get_highlight_color(primary_color) text_color: QColor - if color and color.secondary: + if color and color.secondary and color.color_border: text_color = QColor(color.secondary) else: text_color = get_text_color(primary_color, highlight_color) @@ -150,13 +182,19 @@ class TagColorSelection(PanelWidget): 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"}}" ) radio_button.clicked.connect(lambda checked=False, x=color: self.select_color(x)) color_group_layout.addWidget(radio_button) self.button_group.addButton(radio_button) - self.root_layout.addWidget(color_box_widget) + self.scroll_layout.addWidget(color_box_widget) - def select_color(self, color: TagColorGroup): + def select_color(self, color: TagColorGroup | None): self.selected_color = color def select_radio_button(self, color: TagColorGroup | None): @@ -164,4 +202,13 @@ class TagColorSelection(PanelWidget): for button in self.button_group.buttons(): if button.objectName() == object_name: button.setChecked(True) + self.select_color(color) break + + def _get_primary_color(self, tag_color_group: TagColorGroup | None) -> QColor: + primary_color = QColor( + get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT) + if not tag_color_group + else tag_color_group.primary + ) + return primary_color diff --git a/tagstudio/src/qt/modals/tag_database.py b/tagstudio/src/qt/modals/tag_database.py index 3143fbc3..b552d8d6 100644 --- a/tagstudio/src/qt/modals/tag_database.py +++ b/tagstudio/src/qt/modals/tag_database.py @@ -57,7 +57,7 @@ class TagDatabasePanel(TagSearchPanel): ) self.modal.show() - def remove_tag(self, tag: Tag): + def delete_tag(self, tag: Tag): if tag.id in range(RESERVED_TAG_START, RESERVED_TAG_END): return diff --git a/tagstudio/src/qt/modals/tag_search.py b/tagstudio/src/qt/modals/tag_search.py index af559500..c86f5500 100644 --- a/tagstudio/src/qt/modals/tag_search.py +++ b/tagstudio/src/qt/modals/tag_search.py @@ -284,7 +284,7 @@ class TagSearchPanel(PanelWidget): tag_id = tag.id tag_widget.on_edit.connect(lambda t=tag: self.edit_tag(t)) - tag_widget.on_remove.connect(lambda t=tag: self.remove_tag(t)) + tag_widget.on_remove.connect(lambda t=tag: self.delete_tag(t)) tag_widget.bg_button.clicked.connect(lambda: self.tag_chosen.emit(tag_id)) if self.driver: @@ -348,7 +348,7 @@ class TagSearchPanel(PanelWidget): self.search_field.setFocus() self.search_field.selectAll() - def remove_tag(self, tag: Tag): + def delete_tag(self, tag: Tag): pass def edit_tag(self, tag: Tag): diff --git a/tagstudio/src/qt/ts_qt.py b/tagstudio/src/qt/ts_qt.py index 166b0229..8e64c27d 100644 --- a/tagstudio/src/qt/ts_qt.py +++ b/tagstudio/src/qt/ts_qt.py @@ -87,6 +87,7 @@ from src.qt.modals.file_extension import FileExtensionModal from src.qt.modals.fix_dupes import FixDupeFilesModal from src.qt.modals.fix_unlinked import FixUnlinkedEntriesModal from src.qt.modals.folders_to_tags import FoldersToTagsModal +from src.qt.modals.tag_color_manager import TagColorManager from src.qt.modals.tag_database import TagDatabasePanel from src.qt.modals.tag_search import TagSearchPanel from src.qt.platform_strings import trash_term @@ -140,11 +141,12 @@ class QtDriver(DriverMixin, QObject): SIGTERM = Signal() - preview_panel: PreviewPanel - tag_manager_panel: PanelModal + preview_panel: PreviewPanel | None = None + tag_manager_panel: PanelModal | None = None + color_manager_panel: TagColorManager | None = None file_extension_panel: PanelModal | None = None - tag_search_panel: TagSearchPanel - add_tag_modal: PanelModal + tag_search_panel: TagSearchPanel | None = None + add_tag_modal: PanelModal | None = None lib: Library @@ -309,6 +311,9 @@ class QtDriver(DriverMixin, QObject): self.tag_manager_panel.setWindowTitle, "tag_manager.title" ) + # Initialize the Color Group Manager panel + self.color_manager_panel = TagColorManager(self) + # Initialize the Tag Search panel self.tag_search_panel = TagSearchPanel(self.lib, is_tag_chooser=True) self.tag_search_panel.set_driver(self) @@ -533,6 +538,12 @@ class QtDriver(DriverMixin, QObject): self.tag_manager_action.setToolTip("Ctrl+M") edit_menu.addAction(self.tag_manager_action) + self.color_manager_action = QAction(menu_bar) + Translations.translate_qobject(self.color_manager_action, "edit.color_manager") + self.color_manager_action.triggered.connect(self.color_manager_panel.show) + self.color_manager_action.setEnabled(False) + edit_menu.addAction(self.color_manager_action) + # View Menu ============================================================ show_libs_list_action = QAction(menu_bar) Translations.translate_qobject(show_libs_list_action, "settings.show_recent_libraries") @@ -857,6 +868,8 @@ class QtDriver(DriverMixin, QObject): self.selected.clear() self.frame_content.clear() [x.set_mode(None) for x in self.item_thumbs] + if self.color_manager_panel: + self.color_manager_panel.reset() self.set_clipboard_menu_viability() self.set_select_actions_visibility() @@ -869,6 +882,7 @@ class QtDriver(DriverMixin, QObject): self.close_library_action.setEnabled(False) self.refresh_dir_action.setEnabled(False) self.tag_manager_action.setEnabled(False) + self.color_manager_action.setEnabled(False) self.manage_file_ext_action.setEnabled(False) self.new_tag_action.setEnabled(False) self.fix_unlinked_entries_action.setEnabled(False) @@ -1880,6 +1894,7 @@ class QtDriver(DriverMixin, QObject): self.close_library_action.setEnabled(True) self.refresh_dir_action.setEnabled(True) self.tag_manager_action.setEnabled(True) + self.color_manager_action.setEnabled(True) self.manage_file_ext_action.setEnabled(True) self.new_tag_action.setEnabled(True) self.fix_dupe_files_action.setEnabled(True) diff --git a/tagstudio/src/qt/widgets/color_box.py b/tagstudio/src/qt/widgets/color_box.py new file mode 100644 index 00000000..ac80b1b8 --- /dev/null +++ b/tagstudio/src/qt/widgets/color_box.py @@ -0,0 +1,166 @@ +# Copyright (C) 2025 Travis Abendshien (CyanVoxel). +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + + +import typing +from collections.abc import Iterable + +import structlog +from PySide6.QtCore import Signal +from PySide6.QtWidgets import QMessageBox, QPushButton +from src.core.constants import RESERVED_NAMESPACE_PREFIX +from src.core.library.alchemy.enums import TagColorEnum +from src.core.library.alchemy.models import TagColorGroup +from src.core.palette import ColorType, get_tag_color +from src.qt.flowlayout import FlowLayout +from src.qt.modals.build_color import BuildColorPanel +from src.qt.translations import Translations +from src.qt.widgets.fields import FieldWidget +from src.qt.widgets.panel import PanelModal +from src.qt.widgets.tag_color_label import TagColorLabel + +if typing.TYPE_CHECKING: + from src.core.library import Library + +logger = structlog.get_logger(__name__) + + +class ColorBoxWidget(FieldWidget): + updated = Signal() + + def __init__( + self, + group: str, + colors: list["TagColorGroup"], + library: "Library", + ) -> None: + self.namespace = group + self.colors: list[TagColorGroup] = colors + self.lib: Library = library + + 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) + self.base_layout.setContentsMargins(0, 0, 0, 0) + self.setLayout(self.base_layout) + + self.set_colors(self.colors) + + def set_colors(self, colors: Iterable[TagColorGroup]): + colors_ = sorted( + list(colors), key=lambda color: self.lib.get_namespace_name(color.namespace) + ) + is_mutable = not self.namespace.startswith(RESERVED_NAMESPACE_PREFIX) + max_width = 60 + color_widgets: list[TagColorLabel] = [] + + while self.base_layout.itemAt(0): + self.base_layout.takeAt(0).widget().deleteLater() + + for color in colors_: + color_widget = TagColorLabel( + color=color, + has_edit=is_mutable, + has_remove=is_mutable, + library=self.lib, + ) + hint = color_widget.sizeHint().width() + if hint > max_width: + max_width = hint + color_widget.on_click.connect(lambda c=color: self.edit_color(c)) + color_widget.on_remove.connect(lambda c=color: self.delete_color(c)) + + color_widgets.append(color_widget) + self.base_layout.addWidget(color_widget) + + for color_widget in color_widgets: + color_widget.setFixedWidth(max_width) + + if is_mutable: + add_button = QPushButton() + add_button.setText("+") + add_button.setFlat(True) + add_button.setFixedSize(22, 22) + add_button.setStyleSheet(self.add_button_stylesheet) + add_button.clicked.connect( + lambda: self.edit_color( + TagColorGroup( + slug="slug", + namespace=self.namespace, + name="Color", + primary="#000000", + secondary=None, + ) + ) + ) + self.base_layout.addWidget(add_button) + + def edit_color(self, color_group: TagColorGroup): + build_color_panel = BuildColorPanel(self.lib, color_group) + + self.edit_modal = PanelModal( + build_color_panel, + "Edit Color", + "Edit Color", + has_save=True, + ) + + self.edit_modal.saved.connect( + lambda: (self.lib.update_color(*build_color_panel.build_color()), self.updated.emit()) + ) + self.edit_modal.show() + + def delete_color(self, color_group: TagColorGroup): + message_box = QMessageBox() + Translations.translate_with_setter(message_box.setWindowTitle, "color.delete") + Translations.translate_qobject( + message_box, "color.confirm_delete", color_name=color_group.name + ) + message_box.setIcon(QMessageBox.Icon.Warning) + cancel_button = message_box.addButton( + Translations["generic.cancel_alt"], QMessageBox.ButtonRole.RejectRole + ) + message_box.addButton( + Translations["generic.delete_alt"], QMessageBox.ButtonRole.DestructiveRole + ) + message_box.setEscapeButton(cancel_button) + result = message_box.exec_() + logger.info(QMessageBox.ButtonRole.DestructiveRole.value) + if result != QMessageBox.ButtonRole.ActionRole.value: + return + + logger.info("[ColorBoxWidget] Removing color", color=color_group) + self.lib.delete_color(color_group) + self.updated.emit() diff --git a/tagstudio/src/qt/widgets/fields.py b/tagstudio/src/qt/widgets/fields.py index f3bd5dbf..9cda274b 100644 --- a/tagstudio/src/qt/widgets/fields.py +++ b/tagstudio/src/qt/widgets/fields.py @@ -8,12 +8,15 @@ from pathlib import Path from typing import Callable from warnings import catch_warnings +import structlog from PIL import Image, ImageQt from PySide6.QtCore import QEvent, Qt -from PySide6.QtGui import QEnterEvent, QPixmap +from PySide6.QtGui import QEnterEvent, QPixmap, QResizeEvent from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget from src.core.enums import Theme +logger = structlog.get_logger(__name__) + class FieldContainer(QWidget): # TODO: reference a resources folder rather than path.parents[3]? @@ -78,7 +81,6 @@ class FieldContainer(QWidget): self.title_widget = QLabel() self.title_widget.setMinimumHeight(button_size) - self.title_widget.setMinimumWidth(200) self.title_widget.setObjectName("fieldTitle") self.title_widget.setWordWrap(True) self.title_widget.setText(title) @@ -123,6 +125,7 @@ class FieldContainer(QWidget): self.field.setLayout(self.field_layout) self.inner_layout.addWidget(self.field) + self.set_title(title) self.setStyleSheet(FieldContainer.container_style) def set_copy_callback(self, callback: Callable | None = None): @@ -188,6 +191,10 @@ class FieldContainer(QWidget): self.remove_button.setHidden(True) return super().leaveEvent(event) + def resizeEvent(self, event: QResizeEvent) -> None: # noqa: N802 + self.title_widget.setFixedWidth(int(event.size().width() // 1.5)) + return super().resizeEvent(event) + class FieldWidget(QWidget): def __init__(self, title) -> None: diff --git a/tagstudio/src/qt/widgets/tag.py b/tagstudio/src/qt/widgets/tag.py index 8f1f405e..2d399262 100644 --- a/tagstudio/src/qt/widgets/tag.py +++ b/tagstudio/src/qt/widgets/tag.py @@ -185,7 +185,7 @@ class TagWidget(QWidget): primary_color = get_primary_color(tag) border_color = ( get_border_color(primary_color) - if not (tag.color and tag.color.secondary) + if not (tag.color and tag.color.secondary and tag.color.color_border) else (QColor(tag.color.secondary)) ) highlight_color = get_highlight_color( @@ -209,7 +209,6 @@ class TagWidget(QWidget): 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"}}" @@ -222,8 +221,12 @@ class TagWidget(QWidget): f"border-color: rgba{primary_color.toTuple()};" f"}}" f"QPushButton::focus{{" - f"border-color: rgba{highlight_color.toTuple()};" - f"outline:none;" + 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"}}" ) @@ -305,6 +308,7 @@ def get_highlight_color(primary_color: QColor) -> QColor: 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() diff --git a/tagstudio/src/qt/widgets/tag_color_label.py b/tagstudio/src/qt/widgets/tag_color_label.py new file mode 100644 index 00000000..6fedc476 --- /dev/null +++ b/tagstudio/src/qt/widgets/tag_color_label.py @@ -0,0 +1,201 @@ +# Copyright (C) 2025 Travis Abendshien (CyanVoxel). +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + + +import typing + +import structlog +from PySide6.QtCore import QEvent, Qt, Signal +from PySide6.QtGui import QAction, QColor, QEnterEvent +from PySide6.QtWidgets import ( + QHBoxLayout, + QPushButton, + QVBoxLayout, + QWidget, +) +from src.core.library.alchemy.models import TagColorGroup +from src.qt.helpers.escape_text import escape_text +from src.qt.translations import Translations +from src.qt.widgets.tag import ( + get_border_color, + get_highlight_color, + get_text_color, +) + +logger = structlog.get_logger(__name__) + +# Only import for type checking/autocompletion, will not be imported at runtime. +if typing.TYPE_CHECKING: + from src.core.library.alchemy import Library + + +class TagColorLabel(QWidget): + """A widget for displaying a tag color's name. + + Not to be confused with a tag color swatch widget. + """ + + on_remove = Signal() + on_click = Signal() + + def __init__( + self, + color: TagColorGroup | None, + has_edit: bool, + has_remove: bool, + library: "Library | None" = None, + ) -> None: + super().__init__() + self.color = color + self.lib: Library | None = library + self.has_edit = has_edit + self.has_remove = has_remove + + self.base_layout = QVBoxLayout(self) + self.base_layout.setObjectName("baseLayout") + self.base_layout.setContentsMargins(0, 0, 0, 0) + + self.bg_button = QPushButton(self) + self.bg_button.setFlat(True) + + edit_action = QAction(self) + edit_action.setText(Translations.translate_formatted("generic.edit")) + edit_action.triggered.connect(self.on_click.emit) + self.bg_button.addAction(edit_action) + self.bg_button.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) + if has_edit: + self.bg_button.clicked.connect(self.on_click.emit) + self.setCursor(Qt.CursorShape.PointingHandCursor) + else: + edit_action.setEnabled(False) + + self.inner_layout = QHBoxLayout() + 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.inner_layout.addWidget(self.remove_button) + self.inner_layout.addStretch(1) + if self.has_remove: + self.remove_button.clicked.connect(self.on_remove.emit) + else: + self.remove_button.setHidden(True) + + self.bg_button.setLayout(self.inner_layout) + self.bg_button.setMinimumSize(44, 22) + self.bg_button.setMaximumHeight(22) + + self.base_layout.addWidget(self.bg_button) + + # NOTE: Do this if you don't want the tag to stretch, like in a search. + # self.bg_button.setMaximumWidth(self.bg_button.sizeHint().width()) + + self.set_color(color) + + def set_color(self, color: TagColorGroup | None) -> None: + self.color = color + + if not color: + return + + primary_color = self._get_primary_color(color) + border_color = ( + get_border_color(primary_color) + if not (color and color.secondary and color.color_border) + else (QColor(color.secondary)) + ) + highlight_color = get_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) + + 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"}}" + ) + + 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.bg_button.setText(escape_text(color.name)) + + def _get_primary_color(self, color: TagColorGroup) -> QColor: + primary_color = QColor(color.primary) + + return primary_color + + def set_has_remove(self, has_remove: bool): + self.has_remove = has_remove + + def enterEvent(self, event: QEnterEvent) -> None: # noqa: N802 + if self.has_remove: + self.remove_button.setHidden(False) + self.update() + return super().enterEvent(event) + + def leaveEvent(self, event: QEvent) -> None: # noqa: N802 + if self.has_remove: + self.remove_button.setHidden(True) + self.update() + return super().leaveEvent(event) diff --git a/tagstudio/src/qt/widgets/tag_color_preview.py b/tagstudio/src/qt/widgets/tag_color_preview.py index fcdb052e..595c9859 100644 --- a/tagstudio/src/qt/widgets/tag_color_preview.py +++ b/tagstudio/src/qt/widgets/tag_color_preview.py @@ -3,6 +3,8 @@ # Created for TagStudio: https://github.com/CyanVoxel/TagStudio +import typing + import structlog from PySide6.QtCore import Qt, Signal from PySide6.QtGui import QColor @@ -15,6 +17,14 @@ from src.core.library.alchemy.enums import TagColorEnum from src.core.library.alchemy.models import TagColorGroup from src.core.palette import ColorType, get_tag_color from src.qt.translations import Translations +from src.qt.widgets.tag import ( + get_border_color, + get_highlight_color, + get_text_color, +) + +if typing.TYPE_CHECKING: + from src.core.library import Library logger = structlog.get_logger(__name__) @@ -24,9 +34,11 @@ class TagColorPreview(QWidget): def __init__( self, + library: "Library", tag_color_group: TagColorGroup | None, ) -> None: super().__init__() + self.lib: Library = library self.tag_color_group = tag_color_group self.setCursor(Qt.CursorShape.PointingHandCursor) @@ -44,28 +56,36 @@ class TagColorPreview(QWidget): self.set_tag_color_group(tag_color_group) - def set_tag_color_group(self, tag_color_group: TagColorGroup | None): - self.tag_color_group = tag_color_group + def set_tag_color_group(self, color_group: TagColorGroup | None): + logger.info( + "[TagColorPreview] Setting tag color", + primary=color_group.primary if color_group else None, + secondary=color_group.secondary if color_group else None, + ) + self.tag_color_group = color_group - if tag_color_group: - self.button.setText(tag_color_group.name) + if color_group: + self.button.setText(color_group.name) + self.button.setText( + f"{color_group.name} ({self.lib.get_namespace_name(color_group.namespace)})" + ) else: - Translations.translate_qobject(self.button, "generic.none") + Translations.translate_qobject(self.button, "color.title.no_color") - primary_color = get_primary_color(tag_color_group) + primary_color = self._get_primary_color(color_group) border_color = ( get_border_color(primary_color) - if not (tag_color_group and tag_color_group.secondary) - else (QColor(tag_color_group.secondary)) + if not (color_group and color_group.secondary and color_group.color_border) + else (QColor(color_group.secondary)) ) highlight_color = get_highlight_color( primary_color - if not (tag_color_group and tag_color_group.secondary) - else QColor(tag_color_group.secondary) + if not (color_group and color_group.secondary) + else QColor(color_group.secondary) ) text_color: QColor - if tag_color_group and tag_color_group.secondary: - text_color = QColor(tag_color_group.secondary) + if color_group and color_group.secondary: + text_color = QColor(color_group.secondary) else: text_color = get_text_color(primary_color, highlight_color) @@ -79,50 +99,31 @@ class TagColorPreview(QWidget): f"border-style:solid;" f"border-width: 2px;" f"padding-right: 8px;" - f"padding-bottom: 1px;" 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"}}" + ) + # Add back the padding if the hint is generated while the button has focus (no padding) + self.button.setMinimumWidth( + self.button.sizeHint().width() + (16 if self.button.hasFocus() else 0) ) - self.button.setMaximumWidth(self.button.sizeHint().width()) + def _get_primary_color(self, tag_color_group: TagColorGroup | None) -> QColor: + primary_color = QColor( + get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT) + if not tag_color_group + else tag_color_group.primary + ) -def get_primary_color(tag_color_group: TagColorGroup | None) -> QColor: - primary_color = QColor( - get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT) - if not tag_color_group - else tag_color_group.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: - 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 + return primary_color