/ proof-of-concept / cymbalsim
Sign in
proof-of-concept / cymbalsim
cymbalsim / src / ui / editor.ts
241 lines · 9.0 KBBlameHistoryRaw
1// The design editor: a preset selector with a "Custom (edited)" entry, a
2// Revert button, the script in a CodeMirror view with MATLAB (Octave)
3// highlighting, and a status line. The editor does not compile anything
4// itself: it hands the source to the host (debounced while typing, at once
5// on Ctrl/Cmd-Enter and on preset selection), and the host reports the
6// outcome back through setStatus.
8import { Compartment, EditorState } from "@codemirror/state";
9import { EditorView, drawSelection, highlightActiveLine, highlightActiveLineGutter, keymap, lineNumbers } from "@codemirror/view";
10import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
11import { HighlightStyle, StreamLanguage, bracketMatching, syntaxHighlighting } from "@codemirror/language";
12import { octave } from "@codemirror/legacy-modes/mode/octave";
13import { tags } from "@lezer/highlight";
14import { DEFAULT_PRESET, PRESETS, presetById } from "../presets.ts";
16export interface EditorCallbacks {
17 /** Called (debounced 400 ms after typing, or at once on Ctrl/Cmd-Enter and on preset selection) with the current source. The host compiles it and reports back through setStatus. */
18 onSource: (source: string, presetId: string | null) => void;
21export type StatusKind = "ok" | "error" | "info";
23const CUSTOM = "custom";
24const DEBOUNCE_MS = 400;
26const highlight = HighlightStyle.define([
27 { tag: tags.comment, color: "#7d8893", fontStyle: "italic" },
28 { tag: tags.keyword, color: "#8ab4f8" },
29 { tag: tags.string, color: "#e3b26f" },
30 { tag: tags.number, color: "#b5cea8" },
31 { tag: tags.operator, color: "#c8cdd3" },
32 { tag: tags.variableName, color: "#dde1e6" },
33 { tag: tags.atom, color: "#c586c0" },
34 { tag: tags.bracket, color: "#c8cdd3" },
35]);
37const theme = EditorView.theme(
38 {
39 "&": { backgroundColor: "#0f1113", color: "#dde1e6", border: "1px solid #2b2f35", borderRadius: "4px", fontSize: "12px", height: "360px" },
40 "&.cm-focused": { outline: "1px solid #3b8ce6" },
41 ".cm-scroller": { fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace", lineHeight: "1.4", overflow: "auto" },
42 ".cm-content": { caretColor: "#f0b429" },
43 ".cm-cursor, .cm-dropCursor": { borderLeftColor: "#f0b429" },
44 "&.cm-focused .cm-selectionBackground, .cm-selectionBackground, ::selection": { backgroundColor: "#2b4a70 !important" },
45 ".cm-activeLine": { backgroundColor: "#191c20" },
46 ".cm-gutters": { backgroundColor: "#15171a", color: "#5b636c", border: "none", borderRight: "1px solid #2b2f35" },
47 ".cm-activeLineGutter": { backgroundColor: "#191c20", color: "#9aa0a8" },
48 ".cm-matchingBracket": { backgroundColor: "#2b4a70", outline: "none" },
49 "&.cm-locked": { opacity: "0.65" },
50 },
51 { dark: true },
52);
54export class DesignEditor {
55 private readonly callbacks: EditorCallbacks;
56 private readonly select = document.createElement("select");
57 private readonly revert = document.createElement("button");
58 private readonly status = document.createElement("div");
59 private readonly view: EditorView;
60 private readonly editable = new Compartment();
61 private readonly readOnly = new Compartment();
62 private timer: number | null = null;
63 /** Set while the text is replaced programmatically, so the change is not treated as typing. */
64 private replacing = false;
65 /** The source last handed to the host, so a debounce that changed nothing is skipped. */
66 private lastSent: string | null = null;
68 /**
69 * Builds the editor inside `container`. The default preset's text is loaded
70 * but onSource is not fired; the host calls selectPreset or setSource to
71 * start (and compiles on its own if it used setSource).
72 */
73 constructor(container: HTMLElement, callbacks: EditorCallbacks) {
74 this.callbacks = callbacks;
75 container.classList.add("design-editor");
77 const head = document.createElement("div");
78 head.className = "editor-head";
79 const label = document.createElement("label");
80 label.textContent = "Preset";
81 for (const p of PRESETS) {
82 const o = document.createElement("option");
83 o.value = p.id;
84 o.textContent = p.label;
85 this.select.appendChild(o);
86 }
87 const custom = document.createElement("option");
88 custom.value = CUSTOM;
89 custom.textContent = "Custom (edited)";
90 this.select.appendChild(custom);
91 this.select.title = "Load a predefined design script";
92 this.select.addEventListener("change", () => {
93 const id = this.select.value;
94 if (id === CUSTOM) {
95 // Nothing to load: keep the text as it is and stay on the custom entry.
96 return;
97 }
98 this.selectPreset(id);
99 });
100 this.revert.type = "button";
101 this.revert.textContent = "Revert";
102 this.revert.title = "Restore the selected preset's script (the default preset when the script is custom)";
103 this.revert.addEventListener("click", () => {
104 const id = presetById(this.select.value) ? this.select.value : DEFAULT_PRESET;
105 this.selectPreset(id);
106 });
107 head.append(label, this.select, this.revert);
109 const wrap = document.createElement("div");
110 wrap.className = "editor-script";
111 const initial = presetById(DEFAULT_PRESET) ?? PRESETS[0];
112 this.view = new EditorView({
113 parent: wrap,
114 state: EditorState.create({
115 doc: initial?.source ?? "",
116 extensions: [
117 lineNumbers(),
118 highlightActiveLineGutter(),
119 highlightActiveLine(),
120 drawSelection(),
121 history(),
122 bracketMatching(),
123 StreamLanguage.define(octave),
124 syntaxHighlighting(highlight),
125 theme,
126 this.editable.of(EditorView.editable.of(true)),
127 this.readOnly.of(EditorState.readOnly.of(false)),
128 keymap.of([
129 { key: "Mod-Enter", run: () => { this.fireNow(); return true; } },
130 indentWithTab,
131 ...defaultKeymap,
132 ...historyKeymap,
133 ]),
134 EditorView.updateListener.of((u) => {
135 if (!u.docChanged || this.replacing) return;
136 this.syncSelect();
137 this.schedule();
138 }),
139 ],
140 }),
141 });
142 this.select.value = initial?.id ?? CUSTOM;
144 this.status.className = "editor-status";
145 this.setStatus("", "info");
147 container.append(head, wrap, this.status);
148 }
150 /** Loads the preset's text, selects it, and fires onSource at once. Unknown ids are ignored. */
151 selectPreset(id: string): void {
152 const p = presetById(id);
153 if (!p) return;
154 this.replaceText(p.source);
155 this.select.value = p.id;
156 this.fireNow();
157 }
159 /** Replaces the text without firing onSource. The select shows the matching preset if the text is one, otherwise "Custom (edited)". */
160 setSource(source: string): void {
161 this.cancel();
162 this.replaceText(source);
163 this.syncSelect();
164 }
166 getSource(): string {
167 return this.view.state.doc.toString();
168 }
170 /** The id of the selected preset, or null when the text is custom. */
171 presetId(): string | null {
172 return this.select.value === CUSTOM ? null : this.select.value;
173 }
175 /** Writes the status line under the editor; the kind selects the class (errors are tinted red by the host's stylesheet). */
176 setStatus(text: string, kind: StatusKind): void {
177 this.status.textContent = text;
178 this.status.className = `editor-status editor-status-${kind}`;
179 this.status.hidden = text === "";
180 }
182 /** Makes the text read-only (and the preset controls inert) while a simulation runs. */
183 setLocked(locked: boolean): void {
184 if (locked) this.cancel();
185 this.view.dispatch({
186 effects: [
187 this.editable.reconfigure(EditorView.editable.of(!locked)),
188 this.readOnly.reconfigure(EditorState.readOnly.of(locked)),
189 ],
190 });
191 this.view.dom.classList.toggle("cm-locked", locked);
192 this.select.disabled = locked;
193 this.revert.disabled = locked;
194 }
196 /** Focuses the text. */
197 focus(): void {
198 this.view.focus();
199 }
201 private replaceText(source: string): void {
202 if (source === this.getSource()) return;
203 this.replacing = true;
204 try {
205 this.view.dispatch({ changes: { from: 0, to: this.view.state.doc.length, insert: source } });
206 } finally {
207 this.replacing = false;
208 }
209 }
211 /** Points the select at the preset whose text matches, preferring the one already selected, else at the custom entry. */
212 private syncSelect(): void {
213 const src = this.getSource();
214 const current = presetById(this.select.value);
215 if (current && current.source === src) return;
216 const match = PRESETS.find((p) => p.source === src);
217 this.select.value = match ? match.id : CUSTOM;
218 }
220 private schedule(): void {
221 this.cancel();
222 this.timer = window.setTimeout(() => {
223 this.timer = null;
224 if (this.getSource() !== this.lastSent) this.fireNow();
225 }, DEBOUNCE_MS);
226 }
228 private cancel(): void {
229 if (this.timer !== null) {
230 window.clearTimeout(this.timer);
231 this.timer = null;
232 }
233 }
235 private fireNow(): void {
236 this.cancel();
237 const src = this.getSource();
238 this.lastSent = src;
239 this.callbacks.onSource(src, this.presetId());
240 }
↑↓ move↵openescclose