diff --git a/playground/js/shapeseq/freeze-ui.js b/playground/js/shapeseq/freeze-ui.js new file mode 100644 index 0000000..745c009 --- /dev/null +++ b/playground/js/shapeseq/freeze-ui.js @@ -0,0 +1,159 @@ +/** + * Freeze UI — adds freeze/live toggle interaction to chain param rows. + * + * When frozen: + * - Param rows are dimmed by default (frozen state) + * - Tapping a param row toggles it to "live" (highlighted, receives ML deltas) + * - Live params show a secondary indicator (delta range bar) on the slider + * + * When not frozen: + * - All params show normally (no freeze indicators) + * + * @module shapeseq/freeze-ui + */ + +// ── Inject styles once ─────────────────────────────────────────────── + +let stylesInjected = false; + +function injectStyles() { + if (stylesInjected) return; + stylesInjected = true; + + const css = ` +/* ── Freeze UI states ───────────────────────────────────────── */ + +.param-frozen { + cursor: pointer; +} + +.param-frozen .chain-param-slider { + pointer-events: none; +} + +.param-live { + cursor: pointer; +} + +.param-live .chain-param-slider { + box-shadow: 0 0 0 2px rgba(0, 200, 100, 0.5); +} +`; + + const style = document.createElement('style'); + style.textContent = css; + document.head.appendChild(style); +} + +// ── FreezeUI ───────────────────────────────────────────────────────── + +export class FreezeUI { + /** + * @param {{ container: HTMLElement, freezeManager: import('./freeze.js').FreezeManager, onToggle: (flatIndex: number) => void }} opts + */ + constructor({ container, freezeManager, onToggle }) { + injectStyles(); + + this._container = container; + this._fm = freezeManager; + this._onToggle = onToggle; + this._clickHandler = this._handleClick.bind(this); + + // Delegate click handling on param rows + this._container.addEventListener('click', this._clickHandler); + } + + /** + * Update visual state of all param rows based on freeze state. + * Call this after freeze/unfreeze/toggle. + */ + update() { + if (!this._fm.isFrozen) { + // Remove all freeze styling + this._container.querySelectorAll('.chain-param-row').forEach(row => { + row.classList.remove('param-frozen', 'param-live'); + row.style.opacity = ''; + }); + return; + } + + const liveFlags = this._fm.getLiveFlags(); + if (!liveFlags) return; + + this._container.querySelectorAll('.chain-param-row').forEach(row => { + const idx = parseInt(row.dataset.flatIndex, 10); + if (isNaN(idx) || idx >= liveFlags.length) return; + + if (liveFlags[idx]) { + row.classList.add('param-live'); + row.classList.remove('param-frozen'); + row.style.opacity = ''; + } else { + row.classList.add('param-frozen'); + row.classList.remove('param-live'); + row.style.opacity = '0.4'; + } + }); + } + + /** + * Show delta indicators on live param sliders. + * @param {Float32Array} frozenParams + * @param {Float32Array} effectiveParams + */ + updateDeltaIndicators(frozenParams, effectiveParams) { + if (!this._fm.isFrozen || !frozenParams || !effectiveParams) return; + + this._container.querySelectorAll('.chain-param-row.param-live').forEach(row => { + const idx = parseInt(row.dataset.flatIndex, 10); + if (isNaN(idx)) return; + + // Show delta as a secondary mark on the slider + let indicator = row.querySelector('.delta-indicator'); + if (!indicator) { + indicator = document.createElement('div'); + indicator.className = 'delta-indicator'; + indicator.style.cssText = 'position:absolute;height:4px;background:rgba(0,140,255,0.6);border-radius:2px;bottom:0;pointer-events:none;transition:left 0.1s,width 0.1s;'; + const slider = row.querySelector('.chain-param-slider'); + if (slider) { + slider.parentElement.style.position = 'relative'; + slider.parentElement.appendChild(indicator); + } + } + + // Position indicator between frozen value and effective value + const frozen = frozenParams[idx]; + const effective = effectiveParams[idx]; + const left = Math.min(frozen, effective) * 100; + const width = Math.abs(effective - frozen) * 100; + indicator.style.left = left + '%'; + indicator.style.width = width + '%'; + }); + } + + /** + * @private + */ + _handleClick(e) { + if (!this._fm.isFrozen) return; + + const row = e.target.closest('.chain-param-row'); + if (!row) return; + + // Don't toggle when interacting with the slider itself + if (e.target.closest('.chain-param-slider')) return; + + const idx = parseInt(row.dataset.flatIndex, 10); + if (isNaN(idx)) return; + + this._onToggle(idx); + this.update(); + } + + /** + * Cleanup all DOM listeners. + */ + destroy() { + this._container.removeEventListener('click', this._clickHandler); + } +} diff --git a/playground/js/shapeseq/tests/freeze-ui.test.js b/playground/js/shapeseq/tests/freeze-ui.test.js new file mode 100644 index 0000000..1e55803 --- /dev/null +++ b/playground/js/shapeseq/tests/freeze-ui.test.js @@ -0,0 +1,366 @@ +import { describe, it, beforeEach } from 'node:test'; +import { strict as assert } from 'node:assert'; + +// --------------------------------------------------------------------------- +// Minimal DOM stubs — just enough for FreezeUI's DOM operations +// --------------------------------------------------------------------------- + +class StubClassList { + constructor() { this._set = new Set(); } + add(...names) { names.forEach(n => this._set.add(n)); } + remove(...names) { names.forEach(n => this._set.delete(n)); } + contains(name) { return this._set.has(name); } +} + +class StubStyle { + constructor() { + this.opacity = ''; + this.position = ''; + this.cssText = ''; + this.left = ''; + this.width = ''; + } +} + +class StubElement { + constructor(tag) { + this.tagName = tag.toUpperCase(); + this._className = ''; + this.textContent = ''; + this.type = ''; + this.dataset = {}; + this.classList = new StubClassList(); + this.style = new StubStyle(); + this.children = []; + this.parentElement = null; + this._listeners = {}; + } + get className() { return this._className; } + set className(val) { + this._className = val; + // Sync classList with className string + this.classList._set.clear(); + val.split(/\s+/).filter(Boolean).forEach(c => this.classList._set.add(c)); + } + appendChild(child) { + child.parentElement = this; + this.children.push(child); + return child; + } + querySelector(sel) { + return this._queryAll(sel)[0] || null; + } + querySelectorAll(sel) { + return this._queryAll(sel); + } + _queryAll(sel) { + const results = []; + this._walk(el => { + if (matchesSelector(el, sel)) results.push(el); + }); + return results; + } + _walk(fn) { + for (const child of this.children) { + fn(child); + child._walk(fn); + } + } + closest(sel) { + let cur = this; + while (cur) { + if (matchesSelector(cur, sel)) return cur; + cur = cur.parentElement; + } + return null; + } + addEventListener(type, fn) { + if (!this._listeners[type]) this._listeners[type] = []; + this._listeners[type].push(fn); + } + removeEventListener(type, fn) { + if (!this._listeners[type]) return; + this._listeners[type] = this._listeners[type].filter(f => f !== fn); + } + dispatchEvent(evt) { + const handlers = this._listeners[evt.type] || []; + for (const h of handlers) h(evt); + // Bubble + if (evt.bubbles && this.parentElement) { + this.parentElement.dispatchEvent(evt); + } + } +} + +/** + * Very basic selector matching: handles .class, .class1.class2, tag selectors + */ +function matchesSelector(el, sel) { + // Handle compound selectors like ".chain-param-row.param-live" + const parts = sel.split('.'); + const tagPart = parts[0]; // empty string if selector starts with '.' + const classParts = parts.slice(1); + + if (tagPart && el.tagName !== tagPart.toUpperCase()) return false; + for (const cls of classParts) { + if (!el.classList.contains(cls)) return false; + } + // If selector is just a tag with no classes + if (classParts.length === 0 && tagPart) { + return el.tagName === tagPart.toUpperCase(); + } + return classParts.length > 0 || tagPart !== ''; +} + +function createElement(tag) { + return new StubElement(tag); +} + +// Stub out globalThis.document so injectStyles() doesn't crash +globalThis.document = { + createElement, + head: { appendChild() {} }, +}; + +// --------------------------------------------------------------------------- +// Minimal FreezeManager stub +// --------------------------------------------------------------------------- + +class StubFreezeManager { + constructor() { + this._frozen = false; + this._liveFlags = null; + } + get isFrozen() { return this._frozen; } + freeze(paramCount) { + this._frozen = true; + this._liveFlags = new Uint8Array(paramCount); + } + unfreeze() { + this._frozen = false; + this._liveFlags = null; + } + getLiveFlags() { return this._liveFlags; } + toggleParam(idx) { + if (!this._frozen) throw new Error('not frozen'); + this._liveFlags[idx] = this._liveFlags[idx] ? 0 : 1; + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function buildContainer(paramCount) { + const container = createElement('div'); + for (let i = 0; i < paramCount; i++) { + const row = createElement('div'); + row.classList.add('chain-param-row'); + row.dataset.flatIndex = String(i); + + const label = createElement('span'); + label.classList.add('chain-param-label'); + label.textContent = 'p' + i; + row.appendChild(label); + + const slider = createElement('input'); + slider.type = 'range'; + slider.classList.add('chain-param-slider'); + slider.dataset.flatIndex = String(i); + row.appendChild(slider); + + container.appendChild(row); + } + return container; +} + +function makeClickEvent(bubbles = true) { + return { type: 'click', bubbles, target: null }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('FreezeUI', () => { + let FreezeUI; + let freezeUI; + let container; + let fm; + let toggleLog; + + beforeEach(async () => { + if (!FreezeUI) { + const mod = await import('../freeze-ui.js'); + FreezeUI = mod.FreezeUI; + } + + container = buildContainer(4); + fm = new StubFreezeManager(); + toggleLog = []; + + freezeUI = new FreezeUI({ + container, + freezeManager: fm, + onToggle: (idx) => toggleLog.push(idx), + }); + }); + + // ── Construction ────────────────────────────────────────────────── + + it('can be constructed', () => { + assert.ok(freezeUI, 'FreezeUI instance should exist'); + assert.ok(freezeUI._container === container, 'container reference stored'); + }); + + // ── update() when frozen ────────────────────────────────────────── + + it('update() adds param-frozen class to all rows when frozen', () => { + fm.freeze(4); + freezeUI.update(); + + const rows = container.querySelectorAll('.chain-param-row'); + for (const row of rows) { + assert.ok(row.classList.contains('param-frozen'), + 'row ' + row.dataset.flatIndex + ' should have param-frozen'); + assert.ok(!row.classList.contains('param-live'), + 'row ' + row.dataset.flatIndex + ' should NOT have param-live'); + assert.strictEqual(row.style.opacity, '0.4', + 'frozen row should be dimmed'); + } + }); + + it('update() adds param-live class for live params when frozen', () => { + fm.freeze(4); + fm.toggleParam(1); + fm.toggleParam(3); + freezeUI.update(); + + const rows = container.querySelectorAll('.chain-param-row'); + + // Row 0: frozen + assert.ok(rows[0].classList.contains('param-frozen')); + assert.ok(!rows[0].classList.contains('param-live')); + assert.strictEqual(rows[0].style.opacity, '0.4'); + + // Row 1: live + assert.ok(rows[1].classList.contains('param-live')); + assert.ok(!rows[1].classList.contains('param-frozen')); + assert.notStrictEqual(rows[1].style.opacity, '0.4'); + + // Row 2: frozen + assert.ok(rows[2].classList.contains('param-frozen')); + + // Row 3: live + assert.ok(rows[3].classList.contains('param-live')); + }); + + // ── update() when NOT frozen ────────────────────────────────────── + + it('update() removes freeze styling when not frozen', () => { + fm.freeze(4); + fm.toggleParam(1); + freezeUI.update(); + + // Now unfreeze + fm.unfreeze(); + freezeUI.update(); + + const rows = container.querySelectorAll('.chain-param-row'); + for (const row of rows) { + assert.ok(!row.classList.contains('param-frozen'), + 'row should NOT have param-frozen after unfreeze'); + assert.ok(!row.classList.contains('param-live'), + 'row should NOT have param-live after unfreeze'); + assert.strictEqual(row.style.opacity, '', + 'opacity should be cleared'); + } + }); + + // ── Click toggle ────────────────────────────────────────────────── + + it('clicking a param row label calls onToggle with the flat index', () => { + fm.freeze(4); + freezeUI.update(); + + // Click on the label of row 2 + const row2 = container.querySelectorAll('.chain-param-row')[2]; + const label = row2.querySelector('.chain-param-label'); + + const evt = makeClickEvent(); + evt.target = label; + label.dispatchEvent(evt); + + assert.deepStrictEqual(toggleLog, [2], 'onToggle should be called with index 2'); + }); + + it('clicking does nothing when not frozen', () => { + const row0 = container.querySelectorAll('.chain-param-row')[0]; + const label = row0.querySelector('.chain-param-label'); + + const evt = makeClickEvent(); + evt.target = label; + label.dispatchEvent(evt); + + assert.deepStrictEqual(toggleLog, [], 'no toggle when not frozen'); + }); + + it('clicking the slider itself does not toggle', () => { + fm.freeze(4); + freezeUI.update(); + + const row1 = container.querySelectorAll('.chain-param-row')[1]; + const slider = row1.querySelector('.chain-param-slider'); + + const evt = makeClickEvent(); + evt.target = slider; + slider.dispatchEvent(evt); + + assert.deepStrictEqual(toggleLog, [], 'slider click should not trigger toggle'); + }); + + // ── destroy() ───────────────────────────────────────────────────── + + it('destroy() removes the click listener', () => { + fm.freeze(4); + freezeUI.update(); + freezeUI.destroy(); + + const row0 = container.querySelectorAll('.chain-param-row')[0]; + const label = row0.querySelector('.chain-param-label'); + + const evt = makeClickEvent(); + evt.target = label; + label.dispatchEvent(evt); + + assert.deepStrictEqual(toggleLog, [], 'no toggle after destroy'); + }); + + // ── updateDeltaIndicators() ─────────────────────────────────────── + + it('updateDeltaIndicators() creates delta-indicator elements on live rows', () => { + fm.freeze(4); + fm.toggleParam(1); + freezeUI.update(); + + const frozenParams = new Float32Array([0.3, 0.5, 0.7, 0.2]); + const effectiveParams = new Float32Array([0.3, 0.8, 0.7, 0.2]); + + freezeUI.updateDeltaIndicators(frozenParams, effectiveParams); + + // Row 1 is live — should have a delta indicator + const row1 = container.querySelectorAll('.chain-param-row')[1]; + const indicator = row1.querySelector('.delta-indicator'); + assert.ok(indicator, 'live row should have a delta-indicator'); + }); + + it('updateDeltaIndicators() does nothing when not frozen', () => { + const frozenParams = new Float32Array([0.3, 0.5, 0.7, 0.2]); + const effectiveParams = new Float32Array([0.3, 0.8, 0.7, 0.2]); + + freezeUI.updateDeltaIndicators(frozenParams, effectiveParams); + + const indicators = container.querySelectorAll('.delta-indicator'); + assert.strictEqual(indicators.length, 0, 'no indicators when not frozen'); + }); +});