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:
1 parent
15ffcd5739
commit
6168df41f8
6 files changed
+104
-21
No files matched your search
@@ -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).
|
||||
|
||||
|
||||
@@ -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
@@ -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';
|
||||
|
||||
@@ -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 –
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user