mirror of
https://github.com/TagStudioDev/TagStudio.git
synced 2026-01-28 22:01:24 +00:00
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:
committed by
GitHub
parent
2173d1d4f4
commit
28de21ade7
@@ -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)",
|
||||
|
||||
@@ -25,3 +25,5 @@ TAG_FAVORITE = 1
|
||||
TAG_META = 2
|
||||
RESERVED_TAG_START = 0
|
||||
RESERVED_TAG_END = 999
|
||||
|
||||
RESERVED_NAMESPACE_PREFIX = "tagstudio"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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__()
|
||||
|
||||
|
||||
|
||||
413
tagstudio/src/qt/modals/build_color.py
Normal file
413
tagstudio/src/qt/modals/build_color.py
Normal 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()
|
||||
176
tagstudio/src/qt/modals/build_namespace.py
Normal file
176
tagstudio/src/qt/modals/build_namespace.py
Normal 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()
|
||||
@@ -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(
|
||||
|
||||
226
tagstudio/src/qt/modals/tag_color_manager.py
Normal file
226
tagstudio/src/qt/modals/tag_color_manager.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
166
tagstudio/src/qt/widgets/color_box.py
Normal file
166
tagstudio/src/qt/widgets/color_box.py
Normal 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()
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
201
tagstudio/src/qt/widgets/tag_color_label.py
Normal file
201
tagstudio/src/qt/widgets/tag_color_label.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user