Better edtior

This commit is contained in:
Corey Johnson 2026-01-05 11:30:32 -08:00
parent 82722ec9e4
commit 2c7508c2a4
13 changed files with 301 additions and 71 deletions

View File

@ -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],
})
}

View File

@ -2,9 +2,8 @@ import { linter, type Diagnostic } from '@codemirror/lint'
import { Shrimp } from '#/index'
import { CompilerError } from '#compiler/compilerError'
const shrimp = new Shrimp()
export const shrimpDiagnostics = linter((view) => {
export const createShrimpDiagnostics = (shrimp: Shrimp) =>
linter((view) => {
const code = view.state.doc.toString()
const diagnostics: Diagnostic[] = []
@ -22,4 +21,4 @@ export const shrimpDiagnostics = linter((view) => {
}
return diagnostics
})
})

View File

@ -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;
}

View File

@ -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 (
<div
ref={(el: Element) => {
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(<Editor />, document.getElementById('root')!)
// Trigger onChange with initial content
onChange?.(initialCode)
}

View File

@ -0,0 +1,6 @@
import { Editor } from '#editor/editor'
import { render } from 'hono/jsx/dom'
if (typeof document !== 'undefined') {
render(<Editor initialCode={'# type some code'} />, document.getElementById('root')!)
}

View File

@ -0,0 +1,11 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<link rel="stylesheet" href="../editor.css" />
</head>
<body>
<div id="root"></div>
<script type="module" src="./frontend.tsx"></script>
</body>
</html>

View File

@ -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,10 +15,10 @@ export const shrimpHighlighter = ViewPlugin.fromClass(
}
update(update: ViewUpdate) {
if (update.docChanged) {
if (!update.docChanged) return
this.decorations = this.highlight(update.view)
}
}
highlight(view: EditorView): DecorationSet {
const builder = new RangeSetBuilder<Decoration>()
@ -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<string, string> = {
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
}

View File

@ -1,23 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
</head>
<body>
<div id="root"></div>
<style>
body {
margin: 0;
padding: 0;
}
#root {
margin: 0;
padding: 0;
height: 100vh;
width: 100vw;
}
</style>
<script type="module" src="./editor.tsx"></script>
</body>
</html>

1
src/editor/index.ts Normal file
View File

@ -0,0 +1 @@
export { Editor } from './editor'

8
src/editor/keymap.ts Normal file
View File

@ -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,
])

View File

@ -1,6 +1,6 @@
import { ViewPlugin, ViewUpdate } from '@codemirror/view'
export const persistencePlugin = ViewPlugin.fromClass(
export const persistence = ViewPlugin.fromClass(
class {
saveTimeout?: ReturnType<typeof setTimeout>
@ -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)
}

View File

@ -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 }
)