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

24
calcpad-web/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

19
calcpad-web/index.html Normal file
View File

@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="CalcPad - A modern notepad calculator powered by WebAssembly" />
<meta name="theme-color" content="#6366f1" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="CalcPad" />
<link rel="apple-touch-icon" href="/icons/icon-192.svg" />
<title>CalcPad</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

33
calcpad-web/package.json Normal file
View File

@@ -0,0 +1,33 @@
{
"name": "calcpad-web",
"private": true,
"version": "0.1.0",
"type": "module",
"description": "CalcPad web app — React + CodeMirror 6 + WASM engine",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@codemirror/commands": "^6.8.0",
"@codemirror/language": "^6.11.0",
"@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.36.5",
"@lezer/highlight": "^1.2.1",
"codemirror": "^6.0.1",
"react": "^19.2.4",
"react-dom": "^19.2.4"
},
"devDependencies": {
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
"typescript": "~5.9.3",
"vite": "^7.0.0",
"vite-plugin-pwa": "^1.2.0",
"vitest": "^4.1.0"
}
}

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="6" fill="#6366f1"/>
<text x="16" y="23" text-anchor="middle" font-family="system-ui, sans-serif" font-weight="700" font-size="18" fill="white">=</text>
</svg>

After

Width:  |  Height:  |  Size: 257 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 192">
<rect width="192" height="192" rx="24" fill="#6366f1"/>
<text x="96" y="128" text-anchor="middle" font-family="system-ui, sans-serif" font-weight="700" font-size="100" fill="white">=</text>
</svg>

After

Width:  |  Height:  |  Size: 264 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="64" fill="#6366f1"/>
<text x="256" y="340" text-anchor="middle" font-family="system-ui, sans-serif" font-weight="700" font-size="260" fill="white">=</text>
</svg>

After

Width:  |  Height:  |  Size: 265 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" fill="#6366f1"/>
<text x="256" y="340" text-anchor="middle" font-family="system-ui, sans-serif" font-weight="700" font-size="260" fill="white">=</text>
</svg>

After

Width:  |  Height:  |  Size: 257 B

89
calcpad-web/src/App.tsx Normal file
View File

@@ -0,0 +1,89 @@
/**
* CalcPad main application component.
*
* Two-column layout:
* Left: CodeMirror 6 editor with CalcPad syntax highlighting
* Right: Answer gutter (integrated into CodeMirror) + optional standalone AnswerColumn
*
* The WASM engine runs in a Web Worker. On each document change (debounced),
* the editor sends lines to the worker, which evaluates them and posts back
* results. Results are fed into the CodeMirror answer gutter extension.
*/
import { useCallback } from 'react'
import { CalcEditor } from './editor/CalcEditor.tsx'
import { useEngine } from './engine/useEngine.ts'
import { useOnlineStatus } from './hooks/useOnlineStatus.ts'
import { useInstallPrompt } from './hooks/useInstallPrompt.ts'
import { OfflineBanner } from './components/OfflineBanner.tsx'
import { InstallPrompt } from './components/InstallPrompt.tsx'
import './styles/app.css'
const INITIAL_DOC = `# CalcPad
// Basic arithmetic
2 + 3
10 * 4.5
100 / 7
// Variables
price = 49.99
quantity = 3
subtotal = price * quantity
// Percentages
tax = subtotal * 8%
total = subtotal + tax
// Functions
sqrt(144)
2 ^ 10
`
function App() {
const engine = useEngine()
const isOnline = useOnlineStatus()
const installPrompt = useInstallPrompt()
const handleDocChange = useCallback(
(lines: string[]) => {
engine.evalSheet(lines)
},
[engine.evalSheet],
)
return (
<div className="calcpad-app">
<OfflineBanner isOnline={isOnline} />
<header className="calcpad-header">
<h1>CalcPad</h1>
<p className="subtitle">Notepad Calculator</p>
<div className="header-status">
<span className={`status-dot ${engine.ready ? '' : 'loading'}`} />
<span>{engine.ready ? 'Engine ready' : 'Loading engine...'}</span>
</div>
</header>
<main className="calcpad-editor">
<div className="editor-pane">
<CalcEditor
initialDoc={INITIAL_DOC}
onDocChange={handleDocChange}
results={engine.results}
debounceMs={50}
/>
</div>
</main>
<InstallPrompt
promptEvent={installPrompt.promptEvent}
isInstalled={installPrompt.isInstalled}
onInstall={installPrompt.handleInstall}
onDismiss={installPrompt.handleDismiss}
/>
</div>
)
}
export default App

View File

@@ -0,0 +1,49 @@
/**
* Right-side answer column that displays evaluation results
* as a standalone panel (alternative to the gutter-based display).
*
* This component renders results in a scrollable column synced
* to the editor's line height. Each line shows the display value
* from the engine, color-coded by result type.
*/
import type { EngineLineResult } from '../engine/types.ts'
import '../styles/answer-column.css'
export interface AnswerColumnProps {
results: EngineLineResult[]
}
function resultClassName(type: string): string {
switch (type) {
case 'number':
case 'unitValue':
case 'currencyValue':
case 'dateTime':
case 'timeDelta':
case 'boolean':
return 'answer-value'
case 'error':
return 'answer-error'
default:
return 'answer-empty'
}
}
export function AnswerColumn({ results }: AnswerColumnProps) {
return (
<div className="answer-column" aria-label="Calculation results" role="complementary">
{results.map((result, i) => (
<div
key={i}
className={`answer-line ${resultClassName(result.type)}`}
title={result.error ?? result.display}
>
{result.type === 'error'
? 'Error'
: result.display || '\u00A0'}
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,45 @@
/**
* PWA install prompt shown when the browser supports app installation.
* Adapted from epic/9-3-pwa-support.
*/
import '../styles/install-prompt.css'
interface BeforeInstallPromptEvent extends Event {
prompt(): Promise<void>
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>
}
interface InstallPromptProps {
promptEvent: BeforeInstallPromptEvent | null
isInstalled: boolean
onInstall: () => Promise<void>
onDismiss: () => void
}
export function InstallPrompt({
promptEvent,
isInstalled,
onInstall,
onDismiss,
}: InstallPromptProps) {
if (isInstalled || !promptEvent) return null
return (
<div className="install-prompt" role="complementary" aria-label="Install CalcPad">
<div className="install-content">
<p className="install-text">
Install CalcPad for offline access and a native app experience.
</p>
<div className="install-actions">
<button className="install-btn" onClick={onInstall}>
Install
</button>
<button className="install-dismiss" onClick={onDismiss}>
Not now
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,23 @@
/**
* Banner shown when the user is offline.
* Adapted from epic/9-3-pwa-support.
*/
import '../styles/offline-banner.css'
interface OfflineBannerProps {
isOnline: boolean
}
export function OfflineBanner({ isOnline }: OfflineBannerProps) {
if (isOnline) return null
return (
<div className="offline-banner" role="status" aria-live="polite">
<span className="offline-icon" aria-hidden="true">
&#9679;
</span>
You are offline. Changes are saved locally.
</div>
)
}

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

View File

@@ -0,0 +1,104 @@
/**
* Custom gutter for displaying computed results alongside each line.
* Adapted from epic/9-2-codemirror-6-editor.
*
* The answer column is right-aligned and visually distinct from the input.
*/
import { GutterMarker, gutter } from '@codemirror/view'
import { StateField, StateEffect, type Extension } from '@codemirror/state'
import type { EngineLineResult } from '../engine/types.ts'
// --- State Effects ---
export interface LineAnswer {
line: number // 1-indexed line number
result: EngineLineResult | null
}
export const setAnswersEffect = StateEffect.define<LineAnswer[]>()
// --- Gutter Markers ---
class AnswerMarker extends GutterMarker {
constructor(
readonly text: string,
readonly isError: boolean,
) {
super()
}
override toDOM(): HTMLElement {
const span = document.createElement('span')
span.className = this.isError ? 'cm-answer-error' : 'cm-answer-value'
span.textContent = this.text
return span
}
override eq(other: GutterMarker): boolean {
return (
other instanceof AnswerMarker &&
other.text === this.text &&
other.isError === this.isError
)
}
}
// --- State Field ---
export const answersField = StateField.define<Map<number, EngineLineResult | null>>({
create() {
return new Map()
},
update(answers, tr) {
for (const effect of tr.effects) {
if (effect.is(setAnswersEffect)) {
const newAnswers = new Map<number, EngineLineResult | null>()
for (const { line, result } of effect.value) {
newAnswers.set(line, result)
}
return newAnswers
}
}
return answers
},
})
// --- Gutter Extension ---
const DISPLAYABLE_TYPES = new Set(['number', 'unitValue', 'currencyValue', 'dateTime', 'timeDelta', 'boolean'])
export const answerGutter = gutter({
class: 'cm-answer-gutter',
lineMarker(view, line) {
const doc = view.state.doc
const lineNumber = doc.lineAt(line.from).number
const answers = view.state.field(answersField)
const result = answers.get(lineNumber)
if (!result) return null
if (result.type === 'error') {
return new AnswerMarker('Error', true)
}
if (DISPLAYABLE_TYPES.has(result.type) && result.display) {
return new AnswerMarker(result.display, false)
}
return null
},
lineMarkerChange(update) {
return update.transactions.some((tr) =>
tr.effects.some((e) => e.is(setAnswersEffect)),
)
},
})
/**
* Creates the answer gutter extension bundle.
*/
export function answerGutterExtension(): Extension {
return [answersField, answerGutter]
}

View File

@@ -0,0 +1,145 @@
/**
* CodeMirror 6 language extension for CalcPad syntax highlighting.
* Adapted from epic/9-2-codemirror-6-editor.
*
* Uses StreamLanguage to tokenize CalcPad input and map token types
* to CodeMirror highlight tags.
*/
import { StreamLanguage, type StringStream, LanguageSupport } from '@codemirror/language'
import { tags, type Tag } from '@lezer/highlight'
const KEYWORDS = new Set([
'if', 'then', 'else',
'in', 'to', 'as', 'of', 'per',
'sum', 'total', 'average', 'avg', 'min', 'max', 'count',
])
const BUILTIN_FUNCTIONS = new Set([
'sin', 'cos', 'tan', 'asin', 'acos', 'atan',
'log', 'ln', 'exp', 'sqrt', 'abs',
'floor', 'ceil', 'round',
])
const CONSTANTS = new Set(['pi', 'e'])
export interface CalcPadState {
inComment: boolean
}
export const calcpadStreamParser = {
startState(): CalcPadState {
return { inComment: false }
},
token(stream: StringStream, _state: CalcPadState): string | null {
// Start of line: check for heading
if (stream.sol() && stream.match(/^#{1,6}\s/)) {
stream.skipToEnd()
return 'heading'
}
// Comment
if (stream.match('//')) {
stream.skipToEnd()
return 'lineComment'
}
// Skip whitespace
if (stream.eatSpace()) return null
// Line reference: #N
if (stream.match(/^#\d+/)) {
return 'special'
}
// Line reference: lineN
if (stream.match(/^line\d+/i)) {
return 'special'
}
// Number (integer or decimal, with optional scientific notation)
if (stream.match(/^\d+(\.\d+)?([eE][+-]?\d+)?/)) {
return 'number'
}
// Assignment operator
if (stream.eat('=')) {
return 'definitionOperator'
}
// Operators
if (stream.match(/^[+\-*\/^%]/)) {
return 'operator'
}
// Comparison operators
if (stream.match(/^[<>]=?/) || stream.match(/^[!=]=/)) {
return 'operator'
}
// Parentheses
if (stream.eat('(') || stream.eat(')')) {
return 'paren'
}
// Comma
if (stream.eat(',')) {
return 'punctuation'
}
// Currency symbols
if (stream.eat('$') || stream.eat('\u20AC') || stream.eat('\u00A3') || stream.eat('\u00A5')) {
return 'keyword'
}
// Identifier, keyword, or function
const identMatch = stream.match(/^[a-zA-Z_][a-zA-Z0-9_]*/)
if (identMatch) {
const word = typeof identMatch === 'string' ? identMatch : (identMatch as RegExpMatchArray)[0]
const lower = word.toLowerCase()
if (BUILTIN_FUNCTIONS.has(lower)) {
return 'function'
}
if (CONSTANTS.has(lower)) {
return 'constant'
}
if (KEYWORDS.has(lower)) {
return 'keyword'
}
return 'variableName'
}
// Skip unknown character
stream.next()
return null
},
}
export const calcpadStreamLanguage = StreamLanguage.define<CalcPadState>(calcpadStreamParser)
/**
* Tag mapping for CalcPad token types to CodeMirror highlight tags.
*/
export const calcpadHighlightTags: Record<string, Tag> = {
number: tags.number,
operator: tags.operator,
variableName: tags.variableName,
function: tags.function(tags.variableName),
keyword: tags.keyword,
lineComment: tags.lineComment,
heading: tags.heading,
definitionOperator: tags.definitionOperator,
special: tags.special(tags.variableName),
constant: tags.constant(tags.variableName),
paren: tags.paren,
punctuation: tags.punctuation,
}
/**
* Creates a CalcPad language extension for CodeMirror 6.
*/
export function calcpadLanguage(): LanguageSupport {
return new LanguageSupport(calcpadStreamLanguage)
}

View File

@@ -0,0 +1,142 @@
/**
* Inline error display for CalcPad.
* Adapted from epic/9-2-codemirror-6-editor.
*
* Shows red underline decorations and gutter markers for lines with errors.
*/
import {
Decoration,
type DecorationSet,
GutterMarker,
gutter,
EditorView,
} from '@codemirror/view'
import { StateField, StateEffect, type Extension, RangeSet } from '@codemirror/state'
// --- State Effects ---
export interface LineError {
from: number // absolute start position in document
to: number // absolute end position in document
message: string
}
export const setErrorsEffect = StateEffect.define<LineError[]>()
// --- Error Gutter Marker ---
class ErrorGutterMarker extends GutterMarker {
override toDOM(): HTMLElement {
const span = document.createElement('span')
span.className = 'cm-error-marker'
span.textContent = '\u26A0' // Warning sign
return span
}
override eq(other: GutterMarker): boolean {
return other instanceof ErrorGutterMarker
}
}
const errorMarkerInstance = new ErrorGutterMarker()
// --- Error Underline Decorations (StateField) ---
const errorUnderlineMark = Decoration.mark({ class: 'cm-error-underline' })
export const errorDecorationsField = StateField.define<DecorationSet>({
create() {
return Decoration.none
},
update(decos, tr) {
for (const effect of tr.effects) {
if (effect.is(setErrorsEffect)) {
if (effect.value.length === 0) {
return Decoration.none
}
const ranges = effect.value
.filter((e) => e.from < e.to)
.sort((a, b) => a.from - b.from)
.map((e) => errorUnderlineMark.range(e.from, e.to))
return RangeSet.of(ranges)
}
}
if (tr.docChanged) {
return decos.map(tr.changes)
}
return decos
},
provide(field) {
return EditorView.decorations.from(field)
},
})
// --- Error Lines Set (for gutter) ---
export const errorLinesField = StateField.define<Set<number>>({
create() {
return new Set()
},
update(lines, tr) {
for (const effect of tr.effects) {
if (effect.is(setErrorsEffect)) {
const newLines = new Set<number>()
for (const error of effect.value) {
const lineNumber = tr.state.doc.lineAt(error.from).number
newLines.add(lineNumber)
}
return newLines
}
}
return lines
},
})
// --- Error Gutter ---
export const errorGutter = gutter({
class: 'cm-error-gutter',
lineMarker(view, line) {
const lineNumber = view.state.doc.lineAt(line.from).number
const errorLines = view.state.field(errorLinesField)
return errorLines.has(lineNumber) ? errorMarkerInstance : null
},
lineMarkerChange(update) {
return update.transactions.some((tr) =>
tr.effects.some((e) => e.is(setErrorsEffect)),
)
},
})
// --- Base Theme ---
export const errorBaseTheme = EditorView.baseTheme({
'.cm-error-underline': {
textDecoration: 'underline wavy red',
},
'.cm-error-marker': {
color: '#e53e3e',
fontSize: '14px',
},
'.cm-error-gutter': {
width: '20px',
},
})
/**
* Creates the error display extension bundle.
*/
export function errorDisplayExtension(): Extension {
return [
errorDecorationsField,
errorLinesField,
errorGutter,
errorBaseTheme,
]
}

View File

@@ -0,0 +1,28 @@
/**
* Types shared between the main thread and the WASM engine worker.
* These mirror the JsResult struct from calcpad-wasm/src/types.rs.
*/
/** Result from evaluating a single line via the WASM engine. */
export interface EngineLineResult {
/** Result type: number, unitValue, currencyValue, dateTime, timeDelta, boolean, error, text, comment, empty */
type: string
/** Display-formatted string of the result */
display: string
/** Raw numeric value, if applicable */
rawValue: number | null
/** Error message, if this is an error result */
error: string | null
}
/** Messages sent from the main thread to the worker. */
export type WorkerRequest =
| { kind: 'init' }
| { kind: 'evalSheet'; id: number; lines: string[] }
/** Messages sent from the worker back to the main thread. */
export type WorkerResponse =
| { kind: 'ready' }
| { kind: 'initError'; error: string }
| { kind: 'evalResult'; id: number; results: EngineLineResult[] }
| { kind: 'evalError'; id: number; error: string }

View File

@@ -0,0 +1,91 @@
/**
* React hook that manages communication with the calcpad WASM engine
* running in a Web Worker. Provides a function to evaluate a sheet
* (array of lines) and returns results asynchronously.
*/
import { useRef, useEffect, useCallback, useState } from 'react'
import type { WorkerResponse, EngineLineResult } from './types.ts'
export interface EngineState {
/** Whether the worker is initialized and ready */
ready: boolean
/** Evaluate a full sheet; returns results per line */
evalSheet: (lines: string[]) => void
/** The most recent evaluation results, one per input line */
results: EngineLineResult[]
/** Error from the last evaluation, if any */
error: string | null
}
export function useEngine(): EngineState {
const workerRef = useRef<Worker | null>(null)
const requestIdRef = useRef(0)
const latestIdRef = useRef(0)
const [ready, setReady] = useState(false)
const [results, setResults] = useState<EngineLineResult[]>([])
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const worker = new Worker(
new URL('./worker.ts', import.meta.url),
{ type: 'module' },
)
workerRef.current = worker
worker.onmessage = (event: MessageEvent<WorkerResponse>) => {
const msg = event.data
switch (msg.kind) {
case 'ready':
setReady(true)
break
case 'initError':
setError(msg.error)
break
case 'evalResult':
// Only apply if this is the most recent request
if (msg.id === latestIdRef.current) {
setResults(msg.results)
setError(null)
}
break
case 'evalError':
if (msg.id === latestIdRef.current) {
setError(msg.error)
}
break
}
}
worker.onerror = (err) => {
setError(`Worker error: ${err.message}`)
}
// Initialize the worker (trigger WASM loading)
worker.postMessage({ kind: 'init' })
return () => {
worker.terminate()
workerRef.current = null
}
}, [])
const evalSheet = useCallback((lines: string[]) => {
if (!workerRef.current) return
const id = ++requestIdRef.current
latestIdRef.current = id
workerRef.current.postMessage({
kind: 'evalSheet',
id,
lines,
})
}, [])
return { ready, evalSheet, results, error }
}

View File

@@ -0,0 +1,133 @@
/**
* Web Worker that loads the calcpad-wasm module and evaluates sheets
* off the main thread. Communication is via structured postMessage.
*
* The WASM module is expected at `/wasm/calcpad_wasm_bg.wasm` with
* its JS glue at `/wasm/calcpad_wasm.js` (output of wasm-pack).
*
* If the WASM module is not available (e.g., during development without
* a WASM build), the worker falls back to a lightweight JS evaluator.
*/
import type { WorkerRequest, WorkerResponse, EngineLineResult } from './types.ts'
// ---------- WASM engine interface ----------
interface CalcpadWasm {
evalSheet(lines: string[]): EngineLineResult[]
}
let engine: CalcpadWasm | null = null
// ---------- Fallback JS evaluator ----------
function fallbackEvalSheet(lines: string[]): EngineLineResult[] {
return lines.map((line) => {
const trimmed = line.trim()
if (trimmed === '') {
return { type: 'empty', display: '', rawValue: null, error: null }
}
if (trimmed.startsWith('//')) {
return { type: 'comment', display: trimmed.slice(2).trim(), rawValue: null, error: null }
}
if (/^#{1,6}\s/.test(trimmed)) {
return { type: 'text', display: trimmed, rawValue: null, error: null }
}
// Try to evaluate as a simple math expression
try {
// Strip inline comments
const exprPart = trimmed.replace(/\/\/.*$/, '').trim()
if (exprPart === '') {
return { type: 'empty', display: '', rawValue: null, error: null }
}
// Handle variable assignments: "name = expr"
const assignMatch = exprPart.match(/^([a-zA-Z_]\w*)\s*=\s*(.+)$/)
const expr = assignMatch ? assignMatch[2] : exprPart
// Very basic: try Function-based evaluation for simple arithmetic
// This is a development fallback only; production uses the WASM engine.
const sanitized = expr
.replace(/\^/g, '**')
.replace(/\bpi\b/gi, String(Math.PI))
.replace(/\be\b/gi, String(Math.E))
// Only allow safe characters for eval
if (/^[\d\s+\-*/.()%,eE]+$/.test(sanitized)) {
// eslint-disable-next-line no-new-func
const result = new Function(`"use strict"; return (${sanitized})`)() as number
if (typeof result === 'number' && isFinite(result)) {
const display = Number.isInteger(result) ? result.toString() : parseFloat(result.toPrecision(10)).toString()
return { type: 'number', display, rawValue: result, error: null }
}
}
return { type: 'text', display: '', rawValue: null, error: null }
} catch {
return { type: 'text', display: '', rawValue: null, error: null }
}
})
}
// ---------- WASM loading ----------
async function initWasm(): Promise<boolean> {
try {
// Try to load the wasm-pack output
const wasmModule = await import(/* @vite-ignore */ '/wasm/calcpad_wasm.js') as {
default: () => Promise<void>
evalSheet: (lines: string[]) => EngineLineResult[]
}
await wasmModule.default()
engine = {
evalSheet: (lines: string[]) => wasmModule.evalSheet(lines),
}
return true
} catch {
// WASM not available; fallback mode
console.warn('[calcpad-worker] WASM engine not found, using JS fallback')
return false
}
}
// ---------- Message handler ----------
function sendResponse(msg: WorkerResponse) {
self.postMessage(msg)
}
self.onmessage = async (event: MessageEvent<WorkerRequest>) => {
const msg = event.data
switch (msg.kind) {
case 'init': {
const loaded = await initWasm()
if (loaded) {
sendResponse({ kind: 'ready' })
} else {
// Still report ready — we have the fallback
sendResponse({ kind: 'ready' })
}
break
}
case 'evalSheet': {
try {
const results = engine
? engine.evalSheet(msg.lines)
: fallbackEvalSheet(msg.lines)
sendResponse({ kind: 'evalResult', id: msg.id, results })
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
sendResponse({ kind: 'evalError', id: msg.id, error: message })
}
break
}
}
}

View File

@@ -0,0 +1,69 @@
/**
* React hook that captures the beforeinstallprompt event for PWA install.
* Adapted from epic/9-3-pwa-support.
*/
import { useState, useEffect, useCallback } from 'react'
interface BeforeInstallPromptEvent extends Event {
prompt(): Promise<void>
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>
}
export interface InstallPromptState {
promptEvent: BeforeInstallPromptEvent | null
isInstalled: boolean
handleInstall: () => Promise<void>
handleDismiss: () => void
}
export function useInstallPrompt(): InstallPromptState {
const [promptEvent, setPromptEvent] = useState<BeforeInstallPromptEvent | null>(null)
const [isInstalled, setIsInstalled] = useState(false)
useEffect(() => {
// Check if already installed (standalone mode)
const isStandalone =
window.matchMedia('(display-mode: standalone)').matches ||
('standalone' in navigator && (navigator as unknown as { standalone: boolean }).standalone)
if (isStandalone) {
setIsInstalled(true)
return
}
const handler = (e: Event) => {
e.preventDefault()
setPromptEvent(e as BeforeInstallPromptEvent)
}
const installedHandler = () => {
setIsInstalled(true)
setPromptEvent(null)
}
window.addEventListener('beforeinstallprompt', handler)
window.addEventListener('appinstalled', installedHandler)
return () => {
window.removeEventListener('beforeinstallprompt', handler)
window.removeEventListener('appinstalled', installedHandler)
}
}, [])
const handleInstall = useCallback(async () => {
if (!promptEvent) return
await promptEvent.prompt()
const result = await promptEvent.userChoice
if (result.outcome === 'accepted') {
setIsInstalled(true)
}
setPromptEvent(null)
}, [promptEvent])
const handleDismiss = useCallback(() => {
setPromptEvent(null)
}, [])
return { promptEvent, isInstalled, handleInstall, handleDismiss }
}

View File

@@ -0,0 +1,27 @@
/**
* React hook that tracks whether the browser is online or offline.
* Adapted from epic/9-3-pwa-support.
*/
import { useState, useEffect } from 'react'
export function useOnlineStatus(): boolean {
const [isOnline, setIsOnline] = useState(
typeof navigator !== 'undefined' ? navigator.onLine : true,
)
useEffect(() => {
const handleOnline = () => setIsOnline(true)
const handleOffline = () => setIsOnline(false)
window.addEventListener('online', handleOnline)
window.addEventListener('offline', handleOffline)
return () => {
window.removeEventListener('online', handleOnline)
window.removeEventListener('offline', handleOffline)
}
}, [])
return isOnline
}

31
calcpad-web/src/main.tsx Normal file
View File

@@ -0,0 +1,31 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './styles/index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
// Register service worker for PWA support
if ('serviceWorker' in navigator) {
window.addEventListener('load', async () => {
try {
const { registerSW } = await import('virtual:pwa-register')
registerSW({
onNeedRefresh() {
if (confirm('New version available. Reload to update?')) {
window.location.reload()
}
},
onOfflineReady() {
console.log('CalcPad is ready to work offline')
},
})
} catch {
// PWA registration not available in dev mode
}
})
}

View File

@@ -0,0 +1,47 @@
/* ---------- Answer column (standalone panel mode) ---------- */
.answer-column {
width: 220px;
padding: 12px 0;
border-left: 1px solid var(--border);
background: var(--bg-secondary);
overflow-y: auto;
flex-shrink: 0;
}
.answer-line {
padding: 0 16px;
font-family: var(--mono);
font-size: 14px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
/* Match CodeMirror's line height for alignment */
height: 24px;
}
.answer-value {
color: var(--accent);
font-weight: 600;
text-align: right;
}
.answer-error {
color: var(--error);
font-style: italic;
text-align: right;
}
.answer-empty {
color: transparent;
}
@media (max-width: 640px) {
.answer-column {
width: 100%;
max-height: 120px;
border-left: none;
border-top: 1px solid var(--border);
}
}

View File

@@ -0,0 +1,92 @@
/* ---------- App layout ---------- */
.calcpad-app {
display: flex;
flex-direction: column;
height: 100svh;
overflow: hidden;
}
/* ---------- Header ---------- */
.calcpad-header {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 24px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.calcpad-header h1 {
margin: 0;
font-size: 20px;
letter-spacing: -0.3px;
}
.calcpad-header .subtitle {
margin: 0;
font-size: 13px;
color: var(--text);
}
.header-status {
margin-left: auto;
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--text);
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--success);
}
.status-dot.loading {
background: var(--warning);
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
/* ---------- Editor area ---------- */
.calcpad-editor {
flex: 1;
display: flex;
overflow: hidden;
}
.editor-pane {
flex: 1;
min-width: 0;
overflow: hidden;
}
.editor-pane .calc-editor {
height: 100%;
}
/* Ensure CodeMirror fills its container */
.editor-pane .cm-editor {
height: 100%;
}
/* ---------- Responsive ---------- */
@media (max-width: 640px) {
.calcpad-header {
padding: 10px 16px;
}
.calcpad-header .subtitle {
display: none;
}
}

View File

@@ -0,0 +1,60 @@
:root {
--text: #6b6375;
--text-h: #08060d;
--bg: #fff;
--bg-secondary: #f8f9fa;
--border: #e5e4e7;
--code-bg: #f4f3ec;
--accent: #6366f1;
--accent-bg: rgba(99, 102, 241, 0.1);
--accent-border: rgba(99, 102, 241, 0.5);
--warning: #f59e0b;
--warning-bg: rgba(245, 158, 11, 0.1);
--success: #10b981;
--success-bg: rgba(16, 185, 129, 0.1);
--error: #e53e3e;
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
--mono: ui-monospace, Consolas, 'Courier New', monospace;
font: 16px/1.5 var(--sans);
color-scheme: light dark;
color: var(--text);
background: var(--bg);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
@media (prefers-color-scheme: dark) {
:root {
--text: #9ca3af;
--text-h: #f3f4f6;
--bg: #16171d;
--bg-secondary: #1a1b23;
--border: #2e303a;
--code-bg: #1f2028;
--accent: #818cf8;
--accent-bg: rgba(129, 140, 248, 0.15);
--accent-border: rgba(129, 140, 248, 0.5);
}
}
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
margin: 0;
}
h1,
h2 {
font-family: var(--heading);
font-weight: 600;
color: var(--text-h);
}

View File

@@ -0,0 +1,69 @@
.install-prompt {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 16px 24px;
background: var(--bg);
border-top: 1px solid var(--border);
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.1);
z-index: 100;
}
.install-content {
max-width: 600px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.install-text {
margin: 0;
font-size: 14px;
color: var(--text-h);
}
.install-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.install-btn {
padding: 8px 20px;
border-radius: 6px;
border: none;
background: var(--accent);
color: #fff;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: opacity 0.2s;
}
.install-btn:hover {
opacity: 0.9;
}
.install-dismiss {
padding: 8px 16px;
border-radius: 6px;
border: 1px solid var(--border);
background: transparent;
color: var(--text);
font-size: 14px;
cursor: pointer;
}
.install-dismiss:hover {
background: var(--accent-bg);
}
@media (max-width: 480px) {
.install-content {
flex-direction: column;
text-align: center;
}
}

View File

@@ -0,0 +1,23 @@
.offline-banner {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 8px 16px;
background: var(--warning-bg);
color: var(--warning);
font-size: 14px;
font-weight: 500;
border-bottom: 1px solid var(--warning);
flex-shrink: 0;
}
.offline-icon {
font-size: 8px;
animation: offline-pulse 2s ease-in-out infinite;
}
@keyframes offline-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}

14
calcpad-web/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,14 @@
/// <reference types="vite/client" />
declare module 'virtual:pwa-register' {
export interface RegisterSWOptions {
immediate?: boolean
onNeedRefresh?: () => void
onOfflineReady?: () => void
onRegistered?: (registration: ServiceWorkerRegistration | undefined) => void
onRegisteredSW?: (swScriptUrl: string, registration: ServiceWorkerRegistration | undefined) => void
onRegisterError?: (error: unknown) => void
}
export function registerSW(options?: RegisterSWOptions): (reloadPage?: boolean) => Promise<void>
}

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2023",
"useDefineForClassFields": true,
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,95 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { VitePWA } from 'vite-plugin-pwa'
export default defineConfig({
plugins: [
react(),
VitePWA({
registerType: 'prompt',
includeAssets: ['favicon.svg', 'icons/*.svg'],
manifest: {
name: 'CalcPad',
short_name: 'CalcPad',
description: 'A modern notepad calculator powered by WebAssembly',
theme_color: '#6366f1',
background_color: '#ffffff',
display: 'standalone',
orientation: 'any',
scope: '/',
start_url: '/',
icons: [
{
src: '/icons/icon-192.svg',
sizes: '192x192',
type: 'image/svg+xml',
purpose: 'any',
},
{
src: '/icons/icon-512.svg',
sizes: '512x512',
type: 'image/svg+xml',
purpose: 'any',
},
{
src: '/icons/icon-maskable-512.svg',
sizes: '512x512',
type: 'image/svg+xml',
purpose: 'maskable',
},
],
categories: ['productivity', 'utilities'],
},
workbox: {
globPatterns: ['**/*.{js,css,html,svg,wasm}'],
runtimeCaching: [
{
urlPattern: /^https:\/\/api\./,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
expiration: {
maxEntries: 50,
maxAgeSeconds: 60 * 60 * 24,
},
networkTimeoutSeconds: 5,
},
},
{
urlPattern: /\.(?:png|jpg|jpeg|svg|gif|webp)$/,
handler: 'CacheFirst',
options: {
cacheName: 'image-cache',
expiration: {
maxEntries: 100,
maxAgeSeconds: 60 * 60 * 24 * 30,
},
},
},
],
},
devOptions: {
enabled: false,
},
}),
],
build: {
target: 'es2022',
rollupOptions: {
output: {
manualChunks: {
react: ['react', 'react-dom'],
codemirror: [
'@codemirror/state',
'@codemirror/view',
'@codemirror/language',
'@lezer/highlight',
],
},
},
},
},
worker: {
format: 'es',
},
})