c0dev0id 133117922a Articles: key by label across subtypes, scope per property, FO toggle widget
A single article label (e.g. 'f') may emit on multiple `articles.<subtype>`
lines — `defaults` today, `overwrites` planned — with disjoint property
sets that apply at different stages. Model now merges those emissions:

  { name: 'f', properties: [{ name, value, scope: 'defaults'|'overwrites' }] }

Per-label entries in the AR pane show a property-count breakdown
(`f (1 defaults, 2 overwrites)`). The Article FO pane lists each property
with a `(O-) default` / `(-O) overwrite` toggle — click flips the scope
and marks the score dirty. ASCII glyph mimics a physical switch position
so the active scope is visible at a glance.

Fixture tests cover both the existing single-subtype shape and a synthetic
two-subtype merge (177 assertions total).
2026-06-28 19:11:01 +02:00

128 lines
4.8 KiB
JavaScript

// Group a node's sub-objects by KIND (the SLOT side of SLOT.SUBTYPE in the AST).
// Per the editor design: items sharing a kind share one pane; different kinds
// produce separate panes whose handles render in the AppShell bottom bar.
// Each group is { kind, items: [{ kind, node, label, hasChildren, readOnly? }] }.
// The returned order is the display order for the handle bar.
export const KIND_LABEL = {
tuning: 'TU',
stage: 'ST',
instrument: 'IN',
articles: 'AR',
bar: 'BA',
variation: 'VR',
label_spec: 'LA',
voice: 'VO',
motif: 'MO',
offset: 'OF',
stem_note: 'SN',
};
export function getKindGroups(node) {
if (!node) return [];
if (node.type === 'score') {
const groups = [];
if (node.tuning) {
groups.push({ kind: 'tuning', items: [
{ kind: 'tuning', node: node.tuning, label: `base ${node.tuning.base ?? '?'}`, hasChildren: false },
]});
}
const stage = [];
if (node.stageCone) stage.push({ kind: 'stage', node: node.stageCone, label: 'cone (orchestra)', hasChildren: false });
for (const sv of (node.stageVoices ?? []))
stage.push({ kind: 'stage', node: sv, label: sv.name, hasChildren: false });
if (stage.length) groups.push({ kind: 'stage', items: stage });
if (node.instruments.length) {
groups.push({ kind: 'instrument', items: node.instruments.map(i => ({
kind: 'instrument', node: i, label: i.name, hasChildren: !i.isLinked, readOnly: i.isLinked,
}))});
}
if ((node.articles ?? []).length) {
groups.push({ kind: 'articles', items: node.articles.map(a => {
const counts = a.properties.reduce((acc, p) => {
acc[p.scope] = (acc[p.scope] ?? 0) + 1; return acc;
}, {});
const suffix = Object.entries(counts).map(([s, n]) => `${n} ${s}`).join(', ');
return { kind: 'articles', node: a, label: `${a.name} (${suffix})`, hasChildren: false };
})});
}
if (node.bars.length) {
groups.push({ kind: 'bar', items: node.bars.map(b => ({
kind: 'bar', node: b, label: b.id, hasChildren: Object.keys(b.voices).length > 0,
}))});
}
return groups;
}
if (node.type === 'instrument') {
if (!node.variations.length) return [];
return [{ kind: 'variation', items: node.variations.map((v, idx) => ({
kind: 'variation', node: v,
label: `variation ${idx + 1}${v.dependsOn ? ` (${v.dependsOn})` : ''}`,
hasChildren: true,
}))}];
}
if (node.type === 'variation') {
const groups = [];
if (node.labelSpecs.length) {
groups.push({ kind: 'label_spec', items: node.labelSpecs.map(ls => ({
kind: 'label_spec', node: ls, label: ls.label ?? '(no label)', hasChildren: false,
}))});
}
if (node.subvariations.length) {
groups.push({ kind: 'variation', items: node.subvariations.map((sv, idx) => {
const dep = sv.dependsOn;
const label = dep == null ? `subvariation ${idx + 1}`
: isNaN(Number(dep)) ? `ATTR: ${dep}`
: String(dep);
return { kind: 'variation', node: sv, label, hasChildren: true };
})});
}
return groups;
}
if (node.type === 'bar') {
const entries = Object.entries(node.voices);
if (!entries.length) return [];
return [{ kind: 'voice', items: entries.map(([name, v]) => ({
kind: 'voice', node: v, label: name,
hasChildren: v.offsets.length > 0 || v.motifs.some(m => m.isStatic),
}))}];
}
if (node.type === 'voice') {
const groups = [];
const staticMotifs = node.motifs.filter(m => m.isStatic);
if (staticMotifs.length) {
groups.push({ kind: 'motif', items: staticMotifs.map(m => ({
kind: 'motif', node: m, label: m.label, hasChildren: m.stemNotes.length > 0,
}))});
}
if (node.offsets.length) {
groups.push({ kind: 'offset', items: node.offsets.map((o, idx) => ({
kind: 'offset', node: o, label: `tick ${o.tick ?? idx}`, hasChildren: o.stemNotes.length > 0,
}))});
}
return groups;
}
if (node.type === 'motif' || node.type === 'offset') {
if (!node.stemNotes.length) return [];
return [{ kind: 'stem_note', items: node.stemNotes.map(sn => ({
kind: 'stem_note', node: sn,
label: `pitch ${sn.pitch}${sn.clauses.length ? ` (${sn.clauses.length} clause${sn.clauses.length > 1 ? 's' : ''})` : ''}`,
hasChildren: false,
}))}];
}
return [];
}