From 2c7508c2a460d0341d943dce62b3091e0444c157 Mon Sep 17 00:00:00 2001 From: Corey Johnson Date: Mon, 5 Jan 2026 11:30:32 -0800 Subject: [PATCH] Better edtior --- src/editor/completions.ts | 104 +++++++++++++++++++++++++++- src/editor/diagnostics.ts | 35 +++++----- src/editor/editor.css | 78 +++++++++++++++++++++ src/editor/editor.tsx | 64 +++++++++++++---- src/editor/example/frontend.tsx | 6 ++ src/editor/example/index.html | 11 +++ src/editor/{ => example}/server.tsx | 0 src/editor/highlighter.ts | 19 +++-- src/editor/index.html | 23 ------ src/editor/index.ts | 1 + src/editor/keymap.ts | 8 +++ src/editor/persistence.ts | 6 +- src/editor/theme.ts | 17 ++++- 13 files changed, 301 insertions(+), 71 deletions(-) create mode 100644 src/editor/example/frontend.tsx create mode 100644 src/editor/example/index.html rename src/editor/{ => example}/server.tsx (100%) delete mode 100644 src/editor/index.html create mode 100644 src/editor/index.ts create mode 100644 src/editor/keymap.ts diff --git a/src/editor/completions.ts b/src/editor/completions.ts index 5a8db2e..5a0fa91 100644 --- a/src/editor/completions.ts +++ b/src/editor/completions.ts @@ -1,2 +1,102 @@ -// Autocomplete for Shrimp -// TODO: Keywords and prelude function names +import { autocompletion, type CompletionContext, type Completion } from '@codemirror/autocomplete' +import { Shrimp, type Value } from '#/index' + +const keywords = [ + 'import', + 'end', + 'do', + 'if', + 'else', + 'while', + 'try', + 'catch', + 'finally', + 'throw', + 'not', + 'and', + 'or', + 'true', + 'false', + 'null', +] + +const keywordCompletions: Completion[] = keywords.map((k) => ({ + label: k, + type: 'keyword', + boost: -1, +})) + +const buildFunctionCompletion = (name: string, value: unknown): Completion => { + let detail: string | undefined + + console.log(`🌭`, { name, fn: value?.toString() }) + + // console.log(`🌭`, { name, value }) + + // const isFunction = value?.type === 'function' + // if (isFunction) { + // const paramStrs = value.params.map((p) => (p in value.defaults ? `${p}?` : p)) + + // if (value.variadic && paramStrs.length > 0) { + // paramStrs[paramStrs.length - 1] = `...${paramStrs[paramStrs.length - 1]}` + // } + + // detail = `(${paramStrs.join(', ')})` + // } + + return { + label: name, + type: 'function', + detail, + } +} + +export const createShrimpCompletions = (shrimp: Shrimp) => { + // Build completions from all names in the shrimp scope + const scopeNames = shrimp.vm.scope.vars() + const functionCompletions: Completion[] = scopeNames.map((name) => + buildFunctionCompletion(name, shrimp.get(name)) + ) + + const allCompletions = [...keywordCompletions, ...functionCompletions] + + // Get methods for a module (e.g., math, str, list) + const getModuleMethods = (moduleName: string): Completion[] => { + const module = shrimp.get(moduleName) + if (!module || module.type !== 'dict') return [] + + return Array.from(module.value.keys()).map((name) => + buildFunctionCompletion(name, module.value.get(name)) + ) + } + + const shrimpCompletionSource = (context: CompletionContext) => { + // Check for module.method pattern (e.g., "math.") + const dotMatch = context.matchBefore(/[\w\-]+\.[\w\-\?]*/) + if (dotMatch) { + const [moduleName, methodPrefix] = dotMatch.text.split('.') + const methods = getModuleMethods(moduleName!) + + if (methods.length > 0) { + const dotPos = dotMatch.from + moduleName!.length + 1 + return { + from: dotPos, + options: methods.filter((m) => m.label.startsWith(methodPrefix ?? '')), + } + } + } + + // Regular completions + const word = context.matchBefore(/[\w\-\?\$]+/) + if (!word || (word.from === word.to && !context.explicit)) return null + + return { + from: word.from, + options: allCompletions.filter((c) => c.label.startsWith(word.text)), + } + } + + return autocompletion({ + override: [shrimpCompletionSource], + }) +} diff --git a/src/editor/diagnostics.ts b/src/editor/diagnostics.ts index fe9af90..18c3a86 100644 --- a/src/editor/diagnostics.ts +++ b/src/editor/diagnostics.ts @@ -2,24 +2,23 @@ import { linter, type Diagnostic } from '@codemirror/lint' import { Shrimp } from '#/index' import { CompilerError } from '#compiler/compilerError' -const shrimp = new Shrimp() +export const createShrimpDiagnostics = (shrimp: Shrimp) => + linter((view) => { + const code = view.state.doc.toString() + const diagnostics: Diagnostic[] = [] -export const shrimpDiagnostics = linter((view) => { - const code = view.state.doc.toString() - const diagnostics: Diagnostic[] = [] - - try { - shrimp.parse(code) - } catch (err) { - if (err instanceof CompilerError) { - diagnostics.push({ - from: err.from, - to: err.to, - severity: 'error', - message: err.message, - }) + try { + shrimp.parse(code) + } catch (err) { + if (err instanceof CompilerError) { + diagnostics.push({ + from: err.from, + to: err.to, + severity: 'error', + message: err.message, + }) + } } - } - return diagnostics -}) + return diagnostics + }) diff --git a/src/editor/editor.css b/src/editor/editor.css index e69de29..84282f8 100644 --- a/src/editor/editor.css +++ b/src/editor/editor.css @@ -0,0 +1,78 @@ +:root { + /* Background colors */ + --bg-editor: #011627; + --bg-output: #40318D; + --bg-status-bar: #1E2A4A; + --bg-status-border: #0E1A3A; + --bg-selection: #1D3B53; + + /* Text colors */ + --text-editor: #D6DEEB; + --text-output: #7C70DA; + --text-status: #B3A9FF55; + --caret: #80A4C2; + + /* Syntax highlighting colors */ + --color-keyword: #C792EA; + --color-function: #82AAFF; + --color-string: #C3E88D; + --color-number: #F78C6C; + --color-bool: #FF5370; + --color-operator: #89DDFF; + --color-paren: #676E95; + --color-function-call: #FF9CAC; + --color-variable-def: #FFCB6B; + --color-error: #FF6E6E; + --color-regex: #E1ACFF; + + /* ANSI terminal colors */ + --ansi-black: #011627; + --ansi-red: #FF5370; + --ansi-green: #C3E88D; + --ansi-yellow: #FFCB6B; + --ansi-blue: #82AAFF; + --ansi-magenta: #C792EA; + --ansi-cyan: #89DDFF; + --ansi-white: #D6DEEB; + + /* ANSI bright colors (slightly more vibrant) */ + --ansi-bright-black: #676E95; + --ansi-bright-red: #FF6E90; + --ansi-bright-green: #D4F6A8; + --ansi-bright-yellow: #FFE082; + --ansi-bright-blue: #A8C7FA; + --ansi-bright-magenta: #E1ACFF; + --ansi-bright-cyan: #A8F5FF; + --ansi-bright-white: #FFFFFF; +} + +@font-face { + font-family: 'C64ProMono'; + src: url('../../assets/C64_Pro_Mono-STYLE.woff2') format('woff2'); + font-weight: normal; + font-style: normal; +} + +@font-face { + font-family: 'Pixeloid Mono'; + src: url('../../assets/PixeloidMono.ttf') format('truetype'); + font-weight: normal; + font-style: normal; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + background-color: var(--bg-editor); + font-family: 'Pixeloid Mono', 'Courier New', monospace; + font-size: 18px; + height: 100vh; +} + +#root { + height: 100vh; +} \ No newline at end of file diff --git a/src/editor/editor.tsx b/src/editor/editor.tsx index 19ee5b7..273fe34 100644 --- a/src/editor/editor.tsx +++ b/src/editor/editor.tsx @@ -1,30 +1,70 @@ import { EditorView, basicSetup } from 'codemirror' -import { render } from 'hono/jsx/dom' +import { Shrimp } from '#/index' import { shrimpTheme } from './theme' -import { shrimpDiagnostics } from './diagnostics' -import { persistencePlugin, getContent } from './persistence' +import { createShrimpDiagnostics } from './diagnostics' import { shrimpHighlighter } from './highlighter' +import { createShrimpCompletions } from './completions' +import { shrimpKeymap } from './keymap' +import { getContent, persistence } from './persistence' -export const Editor = () => { +type EditorProps = { + initialCode?: string + onChange?: (code: string) => void + extensions?: import('@codemirror/state').Extension[] + shrimp?: Shrimp +} + +export const Editor = ({ + initialCode = '', + onChange, + extensions: customExtensions = [], + shrimp = new Shrimp(), +}: EditorProps) => { return (
{ - if (!el?.querySelector('.cm-editor')) createEditorView(el) + if (!el?.querySelector('.cm-editor')) + createEditorView(el, getContent() ?? initialCode, onChange, customExtensions, shrimp) }} /> ) } -const createEditorView = (el: Element) => { +const createEditorView = ( + el: Element, + initialCode: string, + onChange: ((code: string) => void) | undefined, + customExtensions: import('@codemirror/state').Extension[], + shrimp: Shrimp +) => { + const extensions = [ + basicSetup, + shrimpTheme, + createShrimpDiagnostics(shrimp), + createShrimpCompletions(shrimp), + shrimpHighlighter, + shrimpKeymap, + persistence, + ...customExtensions, + ] + + if (onChange) { + extensions.push( + EditorView.updateListener.of((update) => { + if (update.docChanged) { + onChange(update.state.doc.toString()) + } + }) + ) + } + new EditorView({ parent: el, - doc: getContent() || '# type some code\necho hello', - extensions: [basicSetup, shrimpTheme, shrimpDiagnostics, persistencePlugin, shrimpHighlighter], + doc: initialCode, + extensions, }) -} -// Mount when running in browser -if (typeof document !== 'undefined') { - render(, document.getElementById('root')!) + // Trigger onChange with initial content + onChange?.(initialCode) } diff --git a/src/editor/example/frontend.tsx b/src/editor/example/frontend.tsx new file mode 100644 index 0000000..da34265 --- /dev/null +++ b/src/editor/example/frontend.tsx @@ -0,0 +1,6 @@ +import { Editor } from '#editor/editor' +import { render } from 'hono/jsx/dom' + +if (typeof document !== 'undefined') { + render(, document.getElementById('root')!) +} diff --git a/src/editor/example/index.html b/src/editor/example/index.html new file mode 100644 index 0000000..8ef8eba --- /dev/null +++ b/src/editor/example/index.html @@ -0,0 +1,11 @@ + + + + + + + +
+ + + diff --git a/src/editor/server.tsx b/src/editor/example/server.tsx similarity index 100% rename from src/editor/server.tsx rename to src/editor/example/server.tsx diff --git a/src/editor/highlighter.ts b/src/editor/highlighter.ts index e561bc4..93e1f3d 100644 --- a/src/editor/highlighter.ts +++ b/src/editor/highlighter.ts @@ -2,6 +2,7 @@ import { EditorView, Decoration, DecorationSet, ViewPlugin, ViewUpdate } from '@ import { RangeSetBuilder } from '@codemirror/state' import { Shrimp } from '#/index' import { type SyntaxNode } from '#parser/node' +import { log } from '#utils/utils' const shrimp = new Shrimp() @@ -14,9 +15,9 @@ export const shrimpHighlighter = ViewPlugin.fromClass( } update(update: ViewUpdate) { - if (update.docChanged) { - this.decorations = this.highlight(update.view) - } + if (!update.docChanged) return + + this.decorations = this.highlight(update.view) } highlight(view: EditorView): DecorationSet { @@ -30,7 +31,8 @@ export const shrimpHighlighter = ViewPlugin.fromClass( tree.iterate({ enter: (node) => { const cls = tokenStyles[node.type.name] - if (cls && isLeaf(node)) { + const isLeaf = node.children.length === 0 + if (cls && isLeaf) { decorations.push({ from: node.from, to: node.to, class: cls }) } }, @@ -42,8 +44,8 @@ export const shrimpHighlighter = ViewPlugin.fromClass( for (const d of decorations) { builder.add(d.from, d.to, Decoration.mark({ class: d.class })) } - } catch { - // Parse failed, no highlighting + } catch (error) { + log('Parsing error in highlighter', error) } return builder.finish() @@ -69,8 +71,3 @@ const tokenStyles: Record = { operator: 'tok-operator', Regex: 'tok-regex', } - -// Check if node is a leaf (should be highlighted) -const isLeaf = (node: SyntaxNode): boolean => { - return node.children.length === 0 -} diff --git a/src/editor/index.html b/src/editor/index.html deleted file mode 100644 index f2129e5..0000000 --- a/src/editor/index.html +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - -
- - - - diff --git a/src/editor/index.ts b/src/editor/index.ts new file mode 100644 index 0000000..eac45fe --- /dev/null +++ b/src/editor/index.ts @@ -0,0 +1 @@ +export { Editor } from './editor' diff --git a/src/editor/keymap.ts b/src/editor/keymap.ts new file mode 100644 index 0000000..7a3e2e7 --- /dev/null +++ b/src/editor/keymap.ts @@ -0,0 +1,8 @@ +import { keymap } from '@codemirror/view' +import { acceptCompletion } from '@codemirror/autocomplete' +import { indentWithTab } from '@codemirror/commands' + +export const shrimpKeymap = keymap.of([ + { key: 'Tab', run: acceptCompletion }, + indentWithTab, +]) diff --git a/src/editor/persistence.ts b/src/editor/persistence.ts index 4654593..d729b26 100644 --- a/src/editor/persistence.ts +++ b/src/editor/persistence.ts @@ -1,6 +1,6 @@ import { ViewPlugin, ViewUpdate } from '@codemirror/view' -export const persistencePlugin = ViewPlugin.fromClass( +export const persistence = ViewPlugin.fromClass( class { saveTimeout?: ReturnType @@ -17,13 +17,13 @@ export const persistencePlugin = ViewPlugin.fromClass( destroy() { if (this.saveTimeout) clearTimeout(this.saveTimeout) } - }, + } ) export const getContent = () => { return localStorage.getItem('shrimp-editor-content') || '' } -const setContent = (data: string) => { +export const setContent = (data: string) => { localStorage.setItem('shrimp-editor-content', data) } diff --git a/src/editor/theme.ts b/src/editor/theme.ts index af24780..5420320 100644 --- a/src/editor/theme.ts +++ b/src/editor/theme.ts @@ -12,11 +12,12 @@ export const shrimpTheme = EditorView.theme( fontFamily: '"Pixeloid Mono", "Courier New", monospace', caretColor: 'var(--caret)', padding: '0px', + borderTop: '1px solid var(--bg-editor)', }, '&.cm-focused .cm-cursor': { borderLeftColor: 'var(--caret)', }, - '&.cm-focused .cm-selectionBackground, ::selection': { + '.cm-selectionLayer .cm-selectionBackground': { backgroundColor: 'var(--bg-selection)', }, '.cm-editor': { @@ -31,8 +32,20 @@ export const shrimpTheme = EditorView.theme( backgroundColor: 'var(--color-string)', }, '.cm-activeLine': { - backgroundColor: 'var(--bg-active-line)', + backgroundColor: 'rgba(255, 255, 255, 0.03)', }, + + // Token highlighting + '.tok-keyword': { color: 'var(--color-keyword)' }, + '.tok-string': { color: 'var(--color-string)' }, + '.tok-number': { color: 'var(--color-number)' }, + '.tok-bool': { color: 'var(--color-bool)' }, + '.tok-null': { color: 'var(--color-number)', fontStyle: 'italic' }, + '.tok-identifier': { color: 'var(--color-function)' }, + '.tok-variable-def': { color: 'var(--color-variable-def)' }, + '.tok-comment': { color: 'var(--ansi-bright-black)', fontStyle: 'italic' }, + '.tok-operator': { color: 'var(--color-operator)' }, + '.tok-regex': { color: 'var(--color-regex)' }, }, { dark: true } )