Add EntitySelect/IconSelect UI improvements across modals

- Portal IconSelect popups to document.body with position:fixed to prevent
  clipping by modal overflow-y:auto
- Replace custom scene selectors in automation editor with EntitySelect
  command-palette pickers (main scene + fallback scene)
- Add IconSelect grid for automation deactivation mode (none/revert/fallback)
- Add IconSelect grid for automation condition type and match type
- Replace mapped zone source dropdowns with EntitySelect pickers
- Replace scene target selector with EntityPalette.pick() pattern
- Remove effect palette preview bar from CSS editor
- Remove sensitivity badge from audio color strip source cards
- Clean up unused scene-selector CSS and scene-target-add-row CSS
- Add locale keys for all new UI elements across en/ru/zh

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-09 16:00:30 +03:00
parent 186940124c
commit 2712c6682e
32 changed files with 1204 additions and 391 deletions

View File

@@ -31,7 +31,7 @@ from wled_controller.core.capture.calibration import (
)
from wled_controller.core.capture.screen_capture import get_available_displays
from wled_controller.core.processing.processor_manager import ProcessorManager
from wled_controller.storage.color_strip_source import ApiInputColorStripSource, NotificationColorStripSource, PictureColorStripSource
from wled_controller.storage.color_strip_source import AdvancedPictureColorStripSource, ApiInputColorStripSource, NotificationColorStripSource, PictureColorStripSource
from wled_controller.storage.color_strip_store import ColorStripStore
from wled_controller.storage.picture_source import ProcessedPictureSource, ScreenCapturePictureSource
from wled_controller.storage.picture_source_store import PictureSourceStore
@@ -47,7 +47,7 @@ router = APIRouter()
def _css_to_response(source, overlay_active: bool = False) -> ColorStripSourceResponse:
"""Convert a ColorStripSource to a ColorStripSourceResponse."""
calibration = None
if isinstance(source, PictureColorStripSource) and source.calibration:
if isinstance(source, (PictureColorStripSource, AdvancedPictureColorStripSource)) and source.calibration:
calibration = CalibrationSchema(**calibration_to_dict(source.calibration))
# Convert raw stop dicts to ColorStop schema objects for gradient sources
@@ -376,7 +376,7 @@ async def test_css_calibration(
if body.edges:
try:
source = store.get_source(source_id)
if not isinstance(source, PictureColorStripSource):
if not isinstance(source, (PictureColorStripSource, AdvancedPictureColorStripSource)):
raise HTTPException(
status_code=400,
detail="Calibration test is only available for picture color strip sources",
@@ -422,12 +422,13 @@ async def start_css_overlay(
"""Start screen overlay visualization for a color strip source."""
try:
source = store.get_source(source_id)
if not isinstance(source, PictureColorStripSource):
if not isinstance(source, (PictureColorStripSource, AdvancedPictureColorStripSource)):
raise HTTPException(status_code=400, detail="Overlay is only supported for picture color strip sources")
if not source.calibration:
raise HTTPException(status_code=400, detail="Color strip source has no calibration configured")
display_index = _resolve_display_index(source.picture_source_id, picture_source_store)
ps_id = getattr(source, "picture_source_id", "") or ""
display_index = _resolve_display_index(ps_id, picture_source_store)
displays = get_available_displays()
if not displays:
raise HTTPException(status_code=409, detail="No displays available")

View File

@@ -45,7 +45,7 @@ from wled_controller.core.capture.screen_capture import (
get_available_displays,
)
from wled_controller.storage.color_strip_store import ColorStripStore
from wled_controller.storage.color_strip_source import PictureColorStripSource
from wled_controller.storage.color_strip_source import AdvancedPictureColorStripSource, PictureColorStripSource
from wled_controller.storage import DeviceStore
from wled_controller.storage.pattern_template_store import PatternTemplateStore
from wled_controller.storage.picture_source import ScreenCapturePictureSource, StaticImagePictureSource
@@ -820,11 +820,12 @@ async def start_target_overlay(
if first_css_id:
try:
css = color_strip_store.get_source(first_css_id)
if isinstance(css, PictureColorStripSource) and css.calibration:
if isinstance(css, (PictureColorStripSource, AdvancedPictureColorStripSource)) and css.calibration:
calibration = css.calibration
# Resolve the display this CSS is capturing
from wled_controller.api.routes.color_strip_sources import _resolve_display_index
display_index = _resolve_display_index(css.picture_source_id, picture_source_store)
ps_id = getattr(css, "picture_source_id", "") or ""
display_index = _resolve_display_index(ps_id, picture_source_store)
displays = get_available_displays()
if displays:
display_index = min(display_index, len(displays) - 1)

View File

@@ -133,14 +133,42 @@ async def update_scene_preset(
data: ScenePresetUpdate,
_auth: AuthRequired,
store: ScenePresetStore = Depends(get_scene_preset_store),
target_store: OutputTargetStore = Depends(get_output_target_store),
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Update scene preset metadata."""
"""Update scene preset metadata and optionally change targets."""
# If target_ids changed, update the snapshot: keep state for existing targets,
# capture fresh state for newly added targets, drop removed ones.
new_targets = None
if data.target_ids is not None:
try:
existing = store.get_preset(preset_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
existing_map = {t.target_id: t for t in existing.targets}
new_target_ids = set(data.target_ids)
# Capture fresh state for newly added targets
added_ids = new_target_ids - set(existing_map.keys())
fresh = capture_current_snapshot(target_store, manager, added_ids) if added_ids else []
fresh_map = {t.target_id: t for t in fresh}
# Build new target list preserving order from target_ids
new_targets = []
for tid in data.target_ids:
if tid in existing_map:
new_targets.append(existing_map[tid])
elif tid in fresh_map:
new_targets.append(fresh_map[tid])
try:
preset = store.update_preset(
preset_id,
name=data.name,
description=data.description,
order=data.order,
targets=new_targets,
)
except ValueError as e:
raise HTTPException(status_code=404 if "not found" in str(e).lower() else 400, detail=str(e))

View File

@@ -49,7 +49,7 @@ class ColorStripSourceCreate(BaseModel):
"""Request to create a color strip source."""
name: str = Field(description="Source name", min_length=1, max_length=100)
source_type: Literal["picture", "static", "gradient", "color_cycle", "effect", "composite", "mapped", "audio", "api_input", "notification"] = Field(default="picture", description="Source type")
source_type: Literal["picture", "picture_advanced", "static", "gradient", "color_cycle", "effect", "composite", "mapped", "audio", "api_input", "notification"] = Field(default="picture", description="Source type")
# picture-type fields
picture_source_id: str = Field(default="", description="Picture source ID (for picture type)")
brightness: float = Field(default=1.0, description="Brightness multiplier (0.0-2.0)", ge=0.0, le=2.0)

View File

@@ -34,9 +34,31 @@ class DeviceUpdate(BaseModel):
zone_mode: Optional[str] = Field(None, description="OpenRGB zone mode: combined or separate")
class CalibrationLineSchema(BaseModel):
"""One LED line in advanced calibration."""
picture_source_id: str = Field(description="Picture source (monitor) to sample from")
edge: Literal["top", "right", "bottom", "left"] = Field(description="Screen edge to sample")
led_count: int = Field(ge=1, description="Number of LEDs in this line")
span_start: float = Field(default=0.0, ge=0.0, le=1.0, description="Start fraction along edge")
span_end: float = Field(default=1.0, ge=0.0, le=1.0, description="End fraction along edge")
reverse: bool = Field(default=False, description="Reverse LED direction")
border_width: int = Field(default=10, ge=1, le=100, description="Sampling depth in pixels")
class Calibration(BaseModel):
"""Calibration configuration for pixel-to-LED mapping."""
mode: Literal["simple", "advanced"] = Field(
default="simple",
description="Calibration mode: simple (4-edge) or advanced (multi-source lines)"
)
# Advanced mode: ordered list of lines
lines: Optional[List[CalibrationLineSchema]] = Field(
default=None,
description="Line list for advanced mode (ignored in simple mode)"
)
# Simple mode fields
layout: Literal["clockwise", "counterclockwise"] = Field(
default="clockwise",
description="LED strip layout direction"

View File

@@ -23,11 +23,12 @@ class ScenePresetCreate(BaseModel):
class ScenePresetUpdate(BaseModel):
"""Update scene preset metadata (not snapshot data — use recapture for that)."""
"""Update scene preset metadata and optionally change which targets are included."""
name: Optional[str] = Field(None, min_length=1, max_length=100)
description: Optional[str] = Field(None, max_length=500)
order: Optional[int] = None
target_ids: Optional[List[str]] = Field(None, description="Update target list: keep state for existing, capture fresh for new, drop removed")
class ScenePresetResponse(BaseModel):