From 133117922a8dd44b27b391ddb3329e4abb7be79e Mon Sep 17 00:00:00 2001 From: c0dev0id Date: Sun, 28 Jun 2026 19:11:01 +0200 Subject: [PATCH] Articles: key by label across subtypes, scope per property, FO toggle widget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A single article label (e.g. 'f') may emit on multiple `articles.` 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). --- static/ast-parser.js | 29 +++++++++++++++++++---------- static/components/PaneFO.js | 23 +++++++++++++++++++++++ static/subobject-kinds.js | 10 +++++++--- test-parser.mjs | 19 +++++++++++++++++-- 4 files changed, 66 insertions(+), 15 deletions(-) diff --git a/static/ast-parser.js b/static/ast-parser.js index fa6aa4f..9d3c84e 100644 --- a/static/ast-parser.js +++ b/static/ast-parser.js @@ -172,7 +172,7 @@ export function buildModel(rawTree) { break; default: if (node.parentSlot === 'articles') { - score.articles.push(buildArticle(node)); + mergeArticleEntry(score, node); } else { score[node.slot] = buildGeneric(node); } @@ -182,6 +182,24 @@ export function buildModel(rawTree) { return score; } +// Articles are keyed by label. The AST emits one line per (label, subtype) +// — e.g. `articles.defaults 'f'` and (future) `articles.overwrites 'f'`. +// We merge them into one entry per label whose properties carry the scope +// of their originating subtype, so the UI can toggle scope per property. +function mergeArticleEntry(score, node) { + const name = node.positionals[0]; + let entry = score.articles.find(a => a.name === name); + if (!entry) { + entry = { type: 'article', name, properties: [] }; + score.articles.push(entry); + } + const scope = node.slot; // 'defaults' | 'overwrites' | future subtype + for (const [key, value] of Object.entries(node.props)) { + entry.properties.push({ name: key, value, scope }); + } + return entry; +} + function buildTuning(node) { const t = { type: 'tuning', base: node.props.base, scales: {}, chords: {}, frequencyFactors: null }; for (const child of node.children) { @@ -199,15 +217,6 @@ function buildTuning(node) { return t; } -function buildArticle(node) { - return { - type: 'article', - subtype: node.slot, - name: node.positionals[0], - props: { ...node.props }, - }; -} - function buildInstrument(node) { const instr = { type: 'instrument', diff --git a/static/components/PaneFO.js b/static/components/PaneFO.js index 6263c40..5d04ec4 100644 --- a/static/components/PaneFO.js +++ b/static/components/PaneFO.js @@ -153,6 +153,29 @@ export const PaneFO = { ? h(EnvelopeEditor, { basicProperties: node.basicProperties, onChange }) : null, ); + } else if (node.type === 'article') { + const markDirty = () => props.store.markDirty(); + const SCOPES = ['defaults', 'overwrites']; + const scopeLabel = s => s === 'defaults' ? '(O-) default' : '(-O) overwrite'; + children.push( + h('h4', H4, `Article: ${node.name}`), + h('ul', { class: 'se-article-props', style: 'list-style:none;padding:0;margin:0' }, + node.properties.map((p, idx) => + h('li', { key: idx, style: 'display:flex;gap:0.5rem;align-items:center;padding:0.25rem 0' }, [ + h('span', { style: 'flex:1' }, `${p.name} = ${p.value}`), + h('button', { + class: ['se-scope-toggle', `scope-${p.scope}`], + style: 'font-family:monospace;min-width:8em', + onClick: () => { + const i = SCOPES.indexOf(p.scope); + p.scope = SCOPES[(i + 1) % SCOPES.length]; + markDirty(); + }, + }, scopeLabel(p.scope)), + ]) + ) + ), + ); } else if (node.type === 'bar') { const markBarDirty = () => { node.isDirty = true; props.store.markDirty(); }; children.push( diff --git a/static/subobject-kinds.js b/static/subobject-kinds.js index 12a7fdf..60bb586 100644 --- a/static/subobject-kinds.js +++ b/static/subobject-kinds.js @@ -43,9 +43,13 @@ export function getKindGroups(node) { } if ((node.articles ?? []).length) { - groups.push({ kind: 'articles', items: node.articles.map(a => ({ - kind: 'articles', node: a, label: `${a.subtype}: ${a.name}`, hasChildren: false, - }))}); + 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) { diff --git a/test-parser.mjs b/test-parser.mjs index d8ab448..55fcb8d 100644 --- a/test-parser.mjs +++ b/test-parser.mjs @@ -48,9 +48,24 @@ ok('stageVoices parsed', model.stageVoices.length === 3); ok('stageVoices[0] is "pi"', model.stageVoices[0].name === 'pi'); ok('stageVoices[0].direction', model.stageVoices[0].direction === '1|1'); ok('articles array non-empty', model.articles.length > 0); -ok('articles[0].subtype defaults', model.articles[0].subtype === 'defaults'); ok('articles[0].name "f"', model.articles[0].name === 'f'); -ok('articles[0].props.add_stress', model.articles[0].props.add_stress === 3); +ok('articles[0].properties[]', Array.isArray(model.articles[0].properties)); +const fProp = model.articles[0].properties.find(p => p.name === 'add_stress'); +ok('articles[0] add_stress prop', fProp && fProp.value === 3 && fProp.scope === 'defaults'); + +// Merge across subtypes: same label, different subtypes → one entry, multi-scope props +const MERGE_FIXTURE = `01 articles.defaults 'g' add_stress=2 +01 articles.overwrites 'g' pitch_bend=0.05 +`; +const mergeRoot = parseAstLog('00 tuning base=\'x\'\n' + MERGE_FIXTURE); +const mergeModel = buildModel(mergeRoot); +ok('merge: single entry per label', mergeModel.articles.length === 1); +ok('merge: label "g"', mergeModel.articles[0].name === 'g'); +ok('merge: 2 properties', mergeModel.articles[0].properties.length === 2); +const gDef = mergeModel.articles[0].properties.find(p => p.scope === 'defaults'); +const gOver = mergeModel.articles[0].properties.find(p => p.scope === 'overwrites'); +ok('merge: default scope present', gDef && gDef.name === 'add_stress' && gDef.value === 2); +ok('merge: overwrite scope present', gOver && gOver.name === 'pitch_bend' && gOver.value === 0.05); // ── Bar IDs ──────────────────────────────────────────────────────────────── // Bar IDs are opaque auto-increment strings; only the raw id string matters.