Add a decimals option, fix dropdown overflow and the texture filter

- NumberField takes a decimals option (default 3); frame rects and the
  slice dialog use 0, matching their integer model.
- Button truncates its label to the drawn width; Dropdown reserves the
  chevron slot. NodeView reuses the shared truncate().
- Setting scaleMode leaves the style's shared resource id in place, so
  the old GPU sampler stayed in use until a reload. Call style.update().
- Graph gets a header bar like the Texture tab: GraphEditor is now the
  frame, the former editor is GraphCanvas. A selection the tab does not
  concern no longer unloads the behavior.
- New e2e step 14 guards text overflow across all widgets.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-08 15:05:28 +02:00
1 parent 35a1a23af8
commit f16801aef3
22 files changed
+874 -493

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@ 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, formatNumber, NUMBER_DECIMALS, snapToStep, zoomFactor } from './math';
export { Label } from './widgets/Label';
export { Label, truncate } from './widgets/Label';
export { Button, type ButtonOptions } from './widgets/Button';
export { Dropdown } from './widgets/Dropdown';
export { Checkbox } from './widgets/Checkbox';
+3 -2
View File
@@ -8,13 +8,14 @@ export function centreY(containerHeight: number, itemHeight: number): number {
return Math.round((containerHeight - itemHeight) / 2);
}
/** Decimal places every number field shows. */
/** Decimal places a number field shows unless it asks for its own (`NumberFieldOptions.decimals`). */
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.
* decimals that dragging in the viewport or a graph node had put into the value. Fields whose
* value is integral by nature (a pixel index, a count) pass 0 and are exact all the same.
* A value that rounds to zero never carries a minus sign.
*/
export function formatNumber(value: number, decimals = NUMBER_DECIMALS): string {
+21 -3
View File
@@ -3,6 +3,7 @@ import { drawControlBox } from '../draw';
import { centreY } from '../math';
import { Theme, textStyle } from '../Theme';
import { type Size, Widget } from '../Widget';
import { truncate } from './Label';
export interface ButtonOptions {
/** Visual weight: 'primary' uses the accent color, 'ghost' has no background until hovered. */
@@ -21,6 +22,8 @@ export interface ButtonOptions {
export class Button extends Widget {
private readonly bg = new Graphics();
private readonly labelText: Text;
/** The label as set. `labelText` holds the possibly shortened version that is on screen. */
private fullText: string;
private pressed = false;
/** Caller-owned toggle state: renders like a primary button but is not flipped by clicks. */
toggled = false;
@@ -37,6 +40,7 @@ export class Button extends Widget {
this.cursor = 'pointer';
this.minHeight = Theme.controlHeight;
if (options.width) this.minWidth = options.width;
this.fullText = text;
this.labelText = new Text({ text, style: textStyle(this.variant === 'primary' ? 'accent' : 'body') });
this.addChild(this.bg, this.labelText);
@@ -61,13 +65,15 @@ export class Button extends Widget {
});
}
/** The full label, not the truncated version on screen. */
get text(): string {
return this.labelText.text;
return this.fullText;
}
/** Repaints only; the parent must relayout when the measured width matters. */
setText(text: string): void {
this.labelText.text = text;
if (text === this.fullText) return;
this.fullText = text;
this.invalidate();
}
@@ -83,13 +89,23 @@ export class Button extends Widget {
}
override measure(): Size {
const metrics = CanvasTextMetrics.measureText(this.labelText.text, this.labelText.style);
// Measured from the full label, never from what is currently on screen: measuring the
// truncated text would let a button that was once too narrow stay too narrow for good.
const metrics = CanvasTextMetrics.measureText(this.fullText, this.labelText.style);
return {
w: Math.max(this.minWidth, Math.ceil(metrics.width) + Theme.padding * 2),
h: Math.max(this.minHeight, Theme.controlHeight),
};
}
/**
* Width the label may occupy. Subclasses that draw something else inside the button take
* their room off here, so the text is shortened instead of running underneath it.
*/
protected labelSlot(): number {
return this.w - Theme.padding * 2;
}
override onKeyDown(event: KeyboardEvent): boolean {
if (event.key === 'Enter' || event.key === ' ') {
this.onClick?.();
@@ -131,8 +147,10 @@ export class Button extends Widget {
} else if (stroke !== null) {
this.bg.roundRect(0.5, 0.5, this.w - 1, this.h - 1, Theme.radius).stroke({ width: 1, color: stroke });
}
// Style first: it decides the glyph widths that the truncation then measures against.
this.labelText.style = textStyle(this.onAccent ? 'accent' : 'body');
const label = this.labelText;
label.text = truncate(this.fullText, label.style, this.labelSlot());
const x = this.align === 'left' ? Theme.padding : (this.w - label.width) / 2;
label.position.set(Math.round(x), centreY(this.h, label.height));
}
+5
View File
@@ -24,6 +24,11 @@ export class Dropdown extends Button {
return { w: size.w + CHEVRON_SLOT, h: size.h };
}
/** The chevron's room is not the label's: a long value is shortened, not drawn under it. */
protected override labelSlot(): number {
return super.labelSlot() - CHEVRON_SLOT;
}
override redraw(): void {
super.redraw();
const x = this.w - CHEVRON_SLOT / 2 - Theme.padding / 2;
+22 -8
View File
@@ -1,5 +1,5 @@
import type { FederatedPointerEvent } from 'pixi.js';
import { clamp, formatNumber, snapToStep } from '../math';
import { clamp, formatNumber, NUMBER_DECIMALS, snapToStep } from '../math';
import { ui } from '../UIContext';
import { TextField, type TextFieldOptions } from './TextField';
@@ -8,9 +8,16 @@ export interface NumberFieldOptions extends Omit<TextFieldOptions, 'onInput' | '
max?: number;
/**
* 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.
* Does not affect the display, which is governed by `decimals` alone.
*/
step?: number;
/**
* Decimal places the text shows (default `NUMBER_DECIMALS`). Only set this to a lower
* precision where the *stored* value is of that precision too – a pixel index, a count of
* tiles. On anything the viewport can drag to a fractional value it would hide the fraction,
* and the text would commit the rounded number back into the project.
*/
decimals?: number;
/** Fires continuously while scrubbing or typing a valid number. */
onInput?: (value: number) => void;
/** Fires once when a scrub ends or an edit is committed. */
@@ -24,14 +31,15 @@ const SCRUB_THRESHOLD = 3;
* Numeric input: click to type, drag horizontally to scrub (Shift = ×10, Alt = ×0.1),
* 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.
* min/max and shown with `NUMBER_DECIMALS` decimals unless the field declares otherwise,
* 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;
@@ -41,10 +49,11 @@ export class NumberField extends TextField {
this.min = options.min ?? -Infinity;
this.max = options.max ?? Infinity;
this.step = options.step ?? 1;
this.decimals = options.decimals ?? NUMBER_DECIMALS;
this.onNumberInput = options.onInput;
this.onNumberChange = options.onChange;
this.numeric = this.clampValue(value);
this.valueText = formatNumber(this.numeric);
this.valueText = this.display;
this.cursor = 'ew-resize';
this.onInput = (text) => {
@@ -57,7 +66,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 = formatNumber(this.numeric);
this.valueText = this.display;
this.invalidate();
this.onNumberChange?.(this.numeric);
};
@@ -90,11 +99,16 @@ export class NumberField extends TextField {
return this.numeric;
}
/** The one place the value becomes text, so every path through the field honours `decimals`. */
private get display(): string {
return formatNumber(this.numeric, this.decimals);
}
/** Model → view update: clamps, and reformats the text unless the user is editing it. */
setNumber(value: number): void {
this.numeric = this.clampValue(value);
if (!this.isEditing) {
this.valueText = formatNumber(this.numeric);
this.valueText = this.display;
this.invalidate();
}
}
@@ -114,7 +128,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 = formatNumber(this.numeric);
this.valueText = this.display;
ui().keyboard.setValue(this.valueText, 0, this.valueText.length);
this.selectAll();
this.onNumberInput?.(this.numeric);