From 5a254e2ab4741bc88c373ae1673c2ad9a7e6a588 Mon Sep 17 00:00:00 2001 From: _ov4 Date: Wed, 21 Oct 2026 11:02:26 +0000 Subject: 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 --- bundle/src/editor.js | 90 ++++++++++++++++++++++++++++++++++++++++++++++++++++ bundle/src/main.js | 90 ++++++++++++++++++++++++++++++++++++++++++++++++++++ bundle/src/style.css | 20 ++++++++++++ 3 files changed, 200 insertions(+) create mode 100644 bundle/src/editor.js create mode 100644 bundle/src/main.js create mode 100644 bundle/src/style.css (limited to 'bundle/src') 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; } -- cgit v1.3.1-4-g156e