diff --git a/src/uix/sema/components/index.ts b/src/uix/sema/components/index.ts index fdfa7aee8..18ebaf7f3 100644 --- a/src/uix/sema/components/index.ts +++ b/src/uix/sema/components/index.ts @@ -27,6 +27,7 @@ export { ratingGroupSema } from './rating-group' export { searchFieldSema } from './search-field' export { selectSema } from './select' export { sliderSema } from './slider' +export { splitterSema } from './splitter' export { stepperSema } from './stepper' export { tabsSema } from './tabs' export { tagGroupSema } from './tag-group' diff --git a/src/uix/sema/components/splitter.ts b/src/uix/sema/components/splitter.ts new file mode 100644 index 000000000..c9b606dd3 --- /dev/null +++ b/src/uix/sema/components/splitter.ts @@ -0,0 +1,55 @@ +import { semaSelector } from '$uix/morfo'; +import { splitterMorfo } from '$uix/morfo/components/splitter'; +import { sound } from '../sounds'; +import type { Sema } from '../sema-map'; + +/** + * Splitter perceptual defaults — mirrors the Slider's three-event shape + * (handle-pick → handle-drag → commit-set) with a heavier sonic register + * appropriate to moving whole panels rather than a thumb on a track. + * + * Sound: + * - `handle-pick` → `handle.pickup.air` (gain 0.10, slightly softer + * than slider's 0.12 — splitters get grabbed more often) + * - `handle-drag` → channel-only rule. Per-emit `signal.overrides` + * are computed by soma (`SplitterResizeTriggerProvider`) via + * `resolveSplitterDragSound({ value01, velocity01, direction })` + * so the dynamic curve survives the cascade. + * - `commit-set` → `handle.snap.chime` + tap haptic for the + * "I committed to this layout" landing cue. + * + * Haptic mirrors slider: tick on pick, tick driven by velocity during + * drag (also from soma overrides), tap on commit. + */ + +const onResizeTrigger = ( + matchers?: Parameters>[2] +) => semaSelector(splitterMorfo, 'resize-trigger', matchers); + +const onProvider = (matchers?: Parameters>[2]) => + semaSelector(splitterMorfo, 'provider', matchers); + +export const splitterSema: Sema = { + name: 'splitter', + cascade: [ + { + selector: onResizeTrigger({ eventName: 'handle-pick' }), + channels: ['sound', 'haptic'], + sound: sound('handle.pickup.air', { gain: 0.1 }), + haptic: { kind: 'tick', intensity: 0.2, duration: 6 } + }, + { + // Channels-only — the per-emit `signal.overrides` from soma + // own the dynamic pitch/centroid/gain. Adding a `sound:` here + // would clobber them. + selector: onResizeTrigger({ eventName: 'handle-drag' }), + channels: ['sound', 'haptic'] + }, + { + selector: onProvider({ eventName: 'commit-set' }), + channels: ['sound', 'haptic'], + sound: sound('handle.snap.chime', { gain: 0.14 }), + haptic: { kind: 'tap', intensity: 0.5, duration: 22 } + } + ] +}; diff --git a/src/uix/sema/exports.ts b/src/uix/sema/exports.ts index 595fa855d..b7a1e6ae7 100644 --- a/src/uix/sema/exports.ts +++ b/src/uix/sema/exports.ts @@ -92,6 +92,7 @@ export { resolveSoundDefinition, resolveHandleDragSound, resolveSliderDragSound, + resolveSplitterDragSound, type DragSoundParams, type SampleSoundDefinition, type SoundDefinition, diff --git a/src/uix/sema/sounds.ts b/src/uix/sema/sounds.ts index 24fb61554..d3ad5b54d 100644 --- a/src/uix/sema/sounds.ts +++ b/src/uix/sema/sounds.ts @@ -205,6 +205,40 @@ export function resolveSliderDragSound(params: DragSoundParams): SoundSignature }; } +/** + * Splitter handle drag sound resolver. + * + * Splitters move whole panels, not a thumb on a track. The gesture has + * more body and lower kinetic register than a slider, so the curve is + * tuned to read as a heavier, thicker timbre: + * + * - Lower pitch range (480..820 Hz vs slider's 760..1280) — "thicker" + * - Lower centroid ceiling (2400..4800 vs 3600..6800) — less brilliant + * - Slightly more roughness (0.006) — woody texture, not metallic + * - Slightly longer envelope (attack 16, decay 72, duration 76) — + * the surface being moved has perceptual mass + * - Modest gain floor (0.025) climbing with velocity — splitters can + * drag for long swipes, gain ceiling stays restrained + * + * `value01` = current handle position in `[0, 1]` (size of the panel + * before the handle as a fraction of total container size). `velocity01` + * = normalized motion speed computed by soma against frame deltas. + */ +export function resolveSplitterDragSound(params: DragSoundParams): SoundSignature { + const value01 = clamp(params.value01, 0, 1); + const velocity01 = clamp(params.velocity01, 0, 1); + return { + pitch: Math.round(lerp(480, 820, value01) + velocity01 * 90), + centroid: Math.round(lerp(2400, 4800, velocity01)), + roughness: 0.006, + attack: 16, + decay: 72, + duration: 76, + contour: params.direction, + gain: Number((0.025 + velocity01 * 0.03).toFixed(3)) + }; +} + function mergeSound(base: SoundSignature, overrides?: Partial): SoundSignature { const next = structuredClone(base); return overrides ? { ...next, ...overrides } : next; diff --git a/src/uix/soma/components/splitter/splitter-provider.svelte.ts b/src/uix/soma/components/splitter/splitter-provider.svelte.ts index 69c76348d..75682eebd 100644 --- a/src/uix/soma/components/splitter/splitter-provider.svelte.ts +++ b/src/uix/soma/components/splitter/splitter-provider.svelte.ts @@ -6,6 +6,15 @@ import { Soma } from '../../core/soma.svelte'; import { splitterMorfo } from '../../../morfo/components/splitter'; import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte'; +import { resolveSplitterDragSound, type SemaSignatureOverride } from '$uix/sema'; + +function nowMs(): number { + return typeof performance !== 'undefined' ? performance.now() : Date.now(); +} + +function clamp(value: number, min: number, max: number): number { + return value < min ? min : value > max ? max : value; +} // ── Types ──────────────────────────────────────────────────────────────────── @@ -377,6 +386,81 @@ export class SplitterResizeTriggerProvider { private dragStartSizeA = 0; private dragStartSizeB = 0; + // ── Sema drag-signal throttling ────────────────────────────────────────── + // + // `handle-drag` fires per pointermove frame, but the sound/haptic + // engine doesn't need a new payload every 16ms — it gets crowded + // and burns audio nodes. Throttle to ~14 Hz (72ms) like the slider + // does: queue the next pending percent, fire one signal per frame + // after the throttle window. Pitch/centroid/gain come from the + // position+velocity curve in `resolveSplitterDragSound`. + private dragSignalFrame: number | null = null; + private pendingDragPercent: number | null = null; + private lastDragSignalAt = 0; + private lastDragPercent = 0; + private static readonly DRAG_SIGNAL_MS = 72; + + private resetDragSignal(percent01: number): void { + this.lastDragSignalAt = 0; + this.lastDragPercent = clamp(percent01, 0, 1); + this.pendingDragPercent = null; + } + + private dragSignalOverrides(percent01: number): SemaSignatureOverride { + const percent = clamp(percent01, 0, 1); + const now = nowMs(); + const delta = percent - this.lastDragPercent; + const dt = this.lastDragSignalAt > 0 ? Math.max(now - this.lastDragSignalAt, 16) : 16; + const velocity01 = clamp((Math.abs(delta) / dt) * 1000, 0, 1); + + this.lastDragSignalAt = now; + this.lastDragPercent = percent; + + return { + sound: resolveSplitterDragSound({ + value01: percent, + velocity01, + direction: delta >= 0 ? 'ascending' : 'descending' + }), + haptic: { + kind: 'tick', + intensity: Number((0.16 + velocity01 * 0.16).toFixed(3)), + duration: 6 + } + }; + } + + private queueHandleDrag(percent01: number): void { + this.pendingDragPercent = percent01; + if (this.dragSignalFrame !== null) return; + this.dragSignalFrame = this.provider.soma.dom.requestFrame(() => { + this.dragSignalFrame = null; + if (!this.dragging || this.pendingDragPercent === null) return; + + const now = nowMs(); + if ( + this.lastDragSignalAt > 0 && + now - this.lastDragSignalAt < SplitterResizeTriggerProvider.DRAG_SIGNAL_MS + ) { + return; + } + + const nextPercent = this.pendingDragPercent; + this.pendingDragPercent = null; + void this.provider.runtime.trigger('handle-drag', { + overrides: this.dragSignalOverrides(nextPercent) + }); + }, this.opts.ref?.current); + } + + private cancelQueuedHandleDrag(): void { + if (this.dragSignalFrame !== null) { + this.provider.soma.dom.cancelFrame(this.dragSignalFrame, this.opts.ref?.current); + this.dragSignalFrame = null; + } + this.pendingDragPercent = null; + } + private constructor(opts: SplitterResizeTriggerOpts) { this.opts = opts; this.provider = SplitterProvider.require(); @@ -404,6 +488,7 @@ export class SplitterResizeTriggerProvider { this.provider.notifyResizeEnd(); } this.pointerDown = false; + this.cancelQueuedHandleDrag(); }; }); } @@ -456,6 +541,11 @@ export class SplitterResizeTriggerProvider { // Capture immediately — resize handle has no child buttons to protect (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); + // Initialize the drag-signal baseline at the current handle + // position. The first `handle-drag` overrides will compute + // velocity from this anchor. + this.resetDragSignal(this.dragStartSizeA / 100); + // Sema: the user grabbed the handle. Coincident with the gesture. void this.provider.runtime.trigger('handle-pick'); }; @@ -488,8 +578,12 @@ export class SplitterResizeTriggerProvider { this.provider.resizePanels(this.panelBeforeId, this.panelAfterId, deltaPercent); - // Sema: continuous drag. Coincident with each motion frame. - void this.provider.runtime.trigger('handle-drag'); + // Sema: continuous drag. Throttled per-frame; the override + // payload (pitch / centroid / gain / haptic intensity) is + // computed from the current handle position + velocity in + // `queueHandleDrag` → `dragSignalOverrides`. + const currentPercent = this.provider.getPanelSize(this.panelBeforeId) / 100; + this.queueHandleDrag(currentPercent); }; readonly onpointerup = (_e: PointerEvent) => { @@ -497,6 +591,7 @@ export class SplitterResizeTriggerProvider { if (!this.dragging) return; this.dragging = false; this.provider.resizing = false; + this.cancelQueuedHandleDrag(); this.provider.notifyResizeEnd(); }; @@ -505,6 +600,7 @@ export class SplitterResizeTriggerProvider { if (this.dragging) { this.dragging = false; this.provider.resizing = false; + this.cancelQueuedHandleDrag(); this.provider.notifyResizeEnd(); } };