From d6bda9afed3a2a5f1dc7ba637eb6686d0445422d Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sun, 8 Mar 2026 22:58:36 +0300 Subject: [PATCH] Unify process picker, improve notification CSS editor, remove notification led_count - Extract shared process picker module (core/process-picker.js) used by both automation conditions and notification CSS app filter - Remove led_count property from notification CSS source (backend + frontend) - Replace comma-separated app filter with newline-separated textarea + browse - Inline color cycle add button (+) into the color row - Fix notification app color layout to horizontal rows Co-Authored-By: Claude Opus 4.6 --- .../core/processing/notification_stream.py | 4 +- .../src/wled_controller/static/css/modal.css | 42 ++++++++ .../static/js/core/process-picker.js | 97 +++++++++++++++++++ .../static/js/features/automations.js | 68 +------------ .../static/js/features/color-strips.js | 25 +++-- .../wled_controller/static/locales/en.json | 4 +- .../wled_controller/static/locales/ru.json | 4 +- .../wled_controller/static/locales/zh.json | 4 +- server/src/wled_controller/static/sw.js | 2 +- .../storage/color_strip_source.py | 3 - .../storage/color_strip_store.py | 3 - .../templates/modals/css-editor.html | 14 ++- 12 files changed, 179 insertions(+), 91 deletions(-) create mode 100644 server/src/wled_controller/static/js/core/process-picker.js diff --git a/server/src/wled_controller/core/processing/notification_stream.py b/server/src/wled_controller/core/processing/notification_stream.py index dcc2879..7e7da1a 100644 --- a/server/src/wled_controller/core/processing/notification_stream.py +++ b/server/src/wled_controller/core/processing/notification_stream.py @@ -70,8 +70,8 @@ class NotificationColorStripStream(ColorStripStream): self._app_colors = dict(getattr(source, "app_colors", {})) self._app_filter_mode = getattr(source, "app_filter_mode", "off") self._app_filter_list = list(getattr(source, "app_filter_list", [])) - self._auto_size = not source.led_count - self._led_count = source.led_count if source.led_count and source.led_count > 0 else 1 + self._auto_size = not getattr(source, "led_count", 0) + self._led_count = getattr(source, "led_count", 0) if getattr(source, "led_count", 0) > 0 else 1 with self._colors_lock: self._colors: Optional[np.ndarray] = np.zeros((self._led_count, 3), dtype=np.uint8) diff --git a/server/src/wled_controller/static/css/modal.css b/server/src/wled_controller/static/css/modal.css index ff45186..399ca7b 100644 --- a/server/src/wled_controller/static/css/modal.css +++ b/server/src/wled_controller/static/css/modal.css @@ -912,6 +912,48 @@ line-height: 1; } +.color-cycle-add-btn { + width: 36px; + height: 28px; + min-width: unset; + padding: 0; + font-size: 1.1rem; + line-height: 1; +} + +/* ── Notification app color mappings ─────────────────────────── */ + +.notif-app-color-row { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 4px; +} + +.notif-app-color-row .notif-app-name { + flex: 1; + min-width: 0; +} + +.notif-app-color-row .notif-app-color { + width: 36px; + height: 32px; + border: 1px solid var(--border-color); + border-radius: 4px; + padding: 1px; + cursor: pointer; + background: transparent; + flex-shrink: 0; +} + +.notif-app-color-remove { + font-size: 0.7rem; + padding: 0 4px; + min-width: unset; + height: 32px; + line-height: 1; +} + /* ── Composite layer editor ────────────────────────────────────── */ #composite-layers-list { diff --git a/server/src/wled_controller/static/js/core/process-picker.js b/server/src/wled_controller/static/js/core/process-picker.js new file mode 100644 index 0000000..24567e2 --- /dev/null +++ b/server/src/wled_controller/static/js/core/process-picker.js @@ -0,0 +1,97 @@ +/** + * Shared process picker — reusable UI for browsing running processes + * and adding them to a textarea (one app per line). + * + * Usage: + * import { attachProcessPicker } from '../core/process-picker.js'; + * attachProcessPicker(containerEl, textareaEl); + * + * The container must already contain: + * + * + */ + +import { fetchWithAuth } from './api.js'; +import { t } from './i18n.js'; +import { escapeHtml } from './api.js'; + +function renderList(picker, processes, existing) { + const listEl = picker.querySelector('.process-picker-list'); + if (processes.length === 0) { + listEl.innerHTML = `
${t('automations.condition.application.no_processes')}
`; + return; + } + listEl.innerHTML = processes.map(p => { + const added = existing.has(p.toLowerCase()); + return `
${escapeHtml(p)}${added ? ' \u2713' : ''}
`; + }).join(''); + + listEl.querySelectorAll('.process-picker-item:not(.added)').forEach(item => { + item.addEventListener('click', () => { + const proc = item.dataset.process; + const textarea = picker._textarea; + const current = textarea.value.trim(); + textarea.value = current ? current + '\n' + proc : proc; + item.classList.add('added'); + item.textContent = proc + ' \u2713'; + picker._existing.add(proc.toLowerCase()); + }); + }); +} + +async function toggle(picker) { + if (picker.style.display !== 'none') { + picker.style.display = 'none'; + return; + } + + const listEl = picker.querySelector('.process-picker-list'); + const searchEl = picker.querySelector('.process-picker-search'); + searchEl.value = ''; + listEl.innerHTML = `
${t('common.loading')}
`; + picker.style.display = ''; + + try { + const resp = await fetchWithAuth('/system/processes'); + if (!resp.ok) throw new Error('Failed to fetch processes'); + const data = await resp.json(); + + const existing = new Set( + picker._textarea.value.split('\n').map(a => a.trim().toLowerCase()).filter(Boolean) + ); + + picker._processes = data.processes; + picker._existing = existing; + renderList(picker, data.processes, existing); + searchEl.focus(); + } catch (e) { + listEl.innerHTML = `
${e.message}
`; + } +} + +function filter(picker) { + const query = picker.querySelector('.process-picker-search').value.toLowerCase(); + const filtered = (picker._processes || []).filter(p => p.includes(query)); + renderList(picker, filtered, picker._existing || new Set()); +} + +/** + * Wire up a process picker inside `containerEl` to feed into `textareaEl`. + * containerEl must contain .btn-browse-apps, .process-picker, .process-picker-search. + */ +export function attachProcessPicker(containerEl, textareaEl) { + const browseBtn = containerEl.querySelector('.btn-browse-apps'); + const picker = containerEl.querySelector('.process-picker'); + if (!browseBtn || !picker) return; + + picker._textarea = textareaEl; + browseBtn.addEventListener('click', () => toggle(picker)); + + const searchInput = picker.querySelector('.process-picker-search'); + if (searchInput) { + searchInput.addEventListener('input', () => filter(picker)); + } +} diff --git a/server/src/wled_controller/static/js/features/automations.js b/server/src/wled_controller/static/js/features/automations.js index 857457d..c1a3bd4 100644 --- a/server/src/wled_controller/static/js/features/automations.js +++ b/server/src/wled_controller/static/js/features/automations.js @@ -9,8 +9,9 @@ import { showToast, showConfirm, setTabRefreshing } from '../core/ui.js'; import { Modal } from '../core/modal.js'; import { CardSection } from '../core/card-sections.js'; import { updateTabBadge } from './tabs.js'; -import { ICON_SETTINGS, ICON_START, ICON_PAUSE, ICON_CLOCK, ICON_AUTOMATION, ICON_HELP, ICON_OK, ICON_TIMER, ICON_MONITOR, ICON_RADIO, ICON_SCENE } from '../core/icons.js'; +import { ICON_SETTINGS, ICON_START, ICON_PAUSE, ICON_CLOCK, ICON_AUTOMATION, ICON_HELP, ICON_OK, ICON_TIMER, ICON_MONITOR, ICON_RADIO, ICON_SCENE, ICON_CLONE } from '../core/icons.js'; import { wrapCard } from '../core/card-colors.js'; +import { attachProcessPicker } from '../core/process-picker.js'; import { csScenes, createSceneCard } from './scene-presets.js'; class AutomationEditorModal extends Modal { @@ -558,11 +559,8 @@ function addAutomationConditionRow(condition) { `; - const browseBtn = container.querySelector('.btn-browse-apps'); - const picker = container.querySelector('.process-picker'); - browseBtn.addEventListener('click', () => toggleProcessPicker(picker, row)); - const searchInput = container.querySelector('.process-picker-search'); - searchInput.addEventListener('input', () => filterProcessPicker(picker)); + const textarea = container.querySelector('.condition-apps'); + attachProcessPicker(container, textarea); } renderFields(condType, condition); @@ -573,65 +571,7 @@ function addAutomationConditionRow(condition) { list.appendChild(row); } -async function toggleProcessPicker(picker, row) { - if (picker.style.display !== 'none') { - picker.style.display = 'none'; - return; - } - const listEl = picker.querySelector('.process-picker-list'); - const searchEl = picker.querySelector('.process-picker-search'); - searchEl.value = ''; - listEl.innerHTML = `
${t('common.loading')}
`; - picker.style.display = ''; - - try { - const resp = await fetchWithAuth('/system/processes'); - if (!resp.ok) throw new Error('Failed to fetch processes'); - const data = await resp.json(); - - const textarea = row.querySelector('.condition-apps'); - const existing = new Set(textarea.value.split('\n').map(a => a.trim().toLowerCase()).filter(Boolean)); - - picker._processes = data.processes; - picker._existing = existing; - renderProcessPicker(picker, data.processes, existing); - searchEl.focus(); - } catch (e) { - listEl.innerHTML = `
${e.message}
`; - } -} - -function renderProcessPicker(picker, processes, existing) { - const listEl = picker.querySelector('.process-picker-list'); - if (processes.length === 0) { - listEl.innerHTML = `
${t('automations.condition.application.no_processes')}
`; - return; - } - listEl.innerHTML = processes.map(p => { - const added = existing.has(p.toLowerCase()); - return `
${escapeHtml(p)}${added ? ' \u2713' : ''}
`; - }).join(''); - - listEl.querySelectorAll('.process-picker-item:not(.added)').forEach(item => { - item.addEventListener('click', () => { - const proc = item.dataset.process; - const row = picker.closest('.automation-condition-row'); - const textarea = row.querySelector('.condition-apps'); - const current = textarea.value.trim(); - textarea.value = current ? current + '\n' + proc : proc; - item.classList.add('added'); - item.textContent = proc + ' \u2713'; - picker._existing.add(proc.toLowerCase()); - }); - }); -} - -function filterProcessPicker(picker) { - const query = picker.querySelector('.process-picker-search').value.toLowerCase(); - const filtered = (picker._processes || []).filter(p => p.includes(query)); - renderProcessPicker(picker, filtered, picker._existing || new Set()); -} function getAutomationEditorConditions() { const rows = document.querySelectorAll('#automation-conditions-list .automation-condition-row'); diff --git a/server/src/wled_controller/static/js/features/color-strips.js b/server/src/wled_controller/static/js/features/color-strips.js index 46dbe47..0a3e9d2 100644 --- a/server/src/wled_controller/static/js/features/color-strips.js +++ b/server/src/wled_controller/static/js/features/color-strips.js @@ -15,6 +15,7 @@ import { ICON_LINK, ICON_SPARKLES, ICON_ACTIVITY, ICON_CLOCK, ICON_BELL, } from '../core/icons.js'; import { wrapCard } from '../core/card-colors.js'; +import { attachProcessPicker } from '../core/process-picker.js'; class CSSEditorModal extends Modal { constructor() { @@ -117,7 +118,7 @@ export function onCSSTypeChange() { _syncAnimationSpeedState(); // LED count — only shown for picture, api_input, notification - const hasLedCount = ['picture', 'api_input', 'notification']; + const hasLedCount = ['picture', 'api_input']; document.getElementById('css-editor-led-count-group').style.display = hasLedCount.includes(type) ? '' : 'none'; @@ -264,7 +265,7 @@ function _colorCycleRenderList() { onclick="colorCycleRemoveColor(${i})">✕` : `
`} - `).join(''); + `).join('') + `
`; } export function colorCycleAddColor() { @@ -597,10 +598,10 @@ function _notificationAppColorsRenderList() { const list = document.getElementById('notification-app-colors-list'); if (!list) return; list.innerHTML = _notificationAppColors.map((entry, i) => ` -
- +
+ -
`).join(''); @@ -647,8 +648,9 @@ function _loadNotificationState(css) { document.getElementById('css-editor-notification-duration-val').textContent = dur; document.getElementById('css-editor-notification-default-color').value = css.default_color || '#ffffff'; document.getElementById('css-editor-notification-filter-mode').value = css.app_filter_mode || 'off'; - document.getElementById('css-editor-notification-filter-list').value = (css.app_filter_list || []).join(', '); + document.getElementById('css-editor-notification-filter-list').value = (css.app_filter_list || []).join('\n'); onNotificationFilterModeChange(); + _attachNotificationProcessPicker(); // App colors dict → list const ac = css.app_colors || {}; @@ -666,11 +668,18 @@ function _resetNotificationState() { document.getElementById('css-editor-notification-filter-mode').value = 'off'; document.getElementById('css-editor-notification-filter-list').value = ''; onNotificationFilterModeChange(); + _attachNotificationProcessPicker(); _notificationAppColors = []; _notificationAppColorsRenderList(); _showNotificationEndpoint(null); } +function _attachNotificationProcessPicker() { + const container = document.getElementById('css-editor-notification-filter-picker-container'); + const textarea = document.getElementById('css-editor-notification-filter-list'); + if (container && textarea) attachProcessPicker(container, textarea); +} + function _showNotificationEndpoint(cssId) { const el = document.getElementById('css-editor-notification-endpoint'); if (!el) return; @@ -811,7 +820,6 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) { ${defColor.toUpperCase()} ${appCount > 0 ? `${ICON_PALETTE} ${appCount} ${t('color_strip.notification.app_count')}` : ''} - ${source.led_count ? `${ICON_LED} ${source.led_count}` : ''} `; } else { const ps = pictureSourceMap && pictureSourceMap[source.picture_source_id]; @@ -1155,7 +1163,7 @@ export async function saveCSSEditor() { if (!cssId) payload.source_type = 'api_input'; } else if (sourceType === 'notification') { const filterList = document.getElementById('css-editor-notification-filter-list').value - .split(',').map(s => s.trim()).filter(Boolean); + .split('\n').map(s => s.trim()).filter(Boolean); payload = { name, notification_effect: document.getElementById('css-editor-notification-effect').value, @@ -1164,7 +1172,6 @@ export async function saveCSSEditor() { app_filter_mode: document.getElementById('css-editor-notification-filter-mode').value, app_filter_list: filterList, app_colors: _notificationGetAppColorsDict(), - led_count: parseInt(document.getElementById('css-editor-led-count').value) || 0, }; if (!cssId) payload.source_type = 'notification'; } else { diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index 2b9b670..97e565a 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -826,8 +826,8 @@ "color_strip.notification.filter_mode.whitelist": "Whitelist", "color_strip.notification.filter_mode.blacklist": "Blacklist", "color_strip.notification.filter_list": "App List:", - "color_strip.notification.filter_list.hint": "Comma-separated app names for the filter.", - "color_strip.notification.filter_list.placeholder": "Discord, Slack, Telegram", + "color_strip.notification.filter_list.hint": "One app name per line. Use Browse to pick from running processes.", + "color_strip.notification.filter_list.placeholder": "Discord\nSlack\nTelegram", "color_strip.notification.app_colors": "App Colors", "color_strip.notification.app_colors.label": "Color Mappings:", "color_strip.notification.app_colors.hint": "Per-app color overrides. Each row maps an app name to a specific notification color.", diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index c3baf94..c0331c2 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -826,8 +826,8 @@ "color_strip.notification.filter_mode.whitelist": "Белый список", "color_strip.notification.filter_mode.blacklist": "Чёрный список", "color_strip.notification.filter_list": "Список приложений:", - "color_strip.notification.filter_list.hint": "Имена приложений через запятую.", - "color_strip.notification.filter_list.placeholder": "Discord, Slack, Telegram", + "color_strip.notification.filter_list.hint": "Одно имя приложения на строку. Используйте «Обзор» для выбора из запущенных процессов.", + "color_strip.notification.filter_list.placeholder": "Discord\nSlack\nTelegram", "color_strip.notification.app_colors": "Цвета приложений", "color_strip.notification.app_colors.label": "Назначения цветов:", "color_strip.notification.app_colors.hint": "Индивидуальные цвета для приложений. Каждая строка связывает имя приложения с цветом уведомления.", diff --git a/server/src/wled_controller/static/locales/zh.json b/server/src/wled_controller/static/locales/zh.json index 4dc65d8..9930c5e 100644 --- a/server/src/wled_controller/static/locales/zh.json +++ b/server/src/wled_controller/static/locales/zh.json @@ -826,8 +826,8 @@ "color_strip.notification.filter_mode.whitelist": "白名单", "color_strip.notification.filter_mode.blacklist": "黑名单", "color_strip.notification.filter_list": "应用列表:", - "color_strip.notification.filter_list.hint": "以逗号分隔的应用名称。", - "color_strip.notification.filter_list.placeholder": "Discord, Slack, Telegram", + "color_strip.notification.filter_list.hint": "每行一个应用名称。使用「浏览」从运行中的进程中选择。", + "color_strip.notification.filter_list.placeholder": "Discord\nSlack\nTelegram", "color_strip.notification.app_colors": "应用颜色", "color_strip.notification.app_colors.label": "颜色映射:", "color_strip.notification.app_colors.hint": "每个应用的自定义通知颜色。每行将一个应用名称映射到特定颜色。", diff --git a/server/src/wled_controller/static/sw.js b/server/src/wled_controller/static/sw.js index 9f698a5..3b40ea5 100644 --- a/server/src/wled_controller/static/sw.js +++ b/server/src/wled_controller/static/sw.js @@ -7,7 +7,7 @@ * - Navigation: network-first with offline fallback */ -const CACHE_NAME = 'ledgrab-v12'; +const CACHE_NAME = 'ledgrab-v14'; // Only pre-cache static assets (no auth required). // Do NOT pre-cache '/' — it requires API key auth and would cache an error page. diff --git a/server/src/wled_controller/storage/color_strip_source.py b/server/src/wled_controller/storage/color_strip_source.py index 3b1d94a..cb48901 100644 --- a/server/src/wled_controller/storage/color_strip_source.py +++ b/server/src/wled_controller/storage/color_strip_source.py @@ -238,7 +238,6 @@ class ColorStripSource: app_filter_mode=data.get("app_filter_mode") or "off", app_filter_list=app_filter_list, os_listener=bool(data.get("os_listener", False)), - led_count=data.get("led_count") or 0, ) # Default: "picture" type @@ -504,7 +503,6 @@ class NotificationColorStripSource(ColorStripSource): app_filter_mode: str = "off" # off | whitelist | blacklist app_filter_list: list = field(default_factory=list) # app names for filter os_listener: bool = False # whether to listen for OS notifications - led_count: int = 0 # 0 = use device LED count def to_dict(self) -> dict: d = super().to_dict() @@ -515,5 +513,4 @@ class NotificationColorStripSource(ColorStripSource): d["app_filter_mode"] = self.app_filter_mode d["app_filter_list"] = list(self.app_filter_list) d["os_listener"] = self.os_listener - d["led_count"] = self.led_count return d diff --git a/server/src/wled_controller/storage/color_strip_store.py b/server/src/wled_controller/storage/color_strip_store.py index 0c1dc53..f641c27 100644 --- a/server/src/wled_controller/storage/color_strip_store.py +++ b/server/src/wled_controller/storage/color_strip_store.py @@ -279,7 +279,6 @@ class ColorStripStore: app_filter_mode=app_filter_mode or "off", app_filter_list=app_filter_list if isinstance(app_filter_list, list) else [], os_listener=bool(os_listener) if os_listener is not None else False, - led_count=led_count, ) else: if calibration is None: @@ -472,8 +471,6 @@ class ColorStripStore: source.app_filter_list = app_filter_list if os_listener is not None: source.os_listener = bool(os_listener) - if led_count is not None: - source.led_count = led_count source.updated_at = datetime.utcnow() self._save() diff --git a/server/src/wled_controller/templates/modals/css-editor.html b/server/src/wled_controller/templates/modals/css-editor.html index ae27f9e..df0a8f4 100644 --- a/server/src/wled_controller/templates/modals/css-editor.html +++ b/server/src/wled_controller/templates/modals/css-editor.html @@ -145,7 +145,6 @@
- @@ -497,8 +496,17 @@ - - + +
+
+ +
+ + +