diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..3b3084a --- /dev/null +++ b/__init__.py @@ -0,0 +1,16 @@ +from flask import Blueprint, render_template, request + +blueprint = Blueprint( + 'vue3_neusik', + __name__, + static_folder='static', + static_url_path='/static', + template_folder='templates', +) + +@blueprint.route('/', methods=['GET']) +def index(): + return render_template( + 'vue3_neusik/index.html', + import_on_load='true' if request.args.get('import') == '1' else 'false', + ) diff --git a/static/api.js b/static/api.js new file mode 100644 index 0000000..91d8b85 --- /dev/null +++ b/static/api.js @@ -0,0 +1,42 @@ +function authHeader(credentials) { + if (!credentials) return {}; + const b64 = btoa(`${credentials.username}:${credentials.password}`); + return { Authorization: `Basic ${b64}` }; +} + +export async function fetchAstLog(credentials) { + const res = await fetch('/sompyle/astlog', { + headers: authHeader(credentials), + }); + if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); + return res.text(); +} + +export async function fetchScoreText(credentials) { + const res = await fetch('/sompyle/score.spls', { + headers: authHeader(credentials), + }); + if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); + return res.text(); +} + +export async function putScoreText(text, credentials) { + const res = await fetch('/sompyle/score.spls', { + method: 'PUT', + headers: { + ...authHeader(credentials), + 'Content-Type': 'text/yaml', + }, + body: text, + }); + if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); + return res; +} + +export async function fetchStatus(credentials) { + const res = await fetch('/sompyle/status.json', { + headers: authHeader(credentials), + }); + if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); + return res.json(); +} diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..52c0e54 --- /dev/null +++ b/static/app.js @@ -0,0 +1,12 @@ +import { createApp, h } from 'vue'; +import { store } from './store.js'; +import { AppShell } from './components/AppShell.js'; + +const root = document.getElementById('score-editor-app'); +const importOnLoad = root?.dataset.importOnLoad === 'true'; + +createApp({ + setup() { + return () => h(AppShell, { store, importOnLoad }); + }, +}).mount('#score-editor-app'); diff --git a/static/ast-parser.js b/static/ast-parser.js new file mode 100644 index 0000000..a88708d --- /dev/null +++ b/static/ast-parser.js @@ -0,0 +1,482 @@ +const LINE_RE = /^(\d{2}) (\S+(?:\.\S+)*) ?(.*)/; +const DEBUG_RE = /^\d{2} # DEBUG/; + + +function coerce(s) { + if (s === 'True' || s === 'Y' || s === 'on' || s === 'true') return true; + if (s === 'False' || s === 'N' || s === 'off' || s === 'false') return false; + if (s === '') return s; + const n = Number(s); + if (!isNaN(n) && s.trim() !== '') return n; + return s; +} + +export function parseAstLog(text) { + const root = { slot: 'root', parentSlot: null, depth: -1, positionals: [], props: {}, children: [] }; + const stack = [root]; + + for (const line of text.split('\n')) { + if (!line.trim()) continue; + if (DEBUG_RE.test(line)) continue; + + const m = LINE_RE.exec(line); + if (!m) continue; + + const depth = parseInt(m[1], 10); + const slotFull = m[2]; + const rest = m[3] || ''; + + const dotIdx = slotFull.indexOf('.'); + let parentSlot = null, slot = slotFull; + if (dotIdx !== -1) { + parentSlot = slotFull.slice(0, dotIdx); + slot = slotFull.slice(dotIdx + 1); + } + + const { positionals, props } = parseRest(rest); + + const node = { slot, parentSlot, depth, positionals, props, children: [] }; + + // Pop stack to find correct parent (parent must have depth < current) + while (stack.length > 1 && stack[stack.length - 1].depth >= depth) { + stack.pop(); + } + + stack[stack.length - 1].children.push(node); + stack.push(node); + } + + return root; +} + +function parseRest(rest) { + const positionals = []; + const props = {}; + let i = 0; + const n = rest.length; + let inProps = false; + + while (i < n) { + // skip spaces + while (i < n && rest[i] === ' ') i++; + if (i >= n) break; + + if (rest[i] === "'") { + // single-quoted value (positional string) + const j = rest.indexOf("'", i + 1); + const val = rest.slice(i + 1, j === -1 ? n : j); + i = j === -1 ? n : j + 1; + if (!inProps) positionals.push(val); + } else { + // scan to next space + let j = i; + while (j < n && rest[j] !== ' ') j++; + const tok = rest.slice(i, j); + i = j; + + const eqIdx = tok.indexOf('='); + if (eqIdx !== -1) { + inProps = true; + const key = tok.slice(0, eqIdx); + let rawVal = tok.slice(eqIdx + 1); + + if (rawVal.startsWith("'")) { + // value continues until next single quote + const valStart = i - (tok.length - eqIdx - 1); + // find closing quote: search from after the opening quote + const openPos = rest.indexOf("'", rest.lastIndexOf(key + '=', i) + key.length + 1); + const closePos = rest.indexOf("'", openPos + 1); + rawVal = closePos === -1 ? rest.slice(openPos + 1) : rest.slice(openPos + 1, closePos); + i = closePos === -1 ? n : closePos + 1; + } + + props[key] = coerce(rawVal); + } else if (!inProps) { + positionals.push(coerce(tok)); + } + // bare token after props start is ignored (shouldn't happen per spec) + } + } + + return { positionals, props }; +} + +// ── Second pass: build typed model ────────────────────────────────────────── + +export function buildModel(rawTree) { + const score = { + type: 'score', + info: null, + tuning: null, + articles: [], + stageVoices: [], + instruments: [], + bars: [], + }; + + for (const node of rawTree.children) { + switch (node.slot) { + case 'info': + score.info = { ...node.props }; + break; + case 'tuning': + score.tuning = buildTuning(node); + break; + case 'article': + score.articles.push(buildArticle(node)); + break; + case 'stage_voice': + score.stageVoices.push({ + name: node.positionals[0], + direction: node.props.direction, + distance: node.props.distance, + }); + break; + case 'instrument': + score.instruments.push(buildInstrument(node)); + break; + case 'bar': + score.bars.push(buildBar(node)); + break; + default: + score[node.slot] = buildGeneric(node); + } + } + + return score; +} + +function buildTuning(node) { + const t = { base: node.props.base, scales: {}, chords: {} }; + for (const child of node.children) { + if (child.slot === 'scales') { + t.scales[child.positionals[0]] = child.positionals.slice(1); + } else if (child.slot === 'chords') { + t.chords[child.positionals[0]] = child.positionals.slice(1); + } + } + return t; +} + +function buildArticle(node) { + return { + type: 'article', + name: node.positionals[0], + props: { ...node.props }, + properties: node.children + .filter(c => c.slot === 'property') + .map(c => ({ name: c.positionals[0], ...c.props })), + }; +} + +function buildInstrument(node) { + const instr = { + type: 'instrument', + name: node.positionals[0], + notChangedSince: node.props.NOT_CHANGED_SINCE ?? null, + isLinked: (node.positionals[0] ?? '').includes('/'), + isDirty: false, + variations: [], + basicProperties: null, + railsbackCurve: null, + volumes: null, + timbre: null, + fmModulations: [], + rawChildren: [], + }; + + for (const child of node.children) { + switch (child.parentSlot + '.' + child.slot) { + case 'character.variation': + instr.variations.push(buildVariation(child)); + break; + case 'character.basic_properties': + instr.basicProperties = buildBasicProperties(child); + break; + case 'RAILSBACK_CURVE.shape': + instr.railsbackCurve = buildShape(child); + break; + case 'VOLUMES.shape': + instr.volumes = buildShape(child); + break; + case 'TIMBRE.shape': + instr.timbre = buildShape(child); + break; + case 'FM.modulation': + instr.fmModulations.push({ ...child.props }); + break; + default: + instr.rawChildren.push(buildGeneric(child)); + } + } + + return instr; +} + +function buildVariation(node) { + const v = { + type: 'variation', + dependsOn: node.props.depends_on ?? null, + basicProperties: null, + labelSpecs: [], + subvariations: [], + spread: null, + rawChildren: [], + }; + + for (const child of node.children) { + const key = (child.parentSlot ?? child.slot) + '.' + child.slot; + switch (key) { + case 'variation.basic_properties': + v.basicProperties = buildBasicProperties(child); + break; + case 'variation.label_spec': + v.labelSpecs.push(buildLabelSpec(child)); + break; + case 'variation.subvariation': + v.subvariations.push(buildVariation(child)); + break; + case 'variation.SPREAD': + v.spread = child.positionals; + break; + default: + v.rawChildren.push(buildGeneric(child)); + } + } + + return v; +} + +function buildBasicProperties(node) { + const bp = { + type: 'basic_properties', + A: null, S: null, R: null, + oscillator: null, + fmModulations: [], + rawChildren: [], + }; + + for (const child of node.children) { + const fqSlot = (child.parentSlot ?? '') + (child.parentSlot ? '.' : '') + child.slot; + if (child.parentSlot === 'A' && child.slot === 'shape') { + bp.A = buildShape(child); + } else if (child.parentSlot === 'S' && child.slot === 'shape') { + bp.S = buildShape(child); + } else if (child.parentSlot === 'R' && child.slot === 'shape') { + bp.R = buildShape(child); + } else if (child.parentSlot === 'variation' && child.slot === 'O') { + bp.oscillator = child.props.ref ?? child.positionals[0]; + } else if (child.parentSlot === 'FM' && child.slot === 'modulation') { + bp.fmModulations.push({ ...child.props }); + } else { + bp.rawChildren.push(buildGeneric(child)); + } + } + + return bp; +} + +function buildLabelSpec(node) { + const ls = { + type: 'label_spec', + label: node.positionals[0], + basicProperties: null, + rawChildren: [], + }; + + for (const child of node.children) { + if (child.parentSlot === 'variation' && child.slot === 'basic_properties') { + ls.basicProperties = buildBasicProperties(child); + } else { + ls.rawChildren.push(buildGeneric(child)); + } + } + + return ls; +} + +function buildShape(node) { + return { + type: 'shape', + length: node.props.length, + start: node.props.start, + z: node.props.z ?? 1, + coords: node.children + .filter(c => c.slot === 'coords') + .map(c => ({ + x: c.props.x, + y: c.props.y, + z: c.props.z ?? 1, + isSharp: c.props.is_sharp ?? false, + })), + }; +} + +function buildBar(node) { + const id = node.positionals[0] ?? ''; + const idMatch = id.match(/^(\w?)(\d+)P(\d+)L(\d+)M(\d+)$/); + const bar = { + type: 'bar', + id, + movement: idMatch ? idMatch[1] : '', + part: idMatch ? parseInt(idMatch[2]) : 0, + line: idMatch ? parseInt(idMatch[3]) : 0, + measure: idMatch ? parseInt(idMatch[4]) : 0, + stressor: null, + tempoShape: null, + tempoLevels: null, + lowerStressBound: null, + upperStressBound: null, + tunings: [], + voices: {}, + rawChildren: [], + }; + + for (const child of node.children) { + const fqSlot = (child.parentSlot ?? '') + (child.parentSlot ? '.' : '') + child.slot; + switch (fqSlot) { + case 'stress_pattern.stressor': + bar.stressor = buildStressor(child); + break; + case 'tempo.shape': + bar.tempoShape = buildShape(child); + break; + case 'tempo.levels': + bar.tempoLevels = child.positionals[0]; + break; + case 'lower_stress_bound.shape': + bar.lowerStressBound = buildShape(child); + break; + case 'upper_stress_bound.shape': + bar.upperStressBound = buildShape(child); + break; + case 'bar.tuning': + bar.tunings.push({ ...child.props }); + break; + case 'bar.voice': + bar.voices[child.positionals[0]] = buildVoice(child); + break; + default: + bar.rawChildren.push(buildGeneric(child)); + } + } + + return bar; +} + +function buildStressor(node) { + const levels = []; + let currentGroup = []; + for (const child of node.children) { + if (child.slot === 'level') { + currentGroup.push(parseInt(child.positionals[0])); + } else if (child.slot === 'subdivision') { + levels.push(currentGroup); + currentGroup = []; + } + } + if (currentGroup.length) levels.push(currentGroup); + return { type: 'stressor', groups: levels }; +} + +function buildVoice(node) { + const voice = { + type: 'voice', + name: node.positionals[0], + offsets: [], + articles: [], + motifs: [], + }; + + for (const child of node.children) { + const fqSlot = (child.parentSlot ?? '') + (child.parentSlot ? '.' : '') + child.slot; + switch (fqSlot) { + case 'voice.offset': + voice.offsets.push(buildOffset(child)); + break; + case 'voice.article': + voice.articles.push(child.positionals[0]); + break; + case 'voice.motif': + voice.motifs.push(child.props.label); + break; + default: + // ignore + } + } + + return voice; +} + +function buildOffset(node) { + const offset = { + type: 'offset', + tick: node.props.tick, + stemNotes: [], + clusters: [], + chains: [], + }; + + for (const child of node.children) { + const fqSlot = (child.parentSlot ?? '') + (child.parentSlot ? '.' : '') + child.slot; + switch (fqSlot) { + case 'offset.stem_note': + offset.stemNotes.push({ pitch: child.props.pitch, effLength: child.props.eff_length }); + break; + case 'offset.cluster': + offset.clusters.push(buildCluster(child)); + break; + case 'offset.chain': + offset.chains.push({ index: child.positionals[0], children: child.children.map(buildGeneric) }); + break; + default: + // ignore + } + } + + return offset; +} + +function buildCluster(node) { + const cluster = { + type: 'cluster', + index: node.positionals[0], + repeat: node.props.repeat ?? null, + notes: [], + pauses: [], + groups: [], + subchains: [], + }; + + for (const child of node.children) { + switch (child.slot) { + case 'note': + cluster.notes.push({ ...child.props }); + break; + case 'pause': + cluster.pauses.push({ length: child.props.length }); + break; + case 'group': + cluster.groups.push({ length: child.props.length, netlength: child.props.netlength }); + break; + case 'subchain': + cluster.subchains.push({ length: child.props.length, children: child.children.map(buildGeneric) }); + break; + default: + // ignore + } + } + + return cluster; +} + +function buildGeneric(node) { + return { + type: node.slot, + parentSlot: node.parentSlot, + depth: node.depth, + positionals: node.positionals, + props: node.props, + children: node.children.map(buildGeneric), + }; +} diff --git a/static/components/AppShell.js b/static/components/AppShell.js new file mode 100644 index 0000000..661520f --- /dev/null +++ b/static/components/AppShell.js @@ -0,0 +1,62 @@ +import { h, ref, onMounted } from 'vue'; +import { PaneCP } from './PaneCP.js'; +import { PaneFO } from './PaneFO.js'; +import { PaneSubObjects } from './PaneSubObjects.js'; +import { ImportDialog } from './ImportDialog.js'; + +const PANES = [ + { id: 'cp', label: 'Position' }, + { id: 'fo', label: 'Object' }, + { id: 'sub', label: 'Sub-objects' }, +]; + +export const AppShell = { + props: ['store', 'importOnLoad'], + setup(props) { + const activePane = ref('cp'); + const showImport = ref(false); + + function openImport() { + if (!props.store.isDirty) showImport.value = true; + } + + onMounted(() => { + if (props.importOnLoad && !props.store.isDirty) showImport.value = true; + }); + + return () => { + const store = props.store; + + 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(PaneCP, { store, onImportClick: openImport })), + h('div', { class: ['se-pane', activePane.value === 'fo' ? 'active' : null] }, + h(PaneFO, { store })), + h('div', { class: ['se-pane', activePane.value === 'sub' ? 'active' : null] }, + h(PaneSubObjects, { store })), + ]), + + // Handle bar (tab switcher at bottom) + h('div', { class: 'se-handle-bar' }, PANES.map(p => + h('button', { + key: p.id, + class: ['se-handle', activePane.value === p.id ? 'active' : null], + 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/EnvelopeEditor.js b/static/components/EnvelopeEditor.js new file mode 100644 index 0000000..6586269 --- /dev/null +++ b/static/components/EnvelopeEditor.js @@ -0,0 +1,74 @@ +import { h, computed } from 'vue'; +import { ShapeEditor } from './ShapeEditor.js'; + +// Default shapes used when toggling a section on for the first time. +function defaultShape(section) { + switch (section) { + case 'A': return { length: 10, start: 0, z: 1, coords: [{ x: 1, y: 10, z: 1, isSharp: false }] }; + case 'S': return { length: 10, start: 0, z: 1, coords: [{ x: 1, y: 100, z: 1, isSharp: false }] }; + case 'R': return { length: 5, start: 0, z: 1, coords: [{ x: 1, y: 0, z: 1, isSharp: false }] }; + default: return { length: 1, start: 0, z: 1, coords: [] }; + } +} + +// Co-dependence: +// - Attack ending y=0 → disable S and R +// - S absent → R disabled +function attackEndsAtZero(shape) { + if (!shape?.coords?.length) return false; + return shape.coords[shape.coords.length - 1].y === 0; +} + +export const EnvelopeEditor = { + props: ['basicProperties', 'onChange'], + setup(props) { + function toggle(section) { + const bp = props.basicProperties; + if (bp[section]) { + bp[section] = null; + } else { + bp[section] = defaultShape(section); + } + props.onChange?.(); + } + + return () => { + const bp = props.basicProperties; + if (!bp) return null; + + const aZero = attackEndsAtZero(bp.A); + const sDisabled = aZero; + const rDisabled = aZero || !bp.S; + + function section(key, label, disabled) { + const active = !!bp[key]; + return h('div', { class: 'se-envelope-section' }, [ + h('div', { class: 'se-envelope-toggle' }, [ + h('input', { + type: 'checkbox', + id: `env-${key}`, + checked: active, + disabled, + onChange: () => !disabled && toggle(key), + }), + h('label', { for: `env-${key}` }, label), + ]), + active + ? h('div', { class: disabled ? 'se-envelope-disabled' : null }, [ + h(ShapeEditor, { + shape: bp[key], + onChange: props.onChange, + }), + ]) + : null, + ]); + } + + return h('div', null, [ + section('A', 'Attack', false), + section('S', 'Sustain', sDisabled), + section('R', 'Release', rDisabled), + ]); + }; + }, +}; diff --git a/static/components/ImportDialog.js b/static/components/ImportDialog.js new file mode 100644 index 0000000..9f0559f --- /dev/null +++ b/static/components/ImportDialog.js @@ -0,0 +1,66 @@ +import { h, ref } from 'vue'; +import { fetchAstLog } from '../api.js'; +import { parseAstLog, buildModel } from '../ast-parser.js'; + +export const ImportDialog = { + props: ['store'], + emits: ['close'], + setup(props, { emit }) { + const username = ref(''); + const password = ref(''); + const error = ref(''); + const loading = ref(false); + + async function doImport() { + error.value = ''; + loading.value = true; + try { + const creds = { username: username.value, password: password.value }; + const text = await fetchAstLog(creds); + const rawTree = parseAstLog(text); + props.store.scoreModel = buildModel(rawTree); + props.store.credentials = creds; + emit('close'); + } catch (e) { + error.value = e.message; + } finally { + loading.value = false; + } + } + + function onKey(e) { + if (e.key === 'Escape') emit('close'); + if (e.key === 'Enter') doImport(); + } + + return () => h('div', null, [ + h('div', { class: 'se-overlay', onClick: () => emit('close') }), + h('div', { class: 'se-import-dialog', onKeydown: onKey, tabindex: -1 }, [ + h('h3', null, 'Import from server'), + h('label', null, 'Username'), + h('input', { + type: 'text', + value: username.value, + onInput: e => { username.value = e.target.value; }, + autocomplete: 'username', + }), + h('label', null, 'Password'), + h('input', { + type: 'password', + value: password.value, + onInput: e => { password.value = e.target.value; }, + autocomplete: 'current-password', + }), + error.value ? h('div', { class: 'se-error' }, error.value) : null, + h('div', { class: 'se-dialog-buttons' }, [ + h('button', { class: 'se-btn', onClick: () => emit('close') }, 'Cancel'), + h('button', { + class: 'se-btn se-btn-primary', + onClick: doImport, + disabled: loading.value, + }, loading.value ? 'Loading…' : 'Import'), + ]), + ]), + ]); + }, +}; diff --git a/static/components/LinkedInstrumentModal.js b/static/components/LinkedInstrumentModal.js new file mode 100644 index 0000000..ea2b7cd --- /dev/null +++ b/static/components/LinkedInstrumentModal.js @@ -0,0 +1,28 @@ +import { h } from 'vue'; + +// Shown when the user edits a linked instrument (name contains '/'). +// onEmbed: drop path prefix, instrument becomes embedded. +// onDiscard: revert the pending edit, keep instrument linked/unchanged. +export const LinkedInstrumentModal = { + props: ['instrumentName', 'onEmbed', 'onDiscard'], + setup(props) { + const basename = props.instrumentName.split('/').pop(); + return () => h('div', null, [ + h('div', { class: 'se-overlay' }), + h('div', { class: 'se-import-dialog' }, [ + h('h3', null, 'Linked instrument edited'), + h('p', { style: 'font-size:0.85rem;margin:0 0 1rem' }, [ + `Instrument `, + h('code', null, props.instrumentName), + ` is linked. Embed it as `, + h('code', null, basename), + `? If not, this edit is discarded.`, + ]), + h('div', { class: 'se-dialog-buttons' }, [ + h('button', { class: 'se-btn', onClick: props.onDiscard }, 'Discard edit'), + h('button', { class: 'se-btn se-btn-primary', onClick: props.onEmbed }, 'Embed'), + ]), + ]), + ]); + }, +}; diff --git a/static/components/ObjectBasic.js b/static/components/ObjectBasic.js new file mode 100644 index 0000000..b83eb20 --- /dev/null +++ b/static/components/ObjectBasic.js @@ -0,0 +1,15 @@ +import { h } from 'vue'; + +// Inline identifier + a few key props — used for list rows. +export const ObjectBasic = { + props: ['label', 'meta'], // meta: array of {key, value} pairs + setup(props) { + return () => h('div', { class: 'se-object-label' }, [ + h('strong', null, props.label), + ...(props.meta ?? []).map(({ key, value }) => + h('span', { style: 'color:#888;margin-left:0.5rem;font-size:0.8em' }, + `${key}=${value}`) + ), + ]); + }, +}; diff --git a/static/components/ObjectExtended.js b/static/components/ObjectExtended.js new file mode 100644 index 0000000..fbbcf94 --- /dev/null +++ b/static/components/ObjectExtended.js @@ -0,0 +1,37 @@ +import { h } from 'vue'; + +// Extended property view rendered as a definition list. +// `fields`: array of { key, value, editable?, type? } +// `onChange`: called with { key, value } when field changes. +export const ObjectExtended = { + props: ['fields', 'onChange'], + setup(props) { + function onInput(key, type, rawValue) { + let value = rawValue; + if (type === 'number') value = parseFloat(rawValue); + if (type === 'boolean') value = rawValue === 'true'; + props.onChange?.({ key, value }); + } + + return () => h('dl', null, (props.fields ?? []).flatMap(f => [ + h('dt', null, f.key), + h('dd', null, f.editable + ? (f.type === 'boolean' + ? h('select', { + value: String(f.value), + onChange: e => onInput(f.key, 'boolean', e.target.value), + }, [ + h('option', { value: 'true' }, 'True'), + h('option', { value: 'false' }, 'False'), + ]) + : h('input', { + type: f.type === 'number' ? 'number' : 'text', + value: f.value ?? '', + onInput: e => onInput(f.key, f.type, e.target.value), + }) + ) + : h('span', null, String(f.value ?? '—')) + ), + ])); + }, +}; diff --git a/static/components/ObjectShort.js b/static/components/ObjectShort.js new file mode 100644 index 0000000..7f11d6c --- /dev/null +++ b/static/components/ObjectShort.js @@ -0,0 +1,23 @@ +import { h } from 'vue'; + +// One-line summary row with a drill-down chevron. +export const ObjectShort = { + props: ['node', 'label', 'typeTag', 'focused', 'hasChildren'], + emits: ['focus', 'drillDown'], + setup(props, { emit }) { + return () => h('li', { + class: ['se-object-item', props.focused ? 'focused' : null], + onClick: () => emit('focus'), + }, [ + props.typeTag ? h('span', { class: 'se-object-type' }, props.typeTag) : null, + h('span', { class: 'se-object-label' }, props.label), + props.hasChildren + ? h('button', { + class: 'se-chevron', + title: 'Drill down', + onClick: e => { e.stopPropagation(); emit('drillDown'); }, + }, '›') + : null, + ]); + }, +}; diff --git a/static/components/PaneCP.js b/static/components/PaneCP.js new file mode 100644 index 0000000..9381631 --- /dev/null +++ b/static/components/PaneCP.js @@ -0,0 +1,98 @@ +import { h, ref } from 'vue'; +import { fetchScoreText, putScoreText } from '../api.js'; +import { patchScore } from '../exporter.js'; +import { StatusPoller } from './StatusPoller.js'; + +export const PaneCP = { + props: ['store', 'onImportClick'], + setup(props) { + const exporting = ref(false); + const exportError = ref(''); + + function breadcrumbLabel(node) { + if (!node) return '?'; + if (node.type === 'score') return 'Score'; + if (node.type === 'instrument') return node.name; + if (node.type === 'variation') return node.dependsOn ? `var(${node.dependsOn})` : 'variation'; + if (node.type === 'label_spec') return node.label ?? 'label'; + if (node.type === 'bar') return node.id; + return node.type; + } + + async function doExport() { + exportError.value = ''; + exporting.value = true; + try { + const raw = await fetchScoreText(props.store.credentials); + props.store.rawScoreText = raw; + const patched = patchScore(raw, props.store.scoreModel.instruments); + await putScoreText(patched, props.store.credentials); + // Start polling + props.store.synthesisStatus = { frozen: false, progress: 0 }; + } catch (e) { + exportError.value = e.message; + } finally { + exporting.value = false; + } + } + + return () => { + const store = props.store; + const model = store.scoreModel; + const fp = store.focusPath; + + return h('div', { class: 'se-pane' }, [ + // Header + h('div', { class: 'se-cp-header' }, [ + h('span', { class: 'se-cp-title' }, + model ? (model.info?.title ?? 'Untitled score') : 'No score loaded'), + h('button', { + class: 'se-btn', + disabled: store.isDirty, + title: store.isDirty ? 'Save or discard edits before re-importing' : 'Import from server', + onClick: props.onImportClick, + }, 'Import'), + model ? h('button', { + class: 'se-btn se-btn-primary', + disabled: !store.isDirty || exporting.value, + onClick: doExport, + }, exporting.value ? 'Exporting…' : 'Export') : null, + ]), + + // Breadcrumb + fp.length ? h('div', { class: 'se-breadcrumb' }, [ + h('span', { onClick: () => store.setFocus([]) }, 'Score'), + ...fp.map((node, i) => [ + ' › ', + h('span', { onClick: () => store.setFocus(fp.slice(0, i + 1)) }, + breadcrumbLabel(node)), + ]).flat(), + ]) : null, + + // Score info + model ? h('dl', { style: 'font-size:0.8rem;margin:0.5rem 0' }, [ + h('dt', null, 'Instruments'), + h('dd', null, String(model.instruments.length)), + h('dt', null, 'Bars'), + h('dd', null, String(model.bars.length)), + ]) : null, + + // Export error + exportError.value ? h('div', { class: 'se-error' }, exportError.value) : null, + + // Status poller (shown after export started) + store.synthesisStatus && !store.synthesisStatus.frozen + ? h(StatusPoller, { store }) + : null, + + // Result link + store.synthesisStatus?.frozen && !store.synthesisStatus?.error + ? h('a', { + href: '/sompyle/result.mp3', + style: 'display:block;margin-top:0.5rem', + }, 'Download result') + : null, + ]); + }; + }, +}; diff --git a/static/components/PaneFO.js b/static/components/PaneFO.js new file mode 100644 index 0000000..b969b94 --- /dev/null +++ b/static/components/PaneFO.js @@ -0,0 +1,138 @@ +import { h, ref } from 'vue'; +import { ObjectExtended } from './ObjectExtended.js'; +import { EnvelopeEditor } from './EnvelopeEditor.js'; +import { LinkedInstrumentModal } from './LinkedInstrumentModal.js'; + +function instrFields(instr) { + return [ + { key: 'name', value: instr.name, editable: false }, + { key: 'linked', value: instr.isLinked, editable: false, type: 'boolean' }, + { key: 'NOT_CHANGED_SINCE', value: instr.notChangedSince ?? '—', editable: false }, + ]; +} + +function variationFields(v) { + return [ + { key: 'depends_on', value: v.dependsOn ?? '—', editable: true }, + ]; +} + +export const PaneFO = { + props: ['store'], + setup(props) { + // Pending edit held while the linked-instrument modal is shown. + const pendingEdit = ref(null); // { instr, apply: fn } + + function focused() { + const fp = props.store.focusPath; + return fp.length ? fp[fp.length - 1] : null; + } + + // Returns a change handler that intercepts the first edit to a linked + // instrument and shows the embed-or-discard modal before applying it. + function makeChangeHandler(instr, apply) { + return () => { + if (instr.isLinked && !instr.isDirty) { + // Stash the apply callback and show the modal. + pendingEdit.value = { instr, apply }; + } else { + apply(); + instr.isDirty = true; + props.store.markDirty(); + } + }; + } + + function embedInstrument(instr) { + instr.name = instr.name.split('/').pop(); + instr.isLinked = false; + instr.isDirty = true; + pendingEdit.value.apply(); + pendingEdit.value = null; + props.store.markDirty(); + } + + function discardEdit() { + pendingEdit.value = null; + } + + return () => { + const node = focused(); + const children = []; + + if (!node || node.type === 'score') { + return h('div', { class: 'se-fo-pane' }, 'Nothing selected'); + } + + if (node.type === 'instrument') { + children.push( + h('h4', { style: 'margin:0 0 0.5rem' }, `Instrument: ${node.name}`), + h(ObjectExtended, { fields: instrFields(node), onChange: null }), + ); + } else if (node.type === 'variation') { + // Find the ancestor instrument for linked-instrument gating. + const instr = props.store.scoreModel?.instruments.find( + i => i.variations?.includes(node) || + i.variations?.some(v => v.subvariations?.includes(node)) + ) ?? null; + + const onChange = instr + ? makeChangeHandler(instr, () => { props.store.markDirty(); }) + : () => props.store.markDirty(); + + children.push( + h('h4', { style: 'margin:0 0 0.5rem' }, 'Variation'), + h(ObjectExtended, { fields: variationFields(node), onChange: ({ key, value }) => { + if (key === 'depends_on') node.dependsOn = value; + onChange(); + }}), + node.basicProperties + ? h(EnvelopeEditor, { basicProperties: node.basicProperties, onChange }) + : null, + ); + } else if (node.type === 'label_spec') { + const instr = props.store.scoreModel?.instruments.find( + i => i.variations?.some(v => + v.labelSpecs?.includes(node) || + v.subvariations?.some(sv => sv.labelSpecs?.includes(node)) + ) + ) ?? null; + + const onChange = instr + ? makeChangeHandler(instr, () => { props.store.markDirty(); }) + : () => props.store.markDirty(); + + children.push( + h('h4', { style: 'margin:0 0 0.5rem' }, `Label: ${node.label}`), + node.basicProperties + ? h(EnvelopeEditor, { basicProperties: node.basicProperties, onChange }) + : null, + ); + } else if (node.type === 'bar') { + children.push( + h('h4', { style: 'margin:0 0 0.5rem' }, `Bar: ${node.id}`), + h(ObjectExtended, { fields: [ + { key: 'movement', value: node.movement }, + { key: 'part', value: node.part }, + { key: 'line', value: node.line }, + { key: 'measure', value: node.measure }, + ], onChange: null }), + ); + } else { + children.push(h('pre', { style: 'font-size:0.75rem;white-space:pre-wrap' }, + JSON.stringify(node, null, 2))); + } + + return h('div', { class: 'se-fo-pane' }, [ + ...children, + pendingEdit.value + ? h(LinkedInstrumentModal, { + instrumentName: pendingEdit.value.instr.name, + onEmbed: () => embedInstrument(pendingEdit.value.instr), + onDiscard: discardEdit, + }) + : null, + ]); + }; + }, +}; diff --git a/static/components/PaneSubObjects.js b/static/components/PaneSubObjects.js new file mode 100644 index 0000000..099ce9a --- /dev/null +++ b/static/components/PaneSubObjects.js @@ -0,0 +1,67 @@ +import { h } from 'vue'; +import { ObjectShort } from './ObjectShort.js'; + +// Shows sub-objects of the currently focused node — variations, bars, voices, etc. +export const PaneSubObjects = { + props: ['store'], + 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') { + return [ + ...node.instruments.map(i => ({ kind: 'instrument', node: i, label: i.name })), + ...node.bars.map(b => ({ kind: 'bar', node: b, label: b.id })), + ]; + } + if (node.type === 'instrument') { + return node.variations.map((v, idx) => ({ + kind: 'variation', + node: v, + label: `variation ${idx + 1}${v.dependsOn ? ` (${v.dependsOn})` : ''}`, + })); + } + if (node.type === 'variation') { + return [ + ...node.labelSpecs.map(ls => ({ + kind: 'label_spec', node: ls, label: ls.label ?? '(no label)', + })), + ...node.subvariations.map((sv, idx) => ({ + kind: 'variation', node: sv, label: `subvariation ${idx + 1}`, + })), + ]; + } + if (node.type === 'bar') { + return Object.entries(node.voices).map(([name, v]) => ({ + kind: 'voice', node: v, label: name, + })); + } + return []; + } + + return () => { + const node = focused(); + const items = subItems(node); + if (!items.length) return h('div', { class: 'se-pane' }, h('em', null, 'No sub-objects')); + + return h('div', { class: 'se-pane' }, [ + h('ul', { class: 'se-object-list' }, items.map((item, idx) => + h(ObjectShort, { + key: idx, + node: item.node, + label: item.label, + typeTag: item.kind, + focused: props.store.focusPath.includes(item.node), + hasChildren: item.kind !== 'bar' && item.kind !== 'voice', + onFocus: () => props.store.pushFocus(item.node), + onDrillDown: () => props.store.pushFocus(item.node), + }) + )), + ]); + }; + }, +}; diff --git a/static/components/ShapeEditor.js b/static/components/ShapeEditor.js new file mode 100644 index 0000000..3f23e26 --- /dev/null +++ b/static/components/ShapeEditor.js @@ -0,0 +1,115 @@ +import { h } from 'vue'; + +// Renders a shape's coord table with cascade-shift and an SVG preview. +// `shape` is mutated in place; `onChange` called after each mutation. + +export const ShapeEditor = { + props: ['shape', 'onChange'], + setup(props) { + function updateCoord(i, field, value) { + const coord = props.shape.coords[i]; + const num = parseFloat(value); + if (isNaN(num)) return; + const old = coord[field]; + const delta = num - old; + coord[field] = num; + + // cascade-shift: if x increased past next coord, shift all following + if (field === 'x' && delta > 0) { + for (let j = i + 1; j < props.shape.coords.length; j++) { + if (props.shape.coords[j].x <= num) { + props.shape.coords[j].x += delta; + } else break; + } + } + props.onChange?.(); + } + + function addCoord() { + const coords = props.shape.coords; + const lastX = coords.length ? coords[coords.length - 1].x + 1 : 1; + coords.push({ x: lastX, y: 0, z: 1, isSharp: false }); + props.onChange?.(); + } + + function removeCoord(i) { + props.shape.coords.splice(i, 1); + props.onChange?.(); + } + + function renderSvg() { + const coords = props.shape.coords; + if (!coords.length) return h('svg', { class: 'se-shape-svg' }); + + const xs = coords.map(c => c.x); + const ys = coords.map(c => c.y); + const minX = Math.min(...xs), maxX = Math.max(...xs); + const minY = Math.min(...ys), maxY = Math.max(...ys); + const W = 200, H = 60; + const rangeX = maxX - minX || 1; + const rangeY = maxY - minY || 1; + + const toSvg = c => { + const sx = ((c.x - minX) / rangeX) * W; + const sy = H - ((c.y - minY) / rangeY) * H; + return [sx, sy]; + }; + + const points = coords.map(c => toSvg(c).join(',') ).join(' '); + + return h('svg', { + class: 'se-shape-svg', + viewBox: `0 0 ${W} ${H}`, + preserveAspectRatio: 'none', + }, [ + h('polyline', { + points, + fill: 'none', + stroke: '#2a6aaa', + 'stroke-width': '1.5', + }), + ...coords.map(c => { + const [sx, sy] = toSvg(c); + return h('circle', { cx: sx, cy: sy, r: 2.5, fill: '#6aacff' }); + }), + ]); + } + + return () => { + const shape = props.shape; + if (!shape) return null; + + return h('div', { class: 'se-shape-editor' }, [ + h('table', { class: 'se-shape-table' }, [ + h('thead', null, h('tr', null, [ + h('th', null, 'x'), h('th', null, 'y'), h('th', null, 'z'), + h('th', null, '♯'), h('th', null, ''), + ])), + h('tbody', null, shape.coords.map((coord, i) => + h('tr', { key: i }, [ + h('td', null, h('input', { + type: 'number', value: coord.x, step: 1, + onInput: e => updateCoord(i, 'x', e.target.value), + })), + h('td', null, h('input', { + type: 'number', value: coord.y, step: 1, + onInput: e => updateCoord(i, 'y', e.target.value), + })), + h('td', null, h('input', { + type: 'number', value: coord.z ?? 1, step: 0.1, + onInput: e => updateCoord(i, 'z', e.target.value), + })), + h('td', null, h('input', { + type: 'checkbox', checked: !!coord.isSharp, + onChange: e => { coord.isSharp = e.target.checked; props.onChange?.(); }, + })), + h('td', null, h('button', { onClick: () => removeCoord(i) }, '✕')), + ]) + )), + ]), + h('button', { class: 'se-btn', style: 'margin-top:0.3rem', onClick: addCoord }, '+ coord'), + renderSvg(), + ]); + }; + }, +}; diff --git a/static/components/StatusPoller.js b/static/components/StatusPoller.js new file mode 100644 index 0000000..7e4c860 --- /dev/null +++ b/static/components/StatusPoller.js @@ -0,0 +1,55 @@ +import { h, ref, onMounted, onUnmounted } from 'vue'; +import { fetchStatus } from '../api.js'; + +export const StatusPoller = { + props: ['store'], + setup(props) { + const timer = ref(null); + const eta = ref(null); + + function nextInterval(status) { + if (!status) return 2000; + const pct = status.progress ?? 0; + // At 0–20%: poll at centile-of-ETA intervals + if (eta.value && pct > 0 && pct <= 20) { + return Math.max(500, (eta.value * 10) | 0); + } + return 2000; + } + + async function poll() { + try { + const status = await fetchStatus(props.store.credentials); + if (status.eta) eta.value = status.eta; + props.store.synthesisStatus = status; + if (status.frozen) { + // done — stop polling + return; + } + } catch (_) { + // transient error — keep polling + } + timer.value = setTimeout(poll, nextInterval(props.store.synthesisStatus)); + } + + onMounted(() => { poll(); }); + onUnmounted(() => { if (timer.value) clearTimeout(timer.value); }); + + return () => { + const s = props.store.synthesisStatus; + if (!s) return h('div', { class: 'se-status' }, 'Polling…'); + + const pct = s.progress ?? 0; + const label = s.frozen + ? (s.error ? `Error: ${s.error}` : 'Done') + : `${s.state ?? 'Running'} ${pct}%`; + + return h('div', { class: 'se-status' }, [ + h('span', null, label), + h('div', { class: 'se-status-progress' }, [ + h('div', { class: 'se-status-bar', style: { width: `${pct}%` } }), + ]), + ]); + }; + }, +}; diff --git a/static/exporter.js b/static/exporter.js new file mode 100644 index 0000000..430c416 --- /dev/null +++ b/static/exporter.js @@ -0,0 +1,151 @@ +// Template-based YAML serializer — instrument blocks only (v1). +// Each object selects a template by finding the first entry in its +// selectExportTemplate() list where all required slots have values. +// Placeholders #0, #1, ... are filled with slot values. + +function fillTemplate(template, slots) { + return template.replace(/#(\d+)/g, (_, i) => slots[parseInt(i, 10)] ?? ''); +} + +function indent(text, level) { + const pad = ' '.repeat(level); + return text.split('\n').map((line, i) => { + if (i === 0) return line; + if (line.startsWith('- ')) return pad.slice(2) + line; + return pad + line; + }).join('\n'); +} + +// ── Shape ────────────────────────────────────────────────────────────────── + +function exportCoord(coord) { + let s = `x=${coord.x} y=${coord.y}`; + if (coord.z !== undefined && coord.z !== 1) s += ` z=${coord.z}`; + if (coord.isSharp) s += ` is_sharp=True`; + return s; +} + +function exportShape(shape, slotName, level) { + if (!shape) return ''; + const coordLines = shape.coords.map(c => ` - coords: ${exportCoord(c)}`).join('\n'); + let header = `${slotName}: length=${shape.length}`; + if (shape.start !== undefined) header += ` start=${shape.start}`; + if (shape.z !== undefined && shape.z !== 1) header += ` z=${shape.z}`; + const block = coordLines ? `${header}\n${coordLines}` : header; + return indent(block, level); +} + +// ── BasicProperties ──────────────────────────────────────────────────────── + +function exportBasicProperties(bp, level) { + if (!bp) return ''; + const lines = []; + if (bp.oscillator) lines.push(` O: ref=${bp.oscillator}`); + if (bp.A) lines.push(` ${exportShape(bp.A, 'A', 1)}`); + if (bp.S) lines.push(` ${exportShape(bp.S, 'S', 1)}`); + if (bp.R) lines.push(` ${exportShape(bp.R, 'R', 1)}`); + for (const fm of (bp.fmModulations ?? [])) { + const parts = Object.entries(fm).map(([k, v]) => `${k}=${v}`).join(' '); + lines.push(` FM:\n modulation: ${parts}`); + } + if (!lines.length) return ''; + return indent('basic_properties:\n' + lines.join('\n'), level); +} + +// ── LabelSpec ───────────────────────────────────────────────────────────── + +function exportLabelSpec(ls, level) { + const label = ls.label ? ` '${ls.label}'` : ''; + const bp = exportBasicProperties(ls.basicProperties, 1); + const body = bp ? `label_spec:${label}\n ${bp}` : `label_spec:${label}`; + return indent(body, level); +} + +// ── Variation ───────────────────────────────────────────────────────────── + +function exportVariation(v, level) { + const dep = v.dependsOn ? ` depends_on=${v.dependsOn}` : ''; + const lines = [`variation:${dep}`]; + if (v.basicProperties) lines.push(` ${exportBasicProperties(v.basicProperties, 1)}`); + for (const ls of (v.labelSpecs ?? [])) lines.push(` ${exportLabelSpec(ls, 1)}`); + for (const sv of (v.subvariations ?? [])) lines.push(` ${exportVariation(sv, 1)}`); + if (v.spread?.length) lines.push(` SPREAD: ${v.spread.join(' ')}`); + return indent(lines.join('\n'), level); +} + +// ── Instrument ──────────────────────────────────────────────────────────── + +export function exportInstrument(instr) { + const lines = []; + const name = instr.name; + lines.push(`instrument: '${name}'`); + + for (const v of (instr.variations ?? [])) { + lines.push(` character:\n ${exportVariation(v, 2)}`); + } + + if (instr.basicProperties) { + lines.push(` character:\n ${exportBasicProperties(instr.basicProperties, 2)}`); + } + + if (instr.railsbackCurve) { + lines.push(` ${exportShape(instr.railsbackCurve, 'RAILSBACK_CURVE', 1)}`); + } + if (instr.volumes) { + lines.push(` ${exportShape(instr.volumes, 'VOLUMES', 1)}`); + } + if (instr.timbre) { + lines.push(` ${exportShape(instr.timbre, 'TIMBRE', 1)}`); + } + for (const fm of (instr.fmModulations ?? [])) { + const parts = Object.entries(fm).map(([k, v]) => `${k}=${v}`).join(' '); + lines.push(` FM:\n modulation: ${parts}`); + } + + return lines.join('\n'); +} + +// ── Score patch ──────────────────────────────────────────────────────────── + +// Replace instrument blocks in rawScoreText with serialized model instruments. +// Non-dirty linked instruments (NOT_CHANGED_SINCE set, not edited) are left as-is +// from rawScoreText. Embedded and dirty instruments are emitted from the model. +export function patchScore(rawScoreText, instruments) { + // Split raw text into instrument blocks and other sections. + // Strategy: locate each `^instrument:` line and replace that block + // (up to next same-indent section or EOF) with the serialized model. + + const lines = rawScoreText.split('\n'); + const result = []; + const instrMap = {}; + for (const instr of instruments) { + const basename = instr.name.includes('/') ? instr.name.split('/').pop() : instr.name; + instrMap[basename] = instr; + instrMap[instr.name] = instr; + } + + let i = 0; + while (i < lines.length) { + const line = lines[i]; + const m = line.match(/^instrument:\s+'?([^']+)'?/); + if (m) { + const rawName = m[1]; + const instr = instrMap[rawName]; + if (instr && instr.isDirty) { + // consume the raw block + i++; + while (i < lines.length && (lines[i].startsWith(' ') || lines[i] === '')) i++; + result.push(exportInstrument(instr)); + result.push(''); + } else { + result.push(line); + i++; + } + } else { + result.push(line); + i++; + } + } + + return result.join('\n'); +} diff --git a/static/store.js b/static/store.js new file mode 100644 index 0000000..6160830 --- /dev/null +++ b/static/store.js @@ -0,0 +1,28 @@ +import { reactive } from 'vue'; + +export const store = reactive({ + scoreModel: null, + rawScoreText: null, + focusPath: [], + credentials: null, + synthesisStatus: null, + isDirty: false, + errorMessage: '', + + setFocus(path) { + this.focusPath = path; + }, + + pushFocus(node) { + const idx = this.focusPath.indexOf(node); + if (idx !== -1) { + this.focusPath = this.focusPath.slice(0, idx + 1); + } else { + this.focusPath = [...this.focusPath, node]; + } + }, + + markDirty() { + this.isDirty = true; + }, +}); diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..f2abf46 --- /dev/null +++ b/static/style.css @@ -0,0 +1,357 @@ +#score-editor-app { + height: calc(100vh - 8rem); + font-size: 0.9rem; +} + +.se-shell { + display: flex; + flex-direction: column; + height: 100%; +} + +.se-pane-area { + flex: 1; + overflow: hidden; + position: relative; +} + +.se-pane { + display: none; + height: 100%; + overflow-y: auto; + padding: 0.5rem; + box-sizing: border-box; +} + +.se-pane.active { + display: block; +} + +.se-handle-bar { + display: flex; + flex-shrink: 0; + border-top: 2px solid #555; + background: #222; +} + +.se-handle { + flex: 1; + padding: 0.3rem 0.5rem; + cursor: pointer; + background: #222; + border: none; + border-right: 1px solid #444; + color: #999; + font-size: 0.75rem; + text-align: center; +} + +.se-handle:last-child { + border-right: none; +} + +.se-handle.active { + background: #333; + color: #fff; + font-weight: bold; +} + +/* CP pane */ +.se-cp-header { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.4rem 0; + border-bottom: 1px solid #444; + margin-bottom: 0.5rem; +} + +.se-cp-title { + flex: 1; + font-weight: bold; + font-size: 1rem; + background: none; + border: none; + color: inherit; + padding: 0; +} + +.se-cp-title:focus { + outline: 1px solid #888; + background: #111; +} + +.se-breadcrumb { + font-size: 0.75rem; + color: #888; + margin-bottom: 0.5rem; +} + +.se-breadcrumb span { + cursor: pointer; + color: #aaa; +} + +.se-breadcrumb span:hover { + color: #fff; + text-decoration: underline; +} + +.se-object-list { + list-style: none; + padding: 0; + margin: 0; +} + +.se-object-item { + display: flex; + align-items: center; + padding: 0.25rem 0.3rem; + border-bottom: 1px solid #333; + cursor: pointer; +} + +.se-object-item:hover { + background: #2a2a2a; +} + +.se-object-item.focused { + background: #1a3a5a; +} + +.se-object-label { + flex: 1; + font-family: monospace; + font-size: 0.85rem; +} + +.se-object-type { + font-size: 0.7rem; + color: #666; + margin-right: 0.5rem; +} + +.se-chevron { + background: none; + border: none; + color: #888; + cursor: pointer; + padding: 0 0.3rem; + font-size: 1rem; + line-height: 1; +} + +.se-chevron:hover { + color: #fff; +} + +/* FO pane */ +.se-fo-pane dl { + display: grid; + grid-template-columns: max-content 1fr; + gap: 0.3rem 1rem; + margin: 0; +} + +.se-fo-pane dt { + font-size: 0.75rem; + color: #888; + align-self: center; +} + +.se-fo-pane dd { + margin: 0; +} + +/* ShapeEditor */ +.se-shape-editor { + margin: 0.5rem 0; +} + +.se-shape-table { + width: 100%; + border-collapse: collapse; + font-family: monospace; + font-size: 0.8rem; +} + +.se-shape-table th { + text-align: left; + padding: 0.2rem 0.3rem; + color: #888; + border-bottom: 1px solid #444; + font-weight: normal; +} + +.se-shape-table td { + padding: 0.15rem 0.2rem; +} + +.se-shape-table input[type=number] { + width: 4rem; + background: #111; + border: 1px solid #444; + color: #ddd; + padding: 0.1rem 0.2rem; + font-family: monospace; +} + +.se-shape-table input[type=checkbox] { + cursor: pointer; +} + +.se-shape-table button { + background: none; + border: 1px solid #555; + color: #888; + cursor: pointer; + padding: 0.1rem 0.3rem; + font-size: 0.75rem; +} + +.se-shape-table button:hover { + color: #fff; + border-color: #888; +} + +.se-shape-svg { + width: 100%; + height: 80px; + background: #111; + border: 1px solid #333; + margin-top: 0.3rem; + display: block; +} + +/* EnvelopeEditor */ +.se-envelope-section { + margin: 0.5rem 0; +} + +.se-envelope-toggle { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.8rem; + font-weight: bold; + color: #aaa; + margin-bottom: 0.3rem; +} + +.se-envelope-toggle label { + cursor: pointer; +} + +.se-envelope-disabled { + opacity: 0.35; + pointer-events: none; +} + +/* Import dialog */ +.se-import-dialog { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: #1e1e1e; + border: 1px solid #555; + padding: 1.5rem; + z-index: 1000; + min-width: 20rem; +} + +.se-import-dialog h3 { + margin: 0 0 1rem 0; + font-size: 1rem; +} + +.se-import-dialog label { + display: block; + font-size: 0.8rem; + color: #aaa; + margin-bottom: 0.2rem; +} + +.se-import-dialog input { + width: 100%; + background: #111; + border: 1px solid #444; + color: #ddd; + padding: 0.3rem; + box-sizing: border-box; + margin-bottom: 0.8rem; + font-family: monospace; +} + +.se-import-dialog .se-dialog-buttons { + display: flex; + gap: 0.5rem; + justify-content: flex-end; +} + +.se-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.6); + z-index: 999; +} + +/* Buttons */ +.se-btn { + background: #333; + border: 1px solid #555; + color: #ccc; + padding: 0.25rem 0.6rem; + cursor: pointer; + font-size: 0.8rem; +} + +.se-btn:hover { + background: #444; + color: #fff; +} + +.se-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.se-btn-primary { + background: #1a4a8a; + border-color: #3a6aaa; + color: #fff; +} + +.se-btn-primary:hover { + background: #2a5a9a; +} + +/* Status poller */ +.se-status { + font-size: 0.8rem; + padding: 0.4rem; + border: 1px solid #444; + margin: 0.5rem 0; +} + +.se-status-progress { + height: 4px; + background: #333; + margin-top: 0.3rem; +} + +.se-status-bar { + height: 100%; + background: #2a6aaa; + transition: width 0.5s; +} + +.se-error { + color: #e06060; + font-size: 0.8rem; + padding: 0.3rem; + border: 1px solid #602020; + margin: 0.3rem 0; + white-space: pre-wrap; + font-family: monospace; +} diff --git a/templates/vue3_neusik/index.html b/templates/vue3_neusik/index.html new file mode 100644 index 0000000..aa80b59 --- /dev/null +++ b/templates/vue3_neusik/index.html @@ -0,0 +1,20 @@ +{% extends "base.tmpl" %} +{% block title %}Score Editor{% endblock %} + +{% block css %} +@import url("{{ url_for('vue3_neusik.static', filename='style.css') }}"); +{% endblock %} + +{% block main %} +
+
+ + +{% endblock %}