diff --git a/static/components/AppShell.js b/static/components/AppShell.js index 146db82..890e8bf 100644 --- a/static/components/AppShell.js +++ b/static/components/AppShell.js @@ -1,13 +1,13 @@ -import { h, ref, onMounted } from 'vue'; +import { h, ref, computed, onMounted, watch } from 'vue'; import { PaneCP } from './PaneCP.js'; import { PaneFO } from './PaneFO.js'; import { PaneSubObjects } from './PaneSubObjects.js'; import { ImportDialog } from './ImportDialog.js'; +import { getKindGroups, KIND_LABEL } from '../subobject-kinds.js'; -const PANES = [ - { id: 'cp', label: 'Position' }, - { id: 'fo', label: 'Object' }, - { id: 'sub', label: 'Sub-objects' }, +const FIXED_PANES = [ + { id: 'cp', label: 'CP', title: 'Current position' }, + { id: 'fo', label: 'FO', title: 'Focused object' }, ]; export const AppShell = { @@ -16,6 +16,25 @@ export const AppShell = { const activePane = ref('cp'); const showImport = ref(false); + function focusedNode() { + const fp = props.store.focusPath; + return fp.length ? fp[fp.length - 1] : props.store.scoreModel; + } + + const subPanes = computed(() => getKindGroups(focusedNode()).map(g => ({ + id: `sub:${g.kind}`, + kind: g.kind, + label: KIND_LABEL[g.kind] ?? g.kind.slice(0, 2).toUpperCase(), + title: g.kind, + }))); + + const panes = computed(() => [...FIXED_PANES, ...subPanes.value]); + + // If active pane disappears after focus change, drop back to FO. + watch(panes, ps => { + if (!ps.some(p => p.id === activePane.value)) activePane.value = 'fo'; + }); + function openImport() { if (!props.store.isDirty) showImport.value = true; } @@ -26,33 +45,34 @@ export const AppShell = { return () => { const store = props.store; + const ap = activePane.value; + + const subPaneNodes = subPanes.value.map(p => + h('div', { key: p.id, class: ['se-pane', ap === p.id ? 'active' : null] }, + h(PaneSubObjects, { store, kind: p.kind, onFocusFO: () => { activePane.value = 'fo'; } }))); return h('div', { class: 'se-shell' }, [ - // Pane area h('div', { class: 'se-pane-area' }, [ - h('div', { class: ['se-pane', activePane.value === 'cp' ? 'active' : null] }, + h('div', { class: ['se-pane', ap === 'cp' ? 'active' : null] }, h(PaneCP, { store, onImportClick: openImport, onFocusFO: () => { activePane.value = 'fo'; } })), - h('div', { class: ['se-pane', activePane.value === 'fo' ? 'active' : null] }, + h('div', { class: ['se-pane', ap === 'fo' ? 'active' : null] }, h(PaneFO, { store })), - h('div', { class: ['se-pane', activePane.value === 'sub' ? 'active' : null] }, - h(PaneSubObjects, { store, onFocusFO: () => { activePane.value = 'fo'; } })), + ...subPaneNodes, ]), - // Handle bar (tab switcher at bottom) - h('div', { class: 'se-handle-bar' }, PANES.map(p => + h('div', { class: 'se-handle-bar' }, panes.value.map(p => h('button', { key: p.id, - class: ['se-handle', activePane.value === p.id ? 'active' : null], + class: ['se-handle', ap === p.id ? 'active' : null], + title: p.title, onClick: () => { activePane.value = p.id; }, }, p.label) )), - // Error banner store.errorMessage ? h('div', { class: 'se-error', style: 'margin:0' }, store.errorMessage) : null, - // Import dialog showImport.value ? h(ImportDialog, { store, onClose: () => { showImport.value = false; } }) : null, diff --git a/static/components/ObjectShort.js b/static/components/ObjectShort.js index bcebc58..006e2d8 100644 --- a/static/components/ObjectShort.js +++ b/static/components/ObjectShort.js @@ -2,11 +2,11 @@ import { h } from 'vue'; // One-line summary row with a drill-down chevron. export const ObjectShort = { - props: ['label', 'typeTag', 'focused', 'hasChildren'], + props: ['label', 'typeTag', 'focused', 'hasChildren', 'readOnly'], emits: ['focus', 'drillDown'], setup(props, { emit }) { return () => h('li', { - class: ['se-object-item', props.focused ? 'focused' : null], + class: ['se-object-item', props.focused ? 'focused' : null, props.readOnly ? 'read-only' : null], onClick: () => emit('focus'), }, [ props.typeTag ? h('span', { class: 'se-object-type' }, props.typeTag) : null, diff --git a/static/components/PaneSubObjects.js b/static/components/PaneSubObjects.js index cc8bffa..f071d4c 100644 --- a/static/components/PaneSubObjects.js +++ b/static/components/PaneSubObjects.js @@ -1,90 +1,23 @@ import { h } from 'vue'; import { ObjectShort } from './ObjectShort.js'; +import { getKindGroups } from '../subobject-kinds.js'; export const PaneSubObjects = { - props: ['store', 'onFocusFO'], + props: ['store', 'kind', 'onFocusFO'], setup(props) { function focused() { const fp = props.store.focusPath; return fp.length ? fp[fp.length - 1] : props.store.scoreModel; } - function subItems(node) { - if (!node) return []; - if (node.type === 'score') { - const items = []; - if (node.info) - items.push({ kind: 'info', node: node.info, label: node.info.title ?? '(no title)', hasChildren: false }); - if (node.tuning) - items.push({ kind: 'tuning', node: node.tuning, label: `base ${node.tuning.base ?? '?'}`, hasChildren: false }); - for (const a of (node.articles ?? [])) - items.push({ kind: 'article', node: a, label: a.name, hasChildren: false }); - for (const sv of (node.stageVoices ?? [])) - items.push({ kind: 'stage', node: sv, label: sv.name, hasChildren: false }); - for (const i of node.instruments) - items.push({ kind: 'instrument', node: i, label: i.name, hasChildren: true }); - for (const b of node.bars) - items.push({ kind: 'bar', node: b, label: b.id, hasChildren: Object.keys(b.voices).length > 0 }); - return items; - } - if (node.type === 'instrument') { - return node.variations.map((v, idx) => ({ - kind: 'variation', - node: v, - label: `variation ${idx + 1}${v.dependsOn ? ` (${v.dependsOn})` : ''}`, - hasChildren: true, - })); - } - if (node.type === 'variation') { - return [ - ...node.labelSpecs.map(ls => ({ - kind: 'label_spec', node: ls, label: ls.label ?? '(no label)', hasChildren: false, - })), - ...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 }; - }), - ]; - } - if (node.type === 'bar') { - return Object.entries(node.voices).map(([name, v]) => ({ - kind: 'voice', node: v, label: name, - hasChildren: v.offsets.length > 0 || v.motifs.some(m => m.isStatic), - })); - } - if (node.type === 'voice') { - return [ - ...node.motifs - .filter(m => m.isStatic) - .map(m => ({ kind: 'motif', node: m, label: m.label, hasChildren: m.stemNotes.length > 0 })), - ...node.offsets.map((o, idx) => ({ - kind: 'offset', node: o, label: `tick ${o.tick ?? idx}`, hasChildren: o.stemNotes.length > 0, - })), - ]; - } - if (node.type === 'motif') { - return 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, - })); - } - if (node.type === 'offset') { - return 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 []; - } - return () => { const node = focused(); - const items = subItems(node); + const groups = getKindGroups(node); + const group = props.kind + ? groups.find(g => g.kind === props.kind) + : groups[0]; + const items = group?.items ?? []; + if (!items.length) return h('div', null, h('em', null, 'No sub-objects')); return h('div', null, @@ -95,6 +28,7 @@ export const PaneSubObjects = { typeTag: item.kind, focused: props.store.focusPath.includes(item.node), hasChildren: item.hasChildren, + readOnly: item.readOnly ?? false, onFocus: () => { props.store.pushFocus(item.node); props.onFocusFO?.(); }, onDrillDown: () => { props.store.pushFocus(item.node); props.onFocusFO?.(); }, }) diff --git a/static/subobject-kinds.js b/static/subobject-kinds.js new file mode 100644 index 0000000..12a7fdf --- /dev/null +++ b/static/subobject-kinds.js @@ -0,0 +1,123 @@ +// 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 => ({ + kind: 'articles', node: a, label: `${a.subtype}: ${a.name}`, 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 []; +}