diff --git a/server/src/wled_controller/api/routes/output_targets.py b/server/src/wled_controller/api/routes/output_targets.py index 147de81..66b7d76 100644 --- a/server/src/wled_controller/api/routes/output_targets.py +++ b/server/src/wled_controller/api/routes/output_targets.py @@ -849,59 +849,42 @@ async def test_kc_target_ws( await websocket.accept() logger.info(f"KC test WS connected for {target_id} (fps={fps})") - capture_stream = None + # Use the shared LiveStreamManager so we share the capture stream with + # running LED targets instead of creating a competing DXGI duplicator. + live_stream_mgr = processor_manager_inst._live_stream_manager + live_stream = None + try: + live_stream = await asyncio.to_thread( + live_stream_mgr.acquire, target.picture_source_id + ) + logger.info(f"KC test WS acquired shared live stream for {target.picture_source_id}") + + prev_frame_ref = None + while True: loop_start = time.monotonic() - pil_image = None - capture_stream_local = None - try: - import httpx - # Reload chain each iteration for dynamic sources - chain = source_store_inst.resolve_stream_chain(target.picture_source_id) - raw_stream = chain["raw_stream"] + capture = await asyncio.to_thread(live_stream.get_latest_frame) - if isinstance(raw_stream, StaticImagePictureSource): - source = raw_stream.image_source - if source.startswith(("http://", "https://")): - async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client: - resp = await client.get(source) - resp.raise_for_status() - pil_image = Image.open(io.BytesIO(resp.content)).convert("RGB") - else: - from pathlib import Path - path = Path(source) - if path.exists(): - pil_image = Image.open(path).convert("RGB") + if capture is None or capture.image is None: + await asyncio.sleep(frame_interval) + continue - elif isinstance(raw_stream, ScreenCapturePictureSource): - try: - capture_tmpl = template_store_inst.get_template(raw_stream.capture_template_id) - except ValueError: - break - - if capture_tmpl.engine_type not in EngineRegistry.get_available_engines(): - break - - capture_stream_local = EngineRegistry.create_stream( - capture_tmpl.engine_type, raw_stream.display_index, capture_tmpl.engine_config - ) - capture_stream_local.initialize() - screen_capture = capture_stream_local.capture_frame() - if screen_capture is not None and isinstance(screen_capture.image, np.ndarray): - pil_image = Image.fromarray(screen_capture.image) - - else: - # VideoCaptureSource or other — not directly supported in WS test - break + # Skip if same frame object (no new capture yet) + if capture is prev_frame_ref: + await asyncio.sleep(frame_interval * 0.5) + continue + prev_frame_ref = capture + pil_image = Image.fromarray(capture.image) if isinstance(capture.image, np.ndarray) else None if pil_image is None: await asyncio.sleep(frame_interval) continue - # Apply postprocessing + # Apply postprocessing (if the source chain has PP templates) + chain = source_store_inst.resolve_stream_chain(target.picture_source_id) pp_template_ids = chain.get("postprocessing_template_ids", []) if pp_template_ids and pp_template_store_inst: img_array = np.array(pil_image) @@ -920,7 +903,7 @@ async def test_kc_target_ws( img_array = result except ValueError: pass - pil_image = Image.fromarray(img_array) + pil_image = Image.fromarray(img_array) # Extract colors img_array = np.array(pil_image) @@ -971,12 +954,6 @@ async def test_kc_target_ws( if isinstance(inner_e, WebSocketDisconnect): raise logger.warning(f"KC test WS frame error for {target_id}: {inner_e}") - finally: - if capture_stream_local: - try: - capture_stream_local.cleanup() - except Exception: - pass elapsed = time.monotonic() - loop_start sleep_time = frame_interval - elapsed @@ -988,9 +965,11 @@ async def test_kc_target_ws( except Exception as e: logger.error(f"KC test WS error for {target_id}: {e}", exc_info=True) finally: - if capture_stream: + if live_stream is not None: try: - capture_stream.cleanup() + await asyncio.to_thread( + live_stream_mgr.release, target.picture_source_id + ) except Exception: pass logger.info(f"KC test WS closed for {target_id}") diff --git a/server/src/wled_controller/static/css/patterns.css b/server/src/wled_controller/static/css/patterns.css index 7f60eb5..9fcb2aa 100644 --- a/server/src/wled_controller/static/css/patterns.css +++ b/server/src/wled_controller/static/css/patterns.css @@ -36,6 +36,7 @@ .stream-card-props { display: flex; flex-wrap: wrap; + align-items: flex-start; gap: 6px; margin-bottom: 8px; } @@ -59,7 +60,7 @@ } .stream-card-prop-full { - flex: 1 1 100%; + max-width: 100%; min-width: 0; white-space: nowrap; overflow: hidden; diff --git a/server/src/wled_controller/static/css/tree-nav.css b/server/src/wled_controller/static/css/tree-nav.css index 79864f4..e370e16 100644 --- a/server/src/wled_controller/static/css/tree-nav.css +++ b/server/src/wled_controller/static/css/tree-nav.css @@ -1,22 +1,20 @@ /* =========================== - Tree Sidebar Navigation + Tree Dropdown Navigation =========================== */ .tree-layout { display: flex; - gap: 20px; - align-items: flex-start; + flex-direction: column; + gap: 0; } .tree-sidebar { - width: 210px; - min-width: 210px; - flex-shrink: 0; position: sticky; - top: calc(var(--sticky-top, 90px) + 8px); - max-height: calc(100vh - var(--sticky-top, 90px) - 24px); - overflow-y: auto; - padding: 4px 0; + top: var(--sticky-top, 90px); + z-index: 20; + padding: 8px 0 4px; + /* dropdown panel positions against this */ + /* sticky already establishes containing block, but be explicit */ } .tree-content { @@ -24,62 +22,144 @@ min-width: 0; } -/* ── Group ── */ +/* ── Trigger bar ── */ -.tree-group { - margin-bottom: 2px; -} - -.tree-group:first-child > .tree-group-header { - margin-top: 0; -} - -.tree-group-header { - display: flex; +.tree-dd-trigger { + display: inline-flex; align-items: center; gap: 6px; - padding: 6px 10px; + padding: 5px 10px; cursor: pointer; - user-select: none; - font-size: 0.72rem; - font-weight: 700; - color: var(--text-muted); - border-radius: 6px; - transition: color 0.15s, background 0.15s; - text-transform: uppercase; - letter-spacing: 0.05em; - margin-top: 6px; -} - -.tree-group-header:hover { - color: var(--text-color); + border: 1px solid var(--border-color); + border-radius: 8px; background: var(--bg-secondary); + user-select: none; + font-size: 0.82rem; + color: var(--text-color); + transition: border-color 0.15s, background 0.15s; } -.tree-chevron { - font-size: 0.5rem; - width: 10px; - display: inline-block; - flex-shrink: 0; - transition: transform 0.2s ease; - color: var(--text-secondary); +.tree-dd-trigger:hover { + border-color: var(--primary-color); + background: color-mix(in srgb, var(--primary-color) 6%, var(--bg-secondary)); } -.tree-chevron.open { - transform: rotate(90deg); +.tree-dd-trigger.open { + border-color: var(--primary-color); } -.tree-node-icon { +.tree-dd-trigger-icon { flex-shrink: 0; line-height: 1; } -.tree-node-icon .icon { +.tree-dd-trigger-icon .icon { width: 14px; height: 14px; } -.tree-node-title { +.tree-dd-trigger-title { + font-weight: 600; + white-space: nowrap; +} + +.tree-dd-trigger-count { + background: var(--primary-color); + color: var(--primary-contrast); + font-size: 0.6rem; + font-weight: 600; + padding: 0 5px; + border-radius: 8px; + min-width: 16px; + text-align: center; +} + +.tree-dd-chevron { + font-size: 0.65rem; + color: var(--text-muted); + transition: transform 0.2s ease; + margin-left: 2px; +} + +.tree-dd-trigger.open .tree-dd-chevron { + transform: rotate(180deg); +} + +.tree-dd-extra { + display: inline-flex; + align-items: center; + gap: 4px; + margin-left: 6px; + border-left: 1px solid var(--border-color); + padding-left: 8px; +} + +/* ── Dropdown panel ── */ + +.tree-dd-panel { + display: none; + position: absolute; + top: 100%; + left: 0; + min-width: 240px; + max-width: 340px; + max-height: 70vh; + overflow-y: auto; + background: var(--bg-color); + border: 1px solid var(--border-color); + border-radius: 8px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25); + z-index: 100; + padding: 4px 0; + margin-top: 4px; +} + +.tree-dd-panel.open { + display: block; +} + +/* ── Group header (non-clickable category label) ── */ + +.tree-dd-group-header { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px 3px; + font-size: 0.68rem; + font-weight: 700; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.04em; + user-select: none; +} + +.tree-dd-group-header.tree-dd-depth-1 { + padding-left: 24px; + font-size: 0.72rem; + font-weight: 600; + text-transform: none; + letter-spacing: normal; +} + +.tree-dd-group-header.tree-dd-depth-2 { + padding-left: 36px; + font-size: 0.72rem; + font-weight: 600; + text-transform: none; + letter-spacing: normal; +} + +.tree-dd-group-header .tree-node-icon { + flex-shrink: 0; + line-height: 1; +} + +.tree-dd-group-header .tree-node-icon .icon { + width: 13px; + height: 13px; +} + +.tree-dd-group-title { flex: 1; min-width: 0; overflow: hidden; @@ -87,108 +167,76 @@ white-space: nowrap; } -.tree-group-count { +.tree-dd-group-count { background: var(--border-color); color: var(--text-secondary); - font-size: 0.6rem; + font-size: 0.58rem; font-weight: 600; - padding: 0 5px; + padding: 0 4px; border-radius: 8px; - flex-shrink: 0; - min-width: 16px; + min-width: 14px; text-align: center; + flex-shrink: 0; } -/* ── Nested sub-group (group inside a group) ── */ +/* ── Leaf (clickable item) ── */ -.tree-group-nested > .tree-group-header { - font-size: 0.75rem; - text-transform: none; - letter-spacing: normal; +.tree-dd-leaf { + display: flex; + align-items: center; + gap: 6px; + padding: 5px 12px 5px 20px; + cursor: pointer; + font-size: 0.8rem; + color: var(--text-secondary); + transition: color 0.1s, background 0.1s; +} + +/* Indent leaves inside nested groups */ +.tree-dd-group-header.tree-dd-depth-1 ~ .tree-dd-children > .tree-dd-leaf, +.tree-dd-group .tree-dd-group .tree-dd-leaf { + padding-left: 32px; +} + +.tree-dd-group .tree-dd-group .tree-dd-group .tree-dd-leaf { + padding-left: 44px; +} + +.tree-dd-leaf:hover { + color: var(--text-color); + background: var(--bg-secondary); +} + +.tree-dd-leaf.active { + color: var(--primary-text-color); + background: color-mix(in srgb, var(--primary-color) 12%, transparent); font-weight: 600; - margin-top: 2px; - padding: 4px 10px 4px 12px; } -/* ── Children (leaves) ── */ +.tree-dd-leaf.active .tree-count { + background: var(--primary-color); + color: var(--primary-contrast); +} -.tree-children { +.tree-dd-leaf .tree-node-icon { + flex-shrink: 0; + line-height: 1; +} + +.tree-dd-leaf .tree-node-icon .icon { + width: 14px; + height: 14px; +} + +.tree-dd-leaf .tree-node-title { + flex: 1; + min-width: 0; overflow: hidden; - margin-left: 14px; - border-left: 1px solid var(--border-color); - padding-left: 0; - max-height: 500px; - opacity: 1; - transition: max-height 0.25s ease, opacity 0.2s ease; + text-overflow: ellipsis; + white-space: nowrap; } -.tree-children.collapsed { - max-height: 0; - opacity: 0; -} - -.tree-leaf { - display: flex; - align-items: center; - gap: 6px; - padding: 5px 10px 5px 12px; - cursor: pointer; - font-size: 0.8rem; - color: var(--text-secondary); - border-radius: 0 6px 6px 0; - margin: 1px 0; - transition: color 0.15s, background 0.15s; -} - -.tree-leaf:hover { - color: var(--text-color); - background: var(--bg-secondary); -} - -.tree-leaf.active { - color: var(--primary-text-color); - background: color-mix(in srgb, var(--primary-color) 12%, transparent); - font-weight: 600; -} - -.tree-leaf.active .tree-count { - background: var(--primary-color); - color: var(--primary-contrast); -} - -/* ── Standalone leaf (top-level, no group) ── */ - -.tree-standalone { - display: flex; - align-items: center; - gap: 6px; - padding: 6px 10px; - cursor: pointer; - font-size: 0.8rem; - font-weight: 500; - color: var(--text-secondary); - border-radius: 6px; - margin: 1px 0; - transition: color 0.15s, background 0.15s; -} - -.tree-standalone:hover { - color: var(--text-color); - background: var(--bg-secondary); -} - -.tree-standalone.active { - color: var(--primary-text-color); - background: color-mix(in srgb, var(--primary-color) 12%, transparent); - font-weight: 600; -} - -.tree-standalone.active .tree-count { - background: var(--primary-color); - color: var(--primary-contrast); -} - -/* ── Count badge ── */ +/* ── Count badge (shared) ── */ .tree-count { background: var(--border-color); @@ -202,96 +250,10 @@ text-align: center; } -/* ── Extra (expand/collapse, tutorial buttons) ── */ +/* ── Group separator ── */ -.tree-extra { - padding: 8px 10px; - margin-top: 4px; +.tree-dd-group + .tree-dd-group { border-top: 1px solid var(--border-color); - display: flex; - gap: 4px; - align-items: center; -} - -/* ── Sidebar eats into card space — allow 2-col with smaller minmax ── */ - -.tree-content .displays-grid, -.tree-content .devices-grid { - grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); -} - -.tree-content .templates-grid { - grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); -} - -/* ── Responsive: stack on narrow screens ── */ - -@media (max-width: 900px) { - .tree-layout { - flex-direction: column; - align-items: stretch; - gap: 0; - } - - .tree-sidebar { - width: 100%; - min-width: unset; - position: static; - max-height: none; - overflow-y: visible; - padding: 0 0 8px 0; - margin-bottom: 8px; - border-bottom: 1px solid var(--border-color); - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 2px; - } - - .tree-group { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 2px; - margin-bottom: 0; - } - - .tree-group-header { - padding: 4px 8px; - text-transform: none; - letter-spacing: normal; - } - - .tree-children { - display: flex; - flex-wrap: wrap; - gap: 2px; - margin-left: 0; - border-left: none; - padding-left: 0; - max-height: none; - opacity: 1; - transition: none; - } - - .tree-children.collapsed { - display: none; - } - - .tree-leaf { - padding: 4px 10px; - margin: 0; - } - - .tree-standalone { - padding: 4px 10px; - } - - .tree-extra { - margin-top: 0; - border-top: none; - padding: 4px; - margin-left: auto; - } - + margin-top: 2px; + padding-top: 2px; } diff --git a/server/src/wled_controller/static/js/core/tree-nav.js b/server/src/wled_controller/static/js/core/tree-nav.js index 62a2aca..64ca647 100644 --- a/server/src/wled_controller/static/js/core/tree-nav.js +++ b/server/src/wled_controller/static/js/core/tree-nav.js @@ -1,6 +1,7 @@ /** - * TreeNav — hierarchical sidebar navigation for Targets and Sources tabs. - * Replaces flat sub-tab bars with a collapsible tree that groups related items. + * TreeNav — dropdown navigation for Targets and Sources tabs. + * Shows a compact trigger bar with the active leaf; click to open a + * grouped dropdown menu for switching between sections. * * Config format (supports arbitrary nesting): * [ @@ -14,20 +15,6 @@ import { t } from './i18n.js'; -const STORAGE_KEY = 'tree_nav_collapsed'; - -function _getCollapsedMap() { - try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}'); } - catch { return {}; } -} - -function _saveCollapsed(key, collapsed) { - const map = _getCollapsedMap(); - if (collapsed) map[key] = true; - else delete map[key]; - localStorage.setItem(STORAGE_KEY, JSON.stringify(map)); -} - /** Recursively sum leaf counts in a tree node. */ function _deepCount(node) { if (!node.children) return node.count || 0; @@ -48,6 +35,9 @@ export class TreeNav { this._activeLeaf = null; this._extraHtml = ''; this._observerSuppressed = false; + this._open = false; + this._outsideHandler = null; + this._escHandler = null; } /** Temporarily suppress scroll-spy (e.g. during programmatic scroll). */ @@ -58,7 +48,7 @@ export class TreeNav { } /** - * Full re-render of the tree. + * Full re-render of the nav. * @param {Array} items - tree structure (groups and/or standalone leaves) * @param {string} activeLeafKey */ @@ -74,9 +64,10 @@ export class TreeNav { const container = document.getElementById(this.containerId); if (!container) return; for (const [key, count] of Object.entries(countMap)) { - const el = container.querySelector(`[data-tree-leaf="${key}"] .tree-count`); - if (el) el.textContent = count; - // Also update in-memory + // Update leaves in dropdown panel + const els = container.querySelectorAll(`[data-tree-leaf="${key}"] .tree-count`); + els.forEach(el => { el.textContent = count; }); + // Update in-memory const leaf = this._leafMap.get(key); if (leaf) leaf.count = count; } @@ -85,20 +76,22 @@ export class TreeNav { groups.reverse(); for (const groupEl of groups) { let total = 0; - // Sum direct leaf children - for (const cnt of groupEl.querySelectorAll(':scope > .tree-children > .tree-leaf .tree-count')) { + for (const cnt of groupEl.querySelectorAll(':scope > .tree-dd-children > [data-tree-leaf] .tree-count')) { total += parseInt(cnt.textContent, 10) || 0; } - // Sum nested sub-group counts - for (const cnt of groupEl.querySelectorAll(':scope > .tree-children > .tree-group > .tree-group-header > .tree-group-count')) { + for (const cnt of groupEl.querySelectorAll(':scope > .tree-dd-children > [data-tree-group] > .tree-dd-group-header .tree-dd-group-count')) { total += parseInt(cnt.textContent, 10) || 0; } - const groupCount = groupEl.querySelector(':scope > .tree-group-header > .tree-group-count'); - if (groupCount) groupCount.textContent = total; + const gc = groupEl.querySelector(':scope > .tree-dd-group-header .tree-dd-group-count'); + if (gc) gc.textContent = total; + } + // Update trigger display if active leaf count changed + if (countMap[this._activeLeaf] !== undefined) { + this._updateTrigger(); } } - /** Set extra HTML appended at the bottom (expand/collapse buttons, etc.) */ + /** Set extra HTML appended in the trigger bar (expand/collapse buttons, etc.) */ setExtraHtml(html) { this._extraHtml = html; } @@ -108,13 +101,10 @@ export class TreeNav { this._activeLeaf = leafKey; const container = document.getElementById(this.containerId); if (!container) return; - container.querySelectorAll('.tree-leaf').forEach(el => - el.classList.toggle('active', el.dataset.treeLeaf === leafKey) - ); - // Also highlight standalone leaves - container.querySelectorAll('.tree-standalone').forEach(el => + container.querySelectorAll('[data-tree-leaf]').forEach(el => el.classList.toggle('active', el.dataset.treeLeaf === leafKey) ); + this._updateTrigger(); } /** Get leaf data for a key. */ @@ -130,6 +120,8 @@ export class TreeNav { return null; } + // ── internal ── + _buildLeafMap() { this._leafMap.clear(); this._collectLeaves(this._items); @@ -149,59 +141,133 @@ export class TreeNav { const container = document.getElementById(this.containerId); if (!container) return; - const collapsed = _getCollapsedMap(); + const leaf = this._leafMap.get(this._activeLeaf); + const triggerIcon = leaf?.icon || ''; + const triggerTitle = leaf ? t(leaf.titleKey) : ''; + const triggerCount = leaf?.count ?? 0; - const html = this._items.map(item => { - if (item.children) { - return this._renderGroup(item, collapsed, 0); - } - return this._renderStandalone(item); - }).join(''); + const panelHtml = this._items.map(item => + item.children ? this._renderGroup(item, 0) : this._renderLeaf(item) + ).join(''); + + container.innerHTML = ` +
+ ${triggerIcon} + ${triggerTitle} + ${triggerCount} + + ${this._extraHtml ? `${this._extraHtml}` : ''} +
+
+ ${panelHtml} +
`; - container.innerHTML = html + - (this._extraHtml ? `
${this._extraHtml}
` : ''); this._bindEvents(container); } - _renderGroup(group, collapsed, depth) { - const isCollapsed = !!collapsed[group.key]; + _renderGroup(group, depth) { const groupCount = _deepCount(group); - - const childrenHtml = group.children.map(child => { - if (child.children) { - return this._renderGroup(child, collapsed, depth + 1); - } - return ` -
- ${child.icon ? `${child.icon}` : ''} - ${t(child.titleKey)} - ${child.count ?? 0} -
`; - }).join(''); + const childrenHtml = group.children.map(child => + child.children ? this._renderGroup(child, depth + 1) : this._renderLeaf(child) + ).join(''); return ` -
-
- +
+
${group.icon ? `${group.icon}` : ''} - ${t(group.titleKey)} - ${groupCount} + ${t(group.titleKey)} + ${groupCount}
-
+
${childrenHtml}
`; } - _renderStandalone(leaf) { + _renderLeaf(leaf) { + const isActive = leaf.key === this._activeLeaf; return ` -
+
${leaf.icon ? `${leaf.icon}` : ''} ${t(leaf.titleKey)} ${leaf.count ?? 0}
`; } + _updateTrigger() { + const container = document.getElementById(this.containerId); + if (!container) return; + const leaf = this._leafMap.get(this._activeLeaf); + if (!leaf) return; + const icon = container.querySelector('.tree-dd-trigger-icon'); + const title = container.querySelector('.tree-dd-trigger-title'); + const count = container.querySelector('.tree-dd-trigger-count'); + if (icon) icon.innerHTML = leaf.icon || ''; + if (title) title.textContent = t(leaf.titleKey); + if (count) count.textContent = leaf.count ?? 0; + } + + _openDropdown(container) { + if (this._open) return; + this._open = true; + const panel = container.querySelector('[data-tree-panel]'); + const trigger = container.querySelector('[data-tree-trigger]'); + if (panel) panel.classList.add('open'); + if (trigger) trigger.classList.add('open'); + + this._outsideHandler = (e) => { + const inTrigger = trigger && trigger.contains(e.target); + const inPanel = panel && panel.contains(e.target); + if (!inTrigger && !inPanel) this._closeDropdown(container); + }; + this._escHandler = (e) => { + if (e.key === 'Escape') this._closeDropdown(container); + }; + // Use timeout so the current pointerdown doesn't immediately trigger close + setTimeout(() => { + window.addEventListener('mousedown', this._outsideHandler, true); + window.addEventListener('pointerdown', this._outsideHandler, true); + window.addEventListener('keydown', this._escHandler, true); + }, 0); + } + + _closeDropdown(container) { + if (!this._open) return; + this._open = false; + const panel = container.querySelector('[data-tree-panel]'); + const trigger = container.querySelector('[data-tree-trigger]'); + if (panel) panel.classList.remove('open'); + if (trigger) trigger.classList.remove('open'); + window.removeEventListener('mousedown', this._outsideHandler, true); + window.removeEventListener('pointerdown', this._outsideHandler, true); + window.removeEventListener('keydown', this._escHandler, true); + } + + _bindEvents(container) { + // Trigger click — toggle dropdown + const trigger = container.querySelector('[data-tree-trigger]'); + if (trigger) { + trigger.addEventListener('pointerdown', (e) => { + // Don't toggle when clicking extra buttons (expand/collapse/help) + if (e.target.closest('.tree-dd-extra')) return; + e.preventDefault(); + if (this._open) this._closeDropdown(container); + else this._openDropdown(container); + }); + } + + // Leaf click — select and close + container.querySelectorAll('[data-tree-leaf]').forEach(el => { + el.addEventListener('click', () => { + const key = el.dataset.treeLeaf; + this.setActive(key); + this._closeDropdown(container); + this.suppressObserver(); + if (this.onSelect) this.onSelect(key, this._leafMap.get(key)); + }); + }); + } + /** * Start observing card-section elements so the active tree leaf * follows whichever section is currently visible on screen. @@ -269,31 +335,4 @@ export class TreeNav { this._observer = null; } } - - _bindEvents(container) { - // Group header toggle - container.querySelectorAll('.tree-group-header').forEach(header => { - header.addEventListener('click', () => { - const key = header.dataset.treeGroupToggle; - const children = header.nextElementSibling; - if (!children) return; - const isNowCollapsed = children.classList.toggle('collapsed'); - const chevron = header.querySelector('.tree-chevron'); - if (chevron) chevron.classList.toggle('open', !isNowCollapsed); - _saveCollapsed(key, isNowCollapsed); - }); - }); - - // Leaf click (both grouped and standalone) - container.querySelectorAll('[data-tree-leaf]').forEach(el => { - el.addEventListener('click', (e) => { - // Don't trigger on group header clicks - if (el.closest('.tree-group-header')) return; - const key = el.dataset.treeLeaf; - this.setActive(key); - this.suppressObserver(); - if (this.onSelect) this.onSelect(key, this._leafMap.get(key)); - }); - }); - } }