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:
C. Cassel
2026-03-17 09:46:40 -04:00
committed by C. Cassel
parent 68fa54615a
commit 806e2f1ec6
73 changed files with 11715 additions and 32 deletions

View 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',
},
})