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
@@ -38,7 +38,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
|
|||||||
The browser suite and the three example games need a machine with a GPU:
|
The browser suite and the three example games need a machine with a GPU:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1103 checks (GM_STEPS=20 for one step)
|
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1109 checks (GM_STEPS=20 for one step)
|
||||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||||
node tools/seed-actor.mjs && node tools/smoke-actor.mjs
|
node tools/seed-actor.mjs && node tools/smoke-actor.mjs
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { CanvasTextMetrics, type FederatedPointerEvent, Rectangle, Text } from 'pixi.js';
|
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 GAP = 4;
|
||||||
const CLOSE = '×';
|
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
|
* 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.
|
* 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;
|
private readonly closeText: Text;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -22,8 +23,7 @@ export class DocumentTab extends Button {
|
|||||||
onPick: () => void,
|
onPick: () => void,
|
||||||
private readonly onClose: () => void,
|
private readonly onClose: () => void,
|
||||||
) {
|
) {
|
||||||
// `toggle`: measured bold, so the active tab does not cut its own name short.
|
super(label, { align: 'left', icon, onClick: onPick });
|
||||||
super(label, { variant: 'ghost', padding: 4, align: 'left', toggle: true, icon, onClick: onPick });
|
|
||||||
this.closeText = new Text({ text: CLOSE, style: textStyle('muted') });
|
this.closeText = new Text({ text: CLOSE, style: textStyle('muted') });
|
||||||
this.closeText.eventMode = 'static';
|
this.closeText.eventMode = 'static';
|
||||||
this.closeText.cursor = Theme.hoverCursor;
|
this.closeText.cursor = Theme.hoverCursor;
|
||||||
@@ -63,7 +63,7 @@ export class DocumentTab extends Button {
|
|||||||
|
|
||||||
override redraw(): void {
|
override redraw(): void {
|
||||||
super.redraw();
|
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;
|
const closeX = this.w - this.padding - this.closeText.width;
|
||||||
this.closeText.position.set(closeX, centreY(this.h, this.closeText.height));
|
this.closeText.position.set(closeX, centreY(this.h, this.closeText.height));
|
||||||
this.closeText.hitArea = new Rectangle(
|
this.closeText.hitArea = new Rectangle(
|
||||||
|
|||||||
+7
-14
@@ -1,6 +1,5 @@
|
|||||||
import type { Application } from 'pixi.js';
|
import type { Application } from 'pixi.js';
|
||||||
import type { Asset } from '../core/model/Asset';
|
import type { Asset } from '../core/model/Asset';
|
||||||
import type { AssetType } from '../core/model/AssetTypes';
|
|
||||||
import { findAsset, findObjectHolder } from '../core/model/Project';
|
import { findAsset, findObjectHolder } from '../core/model/Project';
|
||||||
import type { ProjectStore } from '../core/store/ProjectStore';
|
import type { ProjectStore } from '../core/store/ProjectStore';
|
||||||
import type { FontStore } from '../core/store/FontStore';
|
import type { FontStore } from '../core/store/FontStore';
|
||||||
@@ -26,6 +25,7 @@ import { closeDocument, type DocumentRow, openDocument, replaceDocument } from '
|
|||||||
import { ConsolePanel } from './console/ConsolePanel';
|
import { ConsolePanel } from './console/ConsolePanel';
|
||||||
import { GameViewport } from './viewport/GameViewport';
|
import { GameViewport } from './viewport/GameViewport';
|
||||||
import { SceneViewport } from './viewport/SceneViewport';
|
import { SceneViewport } from './viewport/SceneViewport';
|
||||||
|
import { TAB_OF_ASSET } from './assetTabs';
|
||||||
import { attachAutosave } from './autosave';
|
import { attachAutosave } from './autosave';
|
||||||
import { ContainerEditor } from './container/ContainerEditor';
|
import { ContainerEditor } from './container/ContainerEditor';
|
||||||
import { EditorRun } from './EditorRun';
|
import { EditorRun } from './EditorRun';
|
||||||
@@ -36,7 +36,7 @@ import { resolveList } from './list/slot';
|
|||||||
import { AnimationEditor } from './animation/AnimationEditor';
|
import { AnimationEditor } from './animation/AnimationEditor';
|
||||||
import { TileEditor } from './tile/TileEditor';
|
import { TileEditor } from './tile/TileEditor';
|
||||||
import { AssetPanel } from './panels/AssetPanel';
|
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 { HierarchyPanel } from './panels/HierarchyPanel';
|
||||||
import { TilemapPanel } from './panels/TilemapPanel';
|
import { TilemapPanel } from './panels/TilemapPanel';
|
||||||
import { Toolbar } from './panels/Toolbar';
|
import { Toolbar } from './panels/Toolbar';
|
||||||
@@ -113,17 +113,10 @@ const TAB_LABELS: Record<CentreTab, string> = {
|
|||||||
plugins: 'Plugins',
|
plugins: 'Plugins',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Where an opened asset goes; a placeable one (sprite, camera, text, collider) has no tab of its own. */
|
/** Icons of the fixed tabs (#176); the Plugins tab has none, as plugins have no picture of their own. */
|
||||||
const TAB_OF_ASSET: Partial<Record<AssetType, CentreTab>> = {
|
const FIXED_TAB_ICONS: Partial<Record<FixedTab, string>> = {
|
||||||
scene: 'scene',
|
scene: ASSET_ICON_SVG.scene,
|
||||||
behavior: 'graph',
|
game: GAME_ICON_SVG,
|
||||||
statemachine: 'statemachine',
|
|
||||||
shader: 'shader',
|
|
||||||
particles: 'particles',
|
|
||||||
texture: 'texture',
|
|
||||||
animation: 'animation',
|
|
||||||
tile: 'tile',
|
|
||||||
container: 'container',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function isDocument(tab: StripTab): tab is DocumentId {
|
function isDocument(tab: StripTab): tab is DocumentId {
|
||||||
@@ -279,7 +272,7 @@ export class Editor implements PlayHost {
|
|||||||
|
|
||||||
this.centre = new VStack();
|
this.centre = new VStack();
|
||||||
this.tabs = new TabStrip<StripTab>(
|
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),
|
(tab) => this.pickTab(tab),
|
||||||
);
|
);
|
||||||
this.tabs.minHeight = Theme.controlHeight + 8;
|
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 { DocumentTab } from './DocumentTab';
|
||||||
|
import { TabButton } from './TabButton';
|
||||||
import { tabRowLayout } from './tabRow';
|
import { tabRowLayout } from './tabRow';
|
||||||
|
|
||||||
/** Fixed, so that the count of hidden tabs cannot feed back into how much room the row has. */
|
/** Fixed, so that the count of hidden tabs cannot feed back into how much room the row has. */
|
||||||
const OVERFLOW_WIDTH = 28;
|
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> {
|
export interface TabPlace<T extends string> {
|
||||||
tab: T;
|
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. */
|
/** Top edge and height, so a check can tell the tab from a label of the same text elsewhere. */
|
||||||
y: number;
|
y: number;
|
||||||
h: 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. */
|
/** A document's kind as shown before its name, and where its × is. */
|
||||||
kind?: string;
|
kind?: string;
|
||||||
close?: { x: number; y: number };
|
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. */
|
/** A document's icon, which the `»` menu shows in front of its name too. */
|
||||||
private readonly icons = new Map<T, string>();
|
private readonly icons = new Map<T, string>();
|
||||||
private readonly overflow: Button;
|
private readonly overflow: Button;
|
||||||
|
/** The bar the cards sit on, drawn behind every tab. */
|
||||||
|
private readonly bar = new Graphics();
|
||||||
private hiddenTabs: T[] = [];
|
private hiddenTabs: T[] = [];
|
||||||
private active: T | null;
|
private active: T | null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
tabs: readonly { tab: T; label: string }[],
|
tabs: readonly { tab: T; label: string; icon?: string }[],
|
||||||
private readonly onPick: (tab: T) => void,
|
private readonly onPick: (tab: T) => void,
|
||||||
) {
|
) {
|
||||||
super({ gap: 2, padding: 4, align: 'center' });
|
super({ gap: 2, padding: 4, align: 'center' });
|
||||||
@@ -49,8 +59,9 @@ export class TabStrip<T extends string> extends HStack {
|
|||||||
width: OVERFLOW_WIDTH,
|
width: OVERFLOW_WIDTH,
|
||||||
onClick: () => this.openMenu(),
|
onClick: () => this.openMenu(),
|
||||||
});
|
});
|
||||||
|
this.addChild(this.bar);
|
||||||
this.add(this.overflow);
|
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);
|
this.buttons.get(this.active)!.setToggled(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,17 +100,15 @@ export class TabStrip<T extends string> extends HStack {
|
|||||||
return this.order;
|
return this.order;
|
||||||
}
|
}
|
||||||
|
|
||||||
private place(tab: T, label: string, at: number, document?: DocumentSpec): void {
|
private place(tab: T, label: string, at: number, document?: DocumentSpec, icon?: string): 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).
|
|
||||||
const button = document
|
const button = document
|
||||||
? new DocumentTab(label, document.kind, document.icon, () => this.onPick(tab), document.onClose)
|
? 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.order.splice(at, 0, tab);
|
||||||
this.buttons.set(tab, button);
|
this.buttons.set(tab, button);
|
||||||
if (document) this.icons.set(tab, document.icon);
|
const shown = document?.icon ?? icon;
|
||||||
// The `»` button is the first child, so a tab's index among the children is one past its place.
|
if (shown) this.icons.set(tab, shown);
|
||||||
this.addChildAt(button, at + 1);
|
this.addChildAt(button, at + TABS_AT);
|
||||||
this.setChildIndex(this.overflow, this.children.length - 1);
|
this.setChildIndex(this.overflow, this.children.length - 1);
|
||||||
button.setToggled(tab === this.active);
|
button.setToggled(tab === this.active);
|
||||||
}
|
}
|
||||||
@@ -118,7 +127,15 @@ export class TabStrip<T extends string> extends HStack {
|
|||||||
const button = this.buttons.get(tab)!;
|
const button = this.buttons.get(tab)!;
|
||||||
if (!button.visible) continue;
|
if (!button.visible) continue;
|
||||||
const at = button.toGlobal({ x: 0, y: 0 });
|
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) {
|
if (button instanceof DocumentTab) {
|
||||||
place.kind = button.kindLabel;
|
place.kind = button.kindLabel;
|
||||||
place.close = button.closePoint();
|
place.close = button.closePoint();
|
||||||
@@ -135,6 +152,20 @@ export class TabStrip<T extends string> extends HStack {
|
|||||||
override layout(): void {
|
override layout(): void {
|
||||||
this.applyOverflow();
|
this.applyOverflow();
|
||||||
super.layout();
|
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 {
|
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,
|
menuRows: () => currentMenu()?.rowsDrawn() ?? null,
|
||||||
/** Per entry of `menuRows`, whether an icon stands in front of it. */
|
/** Per entry of `menuRows`, whether an icon stands in front of it. */
|
||||||
menuIcons: () => currentMenu()?.iconsDrawn() ?? null,
|
menuIcons: () => currentMenu()?.iconsDrawn() ?? null,
|
||||||
|
/** Per entry of `menuRows`, whether it can be picked (a greyed-out one cannot). */
|
||||||
|
menuEnabled: () => currentMenu()?.enabledDrawn() ?? null,
|
||||||
textOverflows,
|
textOverflows,
|
||||||
labelsDropped,
|
labelsDropped,
|
||||||
shownLabel,
|
shownLabel,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
createContainer,
|
createContainer,
|
||||||
removeObjects,
|
removeObjects,
|
||||||
} from '../actions';
|
} from '../actions';
|
||||||
|
import { hasAssetEditor } from '../assetTabs';
|
||||||
import { ASSET_ICON_SVG } from './assetIcons';
|
import { ASSET_ICON_SVG } from './assetIcons';
|
||||||
import { type EditorState, objectSelection } from '../state';
|
import { type EditorState, objectSelection } from '../state';
|
||||||
|
|
||||||
@@ -161,6 +162,7 @@ export class HierarchyPanel extends Panel {
|
|||||||
if (!holder) return undefined;
|
if (!holder) return undefined;
|
||||||
const inScene = holder.type === 'scene';
|
const inScene = holder.type === 'scene';
|
||||||
const object = findObject(holder, id);
|
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
|
// Content of a placed container belongs to its template: values can be changed here, the
|
||||||
// structure cannot (see `model/instance.ts`).
|
// structure cannot (see `model/instance.ts`).
|
||||||
const source = this.source();
|
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.
|
// 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',
|
label: 'Open asset',
|
||||||
|
disabled: !asset || !hasAssetEditor(asset.type),
|
||||||
onSelect: () => object && state.openAsset(object.assetId),
|
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 collider from './icons/collider.svg?raw';
|
||||||
import container from './icons/container.svg?raw';
|
import container from './icons/container.svg?raw';
|
||||||
import font from './icons/font.svg?raw';
|
import font from './icons/font.svg?raw';
|
||||||
|
import game from './icons/game.svg?raw';
|
||||||
import list from './icons/list.svg?raw';
|
import list from './icons/list.svg?raw';
|
||||||
import particles from './icons/particles.svg?raw';
|
import particles from './icons/particles.svg?raw';
|
||||||
import rigidbody from './icons/rigidbody.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. */
|
/** The List tab's: it edits a list value, which is no asset. */
|
||||||
export const LIST_ICON_SVG = list;
|
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 |
@@ -28,6 +28,10 @@ export const Theme = {
|
|||||||
controlActive: 0x16181d,
|
controlActive: 0x16181d,
|
||||||
accent: 0x4c8dff,
|
accent: 0x4c8dff,
|
||||||
accentText: 0xffffff,
|
accentText: 0xffffff,
|
||||||
|
/** Tab row: the bar the index cards sit on, the card showing, and one under the pointer. */
|
||||||
|
tabBar: 0x1f2229,
|
||||||
|
tabActive: 0x3f444e,
|
||||||
|
tabHover: 0x2c3038,
|
||||||
text: 0xe6e8ee,
|
text: 0xe6e8ee,
|
||||||
textMuted: 0x8a90a0,
|
textMuted: 0x8a90a0,
|
||||||
selection: 0x2b4a80,
|
selection: 0x2b4a80,
|
||||||
|
|||||||
@@ -29,11 +29,11 @@ const ICON_SIZE = 16;
|
|||||||
const ICON_GAP = 6;
|
const ICON_GAP = 6;
|
||||||
|
|
||||||
export class Button extends Widget {
|
export class Button extends Widget {
|
||||||
private readonly bg = new Graphics();
|
protected readonly bg = new Graphics();
|
||||||
protected readonly labelText: Text;
|
protected readonly labelText: Text;
|
||||||
/** The label as set; `labelText` holds the possibly truncated version on screen. */
|
/** The label as set; `labelText` holds the possibly truncated version on screen. */
|
||||||
private fullText: string;
|
private fullText: string;
|
||||||
private pressed = false;
|
protected pressed = false;
|
||||||
/** Caller-owned: renders like a primary button but is never flipped by a click. */
|
/** Caller-owned: renders like a primary button but is never flipped by a click. */
|
||||||
toggled = false;
|
toggled = false;
|
||||||
variant: NonNullable<ButtonOptions['variant']>;
|
variant: NonNullable<ButtonOptions['variant']>;
|
||||||
@@ -114,15 +114,31 @@ export class Button extends Widget {
|
|||||||
return this.toggled || this.variant === 'primary' || this.variant === 'danger';
|
return this.toggled || this.variant === 'primary' || this.variant === 'danger';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Style of the label as drawn now. */
|
||||||
|
protected labelToken(): TextStyleToken {
|
||||||
|
return this.onAccent ? 'accent' : this.style;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Style the width is measured from: the widest the label can get in any state, or a button that
|
||||||
|
* turns bold when toggled would cut its own text short.
|
||||||
|
*/
|
||||||
|
protected measureToken(): TextStyleToken {
|
||||||
|
return this.variant === 'default' && !this.toggle ? this.style : 'accent';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Colour of the icon in front of the label; it follows the label. */
|
||||||
|
protected iconTint(): number {
|
||||||
|
return this.onAccent ? Theme.colors.accentText : Theme.colors.textMuted;
|
||||||
|
}
|
||||||
|
|
||||||
override measure(): Size {
|
override measure(): Size {
|
||||||
const h = Math.max(this.minHeight, Theme.controlHeight);
|
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.
|
// 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 };
|
if (this.fixedWidth) return { w: this.fixedWidth, h };
|
||||||
// From the full label, never the on-screen one: measuring truncated text would keep a button
|
// 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.
|
// that was once too narrow too narrow for good.
|
||||||
// Measured bold where it can be drawn bold (on an accent fill), or a danger or toggled tab truncates.
|
const metrics = CanvasTextMetrics.measureText(this.fullText, textStyle(this.measureToken()));
|
||||||
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 + this.iconSpace()), h };
|
return { w: Math.max(this.minWidth, Math.ceil(metrics.width) + this.padding * 2 + this.iconSpace()), h };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,6 +147,12 @@ export class Button extends Widget {
|
|||||||
return this.icon ? ICON_SIZE + ICON_GAP : 0;
|
return this.icon ? ICON_SIZE + ICON_GAP : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether the label is drawn in a bold weight (test hook). */
|
||||||
|
get labelBold(): boolean {
|
||||||
|
const weight = this.labelText.style.fontWeight;
|
||||||
|
return weight === 'bold' || weight === 'bolder' || Number(weight) >= 600;
|
||||||
|
}
|
||||||
|
|
||||||
/** Whether an icon is drawn in front of the label (test hook). */
|
/** Whether an icon is drawn in front of the label (test hook). */
|
||||||
get hasIcon(): boolean {
|
get hasIcon(): boolean {
|
||||||
return this.icon !== null;
|
return this.icon !== null;
|
||||||
@@ -151,7 +173,8 @@ export class Button extends Widget {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
override redraw(): void {
|
/** The box behind the label. Overridden where a button is not a rounded rectangle (tabs). */
|
||||||
|
protected drawBackground(): void {
|
||||||
const c = Theme.colors;
|
const c = Theme.colors;
|
||||||
const flat = this.variant === 'default' || this.variant === 'ghost';
|
const flat = this.variant === 'default' || this.variant === 'ghost';
|
||||||
let fill: number | null = c.control;
|
let fill: number | null = c.control;
|
||||||
@@ -184,15 +207,19 @@ export class Button extends Widget {
|
|||||||
} else if (stroke !== null) {
|
} else if (stroke !== null) {
|
||||||
this.bg.roundRect(0.5, 0.5, this.w - 1, this.h - 1, Theme.radius).stroke({ width: 1, color: stroke });
|
this.bg.roundRect(0.5, 0.5, this.w - 1, this.h - 1, Theme.radius).stroke({ width: 1, color: stroke });
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override redraw(): void {
|
||||||
|
this.drawBackground();
|
||||||
// Style first: it decides the glyph widths that the truncation then measures against.
|
// Style first: it decides the glyph widths that the truncation then measures against.
|
||||||
this.labelText.style = textStyle(this.onAccent ? 'accent' : this.style);
|
this.labelText.style = textStyle(this.labelToken());
|
||||||
const label = this.labelText;
|
const label = this.labelText;
|
||||||
label.text = truncate(this.fullText, label.style, this.labelSlot());
|
label.text = truncate(this.fullText, label.style, this.labelSlot());
|
||||||
const content = this.iconSpace() + label.width;
|
const content = this.iconSpace() + label.width;
|
||||||
const x = this.align === 'left' ? this.padding : (this.w - content) / 2;
|
const x = this.align === 'left' ? this.padding : (this.w - content) / 2;
|
||||||
label.position.set(Math.round(x + this.iconSpace()), centreY(this.h, label.height));
|
label.position.set(Math.round(x + this.iconSpace()), centreY(this.h, label.height));
|
||||||
if (this.icon) {
|
if (this.icon) {
|
||||||
const color = this.onAccent ? c.accentText : c.textMuted;
|
const color = this.iconTint();
|
||||||
if (color !== this.iconColor) {
|
if (color !== this.iconColor) {
|
||||||
this.iconColor = color;
|
this.iconColor = color;
|
||||||
this.iconGfx.clear();
|
this.iconGfx.clear();
|
||||||
|
|||||||
@@ -98,6 +98,13 @@ export class Menu extends Popup {
|
|||||||
.map((child) => (child instanceof Button ? child.text : null));
|
.map((child) => (child instanceof Button ? child.text : null));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Per entry of `rowsDrawn`, whether it can be picked; `null` for a separator (test hook). */
|
||||||
|
enabledDrawn(): (boolean | null)[] {
|
||||||
|
return this.list.children
|
||||||
|
.filter((child) => child !== this.empty && child.visible)
|
||||||
|
.map((child) => (child instanceof Button ? child.enabled : null));
|
||||||
|
}
|
||||||
|
|
||||||
/** Per entry of `rowsDrawn`, whether it shows an icon; `null` for a separator (test hook). */
|
/** Per entry of `rowsDrawn`, whether it shows an icon; `null` for a separator (test hook). */
|
||||||
iconsDrawn(): (boolean | null)[] {
|
iconsDrawn(): (boolean | null)[] {
|
||||||
return this.list.children
|
return this.list.children
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { ASSET_TYPES } from '../../src/core/model/AssetTypes';
|
import { ASSET_TYPES } from '../../src/core/model/AssetTypes';
|
||||||
import { ASSET_ICON_SVG, LIST_ICON_SVG } from '../../src/editor/panels/assetIcons';
|
import { ASSET_ICON_SVG, GAME_ICON_SVG, LIST_ICON_SVG } from '../../src/editor/panels/assetIcons';
|
||||||
import { tintSvg } from '../../src/ui/svg';
|
import { tintSvg } from '../../src/ui/svg';
|
||||||
import { SYMBOL_SVG } from '../../src/viewport/symbolIcons';
|
import { SYMBOL_SVG } from '../../src/viewport/symbolIcons';
|
||||||
|
|
||||||
@@ -32,6 +32,7 @@ describe('icon files', () => {
|
|||||||
...Object.entries(SYMBOL_SVG).map(([name, svg]) => [`symbol ${name}`, svg]),
|
...Object.entries(SYMBOL_SVG).map(([name, svg]) => [`symbol ${name}`, svg]),
|
||||||
...Object.entries(ASSET_ICON_SVG).map(([name, svg]) => [`asset ${name}`, svg]),
|
...Object.entries(ASSET_ICON_SVG).map(([name, svg]) => [`asset ${name}`, svg]),
|
||||||
['list tab', LIST_ICON_SVG],
|
['list tab', LIST_ICON_SVG],
|
||||||
|
['game tab', GAME_ICON_SVG],
|
||||||
];
|
];
|
||||||
|
|
||||||
// Pixi draws the file's coordinates as they are and ignores the viewBox, so the viewBox only
|
// Pixi draws the file's coordinates as they are and ignores the viewBox, so the viewBox only
|
||||||
@@ -45,12 +46,14 @@ describe('icon files', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('draws the asset icons in the 16 units a list row and a button reserve', () => {
|
it('draws the asset icons in the 16 units a list row and a button reserve', () => {
|
||||||
for (const svg of [...Object.values(ASSET_ICON_SVG), LIST_ICON_SVG])
|
for (const svg of [...Object.values(ASSET_ICON_SVG), LIST_ICON_SVG, GAME_ICON_SVG])
|
||||||
expect(tags(svg)[0]!.attrs.viewBox).toBe('-8 -8 16 16');
|
expect(tags(svg)[0]!.attrs.viewBox).toBe('-8 -8 16 16');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('gives the List tab a picture no asset type has', () => {
|
it('gives the List and Game tabs a picture no asset type has (#176)', () => {
|
||||||
expect(Object.values(ASSET_ICON_SVG)).not.toContain(LIST_ICON_SVG);
|
expect(Object.values(ASSET_ICON_SVG)).not.toContain(LIST_ICON_SVG);
|
||||||
|
expect(Object.values(ASSET_ICON_SVG)).not.toContain(GAME_ICON_SVG);
|
||||||
|
expect(GAME_ICON_SVG).not.toBe(LIST_ICON_SVG);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each(icons)('%s uses only what the Pixi parser draws like a browser', (_, svg) => {
|
it.each(icons)('%s uses only what the Pixi parser draws like a browser', (_, svg) => {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { same } from '../lib/harness.mjs';
|
|||||||
* without touching the selection, and selecting a nested object unfolds the way down to it.
|
* without touching the selection, and selecting a nested object unfolds the way down to it.
|
||||||
*/
|
*/
|
||||||
export async function hierarchy(t) {
|
export async function hierarchy(t) {
|
||||||
const { check, evaluate, json, click, clickText, doubleClick, select, state, shot } = t;
|
const { check, evaluate, json, click, clickText, doubleClick, press, select, state, shot } = t;
|
||||||
const rowIds = () => json('__gm.hierarchyOrder()');
|
const rowIds = () => json('__gm.hierarchyOrder()');
|
||||||
const rows = async () => JSON.stringify(await rowIds());
|
const rows = async () => JSON.stringify(await rowIds());
|
||||||
const selectionBefore = (await state()).selection;
|
const selectionBefore = (await state()).selection;
|
||||||
@@ -71,4 +71,18 @@ export async function hierarchy(t) {
|
|||||||
JSON.stringify({ opened, openedSelection }),
|
JSON.stringify({ opened, openedSelection }),
|
||||||
);
|
);
|
||||||
await evaluate(`__gm.closeDocument('asset:spawner'); __gm.state.select(null)`);
|
await evaluate(`__gm.closeDocument('asset:spawner'); __gm.state.select(null)`);
|
||||||
|
|
||||||
|
// A sprite has no editor of its own, so the entry that promises to open one is greyed out.
|
||||||
|
const crateRow = await json(`__gm.hierarchyRow('o1')`);
|
||||||
|
await click(crateRow.x, crateRow.y, 'right');
|
||||||
|
const menuRows = await json('__gm.menuRows()');
|
||||||
|
const menuEnabled = await json('__gm.menuEnabled()');
|
||||||
|
check(
|
||||||
|
'Open asset is greyed out for a kind without an editor, Select asset stays on',
|
||||||
|
menuEnabled[menuRows.indexOf('Open asset')] === false &&
|
||||||
|
menuEnabled[menuRows.indexOf('Select asset')] === true,
|
||||||
|
JSON.stringify({ menuRows, menuEnabled }),
|
||||||
|
);
|
||||||
|
await press('Escape');
|
||||||
|
await evaluate('__gm.state.select(null)');
|
||||||
}
|
}
|
||||||
@@ -31,9 +31,72 @@ export async function overflow(t) {
|
|||||||
JSON.stringify(widths),
|
JSON.stringify(widths),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 14a-ii. What a tab looks like (#176): the one showing is a light grey card with rounded upper
|
||||||
|
// corners standing on the row's line, its name in plain weight, and a press on it keeps
|
||||||
|
// that colour instead of flashing the pressed fill of a button.
|
||||||
|
await showTab('scene');
|
||||||
|
const ACTIVE = '#3f444e';
|
||||||
|
const BAR = '#1f2229';
|
||||||
|
const LINE = '#383c46';
|
||||||
|
const pixel = (x, y) => json(`__gm.pixelAt(${Math.round(x)}, ${Math.round(y)})`);
|
||||||
|
const styled = await json('__gm.tabRow()');
|
||||||
|
const card = styled.shown.find((b) => b.tab === 'scene');
|
||||||
|
const plain = styled.shown.find((b) => b.tab !== 'scene');
|
||||||
|
const cardFill = await pixel(card.x + 3, card.y + card.h - 3);
|
||||||
|
const plainFill = await pixel(plain.x + 3, plain.y + plain.h - 3);
|
||||||
|
check(
|
||||||
|
'the tab showing is a light grey card, the others stay on the bar',
|
||||||
|
cardFill === ACTIVE && plainFill === BAR,
|
||||||
|
JSON.stringify({ cardFill, plainFill, card: card.tab, plain: plain.tab }),
|
||||||
|
);
|
||||||
|
const corner = await pixel(card.x, card.y);
|
||||||
|
const top = await pixel(card.x + card.h / 2, card.y + 3);
|
||||||
|
const line = await pixel(plain.x + 4, plain.y + plain.h - 1);
|
||||||
|
check(
|
||||||
|
'its upper corners are rounded and it covers the line the other tabs stand on',
|
||||||
|
corner === BAR && top === ACTIVE && line === LINE,
|
||||||
|
JSON.stringify({ corner, top, line }),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'neither the tab showing nor the others write their name bold',
|
||||||
|
styled.shown.every((b) => b.bold === false),
|
||||||
|
JSON.stringify(styled.shown.map((b) => ({ tab: b.tab, bold: b.bold }))),
|
||||||
|
);
|
||||||
|
const pressX = Math.round(card.x + 3);
|
||||||
|
const pressY = Math.round(card.y + card.h - 3);
|
||||||
|
await t.mouse('mouseMoved', pressX, pressY);
|
||||||
|
await t.mouse('mousePressed', pressX, pressY);
|
||||||
|
const held = await pixel(pressX, pressY);
|
||||||
|
await t.mouse('mouseReleased', pressX, pressY);
|
||||||
|
check(
|
||||||
|
'holding the mouse down on the tab showing does not change its colour',
|
||||||
|
held === ACTIVE,
|
||||||
|
JSON.stringify({ held, expected: ACTIVE }),
|
||||||
|
);
|
||||||
|
// The fixed tabs carry an icon as well (#176); the Scene card shows it in front of its name.
|
||||||
|
const sceneInk = await json(`(() => {
|
||||||
|
const x0 = ${card.x} + 6, y = ${card.y + card.h / 2};
|
||||||
|
let n = 0;
|
||||||
|
for (let dy = -7; dy <= 7; dy++)
|
||||||
|
for (let dx = 0; dx < 16; dx++) if (__gm.pixelAt(x0 + dx, y + dy) !== '${ACTIVE}') n++;
|
||||||
|
return n;
|
||||||
|
})()`);
|
||||||
|
const fixedIcons = styled.shown
|
||||||
|
.filter((b) => ['scene', 'game', 'plugins'].includes(b.tab))
|
||||||
|
.map((b) => [b.tab, b.icon]);
|
||||||
|
check(
|
||||||
|
'Scene and Game show an icon before their name, Plugins none',
|
||||||
|
JSON.stringify(fixedIcons) ===
|
||||||
|
JSON.stringify([
|
||||||
|
['scene', true],
|
||||||
|
['game', true],
|
||||||
|
['plugins', false],
|
||||||
|
]) && sceneInk >= 20,
|
||||||
|
JSON.stringify({ fixedIcons, sceneInk }),
|
||||||
|
);
|
||||||
|
|
||||||
// 14b. The tab row itself: a button past the centre column's edge sits under the inspector, and
|
// 14b. The tab row itself: a button past the centre column's edge sits under the inspector, and
|
||||||
// a click on it lands there (nine tabs on a 90 px floor did exactly that at 1280 px wide).
|
// a click on it lands there (nine tabs on a 90 px floor did exactly that at 1280 px wide).
|
||||||
await showTab('scene');
|
|
||||||
const columnRight = async () => {
|
const columnRight = async () => {
|
||||||
const column = await json('__gm.viewportRect()');
|
const column = await json('__gm.viewportRect()');
|
||||||
return column.x + column.w;
|
return column.x + column.w;
|
||||||
@@ -78,10 +141,12 @@ export async function overflow(t) {
|
|||||||
await clickText('»');
|
await clickText('»');
|
||||||
const offered = (await json('__gm.menuRows()')).filter(Boolean);
|
const offered = (await json('__gm.menuRows()')).filter(Boolean);
|
||||||
const offeredIcons = (await json('__gm.menuIcons()')).filter((icon) => icon !== null);
|
const offeredIcons = (await json('__gm.menuIcons()')).filter((icon) => icon !== null);
|
||||||
|
// Every hidden tab brings its icon along; only Plugins and a plugin's own tab have none (#176).
|
||||||
|
const withIcon = (tab) => tab !== 'plugins' && !tab.startsWith('plugin:');
|
||||||
check(
|
check(
|
||||||
'the overflow button offers every tab that had to go, a document with the icon of its kind',
|
'the overflow button offers every tab that had to go, each with the icon its tab shows',
|
||||||
offered.length === narrow.hidden.length &&
|
offered.length === narrow.hidden.length &&
|
||||||
offeredIcons.filter(Boolean).length === narrow.hidden.filter((tab) => tab.startsWith('asset:')).length,
|
offeredIcons.filter(Boolean).length === narrow.hidden.filter(withIcon).length,
|
||||||
JSON.stringify({ offered, offeredIcons, hidden: narrow.hidden }),
|
JSON.stringify({ offered, offeredIcons, hidden: narrow.hidden }),
|
||||||
);
|
);
|
||||||
const wanted = narrow.hidden[narrow.hidden.length - 1];
|
const wanted = narrow.hidden[narrow.hidden.length - 1];
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export async function documents(t) {
|
|||||||
const kindWords = (await json(`__gm.locateAll('Texture')`)).filter(inTab);
|
const kindWords = (await json(`__gm.locateAll('Texture')`)).filter(inTab);
|
||||||
// The icon's 16 px in front of the name, read against the tab's own background at its left edge.
|
// The icon's 16 px in front of the name, read against the tab's own background at its left edge.
|
||||||
const iconInk = await json(`(() => {
|
const iconInk = await json(`(() => {
|
||||||
const x0 = ${placeA.x} + 4, y = ${placeA.y + placeA.h / 2};
|
const x0 = ${placeA.x} + 6, y = ${placeA.y + placeA.h / 2};
|
||||||
const background = __gm.pixelAt(${placeA.x} + 1, y);
|
const background = __gm.pixelAt(${placeA.x} + 1, y);
|
||||||
let n = 0;
|
let n = 0;
|
||||||
for (let dy = -7; dy <= 7; dy++)
|
for (let dy = -7; dy <= 7; dy++)
|
||||||
|
|||||||
Reference in new issue
Block a user