PaneCP article label + PaneFO editable property rows

Article entries in the CP path list now show the article label
(letter/extension) instead of just "article", with a property count
in the meta column. In the FO pane, each property is rendered as an
editable row: key input, value input (coerced via ast-parser.coerce),
scope toggle, morphing default/overwrite label, and per-row remove
button. A "+ add property" button appends a blank defaults entry.
Every edit marks the score dirty.

`coerce` is now exported from ast-parser.js so the FO pane can apply
the same type-inference rules to user input as the parser does to
AST values.
This commit is contained in:
c0dev0id 2026-06-29 08:17:57 +02:00
parent b822e4d919
commit 5bec1a1d1e
4 changed files with 73 additions and 8 deletions

View File

@ -2,7 +2,7 @@ const LINE_RE = /^(\d{2}) (\S+(?:\.\S+)*) ?(.*)/;
const DEBUG_RE = /^\d{2} # DEBUG/; const DEBUG_RE = /^\d{2} # DEBUG/;
function coerce(s) { export function coerce(s) {
if (s === 'True' || s === 'Y' || s === 'on' || s === 'true') return true; if (s === 'True' || s === 'Y' || s === 'on' || s === 'true') return true;
if (s === 'False' || s === 'N' || s === 'off' || s === 'false') return false; if (s === 'False' || s === 'N' || s === 'off' || s === 'false') return false;
if (s === '') return s; if (s === '') return s;

View File

@ -34,6 +34,10 @@ function shortView(node) {
return { typeTag: 'motif', label: node.label, meta: node.isStatic ? [{ key: 'static', value: '✓' }] : [] }; return { typeTag: 'motif', label: node.label, meta: node.isStatic ? [{ key: 'static', value: '✓' }] : [] };
case 'stem_note': case 'stem_note':
return { typeTag: 'stem_note', label: String(node.pitch), meta: [] }; return { typeTag: 'stem_note', label: String(node.pitch), meta: [] };
case 'article': {
const n = node.properties?.length ?? 0;
return { typeTag: 'article', label: node.name, meta: n ? [{ key: 'props', value: n }] : [] };
}
default: default:
return { typeTag: node.type, label: node.type, meta: [] }; return { typeTag: node.type, label: node.type, meta: [] };
} }

View File

@ -4,6 +4,7 @@ import { EnvelopeEditor } from './EnvelopeEditor.js';
import { ShapeEditor } from './ShapeEditor.js'; import { ShapeEditor } from './ShapeEditor.js';
import { LinkedInstrumentModal } from './LinkedInstrumentModal.js'; import { LinkedInstrumentModal } from './LinkedInstrumentModal.js';
import { stressorToString } from '../exporter.js'; import { stressorToString } from '../exporter.js';
import { coerce } from '../ast-parser.js';
const H4 = { style: 'margin:0 0 0.5rem' }; const H4 = { style: 'margin:0 0 0.5rem' };
@ -155,24 +156,54 @@ export const PaneFO = {
); );
} else if (node.type === 'article') { } else if (node.type === 'article') {
const markDirty = () => props.store.markDirty(); const markDirty = () => props.store.markDirty();
const rowStyle = 'display:flex;gap:0.4rem;align-items:center;padding:0.2rem 0';
children.push( children.push(
h('h4', H4, `Article: ${node.name}`), h('h4', H4, `Article: ${node.name}`),
h('ul', { class: 'se-article-props', style: 'list-style:none;padding:0;margin:0' }, h('ul', { class: 'se-article-props', style: 'list-style:none;padding:0;margin:0' }, [
node.properties.map((p, idx) => { ...node.properties.map((p, idx) => {
const isOverwrite = p.scope === 'overwrites'; const isOverwrite = p.scope === 'overwrites';
const flip = () => { p.scope = isOverwrite ? 'defaults' : 'overwrites'; markDirty(); }; const flip = () => { p.scope = isOverwrite ? 'defaults' : 'overwrites'; markDirty(); };
return h('li', { key: idx, style: 'display:flex;gap:0.5rem;align-items:center;padding:0.25rem 0' }, [ const onKeyInput = (e) => { p.name = e.target.value; markDirty(); };
h('span', { style: 'flex:1' }, `${p.name} = ${p.value}`), const onValInput = (e) => { p.value = coerce(e.target.value); markDirty(); };
const onRemove = () => { node.properties.splice(idx, 1); markDirty(); };
return h('li', { key: idx, style: rowStyle }, [
h('input', {
class: 'se-prop-key',
value: p.name,
style: 'flex:1;min-width:6em',
onInput: onKeyInput,
}),
h('span', null, '='),
h('input', {
class: 'se-prop-val',
value: String(p.value),
style: 'flex:1;min-width:6em',
onInput: onValInput,
}),
h('button', { h('button', {
class: 'se-toggle', class: 'se-toggle',
role: 'switch', role: 'switch',
'aria-checked': String(isOverwrite), 'aria-checked': String(isOverwrite),
onClick: flip, onClick: flip,
}), }),
h('span', { class: 'se-toggle-label active' }, isOverwrite ? 'overwrite' : 'default'), h('span', { class: 'se-toggle-label active', style: 'min-width:4.5em' },
isOverwrite ? 'overwrite' : 'default'),
h('button', {
class: 'se-btn-remove',
title: 'Remove property',
onClick: onRemove,
}, '×'),
]); ]);
}) }),
), h('li', { key: '__add', style: rowStyle },
h('button', {
class: 'se-btn',
onClick: () => {
node.properties.push({ name: '', value: '', scope: 'defaults' });
markDirty();
},
}, '+ add property')),
]),
); );
} else if (node.type === 'bar') { } else if (node.type === 'bar') {
const markBarDirty = () => { node.isDirty = true; props.store.markDirty(); }; const markBarDirty = () => { node.isDirty = true; props.store.markDirty(); };

View File

@ -398,3 +398,33 @@
.se-toggle-label.active { .se-toggle-label.active {
color: #d8d8d8; color: #d8d8d8;
} }
.se-prop-key, .se-prop-val {
background: #1a1a1a;
color: #d8d8d8;
border: 1px solid #444;
border-radius: 0.2rem;
padding: 0.15rem 0.3rem;
font-family: monospace;
font-size: 0.8rem;
}
.se-prop-key:focus, .se-prop-val:focus {
outline: none;
border-color: #7a7a7a;
background: #222;
}
.se-btn-remove {
background: transparent;
color: #888;
border: 1px solid #444;
border-radius: 0.2rem;
cursor: pointer;
padding: 0 0.4rem;
font-size: 0.9rem;
line-height: 1;
}
.se-btn-remove:hover {
color: #e06060;
border-color: #602020;
}