Trim needless comments and fix the picker width
Remove comments that only restate the code, keeping the section banners and the notes that carry a reason, a measured fact or an external contract. ButtonOptions.width claimed to be a fixed width but only set minWidth, so the Graph and Texture pickers grew with the name they showed. It is now what it promises, with a separate minWidth for the buttons that have two forms. Narrow buttons shrink their padding, otherwise a 24px icon button had 8px left for its glyph and truncate dropped it entirely. __gm.labelsDropped() guards that class: the overflow guard only saw text that was too wide, never a label truncated to nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f16801aef3
commit
4d84231b9f
115 files changed
+707
-1564
No files matched your search
+5
-11
@@ -1,14 +1,10 @@
|
||||
/**
|
||||
* Keyboard focus lives in the canvas, not in the DOM: exactly one widget is focused at a time
|
||||
* and key events reach it through this manager, which listens on `window`. The keyboard bridge
|
||||
* input is the only DOM element that ever holds real focus, and its keys are routed separately.
|
||||
*/
|
||||
import type { Container } from 'pixi.js';
|
||||
import { collectFocusables, type Widget } from './Widget';
|
||||
|
||||
/**
|
||||
* Tracks the single focused widget and routes keyboard events to it. Tab / Shift+Tab
|
||||
* cycle through focusable widgets in tree order below `root`.
|
||||
* Keyboard focus lives in the canvas, not in the DOM: one widget is focused at a time and keys
|
||||
* reach it from a `window` listener. The keyboard bridge input is the only DOM element that ever
|
||||
* holds real focus, and its keys are routed separately.
|
||||
*/
|
||||
export class FocusManager {
|
||||
/** Global handlers for keys nobody consumed (Delete, Ctrl+Z, …). */
|
||||
@@ -24,7 +20,6 @@ export class FocusManager {
|
||||
return this.focusedWidget;
|
||||
}
|
||||
|
||||
/** Moves focus; the previous widget is blurred before the new one is focused. */
|
||||
focus(widget: Widget | null): void {
|
||||
if (widget === this.focusedWidget) return;
|
||||
const previous = this.focusedWidget;
|
||||
@@ -38,9 +33,8 @@ export class FocusManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* A pointer went down on `target`. If that is outside the focused widget, the widget
|
||||
* loses focus *before* the press is handled, so a text field commits its edit before
|
||||
* whatever the click triggers (selection change, inspector rebuild, …) can destroy it.
|
||||
* Blurs before the press is handled, so a text field commits its edit before whatever the
|
||||
* click triggers – a selection change, an inspector rebuild – can destroy it.
|
||||
*/
|
||||
blurIfOutside(target: Container | null): void {
|
||||
const w = this.focusedWidget;
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
/**
|
||||
* The one piece of DOM in the whole application: an invisible, off-screen `<input>` that
|
||||
* receives keyboard focus while a canvas text field is being edited. The browser supplies
|
||||
* IME composition, the mobile on-screen keyboard, clipboard and selection semantics; the
|
||||
* canvas field mirrors `value` / selection from here. Nothing visible is DOM.
|
||||
* The one piece of DOM in the application: an invisible `<input>` that holds keyboard focus while
|
||||
* a canvas text field is edited, so the browser supplies IME, clipboard, selection and the mobile
|
||||
* keyboard. The canvas field mirrors its value and selection.
|
||||
*/
|
||||
|
||||
/** A canvas text field while it is being edited; the bridge forwards the input's events to it. */
|
||||
export interface TextEditTarget {
|
||||
/** Called whenever value or selection changed. */
|
||||
onBridgeInput(value: string, selectionStart: number, selectionEnd: number): void;
|
||||
/** Called for navigation keys (Enter, Escape, Tab, arrows …). Return true to consume. */
|
||||
onBridgeKey(event: KeyboardEvent): boolean;
|
||||
/** The bridge lost DOM focus (user clicked elsewhere, window blur). */
|
||||
/** The bridge lost DOM focus. */
|
||||
onBridgeBlur(): void;
|
||||
}
|
||||
|
||||
@@ -65,7 +63,7 @@ export class KeyboardBridge {
|
||||
});
|
||||
}
|
||||
|
||||
/** Start editing: mirror the field's value into the input and grab DOM focus. */
|
||||
/** Mirrors the field's value into the input and grabs DOM focus. */
|
||||
attach(
|
||||
target: TextEditTarget,
|
||||
value: string,
|
||||
@@ -86,21 +84,18 @@ export class KeyboardBridge {
|
||||
this.input.style.top = `${Math.max(0, y)}px`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Push a value/selection the canvas side changed (arrow stepping, caret placement) into the
|
||||
* input, so the next keystroke edits the same text the user sees.
|
||||
*/
|
||||
/** Pushes a value the canvas side changed into the input, so the next keystroke edits that text. */
|
||||
setValue(value: string, selectionStart = value.length, selectionEnd = selectionStart): void {
|
||||
this.input.value = value;
|
||||
if (document.activeElement === this.input) this.input.setSelectionRange(selectionStart, selectionEnd);
|
||||
}
|
||||
|
||||
/** Selects the whole input; the field learns about it through the usual `onBridgeInput` sync. */
|
||||
/** The field learns about it through the usual `onBridgeInput` sync. */
|
||||
selectAll(): void {
|
||||
this.input.select();
|
||||
}
|
||||
|
||||
/** Stop editing for `target`. Ignored when another field attached meanwhile, so a late blur cannot steal it. */
|
||||
/** Ignored when another field attached meanwhile, so a late blur cannot steal it. */
|
||||
detach(target: TextEditTarget): void {
|
||||
if (this.target !== target) return;
|
||||
this.target = null;
|
||||
|
||||
+1
-12
@@ -1,8 +1,4 @@
|
||||
/**
|
||||
* Design tokens for the canvas UI. Widgets take every color and text style from here and
|
||||
* the sizes they share (row/control heights, spacing, the few named control sizes); sizes
|
||||
* that only one widget uses may stay local to it.
|
||||
*/
|
||||
/** Design tokens for the canvas UI. Sizes only one widget uses may stay local to it. */
|
||||
import { TextStyle } from 'pixi.js';
|
||||
|
||||
export const Theme = {
|
||||
@@ -15,13 +11,9 @@ export const Theme = {
|
||||
padding: 8,
|
||||
gap: 6,
|
||||
radius: 4,
|
||||
/** Horizontal inset of text inside text fields. */
|
||||
fieldPadding: 6,
|
||||
/** Side length of the checkbox square (centred inside the control height). */
|
||||
checkboxSize: 16,
|
||||
/** Side length of the small color marker in list rows. */
|
||||
swatchSize: 12,
|
||||
/** Width of the scrollbar thumb. */
|
||||
scrollbarWidth: 6,
|
||||
/** Horizontal indent per nesting level in tree lists. */
|
||||
treeIndent: 14,
|
||||
@@ -46,9 +38,7 @@ export const Theme = {
|
||||
/** Collider outlines: solid (blocking) and trigger (events only). */
|
||||
collider: 0x4fd18b,
|
||||
trigger: 0xf2c84b,
|
||||
/** Container (group) symbols. */
|
||||
container: 0x3fb8c9,
|
||||
/** Rubber-band selection rectangle. */
|
||||
marquee: 0x4c8dff,
|
||||
/** Node graph: canvas, node chrome, per-category title colors, port colors, live highlight. */
|
||||
graphBackground: 0x15171b,
|
||||
@@ -72,7 +62,6 @@ export const Theme = {
|
||||
|
||||
const styleCache = new Map<string, TextStyle>();
|
||||
|
||||
/** Named text styles; every piece of text in the UI uses one of these. */
|
||||
export type TextStyleToken = 'body' | 'bodyBold' | 'muted' | 'small' | 'title' | 'mono' | 'accent';
|
||||
|
||||
/** Shared TextStyle instances so Pixi can cache glyph measurements per token. */
|
||||
|
||||
+2
-6
@@ -2,10 +2,7 @@ import type { Application, Container } from 'pixi.js';
|
||||
import { FocusManager } from './FocusManager';
|
||||
import { KeyboardBridge } from './KeyboardBridge';
|
||||
|
||||
/**
|
||||
* Services shared by all widgets: the focus manager, the keyboard bridge and the overlay
|
||||
* layer where popups render above everything else. Created once by `initUI()`.
|
||||
*/
|
||||
/** Services shared by all widgets, created once by `initUI()`. */
|
||||
export interface UIContext {
|
||||
app: Application;
|
||||
focus: FocusManager;
|
||||
@@ -16,7 +13,7 @@ export interface UIContext {
|
||||
|
||||
let context: UIContext | null = null;
|
||||
|
||||
/** Creates the shared services. `root` is where Tab cycling searches; `overlay` must be drawn above it. */
|
||||
/** `root` is where Tab cycling searches; `overlay` must be drawn above it. */
|
||||
export function initUI(app: Application, root: Container, overlay: Container): UIContext {
|
||||
context = { app, focus: new FocusManager(root), keyboard: new KeyboardBridge(), overlay };
|
||||
// Capture phase on the stage runs before any widget handler: a press outside the focused
|
||||
@@ -28,7 +25,6 @@ export function initUI(app: Application, root: Container, overlay: Container): U
|
||||
return context;
|
||||
}
|
||||
|
||||
/** The shared services; throws when called before `initUI()`. */
|
||||
export function ui(): UIContext {
|
||||
if (!context) throw new Error('UI not initialised – call initUI() first');
|
||||
return context;
|
||||
|
||||
+6
-22
@@ -1,8 +1,3 @@
|
||||
/**
|
||||
* Root of the canvas widget toolkit. Everything visible is a Pixi container; there is no DOM
|
||||
* layout. Sizes are pushed top-down (`resize()`), preferred sizes are pulled bottom-up
|
||||
* (`measure()`), and repaints are coalesced per frame through `invalidate()`.
|
||||
*/
|
||||
import { Container, Rectangle } from 'pixi.js';
|
||||
|
||||
export interface Size {
|
||||
@@ -11,12 +6,9 @@ export interface Size {
|
||||
}
|
||||
|
||||
/**
|
||||
* Base class of every canvas UI element. A widget owns a rectangular area (`w` × `h`) in
|
||||
* its parent's coordinate space. Parents call `resize()`; the widget then lays out its
|
||||
* children in `layout()` and repaints in `redraw()`. `measure()` reports the size the
|
||||
* widget would like to have, used by the stack layouts.
|
||||
*
|
||||
* Note: Pixi's own `width`/`height` scale the container, so sizes live in `w`/`h`.
|
||||
* Base class of every canvas UI element. Sizes are pushed down by the parent (`resize()`),
|
||||
* preferred sizes pulled up (`measure()`), repaints coalesced per frame (`invalidate()`).
|
||||
* Pixi's own `width`/`height` scale the container, so sizes live in `w`/`h` instead.
|
||||
*/
|
||||
export abstract class Widget extends Container {
|
||||
w = 0;
|
||||
@@ -25,7 +17,7 @@ export abstract class Widget extends Container {
|
||||
flex = 0;
|
||||
minWidth = 0;
|
||||
minHeight = 0;
|
||||
/** Whether the widget participates in Tab focus cycling. */
|
||||
/** Whether the widget takes part in Tab cycling. */
|
||||
focusable = false;
|
||||
focused = false;
|
||||
enabled = true;
|
||||
@@ -44,10 +36,7 @@ export abstract class Widget extends Container {
|
||||
return { w: this.minWidth, h: this.minHeight };
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the size, re-runs `layout()` only when it changed and always repaints. Sizes are
|
||||
* pushed down by the parent; a widget never resizes itself.
|
||||
*/
|
||||
/** Re-runs `layout()` only when the size changed, and always repaints. */
|
||||
resize(w: number, h: number): void {
|
||||
const changed = w !== this.w || h !== this.h;
|
||||
this.w = w;
|
||||
@@ -60,7 +49,6 @@ export abstract class Widget extends Container {
|
||||
/** Position children. Called after a resize; must be cheap and idempotent. */
|
||||
layout(): void {}
|
||||
|
||||
/** Repaint own graphics. Called after resize and by `invalidate()`. */
|
||||
redraw(): void {}
|
||||
|
||||
/** Request a repaint at the end of the current frame (coalesced). */
|
||||
@@ -114,10 +102,7 @@ export abstract class Widget extends Container {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs `fn` when a pointer pressed on this widget is released, whether over the widget
|
||||
* (`pointerup`) or anywhere else (`pointerupoutside`). Use it to end drags and scrubs.
|
||||
*/
|
||||
/** Runs `fn` on release, whether the pointer is still over the widget or not. */
|
||||
protected onPointerRelease(fn: () => void): void {
|
||||
this.on('pointerup', fn);
|
||||
this.on('pointerupoutside', fn);
|
||||
@@ -127,7 +112,6 @@ export abstract class Widget extends Container {
|
||||
/** A concrete, empty widget: a sized container for custom drawing or manual child placement. */
|
||||
export class Box extends Widget {}
|
||||
|
||||
/** Depth-first collection of focusable, visible widgets in tree order. */
|
||||
export function collectFocusables(root: Container, out: Widget[] = []): Widget[] {
|
||||
if (!root.visible) return out;
|
||||
if (root instanceof Widget && root.focusable && root.enabled) out.push(root);
|
||||
|
||||
+5
-5
@@ -57,11 +57,14 @@ export function hsvToRgb({ h, s, v }: HSV): number {
|
||||
return (to(r) << 16) | (to(g) << 8) | to(b);
|
||||
}
|
||||
|
||||
// Pixi has no 2D gradients worth the trouble, so pickers paint these into a 2D canvas once and
|
||||
// show the result as a texture.
|
||||
|
||||
// --- gradient rasterisation ------------------------------------------------------------------
|
||||
// Pixi has no 2D gradients worth the trouble, so pickers paint these into a 2D canvas once and
|
||||
// show the result as a texture.
|
||||
|
||||
/** Paints the full hue spectrum left to right over `width` × `height` at the context's origin. */
|
||||
/** Paints the full hue spectrum left to right, at the context's origin. */
|
||||
export function paintHueBar(ctx: CanvasRenderingContext2D, width: number, height: number): void {
|
||||
const grad = ctx.createLinearGradient(0, 0, width, 0);
|
||||
for (let i = 0; i <= 6; i++) grad.addColorStop(i / 6, `hsl(${i * 60}, 100%, 50%)`);
|
||||
@@ -69,10 +72,7 @@ export function paintHueBar(ctx: CanvasRenderingContext2D, width: number, height
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
}
|
||||
|
||||
/**
|
||||
* Paints the saturation (x) / value (y) square for `hue`: white on the left fading to the pure
|
||||
* hue on the right, with black fading in towards the bottom.
|
||||
*/
|
||||
/** Paints the saturation (x) / value (y) square for `hue`. */
|
||||
export function paintSvSquare(ctx: CanvasRenderingContext2D, size: number, hue: number): void {
|
||||
ctx.fillStyle = `hsl(${hue}, 100%, 50%)`;
|
||||
ctx.fillRect(0, 0, size, size);
|
||||
|
||||
+5
-7
@@ -4,15 +4,14 @@ import { Theme } from './Theme';
|
||||
export interface ControlBoxOptions {
|
||||
fill: number;
|
||||
border: number;
|
||||
/** Corner radius; defaults to `Theme.radius`. */
|
||||
radius?: number;
|
||||
x?: number;
|
||||
y?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The shared look of every control: a rounded fill with a 1 px border. The stroke is inset by
|
||||
* half a pixel so it lands on whole device pixels and stays crisp instead of blurring over two.
|
||||
* The shared look of every control. The stroke is inset by half a pixel so it lands on whole
|
||||
* device pixels and stays crisp instead of blurring over two.
|
||||
*/
|
||||
export function drawControlBox(g: Graphics, w: number, h: number, options: ControlBoxOptions): void {
|
||||
const { fill, border, radius = Theme.radius, x = 0, y = 0 } = options;
|
||||
@@ -29,14 +28,13 @@ export interface GridOptions {
|
||||
w: number;
|
||||
h: number;
|
||||
alpha: number;
|
||||
/** Alpha of the two lines through the origin; omitted = no axes (the graph has no origin to show). */
|
||||
/** Alpha of the two lines through the origin; omitted = no axes. */
|
||||
axisAlpha?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The background grid of the scene view and the graph editor. Drawn in white at a low alpha
|
||||
* rather than in a fixed colour, so it reads on whatever background the scene sets.
|
||||
* `g` is cleared by the caller, which also decides when the step is too dense to be useful.
|
||||
* White at a low alpha rather than a fixed colour, so the grid reads on whatever background the
|
||||
* scene sets. `g` is cleared by the caller.
|
||||
*/
|
||||
export function drawGrid(g: Graphics, options: GridOptions): void {
|
||||
const { originX, originY, step, w, h, alpha, axisAlpha } = options;
|
||||
|
||||
@@ -8,9 +8,8 @@ export interface StackOptions {
|
||||
}
|
||||
|
||||
/**
|
||||
* Lays out child widgets in a row or column. Children with `flex > 0` share the space
|
||||
* left over after fixed children got their measured size (weighted by `flex`).
|
||||
* Non-widget children are ignored by the layout but still rendered.
|
||||
* Lays out child widgets in a row or column. Children with `flex > 0` share, weighted, the space
|
||||
* left after the fixed ones took their measured size. Non-widget children are still rendered.
|
||||
*/
|
||||
export abstract class Stack extends Widget {
|
||||
gap: number;
|
||||
@@ -27,12 +26,12 @@ export abstract class Stack extends Widget {
|
||||
this.align = options.align ?? 'stretch';
|
||||
}
|
||||
|
||||
/** The children the layout distributes space among: visible widgets, in child order. */
|
||||
/** The children space is distributed among: visible widgets, in child order. */
|
||||
get items(): Widget[] {
|
||||
return this.children.filter((c): c is Widget => c instanceof Widget && c.visible);
|
||||
}
|
||||
|
||||
/** Appends `widget` with the given flex share. Does not lay out; call `relayout()` once the stack is sized. */
|
||||
/** Does not lay out; call `relayout()` once the stack is sized. */
|
||||
add<T extends Widget>(widget: T, flex = 0): T {
|
||||
widget.flex = flex;
|
||||
this.addChild(widget);
|
||||
@@ -96,14 +95,12 @@ export abstract class Stack extends Widget {
|
||||
}
|
||||
}
|
||||
|
||||
/** Column layout. */
|
||||
export class VStack extends Stack {
|
||||
constructor(options: StackOptions = {}) {
|
||||
super('y', options);
|
||||
}
|
||||
}
|
||||
|
||||
/** Row layout. */
|
||||
export class HStack extends Stack {
|
||||
constructor(options: StackOptions = {}) {
|
||||
super('x', options);
|
||||
|
||||
+6
-12
@@ -12,11 +12,8 @@ export function centreY(containerHeight: number, itemHeight: number): number {
|
||||
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. 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.
|
||||
* Always shows `decimals` places, so a value can never look whole when it is not. 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);
|
||||
@@ -25,11 +22,9 @@ export function formatNumber(value: number, decimals = NUMBER_DECIMALS): string
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* Rounds `value` onto the grid of `stepSize`, so scrubbing lands on clean values even though
|
||||
* pointer deltas are fractional. 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;
|
||||
@@ -38,8 +33,7 @@ export function snapToStep(value: number, stepSize: number): number {
|
||||
|
||||
/**
|
||||
* 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 –
|
||||
* the scene view and the graph editor allow very different zoom levels.
|
||||
* amount lands exactly where it started. The caller clamps into its own range.
|
||||
*/
|
||||
export function zoomFactor(deltaY: number): number {
|
||||
return Math.exp(-deltaY * 0.0015);
|
||||
|
||||
+23
-23
@@ -6,29 +6,26 @@ 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. */
|
||||
variant?: 'default' | 'primary' | 'ghost' | 'danger';
|
||||
/** Fixed width; otherwise measured from the label. */
|
||||
/** Fixed width; otherwise measured from the label, which is truncated when too long. */
|
||||
width?: number;
|
||||
/**
|
||||
* Label position in a button wider than its text. 'center' suits standalone buttons,
|
||||
* 'left' the ones that read as a list or a field (menu entries, dropdowns).
|
||||
*/
|
||||
/** Floor only – for a button whose label changes between forms and must grow with them. */
|
||||
minWidth?: number;
|
||||
align?: 'center' | 'left';
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
/** Push button; Enter/Space activate it while focused. Clicks fire only for the primary pointer button. */
|
||||
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. */
|
||||
/** The label as set; `labelText` holds the possibly truncated version on screen. */
|
||||
private fullText: string;
|
||||
private pressed = false;
|
||||
/** Caller-owned toggle state: renders like a primary button but is not flipped by clicks. */
|
||||
/** Caller-owned: renders like a primary button but is never flipped by a click. */
|
||||
toggled = false;
|
||||
variant: NonNullable<ButtonOptions['variant']>;
|
||||
protected readonly align: NonNullable<ButtonOptions['align']>;
|
||||
protected readonly fixedWidth: number;
|
||||
onClick?: () => void;
|
||||
|
||||
constructor(text: string, options: ButtonOptions = {}) {
|
||||
@@ -39,7 +36,8 @@ export class Button extends Widget {
|
||||
this.focusable = true;
|
||||
this.cursor = 'pointer';
|
||||
this.minHeight = Theme.controlHeight;
|
||||
if (options.width) this.minWidth = options.width;
|
||||
this.fixedWidth = options.width ?? 0;
|
||||
this.minWidth = this.fixedWidth || (options.minWidth ?? 0);
|
||||
this.fullText = text;
|
||||
this.labelText = new Text({ text, style: textStyle(this.variant === 'primary' ? 'accent' : 'body') });
|
||||
this.addChild(this.bg, this.labelText);
|
||||
@@ -65,11 +63,15 @@ export class Button extends Widget {
|
||||
});
|
||||
}
|
||||
|
||||
/** The full label, not the truncated version on screen. */
|
||||
get text(): string {
|
||||
return this.fullText;
|
||||
}
|
||||
|
||||
/** What is drawn – shorter than `text` when the button is too narrow to hold it. */
|
||||
get shownText(): string {
|
||||
return this.labelText.text;
|
||||
}
|
||||
|
||||
/** Repaints only; the parent must relayout when the measured width matters. */
|
||||
setText(text: string): void {
|
||||
if (text === this.fullText) return;
|
||||
@@ -83,27 +85,25 @@ export class Button extends Widget {
|
||||
this.invalidate();
|
||||
}
|
||||
|
||||
/** Whether the label sits on a filled accent surface and therefore needs the light style. */
|
||||
protected get onAccent(): boolean {
|
||||
return this.toggled || this.variant === 'primary' || this.variant === 'danger';
|
||||
}
|
||||
|
||||
override measure(): Size {
|
||||
// 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 h = Math.max(this.minHeight, Theme.controlHeight);
|
||||
// A given width is a width, not a floor: a picker must not resize around the name it shows.
|
||||
if (this.fixedWidth) return { w: this.fixedWidth, h };
|
||||
// From the full label, never the on-screen one: measuring truncated text would keep a button
|
||||
// that was once too narrow 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),
|
||||
};
|
||||
return { w: Math.max(this.minWidth, Math.ceil(metrics.width) + Theme.padding * 2), h };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** Width the label may occupy; subclasses that draw inside the button take their room off here. */
|
||||
protected labelSlot(): number {
|
||||
return this.w - Theme.padding * 2;
|
||||
// The padding is an inset, not a claim: on a 24 px icon button it would leave 8 px and
|
||||
// truncate the glyph away entirely. From 48 px up this is the full padding again.
|
||||
return this.w - Math.min(Theme.padding, Math.floor(this.w / 6)) * 2;
|
||||
}
|
||||
|
||||
override onKeyDown(event: KeyboardEvent): boolean {
|
||||
|
||||
@@ -4,7 +4,6 @@ import { centreY } from '../math';
|
||||
import { Theme } from '../Theme';
|
||||
import { Widget } from '../Widget';
|
||||
|
||||
/** A square toggle used for boolean properties. */
|
||||
export class Checkbox extends Widget {
|
||||
private readonly box = new Graphics();
|
||||
onChange?: (checked: boolean) => void;
|
||||
@@ -20,14 +19,13 @@ export class Checkbox extends Widget {
|
||||
this.on('pointertap', () => this.toggle());
|
||||
}
|
||||
|
||||
/** Model → view update: does not fire `onChange`. */
|
||||
/** Does not fire `onChange`, unlike `toggle`. */
|
||||
setChecked(checked: boolean): void {
|
||||
if (this.checked === checked) return;
|
||||
this.checked = checked;
|
||||
this.invalidate();
|
||||
}
|
||||
|
||||
/** User-driven flip that fires `onChange`. */
|
||||
toggle(): void {
|
||||
this.checked = !this.checked;
|
||||
this.invalidate();
|
||||
|
||||
@@ -7,9 +7,8 @@ import { Button, type ButtonOptions } from './Button';
|
||||
const CHEVRON_SLOT = 16;
|
||||
|
||||
/**
|
||||
* A button that opens a menu: value flush left, chevron flush right. The chevron is drawn
|
||||
* rather than written into the label, so the label stays the plain value – which keeps it
|
||||
* readable, lets it be compared against the menu entries, and stays crisp at any size.
|
||||
* A button that opens a menu: value flush left, chevron flush right. The chevron is drawn rather
|
||||
* than written into the label, so the label stays the plain value.
|
||||
*/
|
||||
export class Dropdown extends Button {
|
||||
private readonly chevron = new Graphics();
|
||||
@@ -21,7 +20,8 @@ export class Dropdown extends Button {
|
||||
|
||||
override measure(): Size {
|
||||
const size = super.measure();
|
||||
return { w: size.w + CHEVRON_SLOT, h: size.h };
|
||||
// A fixed width already includes the chevron's room; only a measured one has to grow.
|
||||
return this.fixedWidth ? size : { 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. */
|
||||
|
||||
@@ -6,7 +6,6 @@ import { type Size, Widget } from '../Widget';
|
||||
export interface LabelOptions {
|
||||
style?: TextStyleToken;
|
||||
align?: 'left' | 'center' | 'right';
|
||||
/** Horizontal padding inside the label bounds. */
|
||||
padding?: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,13 +6,10 @@ import { Widget } from '../Widget';
|
||||
import { truncate } from './Label';
|
||||
import type { ListItem } from './ListView';
|
||||
|
||||
/** Width reserved in front of every row of a tree list for the expand/collapse chevron. */
|
||||
/** Width reserved in front of every row of a tree list for the chevron. */
|
||||
const CHEVRON_SLOT = 14;
|
||||
|
||||
/**
|
||||
* One row of a `ListView`: hover/selection background, tree guide, chevron, swatch, label and
|
||||
* hint. Purely visual – pointer handling and selection state live in the list.
|
||||
*/
|
||||
/** One row of a `ListView`. Purely visual: pointer handling and selection state live in the list. */
|
||||
export class ListRow extends Widget {
|
||||
private readonly bg = new Graphics();
|
||||
private readonly labelText = new Text({ text: '', style: textStyle('body') });
|
||||
@@ -66,7 +63,7 @@ export class ListRow extends Widget {
|
||||
if (this.selected)
|
||||
this.bg.rect(0, 0, this.w, this.h).fill(this.listFocused ? c.selection : c.controlActive);
|
||||
else if (this.hovered) this.bg.rect(0, 0, this.w, this.h).fill(c.controlHover);
|
||||
// Tree guide: a short "└" mark in front of nested rows.
|
||||
// Tree guide: a short "└" in front of nested rows.
|
||||
if (depth > 0) {
|
||||
const gx = indent - 8;
|
||||
this.bg
|
||||
@@ -101,7 +98,7 @@ export class ListRow extends Widget {
|
||||
);
|
||||
}
|
||||
|
||||
/** ▸ when collapsed, ▾ when expanded – drawn, not glyphs, so it stays crisp at any size. */
|
||||
/** Drawn rather than a glyph, so it stays crisp at any size. */
|
||||
private drawChevron(x: number, y: number, expanded: boolean): void {
|
||||
const color = this.selected ? Theme.colors.text : Theme.colors.textMuted;
|
||||
if (expanded)
|
||||
|
||||
@@ -7,25 +7,22 @@ import { ListRow } from './ListRow';
|
||||
export interface ListItem {
|
||||
id: string;
|
||||
label: string;
|
||||
/** Small secondary text on the right (e.g. the asset type). */
|
||||
/** Small secondary text on the right. */
|
||||
hint?: string;
|
||||
/** Left indent in levels (hierarchies). */
|
||||
/** Left indent in levels. */
|
||||
depth?: number;
|
||||
/** Optional marker color drawn as a small square before the label. */
|
||||
/** Marker color drawn as a small square before the label. */
|
||||
swatch?: number;
|
||||
/**
|
||||
* Rows that have children: whether their subtree is currently shown. Such rows get a
|
||||
* chevron that reports clicks through `onToggle`. Left undefined for leaves.
|
||||
*/
|
||||
/** Whether the subtree is shown; such rows get a chevron. Left undefined for leaves. */
|
||||
expanded?: boolean;
|
||||
}
|
||||
|
||||
/** Pixels the pointer must travel before a press turns into a drag. */
|
||||
/** Pixels of travel before a press turns into a drag. */
|
||||
const DRAG_THRESHOLD = 4;
|
||||
|
||||
/** Modifier keys held while a row was clicked. */
|
||||
export interface PickModifiers {
|
||||
/** Ctrl (or ⌘) – toggle the row in a multi-selection. */
|
||||
/** Ctrl or ⌘. */
|
||||
ctrl: boolean;
|
||||
shift: boolean;
|
||||
}
|
||||
@@ -41,10 +38,8 @@ interface DragState {
|
||||
|
||||
/**
|
||||
* List of rows with one selected row (by id, so it survives `setItems()` rebuilds) and,
|
||||
* optionally, further *highlighted* rows the owner manages (a multi-selection). Emits select,
|
||||
* activate (double-click / Enter) and context events. A click with Ctrl/Shift held does not
|
||||
* change the selection itself but reports the modifiers, so the owner can toggle. With
|
||||
* `onDrop` set, rows can be dragged onto other rows (or onto the empty area below).
|
||||
* optionally, further highlighted rows the owner manages as a multi-selection. A click with
|
||||
* Ctrl/Shift held does not change the selection itself but reports the modifiers.
|
||||
*/
|
||||
export class ListView extends Widget {
|
||||
private items: ListItem[] = [];
|
||||
@@ -117,7 +112,7 @@ export class ListView extends Widget {
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
/** Global centre of a row's chevron, for callers that must hit its real area; null when the row has none. */
|
||||
/** Global centre of a row's chevron; null when the row has none. */
|
||||
chevronGlobal(id: string): { x: number; y: number } | null {
|
||||
return this.rows.find((r) => r.item.id === id)?.chevronGlobal() ?? null;
|
||||
}
|
||||
|
||||
+4
-13
@@ -15,20 +15,13 @@ export interface MenuItem {
|
||||
}
|
||||
|
||||
export interface MenuOptions {
|
||||
/**
|
||||
* Adds a search box above the entries that filters them as you type; Enter picks the first
|
||||
* match. For menus that can grow with the project (the parent picker lists every object).
|
||||
*/
|
||||
/** Adds a search box above the entries that filters as you type; Enter picks the first match. */
|
||||
searchable?: boolean;
|
||||
}
|
||||
|
||||
/** How tall the entry list gets before it starts scrolling. */
|
||||
const MAX_LIST_HEIGHT = 320;
|
||||
|
||||
/**
|
||||
* A vertical list of clickable actions. Picking one closes the menu before it runs.
|
||||
* Entries read as a list: left-aligned, scrolled once they no longer fit on screen.
|
||||
*/
|
||||
/** A vertical list of clickable actions. Picking one closes the menu before it runs. */
|
||||
export class Menu extends Popup {
|
||||
private readonly entries: { button: Button; haystack: string }[] = [];
|
||||
private readonly list = new VStack({ gap: 2 });
|
||||
@@ -72,7 +65,6 @@ export class Menu extends Popup {
|
||||
if (this.search) ui().focus.focus(this.search);
|
||||
}
|
||||
|
||||
/** Shows the entries matching the search box and resizes the menu around them. */
|
||||
private filter(): void {
|
||||
const query = this.search?.value.trim().toLowerCase() ?? '';
|
||||
let matches = 0;
|
||||
@@ -88,9 +80,8 @@ export class Menu extends Popup {
|
||||
this.scroll.minHeight = Math.min(size.h, MAX_LIST_HEIGHT);
|
||||
this.scroll.scrollTo(0);
|
||||
this.refreshLayout();
|
||||
// Both calls are needed: a resize alone re-lays out only when the size actually changed,
|
||||
// and two different filters can leave the list exactly as tall as before while showing
|
||||
// an entirely different set of entries.
|
||||
// Both are needed: a resize re-lays out only on an actual size change, and two filters can
|
||||
// leave the list exactly as tall while showing different entries.
|
||||
this.scroll.relayoutContent();
|
||||
this.list.relayout();
|
||||
}
|
||||
|
||||
@@ -6,16 +6,11 @@ import { TextField, type TextFieldOptions } from './TextField';
|
||||
export interface NumberFieldOptions extends Omit<TextFieldOptions, 'onInput' | 'onChange'> {
|
||||
min?: number;
|
||||
max?: number;
|
||||
/**
|
||||
* Increment per scrubbed pixel and per arrow key press, and the grid a scrub snaps to.
|
||||
* Does not affect the display, which is governed by `decimals` alone.
|
||||
*/
|
||||
/** Increment per scrubbed pixel and per arrow key, and the grid a scrub snaps to. */
|
||||
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.
|
||||
* Decimal places the text shows (default `NUMBER_DECIMALS`). Lower it only where the stored
|
||||
* value has that precision too, or an edit commits the rounded number back into the project.
|
||||
*/
|
||||
decimals?: number;
|
||||
/** Fires continuously while scrubbing or typing a valid number. */
|
||||
@@ -24,15 +19,12 @@ export interface NumberFieldOptions extends Omit<TextFieldOptions, 'onInput' | '
|
||||
onChange?: (value: number) => void;
|
||||
}
|
||||
|
||||
/** Pixels the pointer must travel before a press turns into a scrub instead of a click. */
|
||||
/** Pixels of travel before a press becomes a scrub instead of a click. */
|
||||
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 shown with `NUMBER_DECIMALS` decimals unless the field declares otherwise,
|
||||
* so the text never hides that the value is fractional.
|
||||
* 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, so a step-1 field ends on a whole number.
|
||||
*/
|
||||
export class NumberField extends TextField {
|
||||
private numeric: number;
|
||||
@@ -87,7 +79,6 @@ export class NumberField extends TextField {
|
||||
if (scrub.moved) {
|
||||
this.onNumberChange?.(this.numeric);
|
||||
} else {
|
||||
// A plain click: enter text editing with everything selected.
|
||||
ui().focus.focus(this);
|
||||
this.selectAll();
|
||||
}
|
||||
@@ -99,12 +90,12 @@ export class NumberField extends TextField {
|
||||
return this.numeric;
|
||||
}
|
||||
|
||||
/** The one place the value becomes text, so every path through the field honours `decimals`. */
|
||||
/** The one place the value becomes text, so every path 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. */
|
||||
/** Clamps, and reformats the text unless the user is editing it. */
|
||||
setNumber(value: number): void {
|
||||
this.numeric = this.clampValue(value);
|
||||
if (!this.isEditing) {
|
||||
|
||||
@@ -12,10 +12,7 @@ export interface PanelOptions {
|
||||
chrome?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A titled box with a vertical content stack. The optional header row can hold extra
|
||||
* widgets (e.g. a "+" button) via `header.add()`.
|
||||
*/
|
||||
/** A titled box; the header row can hold extra widgets through `header.add()`. */
|
||||
export class Panel extends Widget {
|
||||
private readonly bg = new Graphics();
|
||||
readonly header: HStack;
|
||||
|
||||
+6
-22
@@ -13,11 +13,7 @@ export interface PopupOptions {
|
||||
modal?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A floating box on the overlay layer with a click-away backdrop. Base for menus,
|
||||
* pickers and dialogs. `open()` positions it at global coordinates and keeps it inside
|
||||
* the screen; `close()` removes it and calls `onClose`.
|
||||
*/
|
||||
/** A floating box on the overlay layer with a click-away backdrop. */
|
||||
export class Popup extends Widget {
|
||||
private readonly backdrop = new Graphics();
|
||||
private readonly bg = new Graphics();
|
||||
@@ -45,11 +41,7 @@ export class Popup extends Widget {
|
||||
return this.parent !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mounts the popup on the overlay with its top-left corner at the given global position,
|
||||
* shifted as needed to stay on screen. Takes keyboard focus away from the focused widget;
|
||||
* Escape closes.
|
||||
*/
|
||||
/** Mounts at the given global position, shifted to stay on screen. Blurs the focused widget. */
|
||||
open(globalX: number, globalY: number): void {
|
||||
const { overlay, app, focus } = ui();
|
||||
const screen = app.screen;
|
||||
@@ -66,15 +58,11 @@ export class Popup extends Widget {
|
||||
window.addEventListener('keydown', this.onEscape, { capture: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-measures and repositions an open popup after its content changed size (a menu whose
|
||||
* entries were filtered). No-op while closed, so it is safe to call during construction.
|
||||
*/
|
||||
/** Re-measures an open popup after its content changed size. No-op while closed. */
|
||||
refreshLayout(): void {
|
||||
if (this.isOpen) this.place();
|
||||
}
|
||||
|
||||
/** Sizes the popup to its content and puts it at the requested spot, shifted to stay on screen. */
|
||||
private place(): void {
|
||||
const screen = ui().app.screen;
|
||||
const size = this.measure();
|
||||
@@ -84,24 +72,20 @@ export class Popup extends Widget {
|
||||
this.position.set(Math.round(x), Math.round(y));
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens left-aligned with `anchor`, `gap` px below its bottom edge (dropdown menus, pickers).
|
||||
* The popup is never narrower than the anchor, so a menu reads as the field it drops from.
|
||||
*/
|
||||
/** Opens left-aligned under `anchor`, and never narrower than it. */
|
||||
openBelow(anchor: Widget, gap = 2): void {
|
||||
const g = anchor.getGlobalPosition();
|
||||
this.minWidth = Math.max(this.minWidth, anchor.w);
|
||||
this.open(g.x, g.y + anchor.h + gap);
|
||||
}
|
||||
|
||||
/** Open centered on screen (dialogs). */
|
||||
openCentered(): void {
|
||||
const screen = ui().app.screen;
|
||||
const size = this.measure();
|
||||
this.open((screen.width - size.w) / 2, (screen.height - size.h) / 2);
|
||||
}
|
||||
|
||||
/** Unmounts and fires `onClose`. Safe to call on a popup that is not open. */
|
||||
/** Safe to call on a popup that is not open. */
|
||||
close(): void {
|
||||
if (!this.isOpen) return;
|
||||
window.removeEventListener('keydown', this.onEscape, { capture: true });
|
||||
@@ -109,7 +93,7 @@ export class Popup extends Widget {
|
||||
this.onClose?.();
|
||||
}
|
||||
|
||||
/** Click handler for action buttons: closes the popup first so `action` sees the overlay cleared. */
|
||||
/** Closes first, so `action` runs with the overlay already cleared. */
|
||||
protected closeThen(action?: () => void): () => void {
|
||||
return () => {
|
||||
this.close();
|
||||
|
||||
@@ -3,10 +3,7 @@ import { clamp } from '../math';
|
||||
import { Theme } from '../Theme';
|
||||
import { Widget } from '../Widget';
|
||||
|
||||
/**
|
||||
* Vertically scrolls a single content widget that is taller than the container.
|
||||
* The content is given the container's width and its own measured height.
|
||||
*/
|
||||
/** Vertical scroll of one content widget, which gets the container's width and its own height. */
|
||||
export class ScrollContainer extends Widget {
|
||||
private readonly maskShape = new Graphics();
|
||||
private readonly scrollbar = new Graphics();
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Widget } from '../Widget';
|
||||
|
||||
/** Thin draggable divider between two panes. Reports the drag delta; the parent resizes. */
|
||||
export class Splitter extends Widget {
|
||||
/** Size across the drag axis. The visible bar fills it, so this is also the grab area. */
|
||||
/** Size across the drag axis; the visible bar fills it, so this is also the grab area. */
|
||||
static readonly THICKNESS = 5;
|
||||
private readonly bar = new Graphics();
|
||||
private dragging = false;
|
||||
|
||||
@@ -15,17 +15,15 @@ export interface TextFieldOptions {
|
||||
/** Fires on Enter / blur when the value changed since editing started. */
|
||||
onChange?: (value: string) => void;
|
||||
/**
|
||||
* Fires on Enter only (before the blur commit), regardless of whether the value changed.
|
||||
* Use this for "accept" semantics (search boxes, dialogs) instead of `onChange`, which
|
||||
* also fires when focus is taken away by a click elsewhere.
|
||||
* Fires on Enter only, before the blur commit, whether or not the value changed – "accept"
|
||||
* semantics, unlike `onChange`, which also fires when a click elsewhere takes the focus.
|
||||
*/
|
||||
onSubmit?: (value: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-line text input drawn in the canvas. Keyboard handling is delegated to the
|
||||
* KeyboardBridge while the field is focused; this class only mirrors value, selection
|
||||
* and caret and handles pointer interaction (caret placement, drag selection).
|
||||
* KeyboardBridge while focused; this class only mirrors value, selection and caret.
|
||||
*/
|
||||
export class TextField extends Widget implements TextEditTarget {
|
||||
protected readonly bg = new Graphics();
|
||||
@@ -88,7 +86,7 @@ export class TextField extends Widget implements TextEditTarget {
|
||||
return this.valueText;
|
||||
}
|
||||
|
||||
/** Programmatic update (from the model). Ignored while the user is editing. */
|
||||
/** Ignored while the user is editing. */
|
||||
setValue(value: string): void {
|
||||
if (this.editing || value === this.valueText) return;
|
||||
this.valueText = value;
|
||||
@@ -186,7 +184,7 @@ export class TextField extends Widget implements TextEditTarget {
|
||||
if (changed) this.onInput?.(value);
|
||||
}
|
||||
|
||||
/** Enter submits then commits via blur, Escape reverts, Tab cycles focus; editing keys stay with the browser. */
|
||||
/** Editing keys are left to the browser (returning false). */
|
||||
onBridgeKey(event: KeyboardEvent): boolean {
|
||||
if (event.key === 'Enter') {
|
||||
this.onSubmit?.(this.valueText);
|
||||
@@ -207,7 +205,7 @@ export class TextField extends Widget implements TextEditTarget {
|
||||
}
|
||||
|
||||
onBridgeBlur(): void {
|
||||
// DOM focus went elsewhere (browser UI, another tab). Commit and release focus.
|
||||
// DOM focus went elsewhere: browser UI, another tab.
|
||||
this.stopEditing(true);
|
||||
if (ui().focus.focused === this) ui().focus.blur();
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user