From bbef7e58699c84c655ac18b0a5d0cc1c57be13a4 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Tue, 24 Mar 2026 17:15:22 +0300 Subject: [PATCH] feat: add per-layer LED range and collapsible layers to composite source Composite layers now support optional start/end LED range (toggleable) and reverse flag, making composite a superset of mapped source. Layers are collapsible with animated expand/collapse and consistent 0.85rem font sizing. Delete button restyled as ghost icon button. Also includes minor dashboard CSS overflow fixes. --- TODO-css-improvements.md | 12 +- .../api/schemas/color_strip_sources.py | 3 + .../core/processing/composite_stream.py | 69 ++++++-- .../src/wled_controller/static/css/base.css | 1 + .../wled_controller/static/css/dashboard.css | 10 ++ .../src/wled_controller/static/css/modal.css | 133 ++++++++++++++- .../js/features/color-strips-composite.ts | 154 ++++++++++++++---- .../static/js/features/dashboard.ts | 2 +- .../wled_controller/static/locales/en.json | 4 + .../wled_controller/static/locales/ru.json | 4 + .../wled_controller/static/locales/zh.json | 4 + .../storage/color_strip_source.py | 4 +- 12 files changed, 349 insertions(+), 51 deletions(-) diff --git a/TODO-css-improvements.md b/TODO-css-improvements.md index 5c7bdbe..150ab13 100644 --- a/TODO-css-improvements.md +++ b/TODO-css-improvements.md @@ -11,6 +11,15 @@ --- +## Donation / Open-Source Banner + +- [ ] Add a persistent but dismissible banner or notification in the dashboard UI informing users that the project is open-source and under active development, and that donations are highly appreciated +- [ ] Include a link to the donation page (GitHub Sponsors, Ko-fi, or similar — decide on platform) +- [ ] Remember dismissal in localStorage so it doesn't reappear every session +- [ ] Add i18n keys for the banner text (`en.json`, `ru.json`, `zh.json`) + +--- + # Color Strip Source Improvements ## New Source Types @@ -35,7 +44,6 @@ Need to research HAOS communication options first (WebSocket API, REST API, MQTT - [x] Add effects: rain, comet, bouncing ball, fireworks, sparkle rain, lava lamp, wave interference - [x] Custom palette support: user-defined [[pos,R,G,B],...] stops via JSON textarea -- [ ] Custom palette editor: replace raw JSON textarea with a proper visual editor (reuse gradient editor pattern with color pickers + position sliders) ### `gradient` @@ -63,7 +71,7 @@ Need to research HAOS communication options first (WebSocket API, REST API, MQTT - [ ] Allow nested composites (with cycle detection) - [ ] More blend modes: overlay, soft light, hard light, difference, exclusion -- [ ] Per-layer LED range masks +- [x] Per-layer LED range masks (optional start/end/reverse on each composite layer) ### `notification` diff --git a/server/src/wled_controller/api/schemas/color_strip_sources.py b/server/src/wled_controller/api/schemas/color_strip_sources.py index cb2bdd2..0ab24e6 100644 --- a/server/src/wled_controller/api/schemas/color_strip_sources.py +++ b/server/src/wled_controller/api/schemas/color_strip_sources.py @@ -36,6 +36,9 @@ class CompositeLayer(BaseModel): enabled: bool = Field(default=True, description="Whether this layer is active") brightness_source_id: Optional[str] = Field(None, description="Optional value source ID for dynamic brightness") processing_template_id: Optional[str] = Field(None, description="Optional color strip processing template ID") + start: int = Field(default=0, ge=0, description="First LED index for range (0 = full strip)") + end: int = Field(default=0, ge=0, description="Last LED index exclusive for range (0 = full strip)") + reverse: bool = Field(default=False, description="Reverse layer output within its range") class MappedZone(BaseModel): diff --git a/server/src/wled_controller/core/processing/composite_stream.py b/server/src/wled_controller/core/processing/composite_stream.py index a9446ff..6007f52 100644 --- a/server/src/wled_controller/core/processing/composite_stream.py +++ b/server/src/wled_controller/core/processing/composite_stream.py @@ -155,9 +155,13 @@ class CompositeColorStripStream(ColorStripStream): def update_source(self, source) -> None: """Hot-update: rebuild sub-streams if layer config changed.""" new_layers = list(source.layers) - old_layer_ids = [(layer.get("source_id"), layer.get("blend_mode"), layer.get("opacity"), layer.get("enabled"), layer.get("brightness_source_id")) + old_layer_ids = [(layer.get("source_id"), layer.get("blend_mode"), layer.get("opacity"), + layer.get("enabled"), layer.get("brightness_source_id"), + layer.get("start", 0), layer.get("end", 0), layer.get("reverse", False)) for layer in self._layers] - new_layer_ids = [(layer.get("source_id"), layer.get("blend_mode"), layer.get("opacity"), layer.get("enabled"), layer.get("brightness_source_id")) + new_layer_ids = [(layer.get("source_id"), layer.get("blend_mode"), layer.get("opacity"), + layer.get("enabled"), layer.get("brightness_source_id"), + layer.get("start", 0), layer.get("end", 0), layer.get("reverse", False)) for layer in new_layers] self._layers = new_layers @@ -187,7 +191,15 @@ class CompositeColorStripStream(ColorStripStream): try: stream = self._css_manager.acquire(src_id, consumer_id) if hasattr(stream, "configure") and self._led_count > 0: - stream.configure(self._led_count) + # Configure with zone length if layer has a range, else full strip + layer_start = layer.get("start", 0) + layer_end = layer.get("end", 0) + if layer_start > 0 or layer_end > 0: + eff_end = layer_end if layer_end > 0 else self._led_count + zone_len = max(0, eff_end - layer_start) + stream.configure(zone_len if zone_len > 0 else self._led_count) + else: + stream.configure(self._led_count) self._sub_streams[i] = (src_id, consumer_id, stream) except Exception as e: logger.warning( @@ -416,9 +428,34 @@ class CompositeColorStripStream(ColorStripStream): if _result is not None: colors = _result - # Resize to target LED count if needed - if len(colors) != target_n: - colors = self._resize_to_target(colors, target_n) + # Determine layer range + layer_start = layer.get("start", 0) + layer_end = layer.get("end", 0) + has_range = layer_start > 0 or layer_end > 0 + + if has_range: + # Clamp range to strip bounds + eff_start = max(0, min(layer_start, target_n)) + eff_end = max(eff_start, min(layer_end if layer_end > 0 else target_n, target_n)) + zone_len = eff_end - eff_start + if zone_len <= 0: + continue + # Resize to zone length + if len(colors) != zone_len: + src_x = np.linspace(0, 1, len(colors)) + dst_x = np.linspace(0, 1, zone_len) + resized = np.empty((zone_len, 3), dtype=np.uint8) + for ch in range(3): + np.copyto(resized[:, ch], np.interp(dst_x, src_x, colors[:, ch]), casting="unsafe") + colors = resized + else: + # Full-strip layer: resize to target LED count + if len(colors) != target_n: + colors = self._resize_to_target(colors, target_n) + + # Reverse if requested + if layer.get("reverse", False): + colors = colors[::-1].copy() # Apply per-layer brightness from value source if i in self._brightness_streams: @@ -437,14 +474,20 @@ class CompositeColorStripStream(ColorStripStream): alpha = max(0, min(256, alpha)) if not has_result: - # First layer: copy directly (or blend with black if opacity < 1) - if alpha >= 256 and blend_mode == _BLEND_NORMAL: - result_buf[:] = colors - else: - result_buf[:] = 0 - blend_fn = self._blend_methods.get(blend_mode, self._default_blend_method) - blend_fn(result_buf, colors, alpha, result_buf) + result_buf[:] = 0 has_result = True + + if has_range: + # Blend only into the target range — use scratch sub-slices + rng = result_buf[eff_start:eff_end] + u16a_rng = self._u16_a[:zone_len] + u16b_rng = self._u16_b[:zone_len] + blend_fn = self._blend_methods.get(blend_mode, self._default_blend_method) + # Temporarily swap scratch buffers for the range size + orig_u16a, orig_u16b = self._u16_a, self._u16_b + self._u16_a, self._u16_b = u16a_rng, u16b_rng + blend_fn(rng, colors, alpha, rng) + self._u16_a, self._u16_b = orig_u16a, orig_u16b else: blend_fn = self._blend_methods.get(blend_mode, self._default_blend_method) blend_fn(result_buf, colors, alpha, result_buf) diff --git a/server/src/wled_controller/static/css/base.css b/server/src/wled_controller/static/css/base.css index 6bda796..cc4bf32 100644 --- a/server/src/wled_controller/static/css/base.css +++ b/server/src/wled_controller/static/css/base.css @@ -115,6 +115,7 @@ body { html { background: var(--bg-color); + overflow-x: hidden; overflow-y: scroll; scroll-behavior: smooth; scrollbar-gutter: stable; diff --git a/server/src/wled_controller/static/css/dashboard.css b/server/src/wled_controller/static/css/dashboard.css index fbcdcce..aa5cdd5 100644 --- a/server/src/wled_controller/static/css/dashboard.css +++ b/server/src/wled_controller/static/css/dashboard.css @@ -119,6 +119,11 @@ color: var(--primary-text-color); } +.dashboard-target-info > div { + min-width: 0; + overflow: hidden; +} + .dashboard-target-name { font-size: 0.85rem; font-weight: 600; @@ -130,6 +135,11 @@ gap: 4px; } +.dashboard-target-name-text { + overflow: hidden; + text-overflow: ellipsis; +} + .dashboard-card-link { cursor: pointer; } diff --git a/server/src/wled_controller/static/css/modal.css b/server/src/wled_controller/static/css/modal.css index 907354f..db655f2 100644 --- a/server/src/wled_controller/static/css/modal.css +++ b/server/src/wled_controller/static/css/modal.css @@ -1536,6 +1536,78 @@ background: var(--card-bg); } +.composite-layer-header { + display: flex; + align-items: center; + gap: 6px; + cursor: pointer; + user-select: none; +} + +.composite-layer-expand-btn { + font-size: 0.6rem; + color: var(--text-secondary); + transition: transform 0.15s ease; + flex-shrink: 0; + width: 12px; + text-align: center; +} + +.composite-layer-expanded .composite-layer-expand-btn { + transform: rotate(90deg); +} + +.composite-layer-summary { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + gap: 8px; + overflow: hidden; +} + +.composite-layer-summary-name { + font-size: 0.85rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.composite-layer-summary-blend { + font-size: 0.75rem; + color: var(--text-secondary); + background: var(--bg-secondary); + padding: 1px 6px; + border-radius: 3px; + white-space: nowrap; + flex-shrink: 0; +} + +.composite-layer-body-wrapper { + display: grid; + grid-template-rows: 0fr; + transition: grid-template-rows 0.2s ease; +} + +.composite-layer-expanded .composite-layer-body-wrapper { + grid-template-rows: 1fr; +} + +.composite-layer-body { + display: flex; + flex-direction: column; + gap: 4px; + padding-top: 0; + overflow: hidden; + min-height: 0; + transition: padding-top 0.2s ease; + font-size: 0.85rem; +} + +.composite-layer-expanded .composite-layer-body { + padding-top: 4px; +} + .composite-layer-row { display: flex; align-items: center; @@ -1550,7 +1622,6 @@ .composite-layer-brightness-label { flex-shrink: 0; width: 90px; - font-size: 0.8rem; color: var(--text-secondary); } @@ -1566,7 +1637,6 @@ } .composite-layer-opacity-label { - font-size: 0.8rem; white-space: nowrap; flex-shrink: 0; } @@ -1581,11 +1651,66 @@ } .composite-layer-remove-btn { - font-size: 0.75rem; - padding: 0; + background: none; + border: none; + color: var(--text-muted); + font-size: 0.85rem; width: 26px; height: 26px; flex: 0 0 26px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + border-radius: 4px; + transition: color 0.2s, background 0.2s; +} + +.composite-layer-remove-btn:hover { + color: var(--danger-color); + background: color-mix(in srgb, var(--danger-color) 10%, transparent); +} + +.composite-layer-range-toggle-label { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; + color: var(--text-secondary); + cursor: pointer; + white-space: nowrap; +} + +.composite-layer-range-fields { + display: flex; + align-items: center; + gap: 6px; + flex: 1; + min-width: 0; +} + +.composite-layer-range-fields label { + color: var(--text-secondary); + flex-shrink: 0; +} + +.composite-layer-range-fields input[type="number"] { + width: 60px; + flex-shrink: 0; +} + +.composite-layer-range-disabled { + opacity: 0.35; + pointer-events: none; +} + +.composite-layer-reverse-label { + display: flex; + align-items: center; + gap: 4px; + margin-left: auto; + white-space: nowrap; + color: var(--text-secondary); } /* ── Composite layer drag-to-reorder ── */ diff --git a/server/src/wled_controller/static/js/features/color-strips-composite.ts b/server/src/wled_controller/static/js/features/color-strips-composite.ts index 3733d6a..2581dd2 100644 --- a/server/src/wled_controller/static/js/features/color-strips-composite.ts +++ b/server/src/wled_controller/static/js/features/color-strips-composite.ts @@ -102,51 +102,130 @@ export function compositeRenderList() { `` ).join(''); const canRemove = _compositeLayers.length > 1; + const srcName = _compositeAvailableSources.find(s => s.id === layer.source_id)?.name || '—'; + const blendLabel = t(`color_strip.composite.blend_mode.${layer.blend_mode}`) || layer.blend_mode; return `
-
+
- - -
-
- - + + + ${escapeHtml(srcName)} + ${escapeHtml(blendLabel)} + ${canRemove - ? `` + ? `` : ''}
-
- - +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + 0 || layer.end > 0) ? '' : ' disabled'}> + + 0 || layer.end > 0) ? '' : ' disabled'}> +
+ +
-
- -
`; }).join(''); + // Wire up expand/collapse + list.querySelectorAll('.composite-layer-header').forEach(header => { + const item = header.closest('.composite-layer-item') as HTMLElement; + + header.addEventListener('click', (e: Event) => { + const target = e.target as HTMLElement; + if (target.closest('.settings-toggle, .composite-layer-remove-btn, .composite-layer-drag-handle')) return; + item.classList.toggle('composite-layer-expanded'); + }); + }); + + // Wire up source/blend change to update summary text + list.querySelectorAll('.composite-layer-source').forEach(sel => { + sel.addEventListener('change', () => { + const item = sel.closest('.composite-layer-item') as HTMLElement; + const nameEl = item.querySelector('.composite-layer-summary-name') as HTMLElement; + const selectedOpt = sel.options[sel.selectedIndex]; + if (nameEl && selectedOpt) nameEl.textContent = selectedOpt.text; + }); + }); + list.querySelectorAll('.composite-layer-blend').forEach(sel => { + sel.addEventListener('change', () => { + const item = sel.closest('.composite-layer-item') as HTMLElement; + const blendEl = item.querySelector('.composite-layer-summary-blend') as HTMLElement; + const selectedOpt = sel.options[sel.selectedIndex]; + if (blendEl && selectedOpt) blendEl.textContent = selectedOpt.text; + }); + }); + + // Wire up range toggle: enable/disable fields, clear values when unchecked + list.querySelectorAll('.composite-layer-range-toggle').forEach(el => { + el.addEventListener('change', () => { + const row = el.closest('.composite-layer-range-row')!; + const fields = row.querySelector('.composite-layer-range-fields') as HTMLElement; + const startInput = fields.querySelector('.composite-layer-start') as HTMLInputElement; + const endInput = fields.querySelector('.composite-layer-end') as HTMLInputElement; + if (el.checked) { + fields.classList.remove('composite-layer-range-disabled'); + if (startInput) startInput.disabled = false; + if (endInput) endInput.disabled = false; + } else { + fields.classList.add('composite-layer-range-disabled'); + if (startInput) { startInput.value = '0'; startInput.disabled = true; } + if (endInput) { endInput.value = '0'; endInput.disabled = true; } + } + }); + }); + // Wire up live opacity display list.querySelectorAll('.composite-layer-opacity').forEach(el => { el.addEventListener('input', () => { @@ -205,6 +284,9 @@ export function compositeAddLayer() { enabled: true, brightness_source_id: null, processing_template_id: null, + start: 0, + end: 0, + reverse: false, }); compositeRenderList(); } @@ -225,6 +307,9 @@ function _compositeLayersSyncFromDom() { const enableds = list.querySelectorAll('.composite-layer-enabled'); const briSrcs = list.querySelectorAll('.composite-layer-brightness'); const csptSels = list.querySelectorAll('.composite-layer-cspt'); + const starts = list.querySelectorAll('.composite-layer-start'); + const ends = list.querySelectorAll('.composite-layer-end'); + const reverses = list.querySelectorAll('.composite-layer-reverse'); if (srcs.length === _compositeLayers.length) { for (let i = 0; i < srcs.length; i++) { _compositeLayers[i].source_id = srcs[i].value; @@ -233,6 +318,9 @@ function _compositeLayersSyncFromDom() { _compositeLayers[i].enabled = enableds[i].checked; _compositeLayers[i].brightness_source_id = briSrcs[i] ? (briSrcs[i].value || null) : null; _compositeLayers[i].processing_template_id = csptSels[i] ? (csptSels[i].value || null) : null; + _compositeLayers[i].start = starts[i] ? (parseInt(starts[i].value) || 0) : 0; + _compositeLayers[i].end = ends[i] ? (parseInt(ends[i].value) || 0) : 0; + _compositeLayers[i].reverse = reverses[i] ? reverses[i].checked : false; } } } @@ -393,6 +481,9 @@ export function compositeGetLayers() { }; if (l.brightness_source_id) layer.brightness_source_id = l.brightness_source_id; if (l.processing_template_id) layer.processing_template_id = l.processing_template_id; + if (l.start) layer.start = l.start; + if (l.end) layer.end = l.end; + if (l.reverse) layer.reverse = l.reverse; return layer; }); } @@ -407,7 +498,10 @@ export function loadCompositeState(css: any) { enabled: l.enabled != null ? l.enabled : true, brightness_source_id: l.brightness_source_id || null, processing_template_id: l.processing_template_id || null, + start: l.start || 0, + end: l.end || 0, + reverse: l.reverse || false, })) - : [{ source_id: '', blend_mode: 'normal', opacity: 1.0, enabled: true, brightness_source_id: null, processing_template_id: null }]; + : [{ source_id: '', blend_mode: 'normal', opacity: 1.0, enabled: true, brightness_source_id: null, processing_template_id: null, start: 0, end: 0, reverse: false }]; compositeRenderList(); } diff --git a/server/src/wled_controller/static/js/features/dashboard.ts b/server/src/wled_controller/static/js/features/dashboard.ts index 2a7b8bf..e93de72 100644 --- a/server/src/wled_controller/static/js/features/dashboard.ts +++ b/server/src/wled_controller/static/js/features/dashboard.ts @@ -598,7 +598,7 @@ function renderDashboardTarget(target: any, isRunning: boolean, devicesMap: Reco
${icon}
-
${escapeHtml(target.name)}${healthDot}
+
${escapeHtml(target.name)}${healthDot}
${subtitleParts.length ? `
${escapeHtml(subtitleParts.join(' · '))}
` : ''}
diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index ea26de4..f5f2773 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -1192,6 +1192,10 @@ "color_strip.composite.error.min_layers": "At least 1 layer is required", "color_strip.composite.error.no_source": "Each layer must have a source selected", "color_strip.composite.layers_count": "layers", + "color_strip.composite.range": "LED Range", + "color_strip.composite.range_start": "Start", + "color_strip.composite.range_end": "End", + "color_strip.composite.reverse": "Reverse", "color_strip.mapped.zones": "Zones:", "color_strip.mapped.zones.hint": "Each zone maps a color strip source to a specific LED range. Zones are placed side-by-side — gaps between zones stay black.", "color_strip.mapped.add_zone": "+ Add Zone", diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index eed8ff0..80a4fb9 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -1157,6 +1157,10 @@ "color_strip.composite.error.min_layers": "Необходим хотя бы 1 слой", "color_strip.composite.error.no_source": "Для каждого слоя должен быть выбран источник", "color_strip.composite.layers_count": "слоёв", + "color_strip.composite.range": "Диапазон LED", + "color_strip.composite.range_start": "Начало", + "color_strip.composite.range_end": "Конец", + "color_strip.composite.reverse": "Реверс", "color_strip.mapped.zones": "Зоны:", "color_strip.mapped.zones.hint": "Каждая зона привязывает источник цветовой полосы к определённому диапазону LED. Зоны размещаются рядом — промежутки остаются чёрными.", "color_strip.mapped.add_zone": "+ Добавить зону", diff --git a/server/src/wled_controller/static/locales/zh.json b/server/src/wled_controller/static/locales/zh.json index 40114b3..cfc87ab 100644 --- a/server/src/wled_controller/static/locales/zh.json +++ b/server/src/wled_controller/static/locales/zh.json @@ -1157,6 +1157,10 @@ "color_strip.composite.error.min_layers": "至少需要 1 个图层", "color_strip.composite.error.no_source": "每个图层必须选择一个源", "color_strip.composite.layers_count": "个图层", + "color_strip.composite.range": "LED范围", + "color_strip.composite.range_start": "起始", + "color_strip.composite.range_end": "结束", + "color_strip.composite.reverse": "反转", "color_strip.mapped.zones": "区域:", "color_strip.mapped.zones.hint": "每个区域将色带源映射到特定 LED 范围。区域并排放置 — 区域之间的间隙保持黑色。", "color_strip.mapped.add_zone": "+ 添加区域", diff --git a/server/src/wled_controller/storage/color_strip_source.py b/server/src/wled_controller/storage/color_strip_source.py index 9e57e76..8ad8c1b 100644 --- a/server/src/wled_controller/storage/color_strip_source.py +++ b/server/src/wled_controller/storage/color_strip_source.py @@ -664,7 +664,9 @@ class CompositeColorStripSource(ColorStripSource): when led_count == 0. """ - # Each layer: {"source_id": str, "blend_mode": str, "opacity": float, "enabled": bool, "brightness_source_id": str|None} + # Each layer: {"source_id": str, "blend_mode": str, "opacity": float, "enabled": bool, + # "brightness_source_id": str|None, "processing_template_id": str|None, + # "start": int (0=full), "end": int (0=full), "reverse": bool} layers: list = field(default_factory=list) led_count: int = 0 # 0 = use device LED count