Asset panel grouped by kind with icons: assets are listed under a header per kind (Sprites, Behaviors, ...) with the count on the right, empty groups are not shown, every group starts folded when a project is opened, a click on the header folds and unfolds it, selecting an asset elsewhere unfolds its group and folding the group of the selected asset clears the selection; every asset kind has an SVG icon in src/editor/panels/icons/ drawn before the name, the pure rule for grouping and sorting lives in assetGroups.ts; the same icons replace the kind word in document tabs and the overflow menu (a list icon for the List tab, which also fixes the close x lying over the end of the active tab's text), stand before every entry of the + menu (Button got an icon option) and on the right of scene object rows instead of the type name; the colour swatch next to sprite names is gone from both panels; the SVG loader moved to src/ui/svg.ts for both icon sets; test hook assetRow unfolds the group and scrolls to the row, assetRowShown only reads, new hook menuIcons; e2e 4g and 4h, 14 and 44a check the icons (#176)
game-maker / gate (push) Successful in 1m58s
game-maker / suite (push) Successful in 6m54s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-19 18:36:20 +02:00
1 parent 1c212b1ccc
commit e3ebf44593
46 files changed
+724 -129

No files matched your search

+37 -4
View File
@@ -1,6 +1,7 @@
import { type FederatedPointerEvent, Graphics, Text, CanvasTextMetrics } from 'pixi.js';
import { drawControlBox } from '../draw';
import { centreY } from '../math';
import { drawSvg } from '../svg';
import { Theme, textStyle, type TextStyleToken } from '../Theme';
import { type Size, Widget } from '../Widget';
import { truncate } from './Label';
@@ -18,9 +19,15 @@ export interface ButtonOptions {
style?: TextStyleToken;
/** Switched on and off by its owner (`setToggled`): measured for the bold label it shows when on. */
toggle?: boolean;
/** SVG file drawn in front of the label, 16 units around its origin (see `drawSvg`). */
icon?: string;
onClick?: () => void;
}
/** Units an icon file spans around its origin, and the room between it and the label. */
const ICON_SIZE = 16;
const ICON_GAP = 6;
export class Button extends Widget {
private readonly bg = new Graphics();
protected readonly labelText: Text;
@@ -35,6 +42,10 @@ export class Button extends Widget {
protected readonly padding: number;
private readonly style: TextStyleToken;
private readonly toggle: boolean;
private readonly icon: string | null;
private readonly iconGfx = new Graphics();
/** Colour the icon was last drawn in; it follows the label from muted to the accent text. */
private iconColor = -1;
onClick?: () => void;
constructor(text: string, options: ButtonOptions = {}) {
@@ -52,7 +63,9 @@ export class Button extends Widget {
this.style = options.style ?? 'body';
this.toggle = options.toggle ?? false;
this.labelText = new Text({ text, style: textStyle(this.variant === 'primary' ? 'accent' : this.style) });
this.icon = options.icon ?? null;
this.addChild(this.bg, this.labelText);
if (this.icon) this.addChild(this.iconGfx);
// Leaving while pressed cancels the click: a release elsewhere must not activate.
this.trackHover(() => {
@@ -110,14 +123,24 @@ export class Button extends Widget {
// Measured bold where it can be drawn bold (on an accent fill), or a danger or toggled tab truncates.
const style = textStyle(this.variant === 'default' && !this.toggle ? this.style : 'accent');
const metrics = CanvasTextMetrics.measureText(this.fullText, style);
return { w: Math.max(this.minWidth, Math.ceil(metrics.width) + this.padding * 2), h };
return { w: Math.max(this.minWidth, Math.ceil(metrics.width) + this.padding * 2 + this.iconSpace()), h };
}
/** Room the icon takes in front of the label, gap included; 0 without one. */
protected iconSpace(): number {
return this.icon ? ICON_SIZE + ICON_GAP : 0;
}
/** Whether an icon is drawn in front of the label (test hook). */
get hasIcon(): boolean {
return this.icon !== null;
}
/** Width the label may occupy; subclasses that draw inside the button take their room off here. */
protected labelSlot(): number {
// 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(this.padding, Math.floor(this.w / 6)) * 2;
return this.w - Math.min(this.padding, Math.floor(this.w / 6)) * 2 - this.iconSpace();
}
override onKeyDown(event: KeyboardEvent): boolean {
@@ -165,7 +188,17 @@ export class Button extends Widget {
this.labelText.style = textStyle(this.onAccent ? 'accent' : this.style);
const label = this.labelText;
label.text = truncate(this.fullText, label.style, this.labelSlot());
const x = this.align === 'left' ? this.padding : (this.w - label.width) / 2;
label.position.set(Math.round(x), centreY(this.h, label.height));
const content = this.iconSpace() + label.width;
const x = this.align === 'left' ? this.padding : (this.w - content) / 2;
label.position.set(Math.round(x + this.iconSpace()), centreY(this.h, label.height));
if (this.icon) {
const color = this.onAccent ? c.accentText : c.textMuted;
if (color !== this.iconColor) {
this.iconColor = color;
this.iconGfx.clear();
drawSvg(this.iconGfx, this.icon, color);
}
this.iconGfx.position.set(Math.round(x) + ICON_SIZE / 2, Math.round(this.h / 2));
}
}
}
+25 -12
View File
@@ -1,6 +1,6 @@
import { Color, type ColorSource, Graphics, Text } from 'pixi.js';
import { drawControlBox } from '../draw';
import { centreY } from '../math';
import { drawSvg } from '../svg';
import { Theme, textStyle } from '../Theme';
import { Widget } from '../Widget';
import { truncate } from './Label';
@@ -8,12 +8,15 @@ import type { ListItem } from './ListView';
/** Width reserved in front of every row of a tree list for the chevron. */
const CHEVRON_SLOT = 14;
/** Units an icon file spans around its origin. */
const ICON_SIZE = 16;
/** 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') });
private readonly hint = new Text({ text: '', style: textStyle('small') });
private readonly icon = new Graphics();
private selected = false;
private listFocused = false;
@@ -26,7 +29,12 @@ export class ListRow extends Widget {
this.cursor = Theme.hoverCursor;
this.minHeight = height;
if (item.overridden) this.labelText.style = textStyle('override');
else if (item.heading) this.labelText.style = textStyle('bodyBold');
this.addChild(this.bg, this.labelText, this.hint);
if (item.icon) {
drawSvg(this.icon, item.icon, Theme.colors.textMuted);
this.addChild(this.icon);
}
this.trackHover();
}
@@ -55,6 +63,13 @@ export class ListRow extends Widget {
return { x: p.x + this.indent + CHEVRON_SLOT / 2, y: p.y + this.h / 2 };
}
/** Global centre of the icon, its origin; null for rows without one (test hook). */
iconGlobal(): { x: number; y: number } | null {
if (!this.item.icon) return null;
const p = this.icon.getGlobalPosition();
return { x: p.x, y: p.y };
}
/** Whether a click at this local x lands on the chevron rather than on the row. */
hitsChevron(localX: number): boolean {
if (!this.chevronSlot || this.item.expanded === undefined) return false;
@@ -83,19 +98,17 @@ export class ListRow extends Widget {
if (this.item.expanded !== undefined) this.drawChevron(x + 4, this.h / 2, this.item.expanded);
x += CHEVRON_SLOT;
}
if (this.item.swatch !== undefined) {
const size = Theme.swatchSize;
drawControlBox(this.bg, size, size, {
fill: this.item.swatch,
border: c.border,
radius: 2,
x,
y: centreY(this.h, size),
});
x += size + Theme.gap;
const iconAtEnd = Boolean(this.item.icon && this.item.iconAtEnd);
if (this.item.icon && !iconAtEnd) {
this.icon.position.set(x + ICON_SIZE / 2, this.h / 2);
x += ICON_SIZE + Theme.gap;
}
this.hint.text = this.item.hint ?? '';
const hintW = this.hint.text ? this.hint.width + Theme.padding : 0;
let hintW = this.hint.text ? this.hint.width + Theme.padding : 0;
if (iconAtEnd) {
this.icon.position.set(this.w - Theme.padding - hintW - ICON_SIZE / 2, this.h / 2);
hintW += ICON_SIZE + Theme.padding;
}
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(
+21 -4
View File
@@ -12,12 +12,19 @@ export interface ListItem {
hint?: string;
/** Left indent in levels. */
depth?: number;
/** Marker color drawn as a small square before the label. */
swatch?: number;
/** Whether the subtree is shown; such rows get a chevron. Left undefined for leaves. */
expanded?: boolean;
/** Draws the label in the override colour – the row, or something inside it, deviates. */
overridden?: boolean;
/** SVG file drawn in front of the label, 16 units around its origin, `currentColor` muted. */
icon?: string;
/** Draws `icon` at the right edge, where a hint would stand, instead of in front of the label. */
iconAtEnd?: boolean;
/**
* A heading over the rows that follow: a press anywhere on it folds it, it is never selected,
* and ↑/↓ pass over it. Give it `expanded` for the chevron.
*/
heading?: boolean;
}
/** Pixels of travel before a press turns into a drag. */
@@ -145,6 +152,11 @@ export class ListView extends Widget {
return { x: p.x + this.w / 2, y: p.y + this.rowHeight / 2 };
}
/** Global centre of a row's icon; null when the row has none (test hook). */
iconGlobal(id: string): { x: number; y: number } | null {
return this.rows.find((r) => r.item.id === id)?.iconGlobal() ?? null;
}
/** 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;
@@ -157,12 +169,13 @@ export class ListView extends Widget {
override onKeyDown(event: KeyboardEvent): boolean {
const index = this.items.findIndex((i) => i.id === this.selectedId);
if (event.key === 'ArrowDown') {
const next = this.items[Math.min(this.items.length - 1, index + 1)];
const next = this.items.slice(index + 1).find((i) => !i.heading);
if (next) this.select(next.id);
return true;
}
if (event.key === 'ArrowUp') {
const prev = this.items[Math.max(0, index - 1)];
const above = index < 0 ? this.items : this.items.slice(0, index).reverse();
const prev = above.find((i) => !i.heading);
if (prev) this.select(prev.id);
return true;
}
@@ -200,6 +213,10 @@ export class ListView extends Widget {
private onRowPress(row: ListRow, e: FederatedPointerEvent): void {
const { item } = row;
const modifiers: PickModifiers = { ctrl: e.ctrlKey || e.metaKey, shift: e.shiftKey };
if (item.heading) {
if (e.button === 0) this.onToggle?.(item.id, !item.expanded);
return;
}
// The chevron only folds the row – it never selects, so folding keeps the selection put.
if (e.button === 0 && row.hitsChevron(row.toLocal(e.global).x)) {
this.onToggle?.(item.id, !item.expanded);
+10
View File
@@ -15,6 +15,8 @@ export interface MenuItem {
onSelect: () => void;
danger?: boolean;
disabled?: boolean;
/** SVG file in front of the label (see `ButtonOptions.icon`). */
icon?: string;
}
/** A hairline between two groups of entries; it is not selectable and matches no search. */
@@ -66,6 +68,7 @@ export class Menu extends Popup {
const button = new Button(item.label, {
variant: item.danger ? 'danger' : 'ghost',
align: 'left',
icon: item.icon,
});
button.setEnabled(!item.disabled);
button.onClick = this.closeThen(item.onSelect);
@@ -95,6 +98,13 @@ export class Menu extends Popup {
.map((child) => (child instanceof Button ? child.text : null));
}
/** Per entry of `rowsDrawn`, whether it shows an icon; `null` for a separator (test hook). */
iconsDrawn(): (boolean | null)[] {
return this.list.children
.filter((child) => child !== this.empty && child.visible)
.map((child) => (child instanceof Button ? child.hasIcon : null));
}
private filter(): void {
const query = this.search?.value.trim().toLowerCase() ?? '';
const shown = query ? this.ranked(query) : groupsOf(this.rows);