Show number fields with three decimals and snap dragging to the step

Number fields derived their display precision from `step`, so `step: 1`
(X/Y/Z, width, height, font size) rendered a stored 100.234 as "100" — and
because `onChange` parses the text, touching the field wrote the rounded
value back into the project. `formatNumber` now renders every number with
three decimals, independent of `step`, which is left to control the scrub
and arrow-key increment only.

Dragging now snaps to the increment's grid (`snapToStep`), so a scrub at
`step: 1` always lands on whole numbers even from a fractional start, while
Alt (x0.1) and the 0.1-step fields keep their precision. Typed values and
arrow keys are untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-08 11:48:32 +02:00
1 parent 15ffcd5739
commit 6168df41f8
6 files changed
+104 -21

No files matched your search

+4 -1
View File
@@ -285,7 +285,10 @@ There is no authentication yet; `data/` is world-readable. Both are phase-2 topi
`Ctrl+Z` / `Ctrl+Shift+Z` / `Ctrl+Y` undo/redo · `Delete` remove selection · `Ctrl+G` create
container from the selection · `Ctrl+Enter` play · `Esc` stop · `Tab` cycles fields · number
fields: drag to scrub (`Shift` ×10, `Alt` ×0.1), `↑`/`↓` step · viewport: wheel zooms,
fields: drag to scrub (`Shift` ×10, `Alt` ×0.1, snapping to the grid of that increment, so a
step-1 field lands on whole numbers), `↑`/`↓` step, always shown with three
decimals (`ui/math.ts`, `NUMBER_DECIMALS`) so a fractional value never reads as a whole
number · viewport: wheel zooms,
middle-drag or `Shift`+drag pans, drag on empty space rubber-bands, `Ctrl`-click toggles an
object in the selection (hierarchy rows too).
+4 -2
View File
@@ -16,7 +16,7 @@ import type { BehaviorAsset } from '../../core/model/Asset';
import { findAsset, type Project } from '../../core/model/Project';
import type { PropertyValue } from '../../core/model/Properties';
import type { PropertyDef } from '../../core/properties/PropertySchema';
import { Theme, textStyle, type TextStyleToken, Widget } from '../../ui';
import { formatNumber, Theme, textStyle, type TextStyleToken, Widget } from '../../ui';
import { keyLabel } from '../inspector/fieldFactory';
/**
@@ -266,7 +266,9 @@ function formatParam(
}
function formatValue(value: Value): string {
if (typeof value === 'number') return Number.isInteger(value) ? String(value) : value.toFixed(2);
// Whole numbers stay bare to keep nodes narrow; fractions get the same precision as the
// field that edits them, so a node never rounds harder than the inspector.
if (typeof value === 'number') return Number.isInteger(value) ? String(value) : formatNumber(value);
if (typeof value === 'boolean') return value ? 'true' : 'false';
return asString(value);
}
+1 -1
View File
@@ -3,7 +3,7 @@ export { HStack, VStack, Spacer, Stack, type StackOptions } from './layout/Stack
export { Theme, textStyle, type TextStyleToken } from './Theme';
export { initUI, ui, type UIContext } from './UIContext';
export { drawControlBox, drawGrid, type ControlBoxOptions, type GridOptions } from './draw';
export { clamp, centreY, zoomFactor } from './math';
export { clamp, centreY, formatNumber, NUMBER_DECIMALS, snapToStep, zoomFactor } from './math';
export { Label } from './widgets/Label';
export { Button, type ButtonOptions } from './widgets/Button';
export { Dropdown } from './widgets/Dropdown';
+27
View File
@@ -8,6 +8,33 @@ export function centreY(containerHeight: number, itemHeight: number): number {
return Math.round((containerHeight - itemHeight) / 2);
}
/** Decimal places every number field shows. */
export const NUMBER_DECIMALS = 3;
/**
* Display text for a number. Always shows `decimals` places, so a value can never *look* like
* a whole number when it is not: precision used to follow the field's step, which hid the
* decimals that dragging in the viewport or a graph node had put into the value.
* A value that rounds to zero never carries a minus sign.
*/
export function formatNumber(value: number, decimals = NUMBER_DECIMALS): string {
const zero = (0).toFixed(decimals);
const text = value.toFixed(decimals);
return text === '-' + zero ? zero : text;
}
/**
* Rounds `value` onto the grid of `stepSize`, so scrubbing a field lands on clean values:
* with step 1 on whole numbers, with step 0.1 on tenths. Needed because neither input is
* whole – the pointer delta comes in fractional device pixels (a 150 % display yields
* thirds) and the starting value may already be fractional from a viewport drag.
* The `toFixed` pass removes binary-float noise (0.1 × 3 = 0.30000000000000004).
*/
export function snapToStep(value: number, stepSize: number): number {
if (!(stepSize > 0) || !Number.isFinite(value)) return value;
return Number((Math.round(value / stepSize) * stepSize).toFixed(NUMBER_DECIMALS));
}
/**
* Zoom multiplier for one wheel event. Exponential, so scrolling up and back down by the same
* amount lands exactly where it started. The caller clamps the result into its own range –
+13 -16
View File
@@ -1,5 +1,5 @@
import type { FederatedPointerEvent } from 'pixi.js';
import { clamp } from '../math';
import { clamp, formatNumber, snapToStep } from '../math';
import { ui } from '../UIContext';
import { TextField, type TextFieldOptions } from './TextField';
@@ -7,8 +7,8 @@ export interface NumberFieldOptions extends Omit<TextFieldOptions, 'onInput' | '
min?: number;
max?: number;
/**
* Increment per scrubbed pixel and per arrow key press. Its magnitude also sets the display
* precision: 0.1 shows one decimal, 1 shows none.
* Increment per scrubbed pixel and per arrow key press, and the grid a scrub snaps to.
* Does not affect the display, which always shows `NUMBER_DECIMALS` decimals.
*/
step?: number;
/** Fires continuously while scrubbing or typing a valid number. */
@@ -22,14 +22,16 @@ const SCRUB_THRESHOLD = 3;
/**
* Numeric input: click to type, drag horizontally to scrub (Shift = ×10, Alt = ×0.1),
* arrow keys step. Values are clamped to min/max and formatted with the step's precision.
* arrow keys step. A scrub snaps to the grid of its increment – a step-1 field therefore
* always ends on a whole number, even when it started fractional. Values are clamped to
* min/max and always shown with `NUMBER_DECIMALS` decimals, so the text never hides that
* the value is fractional.
*/
export class NumberField extends TextField {
private numeric: number;
private readonly min: number;
private readonly max: number;
private readonly step: number;
private readonly decimals: number;
private scrub: { startX: number; startValue: number; moved: boolean } | null = null;
onNumberInput?: (value: number) => void;
onNumberChange?: (value: number) => void;
@@ -39,11 +41,10 @@ export class NumberField extends TextField {
this.min = options.min ?? -Infinity;
this.max = options.max ?? Infinity;
this.step = options.step ?? 1;
this.decimals = clamp(Math.ceil(-Math.log10(this.step)), 0, 6);
this.onNumberInput = options.onInput;
this.onNumberChange = options.onChange;
this.numeric = this.clampValue(value);
this.valueText = this.format(this.numeric);
this.valueText = formatNumber(this.numeric);
this.cursor = 'ew-resize';
this.onInput = (text) => {
@@ -56,7 +57,7 @@ export class NumberField extends TextField {
this.onChange = (text) => {
const n = Number(text.trim());
this.numeric = this.clampValue(Number.isFinite(n) && text.trim() !== '' ? n : this.numeric);
this.valueText = this.format(this.numeric);
this.valueText = formatNumber(this.numeric);
this.invalidate();
this.onNumberChange?.(this.numeric);
};
@@ -66,8 +67,8 @@ export class NumberField extends TextField {
const dx = e.global.x - this.scrub.startX;
if (!this.scrub.moved && Math.abs(dx) < SCRUB_THRESHOLD) return;
this.scrub.moved = true;
const factor = e.shiftKey ? 10 : e.altKey ? 0.1 : 1;
this.setNumber(this.scrub.startValue + dx * this.step * factor);
const increment = this.step * (e.shiftKey ? 10 : e.altKey ? 0.1 : 1);
this.setNumber(snapToStep(this.scrub.startValue + dx * increment, increment));
this.onNumberInput?.(this.numeric);
});
this.onPointerRelease(() => {
@@ -93,7 +94,7 @@ export class NumberField extends TextField {
setNumber(value: number): void {
this.numeric = this.clampValue(value);
if (!this.isEditing) {
this.valueText = this.format(this.numeric);
this.valueText = formatNumber(this.numeric);
this.invalidate();
}
}
@@ -113,7 +114,7 @@ export class NumberField extends TextField {
const dir = event.key === 'ArrowUp' ? 1 : -1;
const factor = event.shiftKey ? 10 : event.altKey ? 0.1 : 1;
this.numeric = this.clampValue(this.numeric + dir * this.step * factor);
this.valueText = this.format(this.numeric);
this.valueText = formatNumber(this.numeric);
ui().keyboard.setValue(this.valueText, 0, this.valueText.length);
this.selectAll();
this.onNumberInput?.(this.numeric);
@@ -129,8 +130,4 @@ export class NumberField extends TextField {
if (!Number.isFinite(value)) return this.numeric ?? 0;
return clamp(value, this.min, this.max);
}
private format(value: number): string {
return String(Number(value.toFixed(this.decimals)));
}
}
+55 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { clamp, centreY, zoomFactor } from '../../src/ui/math';
import { clamp, centreY, formatNumber, NUMBER_DECIMALS, snapToStep, zoomFactor } from '../../src/ui/math';
describe('clamp', () => {
it('keeps a value inside the range', () => {
@@ -21,6 +21,60 @@ describe('centreY', () => {
});
});
describe('formatNumber', () => {
it('always shows three decimals', () => {
expect(NUMBER_DECIMALS).toBe(3);
expect(formatNumber(100)).toBe('100.000');
expect(formatNumber(0.5)).toBe('0.500');
expect(formatNumber(-12)).toBe('-12.000');
});
it('never hides a fractional value behind a whole number', () => {
// The bug this replaces: a field with step 1 showed a stored 100.234 as "100", and
// committing that text wrote the rounded value back into the project.
expect(formatNumber(100.234)).toBe('100.234');
expect(formatNumber(0.001)).toBe('0.001');
});
it('rounds to the last shown place', () => {
expect(formatNumber(1.2345)).toBe('1.234');
expect(formatNumber(1.2356)).toBe('1.236');
});
it('drops the minus sign from a value that displays as zero', () => {
expect(formatNumber(-0)).toBe('0.000');
expect(formatNumber(-0.0001)).toBe('0.000');
});
it('honours an explicit precision', () => {
expect(formatNumber(1.25, 1)).toBe('1.3');
expect(formatNumber(1.25, 0)).toBe('1');
});
});
describe('snapToStep', () => {
it('lands on whole numbers for a step-1 field', () => {
// The bug: a scrub added fractional device pixels to an already fractional value, so
// dragging X produced values like 100.667 instead of 101.
expect(snapToStep(100.667, 1)).toBe(101);
expect(snapToStep(100.333, 1)).toBe(100);
expect(snapToStep(-3.5, 1)).toBe(-3);
});
it('uses the increment as the grid, so a fine drag keeps its precision', () => {
// Alt halves the step to 0.1: snapping must not swallow the fine adjustment.
expect(snapToStep(1.24, 0.1)).toBe(1.2);
expect(snapToStep(1.26, 0.1)).toBe(1.3);
expect(snapToStep(3.0000000000000004, 0.1)).toBe(3);
});
it('passes values through when there is no grid', () => {
expect(snapToStep(1.234, 0)).toBe(1.234);
expect(snapToStep(1.234, -1)).toBe(1.234);
expect(snapToStep(NaN, 1)).toBeNaN();
});
});
describe('zoomFactor', () => {
it('zooms in for a negative delta and out for a positive one', () => {
expect(zoomFactor(-100)).toBeGreaterThan(1);