import { type Component, For, Show, createMemo, createSignal } from 'solid-js';
import { Tooltip } from './Tooltip';
import { Reset } from './icons/Icon';
import { PARAM_HELP } from './paramHelp';
import {
type DragMode,
type Range,
dragModeFrom,
valueFromAbsolute,
valueFromIncremental,
valueFromKey,
valueFromWheel,
} from './sliderInput';
interface Props {
name: string;
value: number;
min: number;
max: number;
step?: number;
unit?: string;
ticks?: number;
format?: (v: number) => string;
onChange: (v: number) => void;
/** When provided, shows a reset button that calls this. */
onReset?: () => void;
/** When provided, shows a (?) icon that opens a help popover. */
info?: string;
}
export const ParamSlider: Component<Props> = (props) => {
const fmt = createMemo(() => props.format ?? defaultFormat(props.max));
const range = createMemo<Range>(() => ({ min: props.min, max: props.max, step: props.step }));
const pct = createMemo(() => {
const v = props.value;
return Math.max(0, Math.min(1, (v - props.min) / (props.max - props.min)));
});
// Auto-look up help text by slider name. An explicit `info` prop overrides
// the dictionary, so callers can still set ad-hoc tooltips.
const helpText = createMemo(() => props.info ?? PARAM_HELP[props.name]);
let trackRef: HTMLDivElement | undefined;
const [editing, setEditing] = createSignal(false);
// Drag-state. Captured at pointerdown so the modifier mode stays stable
// across the whole drag — releasing Shift mid-drag won't suddenly snap
// the slider to the pointer's absolute position.
let dragMode: DragMode = 'absolute';
let dragStartX = 0;
let dragStartValue = 0;
const trackWidth = (): number => trackRef?.getBoundingClientRect().width ?? 0;
const trackLeft = (): number => trackRef?.getBoundingClientRect().left ?? 0;
const applyFromClientX = (clientX: number): void => {
if (!trackRef) return;
const w = trackWidth();
const next =
dragMode === 'absolute'
? valueFromAbsolute(clientX, trackLeft(), w, range())
: valueFromIncremental({
clientX,
startX: dragStartX,
startValue: dragStartValue,
rectWidth: w,
mode: dragMode,
range: range(),
});
props.onChange(next);
};
const onPointerDown = (e: PointerEvent & { currentTarget: HTMLDivElement }): void => {
// pointer capture makes the whole drag track this element, even off-track
e.currentTarget.setPointerCapture(e.pointerId);
dragMode = dragModeFrom(e);
dragStartX = e.clientX;
dragStartValue = props.value;
// Incremental modes (Shift/Alt) treat the click position as the anchor,
// not a jump target — only Δx changes the value.
if (dragMode === 'absolute') applyFromClientX(e.clientX);
};
const onPointerMove = (e: PointerEvent & { currentTarget: HTMLDivElement }): void => {
if (!e.currentTarget.hasPointerCapture(e.pointerId)) return;
applyFromClientX(e.clientX);
};
const onWheel = (e: WheelEvent): void => {
// Only intercept when the slider track is the wheel target — otherwise
// the editor panel becomes un-scrollable when the cursor sits on a slider.
e.preventDefault();
const next = valueFromWheel(props.value, e.deltaY, e, range());
props.onChange(next);
};
const onKeyDown = (e: KeyboardEvent): void => {
const next = valueFromKey(props.value, e.key, e, range());
if (next === null) return;
e.preventDefault();
props.onChange(next);
};
const commitText = (raw: string): void => {
const n = Number.parseFloat(raw);
if (Number.isFinite(n)) {
const clamped = Math.max(props.min, Math.min(props.max, n));
props.onChange(clamped);
}
setEditing(false);
};
return (
<div class="phys-slider">
<div class="phys-slider-head">
<span class="name">{props.name}</span>
<div style={{ display: 'flex', 'align-items': 'center', gap: '4px' }}>
<Show
when={editing()}
fallback={
<button
type="button"
class="phys-slider-value"
onClick={() => setEditing(true)}
title="Click to type a value"
>
{fmt()(props.value)}
{props.unit ?? ''}
</button>
}
>
<input
class="phys-slider-input"
type="number"
autofocus
value={props.value}
min={props.min}
max={props.max}
step={props.step ?? 'any'}
onBlur={(e) => commitText(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') e.currentTarget.blur();
else if (e.key === 'Escape') setEditing(false);
}}
/>
</Show>
<Show when={helpText()}>
<Tooltip text={helpText() ?? ''} />
</Show>
<Show when={props.onReset}>
<button
type="button"
class="phys-iconbtn"
style={{ padding: '3px' }}
onClick={() => props.onReset?.()}
title="Reset to preset default"
>
<Reset size={11} />
</button>
</Show>
</div>
</div>
<div
ref={trackRef}
class="phys-track-wrap"
style={{ cursor: 'pointer', 'touch-action': 'none' }}
tabIndex={0}
role="slider"
aria-label={props.name}
aria-valuemin={props.min}
aria-valuemax={props.max}
aria-valuenow={props.value}
title="Drag · Shift = fine · Alt = coarse · Wheel · Arrows / Home / End"
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onWheel={onWheel}
onKeyDown={onKeyDown}
>
<div class="phys-track-bg" style={{ 'pointer-events': 'none' }}>
<div class="phys-track-fill-el" style={{ width: `${pct() * 100}%` }} />
</div>
<div class="phys-thumb-el" style={{ left: `${pct() * 100}%`, 'pointer-events': 'none' }} />
<div class="phys-ticks">
<For each={Array.from({ length: props.ticks ?? 5 })}>{() => <span />}</For>
</div>
</div>
</div>
);
};
function defaultFormat(max: number): (v: number) => string {
if (max >= 100) return (v) => Math.round(v).toString();
if (max >= 10) return (v) => v.toFixed(1);
return (v) => v.toFixed(2);
}