All checks were successful
Lint & Test / test (push) Successful in 1m29s
- Add warm, cool, neon, pastel built-in gradients (promoted from frontend presets) - Change gradient seeding to add missing built-ins on every startup (not just first run) - Add searchable option to IconSelect component for filtering by name - Enable search on gradient, effect palette, and audio palette pickers - Simplify modal titles: "Add Gradient" / "Edit Gradient" instead of "Add Color Strip Source: Gradient" - Update INSTALLATION.md and .env.example
195 lines
7.2 KiB
Python
195 lines
7.2 KiB
Python
"""Gradient storage with built-in seeding.
|
|
|
|
Provides CRUD for gradient entities. On startup, seeds any missing
|
|
built-in gradients. Built-in gradients are read-only and cannot be
|
|
deleted or modified.
|
|
"""
|
|
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
from typing import List, Optional
|
|
|
|
from wled_controller.storage.base_sqlite_store import BaseSqliteStore
|
|
from wled_controller.storage.database import Database
|
|
from wled_controller.storage.gradient import Gradient
|
|
from wled_controller.utils import get_logger
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
# Built-in gradient definitions — matches the legacy _PALETTE_DEFS.
|
|
# Format: (position, R, G, B) tuples converted to stop dicts on seed.
|
|
_BUILTIN_DEFS = {
|
|
"fire": [(0, 0, 0, 0), (0.33, 200, 24, 0), (0.66, 255, 160, 0), (1.0, 255, 255, 200)],
|
|
"ocean": [(0, 0, 0, 32), (0.33, 0, 16, 128), (0.66, 0, 128, 255), (1.0, 128, 224, 255)],
|
|
"lava": [(0, 0, 0, 0), (0.25, 128, 0, 0), (0.5, 255, 32, 0), (0.75, 255, 160, 0), (1.0, 255, 255, 128)],
|
|
"forest": [(0, 0, 16, 0), (0.33, 0, 80, 0), (0.66, 32, 160, 0), (1.0, 128, 255, 64)],
|
|
"rainbow": [
|
|
(0, 255, 0, 0), (0.17, 255, 255, 0), (0.33, 0, 255, 0),
|
|
(0.5, 0, 255, 255), (0.67, 0, 0, 255), (0.83, 255, 0, 255), (1.0, 255, 0, 0),
|
|
],
|
|
"aurora": [
|
|
(0, 0, 16, 32), (0.2, 0, 80, 64), (0.4, 0, 200, 100),
|
|
(0.6, 64, 128, 255), (0.8, 128, 0, 200), (1.0, 0, 16, 32),
|
|
],
|
|
"sunset": [
|
|
(0, 32, 0, 64), (0.25, 128, 0, 128), (0.5, 255, 64, 0),
|
|
(0.75, 255, 192, 64), (1.0, 255, 255, 192),
|
|
],
|
|
"ice": [(0, 0, 0, 64), (0.33, 0, 64, 192), (0.66, 128, 192, 255), (1.0, 240, 248, 255)],
|
|
"warm": [(0, 255, 255, 80), (0.33, 255, 160, 0), (0.67, 255, 60, 0), (1.0, 160, 0, 0)],
|
|
"cool": [(0, 0, 255, 200), (0.33, 0, 120, 255), (0.67, 60, 0, 255), (1.0, 120, 0, 180)],
|
|
"neon": [
|
|
(0, 255, 0, 200), (0.25, 0, 255, 255), (0.5, 0, 255, 50),
|
|
(0.75, 255, 255, 0), (1.0, 255, 0, 100),
|
|
],
|
|
"pastel": [
|
|
(0, 255, 180, 180), (0.2, 255, 220, 160), (0.4, 255, 255, 180),
|
|
(0.6, 180, 255, 200), (0.8, 180, 200, 255), (1.0, 220, 180, 255),
|
|
],
|
|
}
|
|
|
|
|
|
def _tuples_to_stops(tuples: list) -> list:
|
|
"""Convert [(pos, R, G, B), ...] to [{"position": pos, "color": [R, G, B]}, ...]."""
|
|
return [{"position": t[0], "color": [t[1], t[2], t[3]]} for t in tuples]
|
|
|
|
|
|
class GradientStore(BaseSqliteStore[Gradient]):
|
|
_table_name = "gradients"
|
|
_entity_name = "Gradient"
|
|
|
|
def __init__(self, db: Database):
|
|
super().__init__(db, Gradient.from_dict)
|
|
self._seed_missing_builtins()
|
|
|
|
def _seed_missing_builtins(self) -> None:
|
|
"""Seed any built-in gradients not yet in the store."""
|
|
now = datetime.now(timezone.utc)
|
|
added = 0
|
|
for name, tuples in _BUILTIN_DEFS.items():
|
|
gid = f"gr_builtin_{name}"
|
|
if gid in self._items:
|
|
continue
|
|
gradient = Gradient(
|
|
id=gid,
|
|
name=name.capitalize(),
|
|
stops=_tuples_to_stops(tuples),
|
|
is_builtin=True,
|
|
created_at=now,
|
|
updated_at=now,
|
|
description=f"Built-in {name} gradient",
|
|
)
|
|
self._items[gid] = gradient
|
|
self._save_item(gid, gradient)
|
|
added += 1
|
|
if added:
|
|
logger.info(f"Seeded {added} new built-in gradients")
|
|
|
|
# Aliases
|
|
get_all_gradients = BaseSqliteStore.get_all
|
|
|
|
def get_gradient(self, gradient_id: str) -> Gradient:
|
|
return self.get(gradient_id)
|
|
|
|
def get_by_name(self, name: str) -> Optional[Gradient]:
|
|
"""Look up a gradient by name (case-insensitive)."""
|
|
lower = name.lower()
|
|
for g in self._items.values():
|
|
if g.name.lower() == lower:
|
|
return g
|
|
return None
|
|
|
|
def create_gradient(
|
|
self,
|
|
name: str,
|
|
stops: list,
|
|
description: Optional[str] = None,
|
|
tags: Optional[List[str]] = None,
|
|
) -> Gradient:
|
|
self._check_name_unique(name)
|
|
gid = f"gr_{uuid.uuid4().hex[:8]}"
|
|
now = datetime.now(timezone.utc)
|
|
gradient = Gradient(
|
|
id=gid,
|
|
name=name,
|
|
stops=stops,
|
|
is_builtin=False,
|
|
created_at=now,
|
|
updated_at=now,
|
|
description=description,
|
|
tags=tags or [],
|
|
)
|
|
self._items[gid] = gradient
|
|
self._save_item(gid, gradient)
|
|
logger.info(f"Created gradient: {name} ({gid})")
|
|
return gradient
|
|
|
|
def update_gradient(
|
|
self,
|
|
gradient_id: str,
|
|
name: Optional[str] = None,
|
|
stops: Optional[list] = None,
|
|
description: Optional[str] = None,
|
|
tags: Optional[List[str]] = None,
|
|
) -> Gradient:
|
|
gradient = self.get(gradient_id)
|
|
if gradient.is_builtin:
|
|
raise ValueError("Built-in gradients are read-only. Clone to edit.")
|
|
if name is not None:
|
|
self._check_name_unique(name, exclude_id=gradient_id)
|
|
gradient.name = name
|
|
if stops is not None:
|
|
gradient.stops = stops
|
|
if description is not None:
|
|
gradient.description = description
|
|
if tags is not None:
|
|
gradient.tags = tags
|
|
gradient.updated_at = datetime.now(timezone.utc)
|
|
self._save_item(gradient_id, gradient)
|
|
logger.info(f"Updated gradient: {gradient_id}")
|
|
return gradient
|
|
|
|
def delete_gradient(self, gradient_id: str) -> None:
|
|
gradient = self.get(gradient_id)
|
|
if gradient.is_builtin:
|
|
raise ValueError("Built-in gradients cannot be deleted")
|
|
self.delete(gradient_id)
|
|
|
|
def get_stops_by_id(self, gradient_id: str) -> Optional[list]:
|
|
"""Return stops list for a gradient ID, or None if not found."""
|
|
gradient = self._items.get(gradient_id)
|
|
if gradient is None:
|
|
return None
|
|
return gradient.stops
|
|
|
|
def resolve_stops(self, gradient_id: Optional[str]) -> Optional[list]:
|
|
"""Resolve a gradient_id to its stops list, or None if invalid/missing."""
|
|
if not gradient_id:
|
|
return None
|
|
return self.get_stops_by_id(gradient_id)
|
|
|
|
def migrate_palette_references(self, color_strip_store) -> int:
|
|
"""One-way migration: convert legacy palette names to gradient_id.
|
|
|
|
For each color strip source that has a `palette` field but no `gradient_id`,
|
|
sets `gradient_id` to the matching builtin gradient ID.
|
|
|
|
Returns the number of sources migrated.
|
|
"""
|
|
migrated = 0
|
|
for source in color_strip_store.get_all_sources():
|
|
if getattr(source, "gradient_id", None):
|
|
continue # already has a gradient_id
|
|
palette_name = getattr(source, "palette", None)
|
|
if not palette_name:
|
|
continue
|
|
# Map legacy palette name to builtin gradient ID
|
|
builtin_id = f"gr_builtin_{palette_name}"
|
|
if builtin_id in self._items:
|
|
source.gradient_id = builtin_id
|
|
migrated += 1
|
|
if migrated:
|
|
color_strip_store._save()
|
|
logger.info(f"Migrated {migrated} color strip sources from palette names to gradient IDs")
|
|
return migrated
|