aboutsummaryrefslogtreecommitdiff
path: root/bundle/src
diff options
context:
space:
mode:
author_ov4 <ov4@ov4.org>2026-10-21 11:02:26 +0000
committer_ov4 <ov4@ov4.org>2026-10-21 11:02:26 +0000
commit5a254e2ab4741bc88c373ae1673c2ad9a7e6a588 (patch)
treec26f6fb9186ac4c8d121349909689cd045f9d11f /bundle/src
parentc17775323baf93e0d9bd44a6f83cd32a986b6fcb (diff)
feat(bundle): add the Tauri editor with shared Dark+ language support
Add a single-document Monaco editor, native file dialogs, save handling and prefilled input/output panels. Reuse the plugin's grammar and editor core. Verify frontend behavior and compile the desktop app on Linux. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'bundle/src')
-rw-r--r--bundle/src/editor.js90
-rw-r--r--bundle/src/main.js90
-rw-r--r--bundle/src/style.css20
3 files changed, 200 insertions, 0 deletions
diff --git a/bundle/src/editor.js b/bundle/src/editor.js
new file mode 100644
index 0000000..eaef000
--- /dev/null
+++ b/bundle/src/editor.js
@@ -0,0 +1,90 @@
+import * as monaco from 'monaco-editor/editor/editor.api.js';
+import 'monaco-editor/editor/browser/coreCommands.js';
+import 'monaco-editor/editor/contrib/clipboard/browser/clipboard.js';
+import 'monaco-editor/editor/contrib/contextmenu/browser/contextmenu.js';
+import 'monaco-editor/editor/contrib/format/browser/formatActions.js';
+import 'monaco-editor/editor/contrib/bracketMatching/browser/bracketMatching.js';
+import 'monaco-editor/editor/contrib/linesOperations/browser/linesOperations.js';
+import 'monaco-editor/editor/contrib/wordOperations/browser/wordOperations.js';
+import 'monaco-editor/editor/contrib/find/browser/findController.js';
+import EditorWorker from 'monaco-editor/editor/editor.worker.js?worker';
+import textmate from 'vscode-textmate';
+import oniguruma from 'vscode-oniguruma';
+import wasmUrl from 'vscode-oniguruma/release/onig.wasm?url';
+import { parse } from 'jsonc-parser';
+import grammar from '../../plugin/syntaxes/cie-pseudocode.tmLanguage.json';
+import configuration from '../../plugin/language-configuration.json';
+import indentation from '../../plugin/src/indentation.js';
+import brackets from '../../plugin/src/brackets.js';
+import darkPlusSource from '../vendor/vscode/dark_plus.json?raw';
+import darkSource from '../vendor/vscode/dark_vs.json?raw';
+
+const languageId = 'cie-pseudocode';
+const { indentationEdits, onTypeCharacters, shouldFormatOnType, selectionEndLine } = indentation;
+const dark = parse(darkSource);
+const plus = parse(darkPlusSource);
+export const theme = { ...dark, ...plus, tokenColors: [...dark.tokenColors, ...plus.tokenColors] };
+
+class TokenState {
+ constructor(stack) { this.stack = stack; }
+ clone() { return new TokenState(this.stack); }
+ equals(other) { return other instanceof TokenState && (this.stack === other.stack || this.stack.equals(other.stack)); }
+}
+
+export async function createEditor(element) {
+ globalThis.MonacoEnvironment = { getWorker: () => new EditorWorker() };
+ const { Registry, INITIAL } = textmate;
+ const { loadWASM, OnigScanner, OnigString } = oniguruma;
+ await loadWASM(await (await fetch(wasmUrl)).arrayBuffer());
+ const registry = new Registry({
+ theme: { settings: theme.tokenColors },
+ onigLib: Promise.resolve({ createOnigScanner: patterns => new OnigScanner(patterns), createOnigString: text => new OnigString(text) }),
+ loadGrammar: async scope => scope === grammar.scopeName ? grammar : null
+ });
+ monaco.languages.register({ id: languageId, extensions: ['.cps'] });
+ monaco.languages.setLanguageConfiguration(languageId, { ...configuration, wordPattern: new RegExp(configuration.wordPattern) });
+ const tmGrammar = await registry.loadGrammarWithConfiguration(grammar.scopeName, monaco.languages.getEncodedLanguageId(languageId), { balancedBracketSelectors: ['*'] });
+ monaco.editor.defineTheme('cpsc-dark-plus', { base: 'vs-dark', inherit: false, rules: [], colors: dark.colors });
+ monaco.editor.setTheme('cpsc-dark-plus');
+ monaco.languages.setColorMap(registry.getColorMap());
+ monaco.languages.setTokensProvider(languageId, {
+ getInitialState: () => new TokenState(INITIAL),
+ tokenizeEncoded(line, state) {
+ const result = tmGrammar.tokenizeLine2(line, state.stack);
+ return { tokens: result.tokens, endState: new TokenState(result.ruleStack) };
+ }
+ });
+ function edits(model, start, end, keepBlank = false) {
+ return indentationEdits(model.getLinesContent(), start, end, keepBlank).map(edit => ({
+ range: new monaco.Range(edit.line + 1, 1, edit.line + 1, edit.length + 1), text: edit.text
+ }));
+ }
+ monaco.languages.registerDocumentRangeFormattingEditProvider(languageId, {
+ provideDocumentRangeFormattingEdits(model, range) {
+ return edits(model, range.startLineNumber - 1, selectionEndLine(range.startLineNumber - 1, range.endLineNumber - 1, range.endColumn - 1));
+ }
+ });
+ monaco.languages.registerOnTypeFormattingEditProvider(languageId, {
+ autoFormatTriggerCharacters: onTypeCharacters,
+ provideOnTypeFormattingEdits(model, position, character) {
+ return shouldFormatOnType(model.getLineContent(position.lineNumber), character)
+ ? edits(model, position.lineNumber - 1, position.lineNumber - 1, character === '\n') : [];
+ }
+ });
+ const editor = monaco.editor.create(element, {
+ language: languageId, theme: 'cpsc-dark-plus', automaticLayout: true,
+ insertSpaces: false, tabSize: 4, detectIndentation: false, autoIndent: 'full', formatOnType: true,
+ bracketPairColorization: { enabled: true, independentColorPoolPerBracketType: false },
+ minimap: { enabled: false }, smoothScrolling: false, cursorSmoothCaretAnimation: 'off',
+ scrollBeyondLastLine: false, fontSize: 14, accessibilitySupport: 'auto'
+ });
+ const decorations = editor.createDecorationsCollection();
+ editor.onDidChangeModelContent(() => {
+ const model = editor.getModel();
+ decorations.set(brackets.unmatchedBrackets(model.getValue()).map(offset => {
+ const start = model.getPositionAt(offset);
+ return { range: new monaco.Range(start.lineNumber, start.column, start.lineNumber, start.column + 1), options: { inlineClassName: 'invalid-bracket' } };
+ }));
+ });
+ return editor;
+}
diff --git a/bundle/src/main.js b/bundle/src/main.js
new file mode 100644
index 0000000..0d3a010
--- /dev/null
+++ b/bundle/src/main.js
@@ -0,0 +1,90 @@
+import { invoke } from '@tauri-apps/api/core';
+import { getCurrentWindow } from '@tauri-apps/api/window';
+import { open, save, message } from '@tauri-apps/plugin-dialog';
+import { createEditor } from './editor.js';
+import './style.css';
+
+const status = document.querySelector('#status');
+const filename = document.querySelector('#filename');
+const window = getCurrentWindow();
+const filters = [{ name: 'Pseudocode', extensions: ['cps'] }];
+let path = null;
+let savedText = '';
+let busy = false;
+let closing = false;
+let editor;
+
+function dirty() { return editor.getValue() !== savedText; }
+function refreshTitle() {
+ const name = path ? path.split(/[\\/]/).pop() : 'Untitled.cps';
+ filename.textContent = `${dirty() ? '* ' : ''}${name}`;
+ filename.title = path || name;
+ window.setTitle(`${dirty() ? '* ' : ''}${name} — CPSC Bundle`);
+}
+function report(error) { status.textContent = String(error); }
+async function saveFile() {
+ const target = path || await save({ filters, defaultPath: 'Untitled.cps' });
+ if (!target) return false;
+ const text = editor.getValue();
+ await invoke('write_file', { path: target, text });
+ path = target;
+ savedText = text;
+ refreshTitle();
+ status.textContent = 'Saved';
+ return true;
+}
+async function canReplace() {
+ if (!dirty()) return true;
+ const answer = await message('Save changes?', {
+ title: 'CPSC Bundle', kind: 'warning',
+ buttons: { yes: 'Save', no: 'Discard', cancel: 'Cancel' }
+ });
+ if (answer === 'Cancel') return false;
+ return answer === 'Discard' || await saveFile();
+}
+async function action(callback) {
+ if (busy) return;
+ busy = true;
+ try { await callback(); } catch (error) { report(error); } finally { busy = false; }
+}
+
+try {
+ editor = await createEditor(document.querySelector('#editor'));
+ editor.onDidChangeModelContent(refreshTitle);
+ document.querySelector('#save').onclick = () => action(saveFile);
+ document.querySelector('#new').onclick = () => action(async () => {
+ if (!await canReplace()) return;
+ path = null;
+ savedText = '';
+ editor.setValue('');
+ refreshTitle();
+ status.textContent = 'Ready';
+ });
+ document.querySelector('#open').onclick = () => action(async () => {
+ if (!await canReplace()) return;
+ const target = await open({ filters, multiple: false });
+ if (!target) return;
+ const text = await invoke('read_file', { path: target });
+ path = target;
+ savedText = text;
+ editor.setValue(text);
+ refreshTitle();
+ status.textContent = 'Ready';
+ });
+ document.addEventListener('keydown', event => {
+ if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {
+ event.preventDefault();
+ action(saveFile);
+ }
+ });
+ await window.onCloseRequested(async event => {
+ if (closing) return;
+ event.preventDefault();
+ await action(async () => {
+ if (!await canReplace()) return;
+ closing = true;
+ await window.close();
+ });
+ });
+ editor.focus();
+} catch (error) { report(error); }
diff --git a/bundle/src/style.css b/bundle/src/style.css
new file mode 100644
index 0000000..96ee3cd
--- /dev/null
+++ b/bundle/src/style.css
@@ -0,0 +1,20 @@
+:root { color-scheme: dark; font: 13px system-ui, sans-serif; color: #d4d4d4; background: #1e1e1e; }
+* { box-sizing: border-box; }
+body { margin: 0; height: 100vh; display: flex; flex-direction: column; overflow: hidden; }
+header { display: flex; align-items: center; gap: 8px; padding: 8px; border-bottom: 1px solid #454545; }
+button { font: inherit; color: inherit; background: #333; border: 1px solid #555; padding: 5px 16px; border-radius: 2px; cursor: pointer; }
+button:hover { background: #404040; }
+button:disabled { opacity: .5; cursor: default; }
+button:focus-visible, textarea:focus-visible, pre:focus-visible { outline: 1px solid #007acc; outline-offset: -1px; }
+#filename { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-left: 8px; }
+main { min-height: 0; flex: 1; display: grid; grid-template-rows: minmax(160px, 2fr) minmax(100px, 1fr); }
+#editor { min-height: 0; overflow: hidden; }
+#panels { min-height: 0; display: grid; grid-template-columns: 1fr 1fr; border-top: 1px solid #454545; }
+section { min-width: 0; min-height: 0; display: flex; flex-direction: column; }
+section + section { border-left: 1px solid #454545; }
+label { padding: 5px 10px; background: #252526; }
+textarea, pre { margin: 0; padding: 8px 10px; border: 0; color: inherit; background: #1e1e1e; font: 14px Consolas, Menlo, monospace; tab-size: 4; }
+textarea { flex: 1; min-height: 0; width: 100%; resize: none; border-radius: 0; }
+pre { flex: 1; min-height: 0; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; }
+footer { min-height: 24px; padding: 3px 10px; border-top: 1px solid #454545; }
+.invalid-bracket { color: #f44747 !important; }