diff --git a/README.md b/README.md index 0d3a6b62..40c27865 100644 --- a/README.md +++ b/README.md @@ -19,9 +19,9 @@ Live: · Widget playground: deviatesFromAsset(object, asset, def.key)) + ); +} + +/** + * Ids the hierarchy marks: everything deviating itself, plus every ancestor of one. The ancestors + * matter because subtrees fold – without them a collapsed row would hide that something below it + * was changed. + */ +export function deviationMarks(project: Project, holder: ObjectTree): Set { + const view: ObjectTree = { objects: resolveObjects(project, holder) }; + const marks = new Set(); + for (const object of view.objects) { + if (!objectDeviates(project, holder, object)) continue; + marks.add(object.id); + for (const ancestorId of ancestorIdsOf(view, object.id)) marks.add(ancestorId); + } + return marks; +} diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index f20dc215..3fa1cead 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -1,4 +1,4 @@ -import { type Application, type Container, Rectangle, Text } from 'pixi.js'; +import { type Application, Color, type ColorSource, type Container, Rectangle, Text } from 'pixi.js'; import { resolveObjects } from '../core/model/instance'; import { findObjectHolder } from '../core/model/Project'; import { Button, ui, Widget } from '../ui'; @@ -34,6 +34,20 @@ export function installDebugHook(app: Application, state: EditorState, editor: E const p = node.getGlobalPosition(); return { x: p.x + node.width / 2, y: p.y + node.height / 2 }; }; + /** Fill colour of a visible text, as `#rrggbb` – what tells an override label from a plain one. */ + const textColor = (text: string): string | null => { + let found: string | null = null; + const walk = (node: Container): void => { + if (found !== null || !node.visible) return; + if (node instanceof Text && node.text === text) { + found = new Color(node.style.fill as ColorSource).toHex(); + return; + } + for (const child of node.children) walk(child); + }; + walk(app.stage); + return found; + }; /** * The visible text of the overlay layer, in drawing order – everything an open popup shows. * Menus hide filtered-out entries, so this is also what a search box currently leaves. @@ -118,6 +132,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E objectsOf, sceneObjects: () => (state.activeScene ? objectsOf(state.activeScene.id) : []), locate, + textColor, overlayTexts, overlayRect, textOverflows, @@ -159,6 +174,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E /** Rows the hierarchy shows – collapsed subtrees are not in here. */ hierarchyOrder: () => hierarchy.rowOrder(), hierarchyChevron: (id: string) => hierarchy.chevronPosition(id), + hierarchyRowColor: (id: string) => hierarchy.rowColor(id), hierarchyExpandAll: () => hierarchy.expandAll(), hierarchyCollapseAll: () => hierarchy.collapseAll(), /** The colour actually on screen at a point, as `#rrggbb` – what was drawn, not what the model says. */ diff --git a/src/editor/inspector/fieldFactory.ts b/src/editor/inspector/fieldFactory.ts index a8c6a508..c6ff8d17 100644 --- a/src/editor/inspector/fieldFactory.ts +++ b/src/editor/inspector/fieldFactory.ts @@ -46,7 +46,7 @@ export interface FieldRow { /** Push a model value into the widget (no events fired). */ setValue(value: PropertyValue): void; /** - * Highlight the row as deviating from its base value (an override on scene objects, a + * Colour the label as deviating from its base value (an override on scene objects, a * non-default value on assets) and re-evaluate which actions currently apply. */ setModified(modified: boolean): void; @@ -154,7 +154,7 @@ export function createFieldRow( row, setValue: field.setValue, setModified(modified) { - label.setStyle(modified ? 'bodyBold' : 'muted'); + label.setStyle(modified ? 'override' : 'muted'); refreshActions(); }, }; diff --git a/src/editor/inspector/objectSection.ts b/src/editor/inspector/objectSection.ts index 8e3b1e80..a87aeb61 100644 --- a/src/editor/inspector/objectSection.ts +++ b/src/editor/inspector/objectSection.ts @@ -474,7 +474,7 @@ function parentRow(host: InspectorHost, holder: ObjectHolder, object: SceneObjec } /** - * Bold marks exactly what the ↺/↑ buttons offer: for content of a placed container a deviation + * Gold marks exactly what the ↺/↑ buttons offer: for content of a placed container a deviation * from the template (position per axis, overrides), for any other object one from the asset. The * editor offset has one base only – no offset at all. */ diff --git a/src/editor/panels/HierarchyPanel.ts b/src/editor/panels/HierarchyPanel.ts index a62bacdc..5e8bd19f 100644 --- a/src/editor/panels/HierarchyPanel.ts +++ b/src/editor/panels/HierarchyPanel.ts @@ -6,6 +6,7 @@ import { hasColorSwatch } from '../../core/model/AssetTypes'; import { ancestorIdsOf, childIdsOf, findObject, visibleTreeOrder } from '../../core/model/hierarchy'; import { isLiveInstance, isTemplateContent, resolvedHolder } from '../../core/model/instance'; import { findAsset, type ObjectHolder } from '../../core/model/Project'; +import { deviationMarks } from '../../core/properties/deviation'; import { resolveSceneObjectProps } from '../../core/properties/resolve'; import { type DropSpot, ListView, Menu, Panel, ScrollContainer } from '../../ui'; import { addCollider, createContainer, removeObjects } from '../actions'; @@ -18,6 +19,9 @@ import { type EditorState, objectSelection } from '../state'; * siblings, or onto the empty space below the rows to move it to the top level. The object keeps * its world position every time. * + * A row's name turns gold when the object deviates from what it follows – its container template + * or its asset – or when anything below it does, so a folded subtree still shows it. + * * Rows with children fold: the chevron (or ←/→, or a double-click on the row) toggles them. * Everything starts collapsed – which ids are open is view state kept here, never part of the * project, and selecting an object always unfolds the way down to it. @@ -128,6 +132,11 @@ export class HierarchyPanel extends Panel { return holder ? visibleTreeOrder(holder, this.expanded).map((e) => e.object.id) : []; } + /** Label colour of a row as `#rrggbb` – gold means it or something below it deviates (test hook). */ + rowColor(id: string): string | null { + return this.list.rowColor(id); + } + /** Global centre of a row's chevron, so tests can click the real hit area (test hook). */ chevronPosition(id: string): { x: number; y: number } | null { return this.list.chevronGlobal(id); @@ -226,6 +235,10 @@ export class HierarchyPanel extends Panel { this.shownHolderId = holder?.id ?? ''; const kind = holder?.type === 'container' ? 'Container' : 'Scene'; this.setTitle(holder ? `${kind}: ${holder.name}` : 'Scene objects'); + // From the stored holder, not the resolved one: what an object follows depends on where it + // lives, and in a resolved view container content looks stored. + const source = this.source(); + const marks = source ? deviationMarks(this.state.project, source) : new Set(); this.list.setItems( (holder ? visibleTreeOrder(holder, this.expanded) : []).map(({ object, depth, hasChildren }) => { const asset = findAsset(this.state.project, object.assetId); @@ -237,6 +250,7 @@ export class HierarchyPanel extends Panel { depth, swatch: asset && hasColorSwatch(asset.type) ? Number(props.color ?? 0) : undefined, expanded: hasChildren ? this.expanded.has(object.id) : undefined, + overridden: marks.has(object.id), }; }), ); diff --git a/src/ui/Theme.ts b/src/ui/Theme.ts index 8be08cbc..4e4cfcbb 100644 --- a/src/ui/Theme.ts +++ b/src/ui/Theme.ts @@ -41,6 +41,8 @@ export const Theme = { trigger: 0xf2c84b, container: 0x3fb8c9, marquee: 0x4c8dff, + /** A value deviating from what it follows: an override on a placed object, a non-default on an asset. */ + override: 0xf2c84b, /** Node graph: canvas, node chrome, per-category title colors, port colors, live highlight. */ graphBackground: 0x15171b, node: 0x262a32, @@ -67,6 +69,7 @@ export type TextStyleToken = | 'body' | 'bodyBold' | 'muted' + | 'override' | 'small' | 'title' | 'mono' @@ -89,6 +92,9 @@ export function textStyle(token: TextStyleToken = 'body'): TextStyle { case 'muted': style = new TextStyle({ ...base, fill: Theme.colors.textMuted }); break; + case 'override': + style = new TextStyle({ ...base, fill: Theme.colors.override }); + break; case 'small': style = new TextStyle({ ...base, fontSize: Theme.smallFontSize, fill: Theme.colors.textMuted }); break; diff --git a/src/ui/widgets/ListRow.ts b/src/ui/widgets/ListRow.ts index 715ab1d9..7913ebd0 100644 --- a/src/ui/widgets/ListRow.ts +++ b/src/ui/widgets/ListRow.ts @@ -1,4 +1,4 @@ -import { Graphics, Text } from 'pixi.js'; +import { Color, type ColorSource, Graphics, Text } from 'pixi.js'; import { drawControlBox } from '../draw'; import { centreY } from '../math'; import { Theme, textStyle } from '../Theme'; @@ -25,6 +25,7 @@ export class ListRow extends Widget { super(); this.cursor = 'pointer'; this.minHeight = height; + if (item.overridden) this.labelText.style = textStyle('override'); this.addChild(this.bg, this.labelText, this.hint); this.trackHover(); } @@ -37,6 +38,11 @@ export class ListRow extends Widget { this.invalidate(); } + /** Fill of the label as `#rrggbb` – tells a marked row from a plain one (test hook). */ + get labelColor(): string { + return new Color(this.labelText.style.fill as ColorSource).toHex(); + } + /** Left edge of the chevron slot (or of the content itself in a flat list). */ private get indent(): number { return Theme.padding + (this.item.depth ?? 0) * Theme.treeIndent; diff --git a/src/ui/widgets/ListView.ts b/src/ui/widgets/ListView.ts index 8f2ad1ea..aeac9612 100644 --- a/src/ui/widgets/ListView.ts +++ b/src/ui/widgets/ListView.ts @@ -16,6 +16,8 @@ export interface ListItem { 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; } /** Pixels of travel before a press turns into a drag. */ @@ -129,6 +131,11 @@ export class ListView extends Widget { this.redraw(); } + /** Label colour of a row as `#rrggbb`; null when no such row is shown (test hook). */ + rowColor(id: string): string | null { + return this.rows.find((r) => r.item.id === id)?.labelColor ?? 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; diff --git a/tests/core/deviation.test.ts b/tests/core/deviation.test.ts new file mode 100644 index 00000000..2fa87b67 --- /dev/null +++ b/tests/core/deviation.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; +import { CreateContainerCommand } from '../../src/core/commands/containerCommands'; +import { + ClearOverrideCommand, + SetOverrideCommand, + SetParentCommand, +} from '../../src/core/commands/sceneObjectCommands'; +import { instanceChildId } from '../../src/core/model/instance'; +import { findAsset } from '../../src/core/model/Project'; +import type { ContainerAsset } from '../../src/core/model/Asset'; +import { deviationMarks, objectDeviates } from '../../src/core/properties/deviation'; +import { makeSpriteScene, place } from '../helpers'; + +describe('deviation marks', () => { + it('marks an object only once an override really deviates', () => { + const f = makeSpriteScene(); + const id = place(f, f.spriteId, { x: 0, y: 0 }); + const deviates = () => objectDeviates(f.project, f.sceneOf(), f.object(id)); + expect(deviates()).toBe(false); + // The asset's own value carried as an override is no deviation. + f.stack.execute(new SetOverrideCommand(f.sceneId, id, 'width', 100)); + expect(deviates()).toBe(false); + f.stack.execute(new SetOverrideCommand(f.sceneId, id, 'width', 250)); + expect(deviates()).toBe(true); + f.stack.execute(new ClearOverrideCommand(f.sceneId, id, 'width')); + expect(deviates()).toBe(false); + }); + + it('marks the ancestors of a deviating object, so a folded row still shows it', () => { + const f = makeSpriteScene(); + const parent = place(f, f.spriteId, { x: 0, y: 0 }); + const child = place(f, f.spriteId, { x: 0, y: 0 }); + f.stack.execute(new SetParentCommand(f.sceneId, child, parent)); + expect(deviationMarks(f.project, f.sceneOf()).size).toBe(0); + f.stack.execute(new SetOverrideCommand(f.sceneId, child, 'width', 250)); + expect([...deviationMarks(f.project, f.sceneOf())].sort()).toEqual([child, parent].sort()); + }); + + it('marks container content against its template, and the instance above it', () => { + const f = makeSpriteScene(); + const a = place(f, f.spriteId, { x: 0, y: 0 }); + const create = new CreateContainerCommand(f.sceneId, [a]); + f.stack.execute(create); + const template = (findAsset(f.project, create.assetId) as ContainerAsset).objects[0]!.id; + const content = instanceChildId(create.objectId, template); + expect(deviationMarks(f.project, f.sceneOf()).size).toBe(0); + f.stack.execute(new SetOverrideCommand(f.sceneId, content, 'width', 250)); + expect([...deviationMarks(f.project, f.sceneOf())].sort()).toEqual([content, create.objectId].sort()); + }); +}); diff --git a/todo-notes/done/override-darstellung.md b/todo-notes/done/override-darstellung.md new file mode 100644 index 00000000..527f131b --- /dev/null +++ b/todo-notes/done/override-darstellung.md @@ -0,0 +1,19 @@ +override darstellung bei scene objects die werte von assets überschreiben: +- momentan wird es fett dargestellt => stattdessen das label in einer gelb/goldener schriftfarbe darstellen +- auch in der hierarchie der scene/container bitte den namen gelb/gold darstellen, falls es irgendein child/property gibt dass dirty ist + +--- + +**Umgesetzt (2026-09-09), beide Punkte.** + +- Inspector: das Label einer abweichenden Property ist jetzt gold (`#f2c84b`) statt fett. + Die Farbe steht als Theme-Token `override` an einer Stelle. +- Hierarchie: der Name wird gold, wenn das Objekt selbst abweicht **oder etwas darunter** — + eine zugeklappte Zeile soll nicht verbergen, dass im Teilbaum etwas geaendert wurde. + +Die Logik dazu sitzt in `src/core/properties/deviation.ts` (rein, getestet), nicht im Panel: +`objectDeviates` vergleicht gegen das, dem ein Objekt folgt — bei Container-Inhalt gegen das +**Template**, sonst gegen das **Asset** —, `deviationMarks` traegt die Marke auf alle Vorfahren +hoch. Wichtig dabei: `deviationMarks` bekommt den **gespeicherten** Holder, nicht den +aufgeloesten; in der aufgeloesten Sicht sieht Container-Inhalt wie ein normales Objekt aus und +jede Instanz waere faelschlich gold. diff --git a/todo-notes/override-darstellung.md b/todo-notes/override-darstellung.md deleted file mode 100644 index 5b3da3c7..00000000 --- a/todo-notes/override-darstellung.md +++ /dev/null @@ -1,3 +0,0 @@ -override darstellung bei scene objects die werte von assets überschreiben: -- momentan wird es fett dargestellt => stattdessen das label in einer gelb/goldener schriftfarbe darstellen -- auch in der hierarchie der scene/container bitte den namen gelb/gold darstellen, falls es irgendein child/property gibt dass dirty ist diff --git a/tools/e2e/02-inspector.mjs b/tools/e2e/02-inspector.mjs index 5df7e612..9f958c73 100644 --- a/tools/e2e/02-inspector.mjs +++ b/tools/e2e/02-inspector.mjs @@ -14,6 +14,7 @@ export async function inspector(t) { drag, press, typeText, + mutate, undo, redo, state, @@ -33,6 +34,28 @@ export async function inspector(t) { ); await shot('02-object-selected'); + // 1b. Deviations are shown in gold – on the inspector label and on the hierarchy row. The blue + // crate overrides width and color, the camera and the red crate's subtree nothing. + const GOLD = '#f2c84b'; + const PLAIN = '#e6e8ee'; + const rowColor = (id) => json(`__gm.hierarchyRowColor('${id}')`); + check('an overridden object is marked gold in the hierarchy', (await rowColor('o2')) === GOLD); + check('an untouched object is not', (await rowColor('o3')) === PLAIN, await rowColor('o3')); + check('nor is a parent whose subtree is untouched', (await rowColor('o1')) === PLAIN); + check( + 'the overridden field is gold, an inherited one is not', + (await json(`__gm.textColor('Width')`)) === GOLD && + (await json(`__gm.textColor('Height')`)) === '#8a90a0', + JSON.stringify(await json(`[__gm.textColor('Width'), __gm.textColor('Height')]`)), + ); + // The mark also travels up: with the crate's own overrides taken away the row must stay gold + // for the sake of its collider child, which keeps one. Put back right after, byte for byte. + await mutate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').overrides = {}`); + check('a parent carries the mark of a deviating child', (await rowColor('o2')) === GOLD); + await mutate( + `__gm.state.activeScene.objects.find(o => o.id === 'o2').overrides = { width: 140, color: 0x4488ff }`, + ); + // 2. Rename via inspector: the name field sits right of the "Name" label. const nameLabel = await locate('Name'); await click(nameLabel.x + 160, nameLabel.y);