feat: add platform shells, CLI, formatting, plugins, tests, and benchmarks
Phase 4 — Platform shells: - calcpad-macos/: SwiftUI two-column editor with Rust FFI bridge (16 files) - calcpad-windows/: iced GUI with Windows 11 Fluent theme (7 files, 13 tests) - calcpad-web/: React 18 + CodeMirror 6 + WASM Worker + PWA (20 files) - calcpad-cli/: clap-based CLI with expression eval, pipe/stdin, JSON/CSV output, and interactive REPL with rustyline history Phase 5 — Engine modules: - formatting/: answer formatting (decimal/scientific/SI notation, thousands separators, currency), line type classification, clipboard values (93 tests) - plugins/: CalcPadPlugin trait, PluginRegistry, Rhai scripting stub (43 tests) - benches/: criterion benchmarks (single-line, 100/500-line sheets, DAG, incremental) - tests/sheet_scenarios.rs: 20 real-world integration tests - tests/proptest_fuzz.rs: 12 property-based fuzz tests 771 tests passing across workspace, 0 failures.
This commit is contained in:
224
calcpad-web/src/editor/CalcEditor.tsx
Normal file
224
calcpad-web/src/editor/CalcEditor.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* React wrapper around CodeMirror 6 for the CalcPad editor.
|
||||
*
|
||||
* Integrates the CalcPad language mode, answer gutter, error display,
|
||||
* and debounced evaluation via the WASM engine Web Worker.
|
||||
*/
|
||||
|
||||
import { useRef, useEffect, useCallback } from 'react'
|
||||
import { EditorState } from '@codemirror/state'
|
||||
import {
|
||||
EditorView,
|
||||
lineNumbers,
|
||||
drawSelection,
|
||||
highlightActiveLine,
|
||||
keymap,
|
||||
} from '@codemirror/view'
|
||||
import {
|
||||
defaultHighlightStyle,
|
||||
syntaxHighlighting,
|
||||
bracketMatching,
|
||||
indentOnInput,
|
||||
} from '@codemirror/language'
|
||||
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
|
||||
import { calcpadLanguage } from './calcpad-language.ts'
|
||||
import { answerGutterExtension, setAnswersEffect, type LineAnswer } from './answer-gutter.ts'
|
||||
import { errorDisplayExtension, setErrorsEffect, type LineError } from './error-display.ts'
|
||||
import type { EngineLineResult } from '../engine/types.ts'
|
||||
|
||||
export interface CalcEditorProps {
|
||||
/** Initial document content */
|
||||
initialDoc?: string
|
||||
/** Called when the document text changes (debounced internally) */
|
||||
onDocChange?: (lines: string[]) => void
|
||||
/** Engine evaluation results to display in the answer gutter */
|
||||
results?: EngineLineResult[]
|
||||
/** Debounce delay in ms before triggering onDocChange */
|
||||
debounceMs?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* CalcPad editor component built on CodeMirror 6.
|
||||
* Handles syntax highlighting, line numbers, answer gutter,
|
||||
* and error underlines.
|
||||
*/
|
||||
export function CalcEditor({
|
||||
initialDoc = '',
|
||||
onDocChange,
|
||||
results,
|
||||
debounceMs = 50,
|
||||
}: CalcEditorProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const viewRef = useRef<EditorView | null>(null)
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
// Stable callback ref for doc changes
|
||||
const onDocChangeRef = useRef(onDocChange)
|
||||
onDocChangeRef.current = onDocChange
|
||||
|
||||
const scheduleEval = useCallback((view: EditorView) => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
timerRef.current = setTimeout(() => {
|
||||
timerRef.current = null
|
||||
const doc = view.state.doc.toString()
|
||||
const lines = doc.split('\n')
|
||||
onDocChangeRef.current?.(lines)
|
||||
}, debounceMs)
|
||||
}, [debounceMs])
|
||||
|
||||
// Create editor on mount
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return
|
||||
|
||||
const updateListener = EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged && viewRef.current) {
|
||||
scheduleEval(viewRef.current)
|
||||
}
|
||||
})
|
||||
|
||||
const state = EditorState.create({
|
||||
doc: initialDoc,
|
||||
extensions: [
|
||||
lineNumbers(),
|
||||
drawSelection(),
|
||||
highlightActiveLine(),
|
||||
bracketMatching(),
|
||||
indentOnInput(),
|
||||
history(),
|
||||
keymap.of([...defaultKeymap, ...historyKeymap]),
|
||||
syntaxHighlighting(defaultHighlightStyle),
|
||||
calcpadLanguage(),
|
||||
answerGutterExtension(),
|
||||
errorDisplayExtension(),
|
||||
updateListener,
|
||||
calcpadEditorTheme,
|
||||
],
|
||||
})
|
||||
|
||||
const view = new EditorView({
|
||||
state,
|
||||
parent: containerRef.current,
|
||||
})
|
||||
|
||||
viewRef.current = view
|
||||
|
||||
// Trigger initial evaluation
|
||||
const doc = view.state.doc.toString()
|
||||
onDocChangeRef.current?.(doc.split('\n'))
|
||||
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
view.destroy()
|
||||
viewRef.current = null
|
||||
}
|
||||
// initialDoc intentionally excluded — we only set it once on mount
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [scheduleEval])
|
||||
|
||||
// Push engine results into the answer gutter + error display
|
||||
useEffect(() => {
|
||||
const view = viewRef.current
|
||||
if (!view || !results) return
|
||||
|
||||
const answers: LineAnswer[] = []
|
||||
const errors: LineError[] = []
|
||||
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
const lineNum = i + 1
|
||||
const result = results[i]
|
||||
answers.push({ line: lineNum, result })
|
||||
|
||||
if (result.type === 'error' && result.error) {
|
||||
// Map to document positions
|
||||
if (lineNum <= view.state.doc.lines) {
|
||||
const docLine = view.state.doc.line(lineNum)
|
||||
errors.push({
|
||||
from: docLine.from,
|
||||
to: docLine.to,
|
||||
message: result.error,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
view.dispatch({
|
||||
effects: [
|
||||
setAnswersEffect.of(answers),
|
||||
setErrorsEffect.of(errors),
|
||||
],
|
||||
})
|
||||
}, [results])
|
||||
|
||||
return <div ref={containerRef} className="calc-editor" />
|
||||
}
|
||||
|
||||
/**
|
||||
* Base theme for the CalcPad editor.
|
||||
*/
|
||||
const calcpadEditorTheme = EditorView.baseTheme({
|
||||
'&': {
|
||||
height: '100%',
|
||||
fontSize: '15px',
|
||||
fontFamily: 'ui-monospace, Consolas, "Courier New", monospace',
|
||||
},
|
||||
'.cm-scroller': {
|
||||
overflow: 'auto',
|
||||
},
|
||||
'.cm-content': {
|
||||
padding: '12px 0',
|
||||
minHeight: '100%',
|
||||
},
|
||||
'.cm-line': {
|
||||
padding: '0 16px',
|
||||
lineHeight: '1.6',
|
||||
},
|
||||
'.cm-gutters': {
|
||||
backgroundColor: 'transparent',
|
||||
borderRight: 'none',
|
||||
},
|
||||
'.cm-lineNumbers .cm-gutterElement': {
|
||||
padding: '0 8px 0 16px',
|
||||
color: '#9ca3af',
|
||||
fontSize: '13px',
|
||||
minWidth: '32px',
|
||||
},
|
||||
'.cm-answer-gutter': {
|
||||
minWidth: '140px',
|
||||
textAlign: 'right',
|
||||
paddingRight: '16px',
|
||||
borderLeft: '1px solid #e5e4e7',
|
||||
backgroundColor: '#f8f9fa',
|
||||
fontFamily: 'ui-monospace, Consolas, monospace',
|
||||
fontSize: '14px',
|
||||
},
|
||||
'&dark .cm-answer-gutter': {
|
||||
borderLeft: '1px solid #2e303a',
|
||||
backgroundColor: '#1a1b23',
|
||||
},
|
||||
'.cm-answer-value': {
|
||||
color: '#6366f1',
|
||||
fontWeight: '600',
|
||||
},
|
||||
'&dark .cm-answer-value': {
|
||||
color: '#818cf8',
|
||||
},
|
||||
'.cm-answer-error': {
|
||||
color: '#e53e3e',
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
'&dark .cm-answer-error': {
|
||||
color: '#fc8181',
|
||||
},
|
||||
'.cm-activeLine': {
|
||||
backgroundColor: 'rgba(99, 102, 241, 0.04)',
|
||||
},
|
||||
'&dark .cm-activeLine': {
|
||||
backgroundColor: 'rgba(129, 140, 248, 0.06)',
|
||||
},
|
||||
'.cm-selectionBackground': {
|
||||
backgroundColor: 'rgba(99, 102, 241, 0.15) !important',
|
||||
},
|
||||
'.cm-focused': {
|
||||
outline: 'none',
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user