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)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1c212b1ccc
commit
e3ebf44593
46 files changed
+724
-129
No files matched your search
@@ -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
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user