From 6168df41f8b649c5d433e2928fd6947d1eb20a09 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 8 Sep 2026 11:48:32 +0200 Subject: [PATCH] Show number fields with three decimals and snap dragging to the step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 5 +++- src/editor/graph/NodeView.ts | 6 ++-- src/ui/index.ts | 2 +- src/ui/math.ts | 27 +++++++++++++++++ src/ui/widgets/NumberField.ts | 29 ++++++++---------- tests/ui/math.test.ts | 56 ++++++++++++++++++++++++++++++++++- 6 files changed, 104 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 608b3a92..fb3f7407 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/src/editor/graph/NodeView.ts b/src/editor/graph/NodeView.ts index 77537f93..7ba3b127 100644 --- a/src/editor/graph/NodeView.ts +++ b/src/editor/graph/NodeView.ts @@ -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); } diff --git a/src/ui/index.ts b/src/ui/index.ts index c769735f..c9c3c8e0 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -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'; diff --git a/src/ui/math.ts b/src/ui/math.ts index d90c852e..1919fb8b 100644 --- a/src/ui/math.ts +++ b/src/ui/math.ts @@ -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 – diff --git a/src/ui/widgets/NumberField.ts b/src/ui/widgets/NumberField.ts index 6b83248f..470afc9e 100644 --- a/src/ui/widgets/NumberField.ts +++ b/src/ui/widgets/NumberField.ts @@ -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 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))); - } } diff --git a/tests/ui/math.test.ts b/tests/ui/math.test.ts index cafc19aa..cacda8e3 100644 --- a/tests/ui/math.test.ts +++ b/tests/ui/math.test.ts @@ -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);