Add ESLint and Prettier, and close the node test gaps
Tooling: - Prettier as the single formatting authority (110 columns, single quotes, measured against the existing style); Markdown excluded because table-cell padding turns the README glossary into 1000-character lines. - Type-aware ESLint ending in eslint-config-prettier, so a rule always means "this is a bug", never "this looks different". - .editorconfig and an `npm run check` that runs typecheck, lint, format and tests in one go. Findings fixed on the first run: three more duplicate imports, four String(unknown) calls (parameter types narrowed instead of casts), two dead initialisers, two redundant casts, a console.log for a real error. Two warnings were false here and are disabled with a reason in place. Tests 132 -> 269: - node-catalog.test.ts covers every node with a run(), with a guard that fails when a new node has no row. - behavior-events.test.ts covers the ten event nodes through a running World, checking timing rather than existence. - Input's state machine (press/release/releaseAll) is reachable without DOM events now; the DOM handlers are thin adapters. Compare fell back to == without a configured operator although the schema default is <; aligned. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f140c2fd77
commit
d54a620ebd
113 files changed
+5049
-666
No files matched your search
@@ -34,7 +34,9 @@ export class Button extends Widget {
|
||||
this.addChild(this.bg, this.labelText);
|
||||
|
||||
// Leaving while pressed cancels the click: a release elsewhere must not activate.
|
||||
this.trackHover(() => { this.pressed = false; });
|
||||
this.trackHover(() => {
|
||||
this.pressed = false;
|
||||
});
|
||||
this.on('pointerdown', (e: FederatedPointerEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
this.pressed = true;
|
||||
@@ -89,10 +91,22 @@ export class Button extends Widget {
|
||||
const flat = this.variant === 'default' || this.variant === 'ghost';
|
||||
let fill: number | null = c.control;
|
||||
let border: number | null = c.border;
|
||||
if (this.variant === 'primary') { fill = c.accent; border = null; }
|
||||
if (this.variant === 'danger') { fill = c.danger; border = null; }
|
||||
if (this.variant === 'ghost') { fill = null; border = null; }
|
||||
if (this.toggled) { fill = c.accent; border = null; }
|
||||
if (this.variant === 'primary') {
|
||||
fill = c.accent;
|
||||
border = null;
|
||||
}
|
||||
if (this.variant === 'danger') {
|
||||
fill = c.danger;
|
||||
border = null;
|
||||
}
|
||||
if (this.variant === 'ghost') {
|
||||
fill = null;
|
||||
border = null;
|
||||
}
|
||||
if (this.toggled) {
|
||||
fill = c.accent;
|
||||
border = null;
|
||||
}
|
||||
if (this.hovered && !this.toggled && flat) fill = c.controlHover;
|
||||
if (this.pressed && flat) fill = c.controlActive;
|
||||
const stroke = this.focused ? c.accent : border;
|
||||
|
||||
@@ -54,7 +54,11 @@ export class Checkbox extends Widget {
|
||||
y,
|
||||
});
|
||||
if (this.checked) {
|
||||
this.box.moveTo(4, y + 8.5).lineTo(7, y + 11.5).lineTo(12.5, y + 5).stroke({ width: 2, color: c.accentText });
|
||||
this.box
|
||||
.moveTo(4, y + 8.5)
|
||||
.lineTo(7, y + 11.5)
|
||||
.lineTo(12.5, y + 5)
|
||||
.stroke({ width: 2, color: c.accentText });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,7 +54,9 @@ export class ColorPicker extends Popup {
|
||||
this.applyPointer(local.x, local.y);
|
||||
});
|
||||
// The press starts on the body; its release bubbles up to the popup either way.
|
||||
this.onPointerRelease(() => { this.dragging = null; });
|
||||
this.onPointerRelease(() => {
|
||||
this.dragging = null;
|
||||
});
|
||||
this.content.add(this.body);
|
||||
this.renderSv();
|
||||
}
|
||||
|
||||
@@ -22,7 +22,9 @@ export class Dialog extends Popup {
|
||||
const row = new HStack({ gap: Theme.gap, align: 'center' });
|
||||
row.add(new Spacer(), 1); // pushes the buttons to the right
|
||||
for (const b of buttons) {
|
||||
const button = new Button(b.label, { variant: b.primary ? 'primary' : b.danger ? 'danger' : 'default' });
|
||||
const button = new Button(b.label, {
|
||||
variant: b.primary ? 'primary' : b.danger ? 'danger' : 'default',
|
||||
});
|
||||
button.onClick = this.closeThen(b.onClick);
|
||||
row.add(button);
|
||||
}
|
||||
|
||||
@@ -20,7 +20,11 @@ export class ListRow extends Widget {
|
||||
private selected = false;
|
||||
private listFocused = false;
|
||||
|
||||
constructor(readonly item: ListItem, height: number, private readonly chevronSlot: boolean) {
|
||||
constructor(
|
||||
readonly item: ListItem,
|
||||
height: number,
|
||||
private readonly chevronSlot: boolean,
|
||||
) {
|
||||
super();
|
||||
this.cursor = 'pointer';
|
||||
this.minHeight = height;
|
||||
@@ -59,12 +63,17 @@ export class ListRow extends Widget {
|
||||
const depth = this.item.depth ?? 0;
|
||||
const indent = this.indent;
|
||||
this.bg.clear();
|
||||
if (this.selected) this.bg.rect(0, 0, this.w, this.h).fill(this.listFocused ? c.selection : c.controlActive);
|
||||
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.
|
||||
if (depth > 0) {
|
||||
const gx = indent - 8;
|
||||
this.bg.moveTo(gx, 0).lineTo(gx, this.h / 2).lineTo(indent - 2, this.h / 2).stroke({ width: 1, color: c.border });
|
||||
this.bg
|
||||
.moveTo(gx, 0)
|
||||
.lineTo(gx, this.h / 2)
|
||||
.lineTo(indent - 2, this.h / 2)
|
||||
.stroke({ width: 1, color: c.border });
|
||||
}
|
||||
let x = indent;
|
||||
if (this.chevronSlot) {
|
||||
@@ -86,13 +95,26 @@ export class ListRow extends Widget {
|
||||
const hintW = this.hint.text ? this.hint.width + Theme.padding : 0;
|
||||
this.labelText.text = truncate(this.item.label, this.labelText.style, this.w - x - hintW - Theme.padding);
|
||||
this.labelText.position.set(x, centreY(this.h, this.labelText.height));
|
||||
this.hint.position.set(Math.round(this.w - Theme.padding - this.hint.width), centreY(this.h, this.hint.height));
|
||||
this.hint.position.set(
|
||||
Math.round(this.w - Theme.padding - this.hint.width),
|
||||
centreY(this.h, this.hint.height),
|
||||
);
|
||||
}
|
||||
|
||||
/** ▸ when collapsed, ▾ when expanded – drawn, not glyphs, 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) this.bg.moveTo(x - 3, y - 2).lineTo(x + 5, y - 2).lineTo(x + 1, y + 3).fill(color);
|
||||
else this.bg.moveTo(x - 1, y - 4).lineTo(x + 4, y).lineTo(x - 1, y + 4).fill(color);
|
||||
if (expanded)
|
||||
this.bg
|
||||
.moveTo(x - 3, y - 2)
|
||||
.lineTo(x + 5, y - 2)
|
||||
.lineTo(x + 1, y + 3)
|
||||
.fill(color);
|
||||
else
|
||||
this.bg
|
||||
.moveTo(x - 1, y - 4)
|
||||
.lineTo(x + 4, y)
|
||||
.lineTo(x - 1, y + 4)
|
||||
.fill(color);
|
||||
}
|
||||
}
|
||||
@@ -124,6 +124,8 @@ export class NumberField extends TextField {
|
||||
|
||||
/** Clamps to min/max; NaN/Infinity keep the current value (0 before the first assignment). */
|
||||
private clampValue(value: number): number {
|
||||
// `?? 0` is not dead despite the type: the constructor calls this before assigning `numeric`.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (!Number.isFinite(value)) return this.numeric ?? 0;
|
||||
return clamp(value, this.min, this.max);
|
||||
}
|
||||
|
||||
@@ -39,7 +39,9 @@ export class ScrollContainer extends Widget {
|
||||
this.scrollTo(this.dragScrollStart + (e.global.y - this.dragStart) * ratio);
|
||||
});
|
||||
// The press starts on the scrollbar graphics; its release bubbles up to us either way.
|
||||
this.onPointerRelease(() => { this.draggingBar = false; });
|
||||
this.onPointerRelease(() => {
|
||||
this.draggingBar = false;
|
||||
});
|
||||
}
|
||||
|
||||
/** Replaces the scrolled widget. The previous content is detached, not destroyed. */
|
||||
@@ -99,6 +101,8 @@ export class ScrollContainer extends Widget {
|
||||
const x = this.w - trackW - 2;
|
||||
const barH = Math.max(20, (this.h / this.contentHeight) * this.h);
|
||||
const barY = (this.scrollY / this.maxScroll) * (this.h - barH);
|
||||
this.scrollbar.roundRect(x, barY, trackW, barH, trackW / 2).fill({ color: Theme.colors.textMuted, alpha: 0.6 });
|
||||
this.scrollbar
|
||||
.roundRect(x, barY, trackW, barH, trackW / 2)
|
||||
.fill({ color: Theme.colors.textMuted, alpha: 0.6 });
|
||||
}
|
||||
}
|
||||
@@ -61,7 +61,14 @@ export class TextField extends Widget implements TextEditTarget {
|
||||
this.minWidth = options.width ?? 80;
|
||||
this.textNode = new Text({ text: value, style: textStyle(options.style ?? 'body') });
|
||||
this.placeholderNode = new Text({ text: this.placeholder, style: textStyle('muted') });
|
||||
this.addChild(this.bg, this.selectionShape, this.placeholderNode, this.textNode, this.caret, this.clipShape);
|
||||
this.addChild(
|
||||
this.bg,
|
||||
this.selectionShape,
|
||||
this.placeholderNode,
|
||||
this.textNode,
|
||||
this.caret,
|
||||
this.clipShape,
|
||||
);
|
||||
this.mask = this.clipShape;
|
||||
|
||||
this.trackHover();
|
||||
@@ -71,7 +78,9 @@ export class TextField extends Widget implements TextEditTarget {
|
||||
const index = this.indexAt(this.toLocal(e.global).x);
|
||||
this.setSelection(this.selStart, index, true);
|
||||
});
|
||||
this.onPointerRelease(() => { this.dragSelecting = false; });
|
||||
this.onPointerRelease(() => {
|
||||
this.dragSelecting = false;
|
||||
});
|
||||
}
|
||||
|
||||
/** Current text, including an edit in progress that has not been committed yet. */
|
||||
@@ -216,7 +225,10 @@ export class TextField extends Widget implements TextEditTarget {
|
||||
let bestDist = Infinity;
|
||||
for (let i = 0; i <= this.valueText.length; i++) {
|
||||
const dist = Math.abs(this.textWidth(this.valueText.slice(0, i)) - x);
|
||||
if (dist < bestDist) { bestDist = dist; best = i; }
|
||||
if (dist < bestDist) {
|
||||
bestDist = dist;
|
||||
best = i;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
@@ -248,7 +260,10 @@ export class TextField extends Widget implements TextEditTarget {
|
||||
fill: this.hovered && !this.editing ? c.controlHover : c.control,
|
||||
border: this.focused ? c.accent : c.border,
|
||||
});
|
||||
this.clipShape.clear().rect(1, 1, this.w - 2, this.h - 2).fill(0xffffff);
|
||||
this.clipShape
|
||||
.clear()
|
||||
.rect(1, 1, this.w - 2, this.h - 2)
|
||||
.fill(0xffffff);
|
||||
|
||||
this.textNode.text = this.valueText;
|
||||
this.placeholderNode.text = this.valueText ? '' : this.placeholder;
|
||||
@@ -271,7 +286,9 @@ export class TextField extends Widget implements TextEditTarget {
|
||||
if (hi > lo) {
|
||||
const x1 = textX + this.textWidth(this.valueText.slice(0, lo));
|
||||
const x2 = textX + this.textWidth(this.valueText.slice(0, hi));
|
||||
this.selectionShape.rect(x1, textY, x2 - x1, this.textNode.height).fill({ color: c.accent, alpha: 0.45 });
|
||||
this.selectionShape
|
||||
.rect(x1, textY, x2 - x1, this.textNode.height)
|
||||
.fill({ color: c.accent, alpha: 0.45 });
|
||||
}
|
||||
if (this.caretVisible) {
|
||||
this.caret.rect(Math.round(textX + caretX), textY, 1, this.textNode.height).fill(c.text);
|
||||
|
||||
Reference in new issue
Block a user