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