Open asset greyed out and real tab cards: Open asset in the scene object menu is only pickable for kinds with an editor of their own, the table of which asset opens which tab moved from Editor.ts into the new module assetTabs.ts with hasAssetEditor so the menu and the editor read the same list, Select asset stays as it was; tabs are drawn as index cards (new module TabButton.ts): the one showing is a light grey card instead of the aggressive blue, rounded above and square below, its name in plain weight and measured bold so it never clips itself, and a mouse down on the tab already showing keeps its colour; the padding left and right grew to 6 px in one place for both kinds of tab, Scene and Game carry an icon too (the scene one from the asset panel, a new game.svg play sign), Plugins and a plugin's own tab have none; new test hook menuEnabled next to menuRows and menuIcons, e2e 01 checks the greyed out entry on a sprite row, 14a-ii the card and the icons of the fixed tabs, 44a the icon at the wider padding (#176)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4b5b91eaaa
commit
60ea56a2ce
17 files changed
+287
-47
No files matched your search
@@ -1,5 +1,6 @@
|
||||
import { CanvasTextMetrics, type FederatedPointerEvent, Rectangle, Text } from 'pixi.js';
|
||||
import { Button, centreY, Theme, textStyle } from '../ui';
|
||||
import { centreY, Theme, textStyle } from '../ui';
|
||||
import { TabButton } from './TabButton';
|
||||
|
||||
const GAP = 4;
|
||||
const CLOSE = '×';
|
||||
@@ -12,7 +13,7 @@ const MAX_NAME_WIDTH = 160;
|
||||
* A tab that stands for an open document (#171): the icon of its kind before the name, so three
|
||||
* assets called "Player" can be told apart, and an × that closes it – as does a middle click.
|
||||
*/
|
||||
export class DocumentTab extends Button {
|
||||
export class DocumentTab extends TabButton {
|
||||
private readonly closeText: Text;
|
||||
|
||||
constructor(
|
||||
@@ -22,8 +23,7 @@ export class DocumentTab extends Button {
|
||||
onPick: () => void,
|
||||
private readonly onClose: () => void,
|
||||
) {
|
||||
// `toggle`: measured bold, so the active tab does not cut its own name short.
|
||||
super(label, { variant: 'ghost', padding: 4, align: 'left', toggle: true, icon, onClick: onPick });
|
||||
super(label, { align: 'left', icon, onClick: onPick });
|
||||
this.closeText = new Text({ text: CLOSE, style: textStyle('muted') });
|
||||
this.closeText.eventMode = 'static';
|
||||
this.closeText.cursor = Theme.hoverCursor;
|
||||
@@ -63,7 +63,7 @@ export class DocumentTab extends Button {
|
||||
|
||||
override redraw(): void {
|
||||
super.redraw();
|
||||
this.closeText.style = textStyle(this.toggled ? 'accent' : 'muted');
|
||||
this.closeText.style = textStyle(this.toggled ? 'body' : 'muted');
|
||||
const closeX = this.w - this.padding - this.closeText.width;
|
||||
this.closeText.position.set(closeX, centreY(this.h, this.closeText.height));
|
||||
this.closeText.hitArea = new Rectangle(
|
||||
|
||||
+7
-14
@@ -1,6 +1,5 @@
|
||||
import type { Application } from 'pixi.js';
|
||||
import type { Asset } from '../core/model/Asset';
|
||||
import type { AssetType } from '../core/model/AssetTypes';
|
||||
import { findAsset, findObjectHolder } from '../core/model/Project';
|
||||
import type { ProjectStore } from '../core/store/ProjectStore';
|
||||
import type { FontStore } from '../core/store/FontStore';
|
||||
@@ -26,6 +25,7 @@ import { closeDocument, type DocumentRow, openDocument, replaceDocument } from '
|
||||
import { ConsolePanel } from './console/ConsolePanel';
|
||||
import { GameViewport } from './viewport/GameViewport';
|
||||
import { SceneViewport } from './viewport/SceneViewport';
|
||||
import { TAB_OF_ASSET } from './assetTabs';
|
||||
import { attachAutosave } from './autosave';
|
||||
import { ContainerEditor } from './container/ContainerEditor';
|
||||
import { EditorRun } from './EditorRun';
|
||||
@@ -36,7 +36,7 @@ import { resolveList } from './list/slot';
|
||||
import { AnimationEditor } from './animation/AnimationEditor';
|
||||
import { TileEditor } from './tile/TileEditor';
|
||||
import { AssetPanel } from './panels/AssetPanel';
|
||||
import { ASSET_ICON_SVG, LIST_ICON_SVG } from './panels/assetIcons';
|
||||
import { ASSET_ICON_SVG, GAME_ICON_SVG, LIST_ICON_SVG } from './panels/assetIcons';
|
||||
import { HierarchyPanel } from './panels/HierarchyPanel';
|
||||
import { TilemapPanel } from './panels/TilemapPanel';
|
||||
import { Toolbar } from './panels/Toolbar';
|
||||
@@ -113,17 +113,10 @@ const TAB_LABELS: Record<CentreTab, string> = {
|
||||
plugins: 'Plugins',
|
||||
};
|
||||
|
||||
/** Where an opened asset goes; a placeable one (sprite, camera, text, collider) has no tab of its own. */
|
||||
const TAB_OF_ASSET: Partial<Record<AssetType, CentreTab>> = {
|
||||
scene: 'scene',
|
||||
behavior: 'graph',
|
||||
statemachine: 'statemachine',
|
||||
shader: 'shader',
|
||||
particles: 'particles',
|
||||
texture: 'texture',
|
||||
animation: 'animation',
|
||||
tile: 'tile',
|
||||
container: 'container',
|
||||
/** Icons of the fixed tabs (#176); the Plugins tab has none, as plugins have no picture of their own. */
|
||||
const FIXED_TAB_ICONS: Partial<Record<FixedTab, string>> = {
|
||||
scene: ASSET_ICON_SVG.scene,
|
||||
game: GAME_ICON_SVG,
|
||||
};
|
||||
|
||||
function isDocument(tab: StripTab): tab is DocumentId {
|
||||
@@ -279,7 +272,7 @@ export class Editor implements PlayHost {
|
||||
|
||||
this.centre = new VStack();
|
||||
this.tabs = new TabStrip<StripTab>(
|
||||
FIXED_TABS.map((tab) => ({ tab, label: TAB_LABELS[tab] })),
|
||||
FIXED_TABS.map((tab) => ({ tab, label: TAB_LABELS[tab], icon: FIXED_TAB_ICONS[tab] })),
|
||||
(tab) => this.pickTab(tab),
|
||||
);
|
||||
this.tabs.minHeight = Theme.controlHeight + 8;
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Button, type ButtonOptions, Theme, type TextStyleToken } from '../ui';
|
||||
|
||||
/** The upper corners are rounded, the lower ones square: the card sits on the bar of the strip. */
|
||||
const RADIUS = 6;
|
||||
|
||||
/**
|
||||
* Inset of icon and name inside a card. Half the theme padding was too tight once the cards got a
|
||||
* border of their own, so the row keeps its own value instead of `Theme.padding`.
|
||||
*/
|
||||
export const TAB_PADDING = 6;
|
||||
|
||||
/**
|
||||
* One card of the tab row. It is drawn as a real index card rather than as a toggled button: a
|
||||
* light grey card for the tab showing, nothing for the others, and never the pressed fill – a
|
||||
* click on the tab that is already showing must not flash a colour of its own (#176).
|
||||
*/
|
||||
export class TabButton extends Button {
|
||||
constructor(text: string, options: ButtonOptions = {}) {
|
||||
super(text, { padding: TAB_PADDING, ...options, variant: 'ghost' });
|
||||
}
|
||||
|
||||
/** Plain weight in both states: the fill says which tab is showing, the font does not. */
|
||||
protected override labelToken(): TextStyleToken {
|
||||
return this.toggled ? 'body' : 'muted';
|
||||
}
|
||||
|
||||
/** `body` and `muted` differ only in colour, so one width holds for both states. */
|
||||
protected override measureToken(): TextStyleToken {
|
||||
return 'body';
|
||||
}
|
||||
|
||||
protected override iconTint(): number {
|
||||
return this.toggled ? Theme.colors.text : Theme.colors.textMuted;
|
||||
}
|
||||
|
||||
protected override drawBackground(): void {
|
||||
const c = Theme.colors;
|
||||
const { w, h } = this;
|
||||
const fill = this.toggled ? c.tabActive : this.hovered ? c.tabHover : null;
|
||||
this.bg.clear();
|
||||
if (fill !== null) {
|
||||
// Two shapes instead of one rounded rect: the card must meet the bar with square corners.
|
||||
this.bg.roundRect(0, 0, w, h, RADIUS).fill(fill);
|
||||
this.bg.rect(0, h - RADIUS, w, RADIUS).fill(fill);
|
||||
}
|
||||
const border = this.focused ? c.accent : this.toggled ? c.border : null;
|
||||
if (border === null) return;
|
||||
// An open path along the three edges the card has: a closed one would draw a line over the bar.
|
||||
this.bg
|
||||
.moveTo(0.5, h)
|
||||
.lineTo(0.5, RADIUS)
|
||||
.quadraticCurveTo(0.5, 0.5, RADIUS, 0.5)
|
||||
.lineTo(w - RADIUS, 0.5)
|
||||
.quadraticCurveTo(w - 0.5, 0.5, w - 0.5, RADIUS)
|
||||
.lineTo(w - 0.5, h)
|
||||
.stroke({ width: 1, color: border });
|
||||
}
|
||||
}
|
||||
+42
-11
@@ -1,9 +1,13 @@
|
||||
import { Button, HStack, Menu } from '../ui';
|
||||
import { Graphics } from 'pixi.js';
|
||||
import { Button, HStack, Menu, Theme } from '../ui';
|
||||
import { DocumentTab } from './DocumentTab';
|
||||
import { TabButton } from './TabButton';
|
||||
import { tabRowLayout } from './tabRow';
|
||||
|
||||
/** Fixed, so that the count of hidden tabs cannot feed back into how much room the row has. */
|
||||
const OVERFLOW_WIDTH = 28;
|
||||
/** The bar Graphics is the first child, so a tab's index among the children is two past its place. */
|
||||
const TABS_AT = 2;
|
||||
|
||||
export interface TabPlace<T extends string> {
|
||||
tab: T;
|
||||
@@ -12,6 +16,10 @@ export interface TabPlace<T extends string> {
|
||||
/** Top edge and height, so a check can tell the tab from a label of the same text elsewhere. */
|
||||
y: number;
|
||||
h: number;
|
||||
/** Whether the name is drawn bold – the tab showing must not be (#176). */
|
||||
bold: boolean;
|
||||
/** Whether an icon stands in front of the name; the fixed tabs have one too, bar Plugins (#176). */
|
||||
icon: boolean;
|
||||
/** A document's kind as shown before its name, and where its × is. */
|
||||
kind?: string;
|
||||
close?: { x: number; y: number };
|
||||
@@ -35,11 +43,13 @@ export class TabStrip<T extends string> extends HStack {
|
||||
/** A document's icon, which the `»` menu shows in front of its name too. */
|
||||
private readonly icons = new Map<T, string>();
|
||||
private readonly overflow: Button;
|
||||
/** The bar the cards sit on, drawn behind every tab. */
|
||||
private readonly bar = new Graphics();
|
||||
private hiddenTabs: T[] = [];
|
||||
private active: T | null;
|
||||
|
||||
constructor(
|
||||
tabs: readonly { tab: T; label: string }[],
|
||||
tabs: readonly { tab: T; label: string; icon?: string }[],
|
||||
private readonly onPick: (tab: T) => void,
|
||||
) {
|
||||
super({ gap: 2, padding: 4, align: 'center' });
|
||||
@@ -49,8 +59,9 @@ export class TabStrip<T extends string> extends HStack {
|
||||
width: OVERFLOW_WIDTH,
|
||||
onClick: () => this.openMenu(),
|
||||
});
|
||||
this.addChild(this.bar);
|
||||
this.add(this.overflow);
|
||||
for (const { tab, label } of tabs) this.place(tab, label, this.order.length);
|
||||
for (const { tab, label, icon } of tabs) this.place(tab, label, this.order.length, undefined, icon);
|
||||
this.buttons.get(this.active)!.setToggled(true);
|
||||
}
|
||||
|
||||
@@ -89,17 +100,15 @@ export class TabStrip<T extends string> extends HStack {
|
||||
return this.order;
|
||||
}
|
||||
|
||||
private place(tab: T, label: string, at: number, document?: DocumentSpec): void {
|
||||
// Half the theme padding: nine tabs at the full 8 px overflow the centre column of a 1280 px
|
||||
// window by 27 px, and a button past its edge sits under the inspector (measured, CI run 5).
|
||||
private place(tab: T, label: string, at: number, document?: DocumentSpec, icon?: string): void {
|
||||
const button = document
|
||||
? new DocumentTab(label, document.kind, document.icon, () => this.onPick(tab), document.onClose)
|
||||
: new Button(label, { variant: 'ghost', padding: 4, onClick: () => this.onPick(tab) });
|
||||
: new TabButton(label, { icon, onClick: () => this.onPick(tab) });
|
||||
this.order.splice(at, 0, tab);
|
||||
this.buttons.set(tab, button);
|
||||
if (document) this.icons.set(tab, document.icon);
|
||||
// The `»` button is the first child, so a tab's index among the children is one past its place.
|
||||
this.addChildAt(button, at + 1);
|
||||
const shown = document?.icon ?? icon;
|
||||
if (shown) this.icons.set(tab, shown);
|
||||
this.addChildAt(button, at + TABS_AT);
|
||||
this.setChildIndex(this.overflow, this.children.length - 1);
|
||||
button.setToggled(tab === this.active);
|
||||
}
|
||||
@@ -118,7 +127,15 @@ export class TabStrip<T extends string> extends HStack {
|
||||
const button = this.buttons.get(tab)!;
|
||||
if (!button.visible) continue;
|
||||
const at = button.toGlobal({ x: 0, y: 0 });
|
||||
const place: TabPlace<T> = { tab, x: at.x, right: at.x + button.w, y: at.y, h: button.h };
|
||||
const place: TabPlace<T> = {
|
||||
tab,
|
||||
x: at.x,
|
||||
right: at.x + button.w,
|
||||
y: at.y,
|
||||
h: button.h,
|
||||
bold: button.labelBold,
|
||||
icon: button.hasIcon,
|
||||
};
|
||||
if (button instanceof DocumentTab) {
|
||||
place.kind = button.kindLabel;
|
||||
place.close = button.closePoint();
|
||||
@@ -135,6 +152,20 @@ export class TabStrip<T extends string> extends HStack {
|
||||
override layout(): void {
|
||||
this.applyOverflow();
|
||||
super.layout();
|
||||
// The cards reach down to the bar: the stack's padding below them is theirs, not free space.
|
||||
for (const item of this.items) {
|
||||
if (!(item instanceof TabButton)) continue;
|
||||
item.position.set(item.x, this.padding);
|
||||
item.resize(item.w, Math.max(0, this.h - this.padding));
|
||||
}
|
||||
}
|
||||
|
||||
override redraw(): void {
|
||||
const c = Theme.colors;
|
||||
this.bar.clear();
|
||||
this.bar.rect(0, 0, this.w, this.h).fill(c.tabBar);
|
||||
// The line the cards stand on; the one showing covers its own stretch of it.
|
||||
this.bar.rect(0, this.h - 1, this.w, 1).fill(c.border);
|
||||
}
|
||||
|
||||
private applyOverflow(): void {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { AssetType } from '../core/model/AssetTypes';
|
||||
import type { CentreTab } from './Editor';
|
||||
|
||||
/** Where an opened asset goes; a placeable one (sprite, camera, text, collider) has no tab of its own. */
|
||||
export const TAB_OF_ASSET: Partial<Record<AssetType, CentreTab>> = {
|
||||
scene: 'scene',
|
||||
behavior: 'graph',
|
||||
statemachine: 'statemachine',
|
||||
shader: 'shader',
|
||||
particles: 'particles',
|
||||
texture: 'texture',
|
||||
animation: 'animation',
|
||||
tile: 'tile',
|
||||
container: 'container',
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether opening an asset of this type brings an editor up. Everything else is only selected, so a
|
||||
* menu entry that promises to open it is offered greyed out instead.
|
||||
*/
|
||||
export function hasAssetEditor(type: AssetType): boolean {
|
||||
return TAB_OF_ASSET[type] !== undefined;
|
||||
}
|
||||
@@ -262,6 +262,8 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
menuRows: () => currentMenu()?.rowsDrawn() ?? null,
|
||||
/** Per entry of `menuRows`, whether an icon stands in front of it. */
|
||||
menuIcons: () => currentMenu()?.iconsDrawn() ?? null,
|
||||
/** Per entry of `menuRows`, whether it can be picked (a greyed-out one cannot). */
|
||||
menuEnabled: () => currentMenu()?.enabledDrawn() ?? null,
|
||||
textOverflows,
|
||||
labelsDropped,
|
||||
shownLabel,
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
createContainer,
|
||||
removeObjects,
|
||||
} from '../actions';
|
||||
import { hasAssetEditor } from '../assetTabs';
|
||||
import { ASSET_ICON_SVG } from './assetIcons';
|
||||
import { type EditorState, objectSelection } from '../state';
|
||||
|
||||
@@ -161,6 +162,7 @@ export class HierarchyPanel extends Panel {
|
||||
if (!holder) return undefined;
|
||||
const inScene = holder.type === 'scene';
|
||||
const object = findObject(holder, id);
|
||||
const asset = object && findAsset(state.project, object.assetId);
|
||||
// Content of a placed container belongs to its template: values can be changed here, the
|
||||
// structure cannot (see `model/instance.ts`).
|
||||
const source = this.source();
|
||||
@@ -176,7 +178,9 @@ export class HierarchyPanel extends Panel {
|
||||
},
|
||||
{
|
||||
// The same as double-clicking the asset in the asset panel: selects it and opens its tab.
|
||||
// A kind without an editor of its own would only be selected, so it is offered greyed out.
|
||||
label: 'Open asset',
|
||||
disabled: !asset || !hasAssetEditor(asset.type),
|
||||
onSelect: () => object && state.openAsset(object.assetId),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -9,6 +9,7 @@ import camera from './icons/camera.svg?raw';
|
||||
import collider from './icons/collider.svg?raw';
|
||||
import container from './icons/container.svg?raw';
|
||||
import font from './icons/font.svg?raw';
|
||||
import game from './icons/game.svg?raw';
|
||||
import list from './icons/list.svg?raw';
|
||||
import particles from './icons/particles.svg?raw';
|
||||
import rigidbody from './icons/rigidbody.svg?raw';
|
||||
@@ -46,3 +47,6 @@ export const ASSET_ICON_SVG: Record<AssetType, string> = {
|
||||
|
||||
/** The List tab's: it edits a list value, which is no asset. */
|
||||
export const LIST_ICON_SVG = list;
|
||||
|
||||
/** The Game tab's: it runs the project, so neither is it an asset. The Scene tab takes `scene`. */
|
||||
export const GAME_ICON_SVG = game;
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">
|
||||
<!-- The Game tab runs the project: a play sign, no asset type has one. -->
|
||||
<circle cx="0" cy="0" r="6.75" fill="none" stroke="currentColor" stroke-width="1.5" />
|
||||
<path d="M-2.5 -3.5 L4 0 L-2.5 3.5 Z" fill="currentColor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 323 B |
Reference in new issue
Block a user