The theme asks for Source Sans 3 at weight 500, but fonts.ts only
loaded 400, 600 and 700. The browser synthesised the 500 text. The
issue comment also asked for the symbols the font lacks to become SVG
icons. A scan of src/ against the font's cmap found 20 such characters
in 30 places.
- fonts.ts loads source-sans-3-latin-500-normal.woff2 as well.
- Menus draw their ticks and the "current" dot as icons. The dot is a
new MenuItem.current field and no longer part of the label text.
- Inspector action buttons (reset, apply, fit width/height) are
icons. The menu arrow behind them is the drawn Dropdown chevron.
- Kind marks come from a table in markIcons.ts. Picture kinds use an
icon, # and Aa stay text because the font has them.
- Scene marks in the asset panel, sound play/stop and the transition
arrows are icons too.
- Apply is an icon although the font has the arrow. It sits next to
the reset icon and would look different as a glyph.
- A new Icon widget draws on redraw like Button, so Node tests need no
DOMParser.
- Tests find icons through the SVG id. The new hook
__gm.locateIcons(id) and the harness helpers findIcon/clickIcon
replace locate() on the old characters.
Findings:
- Weight 700 is loaded but the theme never uses it. Left as is.
- The old scene-mark check in module 35 accepted any play glyph on
screen. It now checks the mark on the start scene's row.
- Module 44 needs the textures from module 37 and fails without it.
Tests:
- ./check.sh: 145 files, 2187 tests, exit 0 (before 144 / 2143).
- New tests/ui/fonts.test.ts: every theme weight is loaded, and every
character in src/ exists in every shipped face. The cmap reader
matches fontTools (231 characters).
- Icon tests cover the 19 new files. Each kind mark is either an
icon or text, never both.
- e2e 1-16,22,30,34,35,43,44: 586 green, 0 FAIL, then 44 stopped
for lack of module 37. e2e 1-16,37,44,47,48: 618 green.
e2e 1-14 on the final build: 454 green.
- Two new e2e checks: loaded weights in 14, a reset icon pixel in 04.
- Counter-probes all fail as expected: 500 removed, a play glyph put
back, the struct icon removed (one unit test each). In the browser,
500 removed and reset icon hidden fail exactly the two new checks.
- Editor-suite check sum 1312 (1310 measured by CI at 056a622, +2).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
172 lines
6.6 KiB
TypeScript
172 lines
6.6 KiB
TypeScript
import {
|
||
AddAnimationTrackCommand,
|
||
RetargetAnimationTrackCommand,
|
||
} from '../../core/commands/animationCommands';
|
||
import { animationTargets, targetDefs, trackDef } from '../../core/model/animation';
|
||
import type { AnimationAsset, AnimationTrack } from '../../core/model/Asset';
|
||
import { findAsset } from '../../core/model/Project';
|
||
import type { PropertyDef } from '../../core/properties/PropertySchema';
|
||
import { type Button, Dropdown, Label, Menu, type MenuItem } from '../../ui';
|
||
import { dialogSlot, FormDialog } from '../FormDialog';
|
||
import { type EditorState, trackSelection } from '../state';
|
||
|
||
const PICKER_WIDTH = 240;
|
||
|
||
const slot = dialogSlot<RowDialog>();
|
||
|
||
/** The open row dialog, for the check scripts. */
|
||
export const currentRowDialog = slot.current;
|
||
|
||
/** Opens the dialog centred, closing whichever one was open before it. */
|
||
export const openRowDialog = slot.open;
|
||
|
||
/** One choice of the object picker: the root itself (`''`) or an object inside it. */
|
||
interface TargetChoice {
|
||
id: string;
|
||
label: string;
|
||
}
|
||
|
||
/**
|
||
* Adds a row to the timeline, or aims an existing one elsewhere: first the object – the animated
|
||
* asset itself, or one placed inside it when that is a container or scene – then a property of
|
||
* it. Retargeting offers only properties of the row's kind, since its keys stay (#97).
|
||
*/
|
||
export class RowDialog extends FormDialog {
|
||
private target: string;
|
||
private property: string;
|
||
private readonly targetButton: Dropdown;
|
||
private readonly propertyButton: Dropdown;
|
||
private readonly summary: Label;
|
||
private readonly confirm: Button;
|
||
|
||
constructor(
|
||
private readonly state: EditorState,
|
||
private readonly asset: AnimationAsset,
|
||
private readonly track: AnimationTrack | null = null,
|
||
) {
|
||
super(360, track ? 'Change what the row animates' : `Add a row to "${asset.name}"`);
|
||
this.target = track?.target ?? '';
|
||
this.property = track?.property ?? '';
|
||
const root = findAsset(state.project, asset.root);
|
||
this.content.add(new Label(`Animates ${root ? `"${root.name}"` : 'nothing yet'}`, { style: 'muted' }));
|
||
|
||
this.targetButton = new Dropdown('', { width: PICKER_WIDTH, onClick: () => this.openTargets() });
|
||
this.content.add(this.formRow(this.fieldLabel('Object'), this.targetButton));
|
||
this.propertyButton = new Dropdown('', { width: PICKER_WIDTH, onClick: () => this.openProperties() });
|
||
this.content.add(this.formRow(this.fieldLabel('Property'), this.propertyButton));
|
||
|
||
this.summary = new Label('', { style: 'muted' });
|
||
this.content.add(this.summary);
|
||
|
||
this.confirm = this.addButtons(track ? 'Change' : 'Add row', 'primary', () => this.apply());
|
||
|
||
// Without a property yet, the first one the object offers is as good a start as any.
|
||
if (!this.property) this.property = this.properties()[0]?.key ?? '';
|
||
this.refresh();
|
||
}
|
||
|
||
/** What the dialog offers and holds, for the check scripts. */
|
||
debugInfo(): { target: string; property: string; targets: string[]; properties: string[]; ok: boolean } {
|
||
return {
|
||
target: this.target,
|
||
property: this.property,
|
||
targets: this.targets().map((t) => t.label),
|
||
properties: this.properties().map((d) => d.label),
|
||
ok: this.confirm.enabled,
|
||
};
|
||
}
|
||
|
||
/** Picks by id / key rather than by click, for the check scripts. */
|
||
pick(target: string, property: string): void {
|
||
this.target = target;
|
||
this.property = property;
|
||
this.refresh();
|
||
}
|
||
|
||
private targets(): TargetChoice[] {
|
||
const root = findAsset(this.state.project, this.asset.root);
|
||
if (!root) return [];
|
||
const choices: TargetChoice[] = [{ id: '', label: `${root.name} (itself)` }];
|
||
for (const { id, object, depth } of animationTargets(this.state.project, root))
|
||
choices.push({
|
||
id,
|
||
label: `${' '.repeat(depth)}${object.name || findAsset(this.state.project, object.assetId)?.name || '?'}`,
|
||
});
|
||
return choices;
|
||
}
|
||
|
||
/** The properties the picked object offers, less those another row already animates. */
|
||
private properties(): PropertyDef[] {
|
||
const defs = targetDefs(this.state.project, this.asset, this.target);
|
||
if (!defs) return [];
|
||
const was = this.trackKind();
|
||
return defs.filter((def) => {
|
||
if (was !== null && def.kind !== was) return false;
|
||
return !this.asset.tracks.some(
|
||
(t) => t.id !== this.track?.id && t.target === this.target && t.property === def.key,
|
||
);
|
||
});
|
||
}
|
||
|
||
/** The kind the row being changed has; null when its target or property is gone, so it may go anywhere. */
|
||
private trackKind(): string | null {
|
||
return this.track ? (trackDef(this.state.project, this.asset, this.track)?.kind ?? null) : null;
|
||
}
|
||
|
||
private openTargets(): void {
|
||
const items: MenuItem[] = this.targets().map((choice) => ({
|
||
label: choice.label,
|
||
current: choice.id === this.target,
|
||
onSelect: () => {
|
||
this.target = choice.id;
|
||
// Another object may not have the property; fall back to its first.
|
||
if (!this.properties().some((d) => d.key === this.property))
|
||
this.property = this.properties()[0]?.key ?? '';
|
||
this.refresh();
|
||
},
|
||
}));
|
||
new Menu(items, { searchable: items.length > 9 }).openBelow(this.targetButton);
|
||
}
|
||
|
||
private openProperties(): void {
|
||
const items: MenuItem[] = this.properties().map((def) => ({
|
||
label: def.label,
|
||
current: def.key === this.property,
|
||
onSelect: () => {
|
||
this.property = def.key;
|
||
this.refresh();
|
||
},
|
||
}));
|
||
if (items.length === 0) items.push({ label: 'Nothing left to animate here', onSelect: () => undefined });
|
||
new Menu(items, { searchable: true }).openBelow(this.propertyButton);
|
||
}
|
||
|
||
private refresh(): void {
|
||
const target = this.targets().find((t) => t.id === this.target);
|
||
this.targetButton.setText(target ? target.label.trim() : this.target === '' ? '—' : '(missing)');
|
||
const def = this.properties().find((d) => d.key === this.property);
|
||
this.propertyButton.setText(def ? def.label : '—');
|
||
const ok = Boolean(target && def);
|
||
this.summary.setText(
|
||
!target
|
||
? 'Pick the animated asset first.'
|
||
: !def
|
||
? 'Pick a property.'
|
||
: `Keys on this row set ${def.label} (${def.kind}) of ${target.label.trim()}.`,
|
||
);
|
||
this.confirm.setEnabled(ok);
|
||
this.refreshLayout();
|
||
}
|
||
|
||
private apply(): void {
|
||
if (this.track) {
|
||
this.state.execute(
|
||
new RetargetAnimationTrackCommand(this.asset.id, this.track.id, this.target, this.property),
|
||
);
|
||
return;
|
||
}
|
||
const command = new AddAnimationTrackCommand(this.asset.id, this.target, this.property);
|
||
if (this.state.execute(command)) this.state.select(trackSelection(this.asset.id, [], command.trackId));
|
||
}
|
||
}
|