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:
1 parent
35a1a23af8
commit
f16801aef3
22 files changed
+874
-493
No files matched your search
+1
-1
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user