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:
rootandClaude Opus 5 committed 2026-09-08 15:52:05 +02:00
1 parent f16801aef3
commit 4d84231b9f
115 files changed
+707 -1564

No files matched your search

+23 -23
View File
@@ -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 {
+1 -3
View File
@@ -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();
+4 -4
View File
@@ -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. */
-1
View File
@@ -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;
}
+4 -7
View File
@@ -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)
+9 -14
View File
@@ -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
View File
@@ -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();
}
+8 -17
View File
@@ -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) {
+1 -4
View File
@@ -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
View File
@@ -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();
+1 -4
View File
@@ -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();
+1 -1
View File
@@ -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;
+6 -8
View File
@@ -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();
}