Files
feuerwerk/src/editor/inspector/fieldFactory.ts
T
root b87fcdfbad Mark overrides in gold instead of bold, and up the hierarchy
An inspector label whose value deviates from its base was drawn bold, which is
easy to miss next to the regular labels and says nothing in the hierarchy, where
a folded subtree could hide a change entirely.

The colour now lives as a theme token (`override`, #f2c84b) used by both the
inspector labels and the hierarchy rows. Whether a row deviates is decided by
`src/core/properties/deviation.ts`: `objectDeviates` compares an object against
what it follows -- the container template for content, the asset otherwise --
and `deviationMarks` carries the mark up to every ancestor, so a folded subtree
still shows that something inside was changed.

`deviationMarks` takes the stored holder, not the resolved one: in the resolved
view container content looks like an ordinary object and `instanceTargetOf`
would compare against the asset, marking every instance.

Covered by 3 unit tests and 5 e2e checks, including one that strips an object's
own overrides to prove the row stays gold for a deviating child and restores
them byte for byte. Full suite remote on the RTX 4070: 275 checks, all passed.
2026-09-09 10:54:25 +02:00

330 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { PropertyValue } from '../../core/model/Properties';
import type { Project } from '../../core/model/Project';
import {
assetChoiceLabel,
assetRefChoices,
assetRefLabel,
NO_ASSET_LABEL,
} from '../../core/properties/assetRef';
import type { PropertyDef } from '../../core/properties/PropertySchema';
import { NO_TEXTURE_LABEL, textureRefChoices, textureRefLabel } from '../../core/properties/textureRef';
import {
Button,
Checkbox,
ColorField,
Dropdown,
HStack,
Label,
Menu,
type MenuItem,
NumberField,
Spacer,
TextField,
Theme,
ui,
type Widget,
} from '../../ui';
/**
* What a field needs beyond its own value: the project, to resolve an asset reference to a name
* and to list what the picker may offer. A getter rather than the project itself – loading and
* entering play mode replace that object.
*/
export interface FieldContext {
project(): Project;
}
export interface FieldHandlers {
/** Live value while dragging/typing – apply as a preview, no undo entry. */
onInput: (value: PropertyValue) => void;
/** Final value – commit as a command. */
onChange: (value: PropertyValue) => void;
}
export interface FieldRow {
row: Widget;
/** Push a model value into the widget (no events fired). */
setValue(value: PropertyValue): void;
/**
* 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;
}
/** Width of the label column every inspector row aligns to. */
const FIELD_LABEL_WIDTH = 64;
/** One way of resetting or applying a value ("Reset to asset", "Apply to container", …). */
export interface FieldAction {
label: string;
onSelect: () => void;
}
/**
* The actions available *right now* – asked again on every refresh and on every click.
* Anything that would not change the value is left out, so a row never offers a dead entry.
*/
export type FieldActions = () => FieldAction[];
/** One trailing button of a row: its glyph, and what it currently offers. */
export interface FieldActionGroup {
glyph: string;
available: FieldActions;
}
export interface RowActions {
reset?: FieldActions;
apply?: FieldActions;
/** Row-specific buttons, drawn left of ↑/↺ – they act on the value rather than on its origin. */
extra?: readonly FieldActionGroup[];
}
/** A row starting with the fixed-width, muted label column. */
export function labelledRow(text: string): { row: HStack; label: Label } {
const row = new HStack({ gap: Theme.gap, align: 'center' });
row.minHeight = Theme.rowHeight;
const label = new Label(text, { style: 'muted' });
label.minWidth = FIELD_LABEL_WIDTH;
row.add(label);
return { row, label };
}
/**
* Maps one PropertySchema entry to a labelled row containing the matching widget.
* The inspector is entirely generated through this function: adding a new property
* kind means adding a case here, adding a property means adding a schema line.
*
* A row shows its actions at the right: ↺ resets to the base value, ↑ applies the value to
* its source, `extra` adds row-specific ones. Each group is asked on every refresh: no entry
* means no button, one entry means a direct button, several mean a menu (↺ ▾ / ↑ ▾).
*/
export function createFieldRow(
ctx: FieldContext,
def: PropertyDef,
value: PropertyValue,
handlers: FieldHandlers,
rowActions: RowActions = {},
): FieldRow {
const { row, label } = labelledRow(def.label);
const field = createField(ctx, def, value, handlers);
row.add(field.widget, field.grow ? 1 : 0);
if (!field.grow) row.add(new Spacer(), 1);
const actions: { button: Button; glyph: string; available: FieldActions }[] = [];
const addActions = (glyph: string, available: FieldActions): void => {
// A floor, not a width: the same button also carries the two-glyph menu form "↺ ▾".
const button = new Button(glyph, { variant: 'ghost', minWidth: Theme.controlHeight });
// Asked again on click: the menu must not act on a state that has moved on since the
// last refresh (an undo between refreshes, a value edited in the viewport).
button.onClick = () => {
const items = available();
if (items.length === 0) return;
if (items.length === 1) return items[0]!.onSelect();
new Menu(items.map((a) => ({ label: a.label, onSelect: a.onSelect }))).openBelow(button);
};
button.visible = false;
actions.push({ button, glyph, available });
row.add(button);
};
for (const group of rowActions.extra ?? []) addActions(group.glyph, group.available);
if (rowActions.apply) addActions('↑', rowActions.apply);
if (rowActions.reset) addActions('↺', rowActions.reset);
const refreshActions = (): void => {
let changed = false;
for (const { button, glyph, available } of actions) {
const count = available().length;
const text = count > 1 ? `${glyph} ▾` : glyph;
const visible = count > 0;
if (button.visible !== visible) {
button.visible = visible;
changed = true;
}
if (visible && button.text !== text) {
button.setText(text);
changed = true;
}
}
if (changed) row.relayout();
};
refreshActions();
return {
row,
setValue: field.setValue,
setModified(modified) {
label.setStyle(modified ? 'override' : 'muted');
refreshActions();
},
};
}
interface Field {
widget: Widget;
/** Whether the widget should take the remaining row width. */
grow: boolean;
setValue(value: PropertyValue): void;
}
/** The bare widget for a property definition (no label, no reset). */
export function createField(
ctx: FieldContext,
def: PropertyDef,
value: PropertyValue,
handlers: FieldHandlers,
): Field {
switch (def.kind) {
case 'number': {
const field = new NumberField(Number(value), {
min: def.min,
max: def.max,
step: def.step ?? 1,
decimals: def.decimals,
onInput: handlers.onInput,
onChange: handlers.onChange,
});
return { widget: field, grow: true, setValue: (v) => field.setNumber(Number(v)) };
}
case 'text': {
const field = new TextField(String(value), { onInput: handlers.onInput, onChange: handlers.onChange });
return { widget: field, grow: true, setValue: (v) => field.setValue(String(v)) };
}
case 'color': {
const field = new ColorField(Number(value), { onInput: handlers.onInput, onChange: handlers.onChange });
return { widget: field, grow: true, setValue: (v) => field.setValue(Number(v)) };
}
case 'boolean': {
const field = new Checkbox(Boolean(value));
field.onChange = handlers.onChange;
return { widget: field, grow: false, setValue: (v) => field.setChecked(Boolean(v)) };
}
case 'enum': {
const labelOf = (v: PropertyValue) =>
def.options.find((o) => o.value === String(v))?.label ?? (String(v) || '—');
const button = new Dropdown(labelOf(value));
button.onClick = () => {
new Menu(
def.options.map((o) => ({ label: o.label, onSelect: () => handlers.onChange(o.value) })),
).openBelow(button);
};
return { widget: button, grow: true, setValue: (v) => button.setText(labelOf(v)) };
}
case 'key': {
const button = new KeyCaptureButton(String(value), (key) => handlers.onChange(key));
return { widget: button, grow: true, setValue: (v) => button.setKey(String(v)) };
}
case 'asset': {
// The current id is kept here rather than read back off the label: the label is a name,
// two assets may share one, and a dangling reference has no name at all.
let current = String(value);
const button = new Dropdown(assetRefLabel(ctx.project(), current));
button.onClick = () => {
const mark = (id: string, text: string) => (id === current ? `● ${text}` : text);
const items: MenuItem[] = [
{ label: mark('', NO_ASSET_LABEL), onSelect: () => handlers.onChange('') },
// Built on click, not once per row: assets are created, renamed and deleted while
// the inspector stands.
...assetRefChoices(ctx.project(), def.assetTypes).map((asset) => ({
label: mark(asset.id, assetChoiceLabel(asset)),
onSelect: () => handlers.onChange(asset.id),
})),
];
// Searchable: the list grows with the project, like the parent picker.
new Menu(items, { searchable: true }).openBelow(button);
};
return {
widget: button,
grow: true,
setValue: (v) => {
current = String(v);
button.setText(assetRefLabel(ctx.project(), current));
},
};
}
case 'texture': {
// Kept for the same reason as in the asset case, and more urgently: a frame's label
// repeats its sheet's name, so several entries can read identically.
let current = String(value);
const button = new Dropdown(textureRefLabel(ctx.project(), current));
button.onClick = () => {
const mark = (id: string, text: string) => (id === current ? `● ${text}` : text);
const items: MenuItem[] = [
{ label: mark('', NO_TEXTURE_LABEL), onSelect: () => handlers.onChange('') },
...textureRefChoices(ctx.project()).map((choice) => ({
label: mark(choice.id, choice.label),
onSelect: () => handlers.onChange(choice.id),
})),
];
// Searchable: one sliced sheet alone contributes an entry per frame.
new Menu(items, { searchable: true }).openBelow(button);
};
return {
widget: button,
grow: true,
setValue: (v) => {
current = String(v);
button.setText(textureRefLabel(ctx.project(), current));
},
};
}
}
}
/** Shows a key name; click, then press a key to change it (Escape cancels). */
class KeyCaptureButton extends Button {
private key: string;
private capturing = false;
constructor(
key: string,
private readonly onCapture: (key: string) => void,
) {
super(keyLabel(key));
this.key = key;
this.onClick = () => {
this.capturing = true;
this.setText('Press a key…');
ui().focus.focus(this);
};
}
setKey(key: string): void {
this.key = key;
if (!this.capturing) this.setText(keyLabel(key));
}
override onKeyDown(event: KeyboardEvent): boolean {
if (!this.capturing) return super.onKeyDown(event);
event.preventDefault();
this.capturing = false;
if (event.key === 'Escape') {
this.setText(keyLabel(this.key));
return true;
}
if (['Shift', 'Control', 'Alt', 'Meta'].includes(event.key)) {
this.capturing = true;
return true;
}
this.key = event.key;
this.setText(keyLabel(this.key));
this.onCapture(this.key);
return true;
}
override onBlur(): void {
super.onBlur();
if (this.capturing) {
this.capturing = false;
this.setText(keyLabel(this.key));
}
}
}
/** Display name of a `KeyboardEvent.key` value: "Space", "Left arrow", "—" for none. */
export function keyLabel(key: string): string {
if (key === ' ') return 'Space';
if (key === '') return '—';
return key.replace(/^Arrow/, '') + (key.startsWith('Arrow') ? ' arrow' : '');
}