Files
feuerwerk/src/editor/inspector/fieldFactory.ts
T
rootandClaude Opus 5 f16801aef3 Add a decimals option, fix dropdown overflow and the texture filter
- NumberField takes a decimals option (default 3); frame rects and the
  slice dialog use 0, matching their integer model.
- Button truncates its label to the drawn width; Dropdown reserves the
  chevron slot. NodeView reuses the shared truncate().
- Setting scaleMode leaves the style's shared resource id in place, so
  the old GPU sampler stayed in use until a reload. Call style.update().
- Graph gets a header bar like the Texture tab: GraphEditor is now the
  frame, the former editor is GraphCanvas. A selection the tab does not
  concern no longer unloads the behavior.
- New e2e step 14 guards text overflow across all widgets.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 15:05:28 +02:00

322 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 (an id)
* to a name and to list what the picker may offer.
*
* A getter rather than the project itself – loading a document and entering play mode replace
* the object, and a row that outlives either must not keep answering from the old one.
*/
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;
/**
* Highlight the row 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", …). */
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[];
/** 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. Which of them exist is asked from `reset`/`apply` 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,
reset?: FieldActions,
apply?: FieldActions,
): 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 => {
const button = new Button(glyph, { variant: 'ghost', width: 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);
};
if (apply) addActions('↑', apply);
if (reset) addActions('↺', 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 ? 'bodyBold' : '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, and the picker has to show the project as it is now.
...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': {
// The id is kept here 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('') },
// Built on click, not once per row: textures are uploaded and frames are cut, renamed
// and sliced away while the inspector stands, and the picker has to show what exists now.
...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. The entries read
// "Sheet / Frame", so the sheet's own name gathers its frames into one block of the
// list and typing either name finds them.
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' : '');
}