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
This commit is contained in:
Travis Abendshien
2025-02-17 14:36:34 -08:00
committed by GitHub
parent 2173d1d4f4
commit 28de21ade7
19 changed files with 1678 additions and 101 deletions

View File

@@ -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": "<h2>v9.4 Library</h2>",
"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)",

View File

@@ -25,3 +25,5 @@ TAG_FAVORITE = 1
TAG_META = 2
RESERVED_TAG_START = 0
RESERVED_TAG_END = 999
RESERVED_NAMESPACE_PREFIX = "tagstudio"

View File

@@ -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

View File

@@ -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,

View File

@@ -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:

View File

@@ -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__()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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(

View File

@@ -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"<h3>{Translations["color_manager.title"]}</h3>")
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)

View File

@@ -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"<h4>{display_name if display_name else group}</h4>")
)
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"<h4>{no_color_str}</h4>"))
self.scroll_layout.addWidget(QLabel(f"<h4>{no_color_str}</h4>"))
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

View File

@@ -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

View File

@@ -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):

View File

@@ -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)

View File

@@ -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()

View File

@@ -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:

View File

@@ -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()

View File

@@ -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)

View File

@@ -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