From f376622482c97e25f2c9155801177563e774268a Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sun, 22 Mar 2026 20:58:13 +0300 Subject: [PATCH] =?UTF-8?q?fix:=20UI=20polish=20=E2=80=94=20notification?= =?UTF-8?q?=20history=20buttons,=20CSS=20test=20error=20handling,=20schedu?= =?UTF-8?q?le=20time=20picker,=20empty=20state=20labels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Notification history: replace text buttons with icon buttons, use modal-footer for proper positioning - CSS test: reject 0-LED picture sources with clear error message, show WS close reason in UI - Calibration: distribute LEDs by aspect ratio (16:9 default) instead of evenly across edges - Value source schedule: replace native time input with custom HH:MM picker matching automation style - Remove "No ... yet" empty state labels from all CardSection instances --- .../api/routes/color_strip_sources.py | 9 +++ .../core/capture/calibration.py | 64 ++++++++++++++---- .../wled_controller/static/css/components.css | 65 +++++++++++++++++++ .../static/js/core/card-sections.ts | 25 ++----- .../static/js/features/color-strips-test.ts | 12 +++- .../static/js/features/value-sources.ts | 59 ++++++++++++++++- .../modals/notification-history.html | 6 +- 7 files changed, 201 insertions(+), 39 deletions(-) diff --git a/server/src/wled_controller/api/routes/color_strip_sources.py b/server/src/wled_controller/api/routes/color_strip_sources.py index 27fbf3e..db50afc 100644 --- a/server/src/wled_controller/api/routes/color_strip_sources.py +++ b/server/src/wled_controller/api/routes/color_strip_sources.py @@ -829,6 +829,15 @@ async def test_color_strip_ws( if hasattr(stream, "configure"): stream.configure(max(1, led_count)) + # Reject picture sources with 0 calibration LEDs (no edges configured) + if stream.led_count <= 0: + csm.release(source_id, consumer_id) + await websocket.close( + code=4005, + reason="No LEDs configured. Open Calibration and set LED counts for each edge.", + ) + return + # Clamp FPS to sane range fps = max(1, min(60, fps)) _frame_interval = 1.0 / fps diff --git a/server/src/wled_controller/core/capture/calibration.py b/server/src/wled_controller/core/capture/calibration.py index 95279a8..4f5bc5a 100644 --- a/server/src/wled_controller/core/capture/calibration.py +++ b/server/src/wled_controller/core/capture/calibration.py @@ -668,14 +668,20 @@ def create_pixel_mapper( return PixelMapper(calibration, interpolation_mode) -def create_default_calibration(led_count: int) -> CalibrationConfig: +def create_default_calibration( + led_count: int, + aspect_width: int = 16, + aspect_height: int = 9, +) -> CalibrationConfig: """Create a default calibration for a rectangular screen. - Assumes LEDs are evenly distributed around the screen edges in clockwise order - starting from bottom-left. + Distributes LEDs proportionally to the screen aspect ratio so that + horizontal and vertical edges have equal LED density. Args: led_count: Total number of LEDs + aspect_width: Screen width component of the aspect ratio (default 16) + aspect_height: Screen height component of the aspect ratio (default 9) Returns: Default calibration configuration @@ -683,15 +689,48 @@ def create_default_calibration(led_count: int) -> CalibrationConfig: if led_count < 4: raise ValueError("Need at least 4 LEDs for default calibration") - # Distribute LEDs evenly across 4 edges - leds_per_edge = led_count // 4 - remainder = led_count % 4 + # Distribute LEDs proportionally to aspect ratio (same density per edge) + perimeter = 2 * (aspect_width + aspect_height) + h_frac = aspect_width / perimeter # fraction for each horizontal edge + v_frac = aspect_height / perimeter # fraction for each vertical edge - # Distribute remainder to longer edges (bottom and top) - bottom_count = leds_per_edge + (1 if remainder > 0 else 0) - right_count = leds_per_edge - top_count = leds_per_edge + (1 if remainder > 1 else 0) - left_count = leds_per_edge + (1 if remainder > 2 else 0) + # Float counts, then round so total == led_count + raw_h = led_count * h_frac + raw_v = led_count * v_frac + bottom_count = round(raw_h) + top_count = round(raw_h) + right_count = round(raw_v) + left_count = round(raw_v) + + # Fix rounding error + diff = led_count - (bottom_count + top_count + right_count + left_count) + # Distribute remainder to horizontal edges first (longer edges) + if diff > 0: + bottom_count += 1 + diff -= 1 + if diff > 0: + top_count += 1 + diff -= 1 + if diff > 0: + right_count += 1 + diff -= 1 + if diff > 0: + left_count += 1 + diff -= 1 + # If we over-counted, remove from shorter edges first + if diff < 0: + left_count += diff # diff is negative + diff = 0 + if left_count < 0: + diff = left_count + left_count = 0 + right_count += diff + + # Ensure each edge has at least 1 LED + bottom_count = max(1, bottom_count) + top_count = max(1, top_count) + right_count = max(1, right_count) + left_count = max(1, left_count) config = CalibrationConfig( layout="clockwise", @@ -703,7 +742,8 @@ def create_default_calibration(led_count: int) -> CalibrationConfig: ) logger.info( - f"Created default calibration for {led_count} LEDs: " + f"Created default calibration for {led_count} LEDs " + f"(aspect {aspect_width}:{aspect_height}): " f"bottom={bottom_count}, right={right_count}, " f"top={top_count}, left={left_count}" ) diff --git a/server/src/wled_controller/static/css/components.css b/server/src/wled_controller/static/css/components.css index 97af07c..df80fa4 100644 --- a/server/src/wled_controller/static/css/components.css +++ b/server/src/wled_controller/static/css/components.css @@ -1024,3 +1024,68 @@ textarea:focus-visible { width: 18px; height: 18px; } + +/* ── Schedule time picker (value sources) ── */ +.schedule-row { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 6px; +} +.schedule-time-wrap { + display: flex; + align-items: center; + gap: 2px; + background: var(--bg-color); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + padding: 2px 6px; + transition: border-color var(--duration-fast) ease; +} +.schedule-time-wrap:focus-within { + border-color: var(--primary-color); + box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.15); +} +.schedule-time-wrap input[type="number"] { + width: 2.4ch; + text-align: center; + font-size: 1.1rem; + font-weight: 600; + font-variant-numeric: tabular-nums; + font-family: inherit; + background: var(--bg-secondary); + border: 1px solid transparent; + border-radius: var(--radius-sm); + color: var(--text-color); + padding: 4px 2px; + -moz-appearance: textfield; + transition: border-color var(--duration-fast) ease, + background var(--duration-fast) ease; +} +.schedule-time-wrap input[type="number"]::-webkit-inner-spin-button, +.schedule-time-wrap input[type="number"]::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; +} +.schedule-time-wrap input[type="number"]:focus { + outline: none; + border-color: var(--primary-color); + background: color-mix(in srgb, var(--primary-color) 8%, var(--bg-secondary)); +} +.schedule-time-colon { + font-size: 1.1rem; + font-weight: 700; + color: var(--text-muted); + line-height: 1; + padding: 0 1px; + user-select: none; +} +.schedule-value { + flex: 1; + min-width: 60px; +} +.schedule-value-display { + min-width: 2.5ch; + text-align: right; + font-variant-numeric: tabular-nums; +} diff --git a/server/src/wled_controller/static/js/core/card-sections.ts b/server/src/wled_controller/static/js/core/card-sections.ts index a506932..07b661e 100644 --- a/server/src/wled_controller/static/js/core/card-sections.ts +++ b/server/src/wled_controller/static/js/core/card-sections.ts @@ -146,9 +146,7 @@ export class CardSection { ? `
+
` : ''; - const emptyState = (count === 0 && this.emptyKey) - ? `
${t(this.emptyKey)}
` - : ''; + const emptyState = ''; return `
@@ -314,24 +312,9 @@ export class CardSection { const countEl = document.querySelector(`[data-cs-toggle="${this.sectionKey}"] .cs-count`); if (countEl && !this._filterValue) countEl.textContent = String(items.length); - // Show/hide empty state - if (this.emptyKey) { - let emptyEl = content.querySelector(`[data-cs-empty="${this.sectionKey}"]`) as HTMLElement | null; - if (items.length === 0) { - if (!emptyEl) { - emptyEl = document.createElement('div'); - emptyEl.className = 'cs-empty-state'; - emptyEl.setAttribute('data-cs-empty', this.sectionKey); - emptyEl.innerHTML = `${t(this.emptyKey)}`; - const addCard = content.querySelector('.cs-add-card'); - if (addCard) content.insertBefore(emptyEl, addCard); - else content.appendChild(emptyEl); - } - emptyEl.style.display = ''; - } else if (emptyEl) { - emptyEl.style.display = 'none'; - } - } + // Remove any stale empty-state element from DOM + const staleEmpty = content.querySelector(`[data-cs-empty="${this.sectionKey}"]`); + if (staleEmpty) staleEmpty.remove(); const newMap = new Map(items.map(i => [i.key, i.html])); const addCard = content.querySelector('.cs-add-card'); diff --git a/server/src/wled_controller/static/js/features/color-strips-test.ts b/server/src/wled_controller/static/js/features/color-strips-test.ts index a91a2c8..acdf1c5 100644 --- a/server/src/wled_controller/static/js/features/color-strips-test.ts +++ b/server/src/wled_controller/static/js/features/color-strips-test.ts @@ -383,10 +383,18 @@ function _cssTestConnect(sourceId: string, ledCount: number, fps?: number) { _cssTestWs.onerror = () => { if (gen !== _cssTestGeneration) return; (document.getElementById('css-test-status') as HTMLElement).textContent = t('color_strip.test.error'); + (document.getElementById('css-test-status') as HTMLElement).style.display = ''; }; - _cssTestWs.onclose = () => { - if (gen === _cssTestGeneration) _cssTestWs = null; + _cssTestWs.onclose = (ev) => { + if (gen !== _cssTestGeneration) return; + _cssTestWs = null; + // Show server-provided close reason (e.g. "No LEDs configured") + if (ev.reason) { + const statusEl = document.getElementById('css-test-status') as HTMLElement; + statusEl.textContent = ev.reason; + statusEl.style.display = ''; + } }; // Start render loop (only once) diff --git a/server/src/wled_controller/static/js/features/value-sources.ts b/server/src/wled_controller/static/js/features/value-sources.ts index 717ee46..19e3eb8 100644 --- a/server/src/wled_controller/static/js/features/value-sources.ts +++ b/server/src/wled_controller/static/js/features/value-sources.ts @@ -874,16 +874,73 @@ function _populatePictureSourceDropdown(selectedId: any) { export function addSchedulePoint(time: string = '', value: number = 1.0) { const list = document.getElementById('value-source-schedule-list'); if (!list) return; + const timeStr = time || '12:00'; + const [h, m] = timeStr.split(':').map(Number); + const pad = (n: number) => String(n).padStart(2, '0'); + const row = document.createElement('div'); row.className = 'schedule-row'; row.innerHTML = ` - +
+ + : + +
+ ${value} `; list.appendChild(row); + _wireScheduleTimePicker(row); +} + +function _wireScheduleTimePicker(row: HTMLElement) { + const hInput = row.querySelector('.schedule-h') as HTMLInputElement; + const mInput = row.querySelector('.schedule-m') as HTMLInputElement; + const hidden = row.querySelector('.schedule-time') as HTMLInputElement; + if (!hInput || !mInput || !hidden) return; + + const pad = (n: number) => String(n).padStart(2, '0'); + + function clamp(input: HTMLInputElement, min: number, max: number) { + let v = parseInt(input.value, 10); + if (isNaN(v)) v = min; + if (v < min) v = min; + if (v > max) v = max; + input.value = pad(v); + return v; + } + + function sync() { + const hv = clamp(hInput, 0, 23); + const mv = clamp(mInput, 0, 59); + hidden.value = `${pad(hv)}:${pad(mv)}`; + } + + [hInput, mInput].forEach(inp => { + inp.addEventListener('focus', () => inp.select()); + inp.addEventListener('input', sync); + inp.addEventListener('blur', sync); + inp.addEventListener('keydown', (e) => { + const isHour = inp.dataset.role === 'hour'; + const max = isHour ? 23 : 59; + if (e.key === 'ArrowUp') { + e.preventDefault(); + let v = parseInt(inp.value, 10) || 0; + inp.value = pad(v >= max ? 0 : v + 1); + sync(); + } else if (e.key === 'ArrowDown') { + e.preventDefault(); + let v = parseInt(inp.value, 10) || 0; + inp.value = pad(v <= 0 ? max : v - 1); + sync(); + } + }); + }); + + sync(); } function _getScheduleFromUI() { diff --git a/server/src/wled_controller/templates/modals/notification-history.html b/server/src/wled_controller/templates/modals/notification-history.html index fd0e78a..7e28213 100644 --- a/server/src/wled_controller/templates/modals/notification-history.html +++ b/server/src/wled_controller/templates/modals/notification-history.html @@ -10,9 +10,9 @@
-