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:
@@ -23,7 +23,8 @@
|
|||||||
"Bash(git commit:*)",
|
"Bash(git commit:*)",
|
||||||
"Bash(/Users/cassel/.cargo/bin/cargo test:*)",
|
"Bash(/Users/cassel/.cargo/bin/cargo test:*)",
|
||||||
"Bash(tee /tmp/test-output.txt)",
|
"Bash(tee /tmp/test-output.txt)",
|
||||||
"Bash(echo \"EXIT: $?\")"
|
"Bash(echo \"EXIT: $?\")",
|
||||||
|
"Bash(/Users/cassel/.cargo/bin/cargo build:*)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
4145
Cargo.lock
generated
4145
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,8 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
members = [
|
members = [
|
||||||
"calcpad-engine",
|
"calcpad-engine",
|
||||||
|
"calcpad-cli",
|
||||||
"calcpad-wasm",
|
"calcpad-wasm",
|
||||||
|
"calcpad-windows",
|
||||||
]
|
]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|||||||
15
calcpad-cli/Cargo.toml
Normal file
15
calcpad-cli/Cargo.toml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
[package]
|
||||||
|
name = "calcpad-cli"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "CLI tool for CalcPad — notepad-style calculator"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "calcpad"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
calcpad-engine = { path = "../calcpad-engine" }
|
||||||
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
rustyline = "15"
|
||||||
|
serde_json = "1"
|
||||||
328
calcpad-cli/src/main.rs
Normal file
328
calcpad-cli/src/main.rs
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
use std::io::{self, IsTerminal, Read};
|
||||||
|
use std::process::ExitCode;
|
||||||
|
|
||||||
|
use clap::{Parser, ValueEnum};
|
||||||
|
use rustyline::error::ReadlineError;
|
||||||
|
use rustyline::DefaultEditor;
|
||||||
|
|
||||||
|
use calcpad_engine::{eval_line, eval_sheet, CalcResult, CalcValue, EvalContext, ResultType};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// CLI argument parsing
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, ValueEnum)]
|
||||||
|
enum OutputFormat {
|
||||||
|
Plain,
|
||||||
|
Json,
|
||||||
|
Csv,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
#[command(
|
||||||
|
name = "calcpad",
|
||||||
|
about = "CalcPad — notepad-style calculator",
|
||||||
|
long_about = "Evaluate mathematical expressions, unit conversions, and more.\n\n\
|
||||||
|
Examples:\n \
|
||||||
|
calcpad \"2 + 3 * 4\"\n \
|
||||||
|
calcpad \"5kg in lbs\"\n \
|
||||||
|
echo \"100 + 200\" | calcpad\n \
|
||||||
|
calcpad --repl"
|
||||||
|
)]
|
||||||
|
struct Cli {
|
||||||
|
/// Expression to evaluate (omit for stdin/pipe mode).
|
||||||
|
expression: Option<String>,
|
||||||
|
|
||||||
|
/// Start an interactive REPL session.
|
||||||
|
#[arg(long)]
|
||||||
|
repl: bool,
|
||||||
|
|
||||||
|
/// Output format.
|
||||||
|
#[arg(long, value_enum, default_value_t = OutputFormat::Plain)]
|
||||||
|
format: OutputFormat,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Result helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn is_error(result: &CalcResult) -> bool {
|
||||||
|
result.metadata.result_type == ResultType::Error
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_empty_error(result: &CalcResult) -> bool {
|
||||||
|
// The engine returns CalcResult::error("empty input", ..) or
|
||||||
|
// CalcResult::error("no expression found", ..) for blank/comment lines.
|
||||||
|
if let CalcValue::Error { ref message, .. } = result.value {
|
||||||
|
message == "empty input" || message == "no expression found"
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn error_message(result: &CalcResult) -> Option<&str> {
|
||||||
|
if let CalcValue::Error { ref message, .. } = result.value {
|
||||||
|
Some(message)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Output formatting
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn print_result_json(result: &CalcResult) {
|
||||||
|
// CalcResult derives Serialize via the engine, so we can emit it directly.
|
||||||
|
match serde_json::to_string(result) {
|
||||||
|
Ok(json) => println!("{}", json),
|
||||||
|
Err(e) => eprintln!("Error: failed to serialize result: {}", e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_results_json(results: &[CalcResult]) {
|
||||||
|
match serde_json::to_string_pretty(results) {
|
||||||
|
Ok(json) => println!("{}", json),
|
||||||
|
Err(e) => eprintln!("Error: failed to serialize results: {}", e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_result_csv(result: &CalcResult, line_num: usize) {
|
||||||
|
if is_empty_error(result) {
|
||||||
|
println!("{},,,", line_num);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// line,type,display,raw_value
|
||||||
|
let rtype = result.metadata.result_type;
|
||||||
|
let display = &result.metadata.display;
|
||||||
|
// Escape display for CSV (wrap in quotes if it contains comma/quote)
|
||||||
|
let escaped = csv_escape(display);
|
||||||
|
let raw = result
|
||||||
|
.metadata
|
||||||
|
.raw_value
|
||||||
|
.map(|v| v.to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
println!("{},{},{},{}", line_num, rtype, escaped, raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn csv_escape(s: &str) -> String {
|
||||||
|
if s.contains(',') || s.contains('"') || s.contains('\n') {
|
||||||
|
format!("\"{}\"", s.replace('"', "\"\""))
|
||||||
|
} else {
|
||||||
|
s.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Modes
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn run_single_expression(expression: &str, format: OutputFormat) -> ExitCode {
|
||||||
|
let mut ctx = EvalContext::new();
|
||||||
|
let result = eval_line(expression, &mut ctx);
|
||||||
|
|
||||||
|
if is_empty_error(&result) {
|
||||||
|
return ExitCode::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
match format {
|
||||||
|
OutputFormat::Plain => {
|
||||||
|
if is_error(&result) {
|
||||||
|
if let Some(msg) = error_message(&result) {
|
||||||
|
eprintln!("Error: {}", msg);
|
||||||
|
}
|
||||||
|
return ExitCode::from(1);
|
||||||
|
}
|
||||||
|
println!("{}", result.metadata.display);
|
||||||
|
}
|
||||||
|
OutputFormat::Json => {
|
||||||
|
print_result_json(&result);
|
||||||
|
if is_error(&result) {
|
||||||
|
return ExitCode::from(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OutputFormat::Csv => {
|
||||||
|
println!("line,type,display,raw_value");
|
||||||
|
print_result_csv(&result, 1);
|
||||||
|
if is_error(&result) {
|
||||||
|
return ExitCode::from(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ExitCode::SUCCESS
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_stdin(format: OutputFormat) -> ExitCode {
|
||||||
|
let mut input = String::new();
|
||||||
|
if let Err(e) = io::stdin().read_to_string(&mut input) {
|
||||||
|
eprintln!("Error: failed to read stdin: {}", e);
|
||||||
|
return ExitCode::from(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let lines: Vec<&str> = input.lines().collect();
|
||||||
|
if lines.is_empty() {
|
||||||
|
return ExitCode::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut ctx = EvalContext::new();
|
||||||
|
let results = eval_sheet(&lines, &mut ctx);
|
||||||
|
let mut has_error = false;
|
||||||
|
|
||||||
|
match format {
|
||||||
|
OutputFormat::Plain => {
|
||||||
|
for result in &results {
|
||||||
|
if is_empty_error(result) {
|
||||||
|
println!();
|
||||||
|
} else if is_error(result) {
|
||||||
|
if let Some(msg) = error_message(result) {
|
||||||
|
println!("Error: {}", msg);
|
||||||
|
}
|
||||||
|
has_error = true;
|
||||||
|
} else {
|
||||||
|
println!("{}", result.metadata.display);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OutputFormat::Json => {
|
||||||
|
print_results_json(&results);
|
||||||
|
has_error = results.iter().any(|r| is_error(r) && !is_empty_error(r));
|
||||||
|
}
|
||||||
|
OutputFormat::Csv => {
|
||||||
|
println!("line,type,display,raw_value");
|
||||||
|
for (i, result) in results.iter().enumerate() {
|
||||||
|
print_result_csv(result, i + 1);
|
||||||
|
if is_error(result) && !is_empty_error(result) {
|
||||||
|
has_error = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if has_error {
|
||||||
|
ExitCode::from(1)
|
||||||
|
} else {
|
||||||
|
ExitCode::SUCCESS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_repl(format: OutputFormat) -> ExitCode {
|
||||||
|
let mut rl = match DefaultEditor::new() {
|
||||||
|
Ok(editor) => editor,
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("Error: failed to initialize line editor: {}", err);
|
||||||
|
return ExitCode::from(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Try to load history from ~/.calcpad_history
|
||||||
|
let history_path = dirs_history_path();
|
||||||
|
if let Some(ref path) = history_path {
|
||||||
|
let _ = rl.load_history(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut ctx = EvalContext::new();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
match rl.readline("> ") {
|
||||||
|
Ok(line) => {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if is_exit_command(trimmed) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = rl.add_history_entry(&line);
|
||||||
|
let result = eval_line(&line, &mut ctx);
|
||||||
|
|
||||||
|
if is_empty_error(&result) {
|
||||||
|
// Blank or comment — no output.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
match format {
|
||||||
|
OutputFormat::Plain => {
|
||||||
|
if is_error(&result) {
|
||||||
|
if let Some(msg) = error_message(&result) {
|
||||||
|
eprintln!("Error: {}", msg);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
println!("{}", result.metadata.display);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OutputFormat::Json => {
|
||||||
|
print_result_json(&result);
|
||||||
|
}
|
||||||
|
OutputFormat::Csv => {
|
||||||
|
// In REPL mode we don't print a CSV header each time;
|
||||||
|
// just emit one row per expression.
|
||||||
|
let rtype = result.metadata.result_type;
|
||||||
|
let display = csv_escape(&result.metadata.display);
|
||||||
|
let raw = result
|
||||||
|
.metadata
|
||||||
|
.raw_value
|
||||||
|
.map(|v| v.to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
println!("{},{},{}", rtype, display, raw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(ReadlineError::Eof) => break,
|
||||||
|
Err(ReadlineError::Interrupted) => break,
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("Error: {}", err);
|
||||||
|
return ExitCode::from(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save history
|
||||||
|
if let Some(ref path) = history_path {
|
||||||
|
let _ = rl.save_history(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
ExitCode::SUCCESS
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn is_exit_command(input: &str) -> bool {
|
||||||
|
let t = input.trim();
|
||||||
|
t.eq_ignore_ascii_case("exit") || t.eq_ignore_ascii_case("quit")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dirs_history_path() -> Option<std::path::PathBuf> {
|
||||||
|
// Use $HOME/.calcpad_history if available
|
||||||
|
std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".calcpad_history"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Entry point
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn main() -> ExitCode {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
|
||||||
|
if cli.repl {
|
||||||
|
return run_repl(cli.format);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref expr) = cli.expression {
|
||||||
|
return run_single_expression(expr, cli.format);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No expression argument — check if stdin is piped / redirected.
|
||||||
|
if !io::stdin().is_terminal() {
|
||||||
|
return run_stdin(cli.format);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing provided — show help.
|
||||||
|
// Re-parse with --help to trigger clap's help output.
|
||||||
|
eprintln!("Usage: calcpad <EXPRESSION>");
|
||||||
|
eprintln!(" calcpad --repl");
|
||||||
|
eprintln!(" echo \"100 + 200\" | calcpad");
|
||||||
|
eprintln!();
|
||||||
|
eprintln!("Run 'calcpad --help' for full usage information.");
|
||||||
|
ExitCode::from(1)
|
||||||
|
}
|
||||||
@@ -16,3 +16,9 @@ ureq = { version = "2", features = ["json"] }
|
|||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
criterion = { version = "0.5", features = ["html_reports"] }
|
||||||
|
proptest = "1"
|
||||||
|
|
||||||
|
[[bench]]
|
||||||
|
name = "eval_benchmark"
|
||||||
|
harness = false
|
||||||
|
|||||||
155
calcpad-engine/benches/eval_benchmark.rs
Normal file
155
calcpad-engine/benches/eval_benchmark.rs
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
use criterion::{black_box, criterion_group, criterion_main, Criterion};
|
||||||
|
|
||||||
|
use calcpad_engine::context::EvalContext;
|
||||||
|
use calcpad_engine::pipeline::{eval_line, eval_sheet};
|
||||||
|
use calcpad_engine::SheetContext;
|
||||||
|
|
||||||
|
// --- Single-line benchmarks ---
|
||||||
|
|
||||||
|
fn bench_single_line_arithmetic(c: &mut Criterion) {
|
||||||
|
c.bench_function("single_line_arithmetic", |b| {
|
||||||
|
b.iter(|| {
|
||||||
|
let mut ctx = EvalContext::new();
|
||||||
|
eval_line(black_box("(3 + 4) * 2 ^ 3 - 1"), &mut ctx)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bench_single_line_unit_conversion(c: &mut Criterion) {
|
||||||
|
c.bench_function("single_line_unit_conversion", |b| {
|
||||||
|
b.iter(|| {
|
||||||
|
let mut ctx = EvalContext::new();
|
||||||
|
eval_line(black_box("5kg in lb"), &mut ctx)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Sheet benchmarks (pipeline API) ---
|
||||||
|
|
||||||
|
fn bench_100_line_sheet(c: &mut Criterion) {
|
||||||
|
let lines: Vec<String> = (0..100)
|
||||||
|
.map(|i| {
|
||||||
|
if i == 0 {
|
||||||
|
"x = 1".to_string()
|
||||||
|
} else {
|
||||||
|
format!("x = x + {}", i)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let line_refs: Vec<&str> = lines.iter().map(|s| s.as_str()).collect();
|
||||||
|
|
||||||
|
c.bench_function("100_line_sheet_pipeline", |b| {
|
||||||
|
b.iter(|| {
|
||||||
|
let mut ctx = EvalContext::new();
|
||||||
|
eval_sheet(black_box(&line_refs), &mut ctx)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bench_variable_heavy_sheet(c: &mut Criterion) {
|
||||||
|
let mut lines: Vec<String> = Vec::with_capacity(50);
|
||||||
|
for i in 0..10 {
|
||||||
|
lines.push(format!("v{} = {}", i, i * 10 + 1));
|
||||||
|
}
|
||||||
|
for i in 10..50 {
|
||||||
|
let a = i % 10;
|
||||||
|
let b = (i + 3) % 10;
|
||||||
|
lines.push(format!("r{} = v{} + v{}", i, a, b));
|
||||||
|
}
|
||||||
|
let line_refs: Vec<&str> = lines.iter().map(|s| s.as_str()).collect();
|
||||||
|
|
||||||
|
c.bench_function("variable_heavy_sheet_dag", |b| {
|
||||||
|
b.iter(|| {
|
||||||
|
let mut ctx = EvalContext::new();
|
||||||
|
eval_sheet(black_box(&line_refs), &mut ctx)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- SheetContext benchmarks (with dependency tracking) ---
|
||||||
|
|
||||||
|
fn bench_sheet_context_500_lines(c: &mut Criterion) {
|
||||||
|
let lines: Vec<String> = (0..500)
|
||||||
|
.map(|i| match i % 5 {
|
||||||
|
0 => format!("x{} = {}", i, i),
|
||||||
|
1 => format!("{} + {} * {}", i, i + 1, i + 2),
|
||||||
|
2 => format!("{}kg in g", (i as f64) * 0.1),
|
||||||
|
3 => format!("// Comment line {}", i),
|
||||||
|
4 => format!("sqrt({})", (i * i) as f64),
|
||||||
|
_ => unreachable!(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
c.bench_function("sheet_context_500_lines_full_eval", |b| {
|
||||||
|
b.iter(|| {
|
||||||
|
let mut sheet = SheetContext::new();
|
||||||
|
for (i, line) in lines.iter().enumerate() {
|
||||||
|
sheet.set_line(i, line);
|
||||||
|
}
|
||||||
|
sheet.eval()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bench_sheet_context_incremental_edit(c: &mut Criterion) {
|
||||||
|
let lines: Vec<String> = (0..500)
|
||||||
|
.map(|i| match i % 3 {
|
||||||
|
0 => format!("x{} = {}", i, i),
|
||||||
|
1 => format!("{} + {}", i, i + 1),
|
||||||
|
2 => format!("sqrt({})", (i * i) as f64),
|
||||||
|
_ => unreachable!(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut sheet = SheetContext::new();
|
||||||
|
for (i, line) in lines.iter().enumerate() {
|
||||||
|
sheet.set_line(i, line);
|
||||||
|
}
|
||||||
|
sheet.eval();
|
||||||
|
|
||||||
|
c.bench_function("sheet_context_incremental_single_edit", |b| {
|
||||||
|
let mut s = sheet.clone();
|
||||||
|
let mut counter = 0;
|
||||||
|
b.iter(|| {
|
||||||
|
counter += 1;
|
||||||
|
// Edit a line that doesn't affect others (no variable)
|
||||||
|
s.set_line(250, &format!("{} + {}", counter, counter + 1));
|
||||||
|
s.eval()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bench_sheet_context_incremental_variable_change(c: &mut Criterion) {
|
||||||
|
let mut sheet = SheetContext::new();
|
||||||
|
sheet.set_line(0, "base = 100");
|
||||||
|
for i in 1..500 {
|
||||||
|
if i % 10 == 0 {
|
||||||
|
sheet.set_line(i, "base + 1");
|
||||||
|
} else {
|
||||||
|
sheet.set_line(i, &format!("{} + {}", i, i + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sheet.eval();
|
||||||
|
|
||||||
|
c.bench_function("sheet_context_incremental_variable_change", |b| {
|
||||||
|
let mut s = sheet.clone();
|
||||||
|
let mut counter = 100;
|
||||||
|
b.iter(|| {
|
||||||
|
counter += 1;
|
||||||
|
s.set_line(0, &format!("base = {}", counter));
|
||||||
|
s.eval()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
criterion_group!(
|
||||||
|
benches,
|
||||||
|
bench_single_line_arithmetic,
|
||||||
|
bench_single_line_unit_conversion,
|
||||||
|
bench_100_line_sheet,
|
||||||
|
bench_variable_heavy_sheet,
|
||||||
|
bench_sheet_context_500_lines,
|
||||||
|
bench_sheet_context_incremental_edit,
|
||||||
|
bench_sheet_context_incremental_variable_change,
|
||||||
|
);
|
||||||
|
criterion_main!(benches);
|
||||||
666
calcpad-engine/src/formatting/answer_format.rs
Normal file
666
calcpad-engine/src/formatting/answer_format.rs
Normal file
@@ -0,0 +1,666 @@
|
|||||||
|
//! Answer column formatting for CalcPad.
|
||||||
|
//!
|
||||||
|
//! Pure-Rust formatting utilities ported from the cross-platform spec in
|
||||||
|
//! Epic 10-4 (Answer Column Formatting). Supports standard, scientific, and
|
||||||
|
//! SI notation with configurable decimal places (0--10), thousands separators,
|
||||||
|
//! and currency symbol placement.
|
||||||
|
//!
|
||||||
|
//! This module is platform-agnostic: the same options produce identical output
|
||||||
|
//! on macOS (SwiftUI), Windows (Iced), and Web (WASM), ensuring the engine is
|
||||||
|
//! the single source of truth for how numbers appear in the answer column.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Notation style for the answer column.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum Notation {
|
||||||
|
/// Fixed-point with thousands separators (e.g. `1,234.50`).
|
||||||
|
Standard,
|
||||||
|
/// Mantissa + exponent (e.g. `1.50e6`).
|
||||||
|
Scientific,
|
||||||
|
/// SI prefix (e.g. `1.50M`, `3.00k`).
|
||||||
|
SI,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Thousands-separator style.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum ThousandsSeparator {
|
||||||
|
/// `1,234,567.89`
|
||||||
|
Comma,
|
||||||
|
/// `1.234.567,89` (European-style; decimal separator becomes `,`).
|
||||||
|
Period,
|
||||||
|
/// `1 234 567.89`
|
||||||
|
Space,
|
||||||
|
/// `1234567.89`
|
||||||
|
None,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where to place a currency symbol relative to the number.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum CurrencyPosition {
|
||||||
|
/// `$1,234.50`
|
||||||
|
Prefix,
|
||||||
|
/// `1,234.50€`
|
||||||
|
Suffix,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Complete set of formatting options for the answer column.
|
||||||
|
///
|
||||||
|
/// Persisted per-sheet (or globally) and optionally overridden per-line.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct FormattingOptions {
|
||||||
|
/// Number of decimal places (clamped to 0..=10).
|
||||||
|
pub decimal_places: u8,
|
||||||
|
/// Notation style.
|
||||||
|
pub notation: Notation,
|
||||||
|
/// Thousands-separator style.
|
||||||
|
pub thousands_separator: ThousandsSeparator,
|
||||||
|
/// Currency symbol string (e.g. `"$"`, `"€"`). Empty means no symbol.
|
||||||
|
pub currency_symbol: String,
|
||||||
|
/// Where to place the currency symbol.
|
||||||
|
pub currency_position: CurrencyPosition,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-line override. Every field is optional; `None` inherits from global.
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct LineFormatOverride {
|
||||||
|
pub decimal_places: Option<u8>,
|
||||||
|
pub notation: Option<Notation>,
|
||||||
|
pub thousands_separator: Option<ThousandsSeparator>,
|
||||||
|
pub currency_symbol: Option<String>,
|
||||||
|
pub currency_position: Option<CurrencyPosition>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Defaults
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
impl Default for FormattingOptions {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
decimal_places: 2,
|
||||||
|
notation: Notation::Standard,
|
||||||
|
thousands_separator: ThousandsSeparator::Comma,
|
||||||
|
currency_symbol: String::new(),
|
||||||
|
currency_position: CurrencyPosition::Prefix,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Resolution
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Merge a per-line override on top of global settings. Missing override
|
||||||
|
/// fields inherit from the global options.
|
||||||
|
pub fn resolve_formatting(
|
||||||
|
global: &FormattingOptions,
|
||||||
|
line_override: Option<&LineFormatOverride>,
|
||||||
|
) -> FormattingOptions {
|
||||||
|
match line_override {
|
||||||
|
None => global.clone(),
|
||||||
|
Some(ov) => FormattingOptions {
|
||||||
|
decimal_places: ov.decimal_places.unwrap_or(global.decimal_places),
|
||||||
|
notation: ov.notation.unwrap_or(global.notation),
|
||||||
|
thousands_separator: ov.thousands_separator.unwrap_or(global.thousands_separator),
|
||||||
|
currency_symbol: ov
|
||||||
|
.currency_symbol
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| global.currency_symbol.clone()),
|
||||||
|
currency_position: ov.currency_position.unwrap_or(global.currency_position),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SI prefixes
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
struct SIPrefix {
|
||||||
|
symbol: &'static str,
|
||||||
|
exponent: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
const SI_PREFIXES: &[SIPrefix] = &[
|
||||||
|
SIPrefix { symbol: "T", exponent: 12 },
|
||||||
|
SIPrefix { symbol: "G", exponent: 9 },
|
||||||
|
SIPrefix { symbol: "M", exponent: 6 },
|
||||||
|
SIPrefix { symbol: "k", exponent: 3 },
|
||||||
|
SIPrefix { symbol: "", exponent: 0 },
|
||||||
|
SIPrefix { symbol: "m", exponent: -3 },
|
||||||
|
SIPrefix { symbol: "\u{03bc}", exponent: -6 }, // μ
|
||||||
|
SIPrefix { symbol: "n", exponent: -9 },
|
||||||
|
SIPrefix { symbol: "p", exponent: -12 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Core formatting helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Insert thousands separators into an integer-part string.
|
||||||
|
fn insert_thousands_separator(integer_part: &str, sep: ThousandsSeparator) -> String {
|
||||||
|
if sep == ThousandsSeparator::None {
|
||||||
|
return integer_part.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
let sep_char = match sep {
|
||||||
|
ThousandsSeparator::Comma => ',',
|
||||||
|
ThousandsSeparator::Period => '.',
|
||||||
|
ThousandsSeparator::Space => ' ',
|
||||||
|
ThousandsSeparator::None => unreachable!(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let (negative, digits) = if let Some(rest) = integer_part.strip_prefix('-') {
|
||||||
|
(true, rest)
|
||||||
|
} else {
|
||||||
|
(false, integer_part)
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut parts: Vec<&str> = Vec::new();
|
||||||
|
let len = digits.len();
|
||||||
|
let mut end = len;
|
||||||
|
while end > 3 {
|
||||||
|
let start = end - 3;
|
||||||
|
parts.push(&digits[start..end]);
|
||||||
|
end = start;
|
||||||
|
}
|
||||||
|
parts.push(&digits[..end]);
|
||||||
|
parts.reverse();
|
||||||
|
|
||||||
|
let joined = parts.join(&sep_char.to_string());
|
||||||
|
if negative {
|
||||||
|
format!("-{}", joined)
|
||||||
|
} else {
|
||||||
|
joined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format in standard (fixed-point) notation.
|
||||||
|
fn format_standard(value: f64, decimal_places: u8, thousands_sep: ThousandsSeparator) -> String {
|
||||||
|
let dp = decimal_places as usize;
|
||||||
|
let fixed = format!("{:.prec$}", value, prec = dp);
|
||||||
|
|
||||||
|
let (int_part, dec_part) = if let Some(dot_pos) = fixed.find('.') {
|
||||||
|
(&fixed[..dot_pos], Some(&fixed[dot_pos + 1..]))
|
||||||
|
} else {
|
||||||
|
(fixed.as_str(), None)
|
||||||
|
};
|
||||||
|
|
||||||
|
let formatted_int = insert_thousands_separator(int_part, thousands_sep);
|
||||||
|
|
||||||
|
if dp == 0 {
|
||||||
|
return formatted_int;
|
||||||
|
}
|
||||||
|
|
||||||
|
// When using period as thousands separator, the decimal separator is comma.
|
||||||
|
let dec_sep = if thousands_sep == ThousandsSeparator::Period {
|
||||||
|
','
|
||||||
|
} else {
|
||||||
|
'.'
|
||||||
|
};
|
||||||
|
|
||||||
|
match dec_part {
|
||||||
|
Some(d) => format!("{}{}{}", formatted_int, dec_sep, d),
|
||||||
|
None => formatted_int,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format in scientific notation (e.g. `1.50e6`).
|
||||||
|
fn format_scientific(value: f64, decimal_places: u8) -> String {
|
||||||
|
let dp = decimal_places as usize;
|
||||||
|
|
||||||
|
if value == 0.0 {
|
||||||
|
let mantissa = format!("{:.prec$}", 0.0_f64, prec = dp);
|
||||||
|
return format!("{}e0", mantissa);
|
||||||
|
}
|
||||||
|
if !value.is_finite() {
|
||||||
|
return format!("{}", value);
|
||||||
|
}
|
||||||
|
|
||||||
|
let exponent = value.abs().log10().floor() as i32;
|
||||||
|
let mantissa = value / 10_f64.powi(exponent);
|
||||||
|
let mantissa_str = format!("{:.prec$}", mantissa, prec = dp);
|
||||||
|
|
||||||
|
format!("{}e{}", mantissa_str, exponent)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format in SI notation (e.g. `1.50M`).
|
||||||
|
fn format_si(value: f64, decimal_places: u8) -> String {
|
||||||
|
let dp = decimal_places as usize;
|
||||||
|
|
||||||
|
if value == 0.0 {
|
||||||
|
return format!("{:.prec$}", 0.0_f64, prec = dp);
|
||||||
|
}
|
||||||
|
if !value.is_finite() {
|
||||||
|
return format!("{}", value);
|
||||||
|
}
|
||||||
|
|
||||||
|
let abs_value = value.abs();
|
||||||
|
let sign = if value < 0.0 { "-" } else { "" };
|
||||||
|
|
||||||
|
for prefix in SI_PREFIXES {
|
||||||
|
let threshold = 10_f64.powi(prefix.exponent);
|
||||||
|
let is_last = prefix.exponent == SI_PREFIXES.last().unwrap().exponent;
|
||||||
|
if abs_value >= threshold || is_last {
|
||||||
|
let scaled = abs_value / threshold;
|
||||||
|
return format!("{}{:.prec$}{}", sign, scaled, prefix.symbol, prec = dp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback (should not be reached).
|
||||||
|
format!("{:.prec$}", value, prec = dp)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wrap a formatted number string with currency symbol.
|
||||||
|
fn apply_currency(formatted: &str, symbol: &str, position: CurrencyPosition) -> String {
|
||||||
|
if symbol.is_empty() {
|
||||||
|
return formatted.to_string();
|
||||||
|
}
|
||||||
|
match position {
|
||||||
|
CurrencyPosition::Prefix => format!("{}{}", symbol, formatted),
|
||||||
|
CurrencyPosition::Suffix => format!("{}{}", formatted, symbol),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public API
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Format a numeric value according to the given formatting options.
|
||||||
|
///
|
||||||
|
/// This is the main entry point for answer-column number formatting.
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use calcpad_engine::formatting::answer_format::*;
|
||||||
|
///
|
||||||
|
/// let opts = FormattingOptions::default();
|
||||||
|
/// assert_eq!(format_number(1234.5, &opts), "1,234.50");
|
||||||
|
///
|
||||||
|
/// let sci = FormattingOptions { notation: Notation::Scientific, ..Default::default() };
|
||||||
|
/// assert_eq!(format_number(1500000.0, &sci), "1.50e6");
|
||||||
|
/// ```
|
||||||
|
pub fn format_number(value: f64, options: &FormattingOptions) -> String {
|
||||||
|
if value.is_nan() {
|
||||||
|
return "NaN".to_string();
|
||||||
|
}
|
||||||
|
if value.is_infinite() {
|
||||||
|
return if value > 0.0 {
|
||||||
|
"Infinity".to_string()
|
||||||
|
} else {
|
||||||
|
"-Infinity".to_string()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let formatted = match options.notation {
|
||||||
|
Notation::Standard => {
|
||||||
|
format_standard(value, options.decimal_places, options.thousands_separator)
|
||||||
|
}
|
||||||
|
Notation::Scientific => format_scientific(value, options.decimal_places),
|
||||||
|
Notation::SI => format_si(value, options.decimal_places),
|
||||||
|
};
|
||||||
|
|
||||||
|
apply_currency(&formatted, &options.currency_symbol, options.currency_position)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn default_opts() -> FormattingOptions {
|
||||||
|
FormattingOptions::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Standard notation ────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn standard_default() {
|
||||||
|
assert_eq!(format_number(1234.5, &default_opts()), "1,234.50");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn standard_zero_decimal_places() {
|
||||||
|
let opts = FormattingOptions { decimal_places: 0, ..default_opts() };
|
||||||
|
assert_eq!(format_number(1234.567, &opts), "1,235");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn standard_four_decimal_places() {
|
||||||
|
let opts = FormattingOptions { decimal_places: 4, ..default_opts() };
|
||||||
|
assert_eq!(format_number(1234.5, &opts), "1,234.5000");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn standard_ten_decimal_places() {
|
||||||
|
let opts = FormattingOptions { decimal_places: 10, ..default_opts() };
|
||||||
|
assert_eq!(format_number(3.14, &opts), "3.1400000000");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn standard_small_number_no_thousands() {
|
||||||
|
assert_eq!(format_number(42.0, &default_opts()), "42.00");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn standard_negative() {
|
||||||
|
assert_eq!(format_number(-1234.5, &default_opts()), "-1,234.50");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn standard_zero() {
|
||||||
|
assert_eq!(format_number(0.0, &default_opts()), "0.00");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Thousands separators ─────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn thousands_comma() {
|
||||||
|
let opts = FormattingOptions {
|
||||||
|
thousands_separator: ThousandsSeparator::Comma,
|
||||||
|
..default_opts()
|
||||||
|
};
|
||||||
|
assert_eq!(format_number(1234567.89, &opts), "1,234,567.89");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn thousands_period() {
|
||||||
|
let opts = FormattingOptions {
|
||||||
|
thousands_separator: ThousandsSeparator::Period,
|
||||||
|
..default_opts()
|
||||||
|
};
|
||||||
|
assert_eq!(format_number(1234567.89, &opts), "1.234.567,89");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn thousands_space() {
|
||||||
|
let opts = FormattingOptions {
|
||||||
|
thousands_separator: ThousandsSeparator::Space,
|
||||||
|
..default_opts()
|
||||||
|
};
|
||||||
|
assert_eq!(format_number(1234567.89, &opts), "1 234 567.89");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn thousands_none() {
|
||||||
|
let opts = FormattingOptions {
|
||||||
|
thousands_separator: ThousandsSeparator::None,
|
||||||
|
..default_opts()
|
||||||
|
};
|
||||||
|
assert_eq!(format_number(1234567.89, &opts), "1234567.89");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn thousands_period_negative() {
|
||||||
|
let opts = FormattingOptions {
|
||||||
|
thousands_separator: ThousandsSeparator::Period,
|
||||||
|
..default_opts()
|
||||||
|
};
|
||||||
|
assert_eq!(format_number(-1234.56, &opts), "-1.234,56");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Scientific notation ──────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scientific_large() {
|
||||||
|
let opts = FormattingOptions { notation: Notation::Scientific, ..default_opts() };
|
||||||
|
assert_eq!(format_number(1500000.0, &opts), "1.50e6");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scientific_small() {
|
||||||
|
let opts = FormattingOptions { notation: Notation::Scientific, ..default_opts() };
|
||||||
|
assert_eq!(format_number(0.005, &opts), "5.00e-3");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scientific_42() {
|
||||||
|
let opts = FormattingOptions { notation: Notation::Scientific, ..default_opts() };
|
||||||
|
assert_eq!(format_number(42.0, &opts), "4.20e1");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scientific_negative() {
|
||||||
|
let opts = FormattingOptions { notation: Notation::Scientific, ..default_opts() };
|
||||||
|
assert_eq!(format_number(-1500000.0, &opts), "-1.50e6");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scientific_zero() {
|
||||||
|
let opts = FormattingOptions { notation: Notation::Scientific, ..default_opts() };
|
||||||
|
assert_eq!(format_number(0.0, &opts), "0.00e0");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scientific_one() {
|
||||||
|
let opts = FormattingOptions { notation: Notation::Scientific, ..default_opts() };
|
||||||
|
assert_eq!(format_number(1.0, &opts), "1.00e0");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scientific_four_dp() {
|
||||||
|
let opts = FormattingOptions {
|
||||||
|
notation: Notation::Scientific,
|
||||||
|
decimal_places: 4,
|
||||||
|
..default_opts()
|
||||||
|
};
|
||||||
|
assert_eq!(format_number(1500000.0, &opts), "1.5000e6");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scientific_zero_dp() {
|
||||||
|
let opts = FormattingOptions {
|
||||||
|
notation: Notation::Scientific,
|
||||||
|
decimal_places: 0,
|
||||||
|
..default_opts()
|
||||||
|
};
|
||||||
|
assert_eq!(format_number(1500000.0, &opts), "2e6");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SI notation ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn si_mega() {
|
||||||
|
let opts = FormattingOptions { notation: Notation::SI, ..default_opts() };
|
||||||
|
assert_eq!(format_number(1500000.0, &opts), "1.50M");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn si_kilo() {
|
||||||
|
let opts = FormattingOptions { notation: Notation::SI, ..default_opts() };
|
||||||
|
assert_eq!(format_number(1500.0, &opts), "1.50k");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn si_giga() {
|
||||||
|
let opts = FormattingOptions { notation: Notation::SI, ..default_opts() };
|
||||||
|
assert_eq!(format_number(1500000000.0, &opts), "1.50G");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn si_tera() {
|
||||||
|
let opts = FormattingOptions { notation: Notation::SI, ..default_opts() };
|
||||||
|
assert_eq!(format_number(1500000000000.0, &opts), "1.50T");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn si_plain_42() {
|
||||||
|
let opts = FormattingOptions { notation: Notation::SI, ..default_opts() };
|
||||||
|
assert_eq!(format_number(42.0, &opts), "42.00");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn si_milli() {
|
||||||
|
let opts = FormattingOptions { notation: Notation::SI, ..default_opts() };
|
||||||
|
assert_eq!(format_number(0.005, &opts), "5.00m");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn si_micro() {
|
||||||
|
let opts = FormattingOptions { notation: Notation::SI, ..default_opts() };
|
||||||
|
assert_eq!(format_number(0.000005, &opts), "5.00\u{03bc}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn si_negative() {
|
||||||
|
let opts = FormattingOptions { notation: Notation::SI, ..default_opts() };
|
||||||
|
assert_eq!(format_number(-1500000.0, &opts), "-1.50M");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn si_zero() {
|
||||||
|
let opts = FormattingOptions { notation: Notation::SI, ..default_opts() };
|
||||||
|
assert_eq!(format_number(0.0, &opts), "0.00");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn si_zero_dp() {
|
||||||
|
let opts = FormattingOptions {
|
||||||
|
notation: Notation::SI,
|
||||||
|
decimal_places: 0,
|
||||||
|
..default_opts()
|
||||||
|
};
|
||||||
|
assert_eq!(format_number(1500000.0, &opts), "2M");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Currency ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn currency_prefix() {
|
||||||
|
let opts = FormattingOptions {
|
||||||
|
currency_symbol: "$".to_string(),
|
||||||
|
currency_position: CurrencyPosition::Prefix,
|
||||||
|
..default_opts()
|
||||||
|
};
|
||||||
|
assert_eq!(format_number(1234.5, &opts), "$1,234.50");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn currency_suffix() {
|
||||||
|
let opts = FormattingOptions {
|
||||||
|
currency_symbol: "\u{20ac}".to_string(), // €
|
||||||
|
currency_position: CurrencyPosition::Suffix,
|
||||||
|
..default_opts()
|
||||||
|
};
|
||||||
|
assert_eq!(format_number(1234.5, &opts), "1,234.50\u{20ac}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn currency_empty_means_none() {
|
||||||
|
assert_eq!(format_number(1234.5, &default_opts()), "1,234.50");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn currency_with_scientific() {
|
||||||
|
let opts = FormattingOptions {
|
||||||
|
notation: Notation::Scientific,
|
||||||
|
currency_symbol: "$".to_string(),
|
||||||
|
currency_position: CurrencyPosition::Prefix,
|
||||||
|
..default_opts()
|
||||||
|
};
|
||||||
|
assert_eq!(format_number(1500000.0, &opts), "$1.50e6");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn currency_with_si() {
|
||||||
|
let opts = FormattingOptions {
|
||||||
|
notation: Notation::SI,
|
||||||
|
currency_symbol: "\u{20ac}".to_string(),
|
||||||
|
currency_position: CurrencyPosition::Suffix,
|
||||||
|
..default_opts()
|
||||||
|
};
|
||||||
|
assert_eq!(format_number(1500000.0, &opts), "1.50M\u{20ac}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Edge cases ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nan() {
|
||||||
|
assert_eq!(format_number(f64::NAN, &default_opts()), "NaN");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pos_infinity() {
|
||||||
|
assert_eq!(format_number(f64::INFINITY, &default_opts()), "Infinity");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn neg_infinity() {
|
||||||
|
assert_eq!(format_number(f64::NEG_INFINITY, &default_opts()), "-Infinity");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn very_large_standard() {
|
||||||
|
let opts = FormattingOptions { decimal_places: 0, ..default_opts() };
|
||||||
|
assert_eq!(format_number(1e15, &opts), "1,000,000,000,000,000");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn very_small_standard() {
|
||||||
|
let opts = FormattingOptions { decimal_places: 6, ..default_opts() };
|
||||||
|
assert_eq!(format_number(0.000001, &opts), "0.000001");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── resolve_formatting ───────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_no_override() {
|
||||||
|
let global = default_opts();
|
||||||
|
let result = resolve_formatting(&global, None);
|
||||||
|
assert_eq!(result, global);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_partial_override() {
|
||||||
|
let global = default_opts();
|
||||||
|
let ov = LineFormatOverride {
|
||||||
|
notation: Some(Notation::Scientific),
|
||||||
|
decimal_places: Some(4),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let result = resolve_formatting(&global, Some(&ov));
|
||||||
|
assert_eq!(result.notation, Notation::Scientific);
|
||||||
|
assert_eq!(result.decimal_places, 4);
|
||||||
|
// Inherited
|
||||||
|
assert_eq!(result.thousands_separator, ThousandsSeparator::Comma);
|
||||||
|
assert_eq!(result.currency_symbol, "");
|
||||||
|
assert_eq!(result.currency_position, CurrencyPosition::Prefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_full_override() {
|
||||||
|
let global = default_opts();
|
||||||
|
let ov = LineFormatOverride {
|
||||||
|
decimal_places: Some(5),
|
||||||
|
notation: Some(Notation::SI),
|
||||||
|
thousands_separator: Some(ThousandsSeparator::Space),
|
||||||
|
currency_symbol: Some("\u{00a3}".to_string()), // £
|
||||||
|
currency_position: Some(CurrencyPosition::Suffix),
|
||||||
|
};
|
||||||
|
let result = resolve_formatting(&global, Some(&ov));
|
||||||
|
assert_eq!(result.decimal_places, 5);
|
||||||
|
assert_eq!(result.notation, Notation::SI);
|
||||||
|
assert_eq!(result.thousands_separator, ThousandsSeparator::Space);
|
||||||
|
assert_eq!(result.currency_symbol, "\u{00a3}");
|
||||||
|
assert_eq!(result.currency_position, CurrencyPosition::Suffix);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_empty_override_inherits_global() {
|
||||||
|
let global = default_opts();
|
||||||
|
let ov = LineFormatOverride::default();
|
||||||
|
let result = resolve_formatting(&global, Some(&ov));
|
||||||
|
assert_eq!(result, global);
|
||||||
|
}
|
||||||
|
}
|
||||||
290
calcpad-engine/src/formatting/clipboard.rs
Normal file
290
calcpad-engine/src/formatting/clipboard.rs
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
//! Clipboard value formatting for CalcPad.
|
||||||
|
//!
|
||||||
|
//! Ported from the cross-platform spec in Epic 10-2 (Click-to-Copy Answer).
|
||||||
|
//! The UI layer decides *when* to copy (single-click vs double-click); this
|
||||||
|
//! module decides *what* text to produce from a `CalcResult`.
|
||||||
|
//!
|
||||||
|
//! # Copy modes
|
||||||
|
//!
|
||||||
|
//! | Gesture | What gets copied | Example |
|
||||||
|
//! |------------- |------------------------------|------------------|
|
||||||
|
//! | Single-click | Raw numeric value only | `11.023` |
|
||||||
|
//! | Double-click | Display value (with unit) | `11.023 lbs` |
|
||||||
|
//!
|
||||||
|
//! The engine provides both strings so the platform layer never needs to
|
||||||
|
//! inspect the result internals.
|
||||||
|
|
||||||
|
use crate::types::{CalcResult, CalcValue};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Pair of clipboard strings derived from a calculation result.
|
||||||
|
///
|
||||||
|
/// The UI layer selects which one to copy based on the user gesture.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct ClipboardValues {
|
||||||
|
/// Raw numeric value as a string (no unit, no currency symbol).
|
||||||
|
/// Used on single-click.
|
||||||
|
pub raw_value: String,
|
||||||
|
/// Human-readable display string (may include unit or currency symbol).
|
||||||
|
/// Used on double-click.
|
||||||
|
pub display_value: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public API
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Extract clipboard-ready strings from a `CalcResult`.
|
||||||
|
///
|
||||||
|
/// Returns `None` for error results (nothing useful to copy).
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use calcpad_engine::types::CalcResult;
|
||||||
|
/// use calcpad_engine::formatting::clipboard::clipboard_values;
|
||||||
|
/// use calcpad_engine::span::Span;
|
||||||
|
///
|
||||||
|
/// let r = CalcResult::number(42.0, Span::new(0, 2));
|
||||||
|
/// let cv = clipboard_values(&r).unwrap();
|
||||||
|
/// assert_eq!(cv.raw_value, "42");
|
||||||
|
/// assert_eq!(cv.display_value, "42");
|
||||||
|
///
|
||||||
|
/// let r = CalcResult::unit_value(11.023, "lbs", Span::new(0, 5));
|
||||||
|
/// let cv = clipboard_values(&r).unwrap();
|
||||||
|
/// assert_eq!(cv.raw_value, "11.023");
|
||||||
|
/// assert_eq!(cv.display_value, "11.023 lbs");
|
||||||
|
/// ```
|
||||||
|
pub fn clipboard_values(result: &CalcResult) -> Option<ClipboardValues> {
|
||||||
|
match &result.value {
|
||||||
|
CalcValue::Number { value } => {
|
||||||
|
let raw = format_raw(*value);
|
||||||
|
Some(ClipboardValues {
|
||||||
|
raw_value: raw.clone(),
|
||||||
|
display_value: raw,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
CalcValue::UnitValue { value, unit } => {
|
||||||
|
let raw = format_raw(*value);
|
||||||
|
let display = format!("{} {}", raw, unit);
|
||||||
|
Some(ClipboardValues {
|
||||||
|
raw_value: raw,
|
||||||
|
display_value: display,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
CalcValue::CurrencyValue { amount, currency } => {
|
||||||
|
let raw = format_raw(*amount);
|
||||||
|
// Display uses the result's pre-formatted string which includes
|
||||||
|
// the currency symbol. Fall back to `raw currency` if display is
|
||||||
|
// empty (shouldn't happen in practice).
|
||||||
|
let display = if result.metadata.display.is_empty() {
|
||||||
|
format!("{} {}", raw, currency)
|
||||||
|
} else {
|
||||||
|
result.metadata.display.clone()
|
||||||
|
};
|
||||||
|
Some(ClipboardValues {
|
||||||
|
raw_value: raw,
|
||||||
|
display_value: display,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
CalcValue::DateTime { date } => Some(ClipboardValues {
|
||||||
|
raw_value: date.clone(),
|
||||||
|
display_value: date.clone(),
|
||||||
|
}),
|
||||||
|
CalcValue::TimeDelta { days, description } => Some(ClipboardValues {
|
||||||
|
raw_value: days.to_string(),
|
||||||
|
display_value: description.clone(),
|
||||||
|
}),
|
||||||
|
CalcValue::Boolean { value } => {
|
||||||
|
let s = value.to_string();
|
||||||
|
Some(ClipboardValues {
|
||||||
|
raw_value: s.clone(),
|
||||||
|
display_value: s,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
CalcValue::Error { .. } => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Internal helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Format a raw numeric value for clipboard. Integers are printed without
|
||||||
|
/// a decimal point; floats keep their natural representation.
|
||||||
|
fn format_raw(val: f64) -> String {
|
||||||
|
if val == val.floor() && val.abs() < 1e15 {
|
||||||
|
format!("{}", val as i64)
|
||||||
|
} else {
|
||||||
|
format!("{}", val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::span::Span;
|
||||||
|
use crate::types::CalcResult;
|
||||||
|
|
||||||
|
fn sp() -> Span {
|
||||||
|
Span::new(0, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Numbers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn number_integer() {
|
||||||
|
let r = CalcResult::number(42.0, sp());
|
||||||
|
let cv = clipboard_values(&r).unwrap();
|
||||||
|
assert_eq!(cv.raw_value, "42");
|
||||||
|
assert_eq!(cv.display_value, "42");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn number_decimal() {
|
||||||
|
let r = CalcResult::number(3.14, sp());
|
||||||
|
let cv = clipboard_values(&r).unwrap();
|
||||||
|
assert_eq!(cv.raw_value, "3.14");
|
||||||
|
assert_eq!(cv.display_value, "3.14");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn number_negative() {
|
||||||
|
let r = CalcResult::number(-100.0, sp());
|
||||||
|
let cv = clipboard_values(&r).unwrap();
|
||||||
|
assert_eq!(cv.raw_value, "-100");
|
||||||
|
assert_eq!(cv.display_value, "-100");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn number_zero() {
|
||||||
|
let r = CalcResult::number(0.0, sp());
|
||||||
|
let cv = clipboard_values(&r).unwrap();
|
||||||
|
assert_eq!(cv.raw_value, "0");
|
||||||
|
assert_eq!(cv.display_value, "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Unit values ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unit_value_single_click_raw() {
|
||||||
|
let r = CalcResult::unit_value(11.023, "lbs", sp());
|
||||||
|
let cv = clipboard_values(&r).unwrap();
|
||||||
|
assert_eq!(cv.raw_value, "11.023");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unit_value_double_click_display() {
|
||||||
|
let r = CalcResult::unit_value(11.023, "lbs", sp());
|
||||||
|
let cv = clipboard_values(&r).unwrap();
|
||||||
|
assert_eq!(cv.display_value, "11.023 lbs");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unit_value_integer_amount() {
|
||||||
|
let r = CalcResult::unit_value(5.0, "kg", sp());
|
||||||
|
let cv = clipboard_values(&r).unwrap();
|
||||||
|
assert_eq!(cv.raw_value, "5");
|
||||||
|
assert_eq!(cv.display_value, "5 kg");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Currency values ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn currency_raw_is_numeric() {
|
||||||
|
let r = CalcResult::currency_value(1234.56, "USD", sp());
|
||||||
|
let cv = clipboard_values(&r).unwrap();
|
||||||
|
assert_eq!(cv.raw_value, "1234.56");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn currency_display_has_symbol() {
|
||||||
|
let r = CalcResult::currency_value(1234.56, "USD", sp());
|
||||||
|
let cv = clipboard_values(&r).unwrap();
|
||||||
|
// Display comes from CalcResult::currency_value which formats as "$1234.56"
|
||||||
|
assert!(cv.display_value.contains("1234.56"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn currency_euro() {
|
||||||
|
let r = CalcResult::currency_value(99.99, "EUR", sp());
|
||||||
|
let cv = clipboard_values(&r).unwrap();
|
||||||
|
assert_eq!(cv.raw_value, "99.99");
|
||||||
|
assert!(cv.display_value.contains("99.99"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DateTime ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn datetime_both_same() {
|
||||||
|
use chrono::NaiveDate;
|
||||||
|
let r = CalcResult::datetime(
|
||||||
|
NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
|
||||||
|
sp(),
|
||||||
|
);
|
||||||
|
let cv = clipboard_values(&r).unwrap();
|
||||||
|
assert_eq!(cv.raw_value, "2024-01-15");
|
||||||
|
assert_eq!(cv.display_value, "2024-01-15");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TimeDelta ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn time_delta() {
|
||||||
|
let r = CalcResult::time_delta(30, "30 days", sp());
|
||||||
|
let cv = clipboard_values(&r).unwrap();
|
||||||
|
assert_eq!(cv.raw_value, "30");
|
||||||
|
assert_eq!(cv.display_value, "30 days");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Boolean ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn boolean_true() {
|
||||||
|
let r = CalcResult::boolean(true, sp());
|
||||||
|
let cv = clipboard_values(&r).unwrap();
|
||||||
|
assert_eq!(cv.raw_value, "true");
|
||||||
|
assert_eq!(cv.display_value, "true");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn boolean_false() {
|
||||||
|
let r = CalcResult::boolean(false, sp());
|
||||||
|
let cv = clipboard_values(&r).unwrap();
|
||||||
|
assert_eq!(cv.raw_value, "false");
|
||||||
|
assert_eq!(cv.display_value, "false");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Errors ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn error_returns_none() {
|
||||||
|
let r = CalcResult::error("Division by zero", sp());
|
||||||
|
assert!(clipboard_values(&r).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Spec alignment: raw vs display divergence ────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn raw_and_display_differ_for_units() {
|
||||||
|
let r = CalcResult::unit_value(42.5, "km", sp());
|
||||||
|
let cv = clipboard_values(&r).unwrap();
|
||||||
|
assert_ne!(cv.raw_value, cv.display_value);
|
||||||
|
assert_eq!(cv.raw_value, "42.5");
|
||||||
|
assert_eq!(cv.display_value, "42.5 km");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn raw_and_display_same_for_plain_number() {
|
||||||
|
let r = CalcResult::number(42.0, sp());
|
||||||
|
let cv = clipboard_values(&r).unwrap();
|
||||||
|
assert_eq!(cv.raw_value, cv.display_value);
|
||||||
|
}
|
||||||
|
}
|
||||||
471
calcpad-engine/src/formatting/line_types.rs
Normal file
471
calcpad-engine/src/formatting/line_types.rs
Normal file
@@ -0,0 +1,471 @@
|
|||||||
|
//! Line-type detection for CalcPad's notepad UX.
|
||||||
|
//!
|
||||||
|
//! Classifies raw input lines into semantic types that the UI layer uses
|
||||||
|
//! to decide rendering (styling, gutter icons, whether to show an answer).
|
||||||
|
//!
|
||||||
|
//! Ported from the cross-platform spec in Epic 10-1 (Headers, Comments &
|
||||||
|
//! Labels). The **lexer** already produces `TokenKind::Comment` and
|
||||||
|
//! `TokenKind::Text` tokens, and `variables::aggregators::is_heading` detects
|
||||||
|
//! heading lines. This module provides a **higher-level** classification that
|
||||||
|
//! the UI layer can consume directly, without touching tokens.
|
||||||
|
//!
|
||||||
|
//! # Design note — no duplication
|
||||||
|
//!
|
||||||
|
//! Comment detection (`//`) is intentionally compatible with the lexer's
|
||||||
|
//! full-line comment check (see `lexer.rs` lines 28-40). Heading detection
|
||||||
|
//! uses the same rules as `variables::aggregators::is_heading` (1-6 `#`
|
||||||
|
//! characters followed by a space) but is implemented locally to avoid a
|
||||||
|
//! module dependency and to also extract the heading level and text. This
|
||||||
|
//! module adds *label detection* (`Label: expr`) and the unified `LineType`
|
||||||
|
//! enum that were not previously available in the engine.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// The semantic type of a line in a CalcPad sheet.
|
||||||
|
///
|
||||||
|
/// Determined purely from the text content. The order of classification is:
|
||||||
|
///
|
||||||
|
/// 1. Blank / whitespace-only
|
||||||
|
/// 2. Heading (`# ...`, `## ...`, up to `######`)
|
||||||
|
/// 3. Comment (`// ...`)
|
||||||
|
/// 4. Label with expression (`Label: expr`)
|
||||||
|
/// 5. Expression (anything else that might evaluate)
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum LineType {
|
||||||
|
/// Empty or whitespace-only line.
|
||||||
|
Blank,
|
||||||
|
/// Heading line. Contains the heading text (without leading `#` characters
|
||||||
|
/// and surrounding whitespace). `level` is 1--6.
|
||||||
|
Heading { level: u8, text: String },
|
||||||
|
/// Comment line. Contains the comment body (without leading `//`).
|
||||||
|
Comment { text: String },
|
||||||
|
/// Labeled expression. `label` includes the trailing colon
|
||||||
|
/// (e.g. `"Rent:"`), `expression` is the text after the colon.
|
||||||
|
Label { label: String, expression: String },
|
||||||
|
/// A calculable expression (or text that the evaluator should attempt).
|
||||||
|
Expression,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public API
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Classify a single line of input into its [`LineType`].
|
||||||
|
///
|
||||||
|
/// This is a **pure function** — it depends only on the text content and
|
||||||
|
/// performs no evaluation.
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use calcpad_engine::formatting::line_types::{classify_line, LineType};
|
||||||
|
///
|
||||||
|
/// assert_eq!(classify_line(""), LineType::Blank);
|
||||||
|
/// assert_eq!(
|
||||||
|
/// classify_line("# Budget"),
|
||||||
|
/// LineType::Heading { level: 1, text: "Budget".to_string() },
|
||||||
|
/// );
|
||||||
|
/// assert_eq!(
|
||||||
|
/// classify_line("// a note"),
|
||||||
|
/// LineType::Comment { text: " a note".to_string() },
|
||||||
|
/// );
|
||||||
|
/// assert_eq!(
|
||||||
|
/// classify_line("Rent: 1500"),
|
||||||
|
/// LineType::Label {
|
||||||
|
/// label: "Rent:".to_string(),
|
||||||
|
/// expression: "1500".to_string(),
|
||||||
|
/// },
|
||||||
|
/// );
|
||||||
|
/// assert_eq!(classify_line("2 + 3"), LineType::Expression);
|
||||||
|
/// ```
|
||||||
|
pub fn classify_line(input: &str) -> LineType {
|
||||||
|
let trimmed = input.trim();
|
||||||
|
|
||||||
|
// 1. Blank
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return LineType::Blank;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Heading — delegate to the existing aggregators utility so we don't
|
||||||
|
// duplicate the `#` logic. Re-implement the level + text extraction
|
||||||
|
// here since `is_heading` only returns bool.
|
||||||
|
if let Some(heading) = detect_heading(trimmed) {
|
||||||
|
return heading;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Comment
|
||||||
|
if trimmed.starts_with("//") {
|
||||||
|
let text = trimmed[2..].to_string();
|
||||||
|
return LineType::Comment { text };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Label
|
||||||
|
if let Some(label_type) = detect_label(trimmed) {
|
||||||
|
return label_type;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Expression (fallback)
|
||||||
|
LineType::Expression
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `true` if the line is non-calculable (blank, heading, or comment).
|
||||||
|
///
|
||||||
|
/// Useful for quickly deciding whether to show an answer column entry.
|
||||||
|
pub fn is_non_calculable(input: &str) -> bool {
|
||||||
|
matches!(
|
||||||
|
classify_line(input),
|
||||||
|
LineType::Blank | LineType::Heading { .. } | LineType::Comment { .. }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Internal helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Detect a markdown-style heading (`# Title` through `###### Title`).
|
||||||
|
fn detect_heading(trimmed: &str) -> Option<LineType> {
|
||||||
|
let bytes = trimmed.as_bytes();
|
||||||
|
if bytes.is_empty() || bytes[0] != b'#' {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut level: u8 = 0;
|
||||||
|
let mut i = 0;
|
||||||
|
while i < bytes.len() && bytes[i] == b'#' {
|
||||||
|
level += 1;
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Max 6 levels, and must be followed by a space or end of string.
|
||||||
|
// A `#` followed immediately by a digit (e.g. `#1`) is a line reference,
|
||||||
|
// not a heading.
|
||||||
|
if level == 0 || level > 6 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if i < bytes.len() && bytes[i] != b' ' {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let text = if i < bytes.len() {
|
||||||
|
trimmed[i..].trim().to_string()
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
Some(LineType::Heading { level, text })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detect a label pattern: `Label: expression`.
|
||||||
|
///
|
||||||
|
/// A label colon must:
|
||||||
|
/// - Not be at the very end of the string
|
||||||
|
/// - Have non-empty text before it that contains at least one alphabetic char
|
||||||
|
/// (to distinguish from time expressions like `12:30`)
|
||||||
|
/// - Have non-empty content after it
|
||||||
|
fn detect_label(trimmed: &str) -> Option<LineType> {
|
||||||
|
for (i, ch) in trimmed.char_indices() {
|
||||||
|
if ch == ':' {
|
||||||
|
let before = trimmed[..i].trim();
|
||||||
|
let after = trimmed[i + 1..].trim();
|
||||||
|
|
||||||
|
if !before.is_empty()
|
||||||
|
&& !after.is_empty()
|
||||||
|
&& before.chars().any(|c| c.is_alphabetic())
|
||||||
|
{
|
||||||
|
// Include the colon in the label (matches 10-1 spec).
|
||||||
|
let label = format!("{}:", before);
|
||||||
|
return Some(LineType::Label {
|
||||||
|
label,
|
||||||
|
expression: after.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// ── Blank ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn blank_empty() {
|
||||||
|
assert_eq!(classify_line(""), LineType::Blank);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn blank_spaces() {
|
||||||
|
assert_eq!(classify_line(" "), LineType::Blank);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn blank_tab() {
|
||||||
|
assert_eq!(classify_line("\t"), LineType::Blank);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Headings ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn heading_h1() {
|
||||||
|
assert_eq!(
|
||||||
|
classify_line("# Monthly Budget"),
|
||||||
|
LineType::Heading { level: 1, text: "Monthly Budget".to_string() },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn heading_h2() {
|
||||||
|
assert_eq!(
|
||||||
|
classify_line("## Expenses"),
|
||||||
|
LineType::Heading { level: 2, text: "Expenses".to_string() },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn heading_h3_indented() {
|
||||||
|
assert_eq!(
|
||||||
|
classify_line(" ### Indented"),
|
||||||
|
LineType::Heading { level: 3, text: "Indented".to_string() },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn heading_no_space_after_hash_is_not_heading() {
|
||||||
|
// `#Section` without space is not a heading (could be a reference).
|
||||||
|
// This aligns with the aggregators::is_heading behaviour.
|
||||||
|
assert_ne!(
|
||||||
|
classify_line("#Section"),
|
||||||
|
LineType::Heading { level: 1, text: "Section".to_string() },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn heading_hash_only() {
|
||||||
|
// A single `# ` with trailing space should be heading with empty text.
|
||||||
|
// `#` alone followed by nothing — also heading with empty text.
|
||||||
|
assert_eq!(
|
||||||
|
classify_line("# "),
|
||||||
|
LineType::Heading { level: 1, text: String::new() },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn heading_not_line_ref() {
|
||||||
|
// `#1` is a line reference, not a heading.
|
||||||
|
assert_eq!(classify_line("#1 * 2"), LineType::Expression);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn heading_six_levels() {
|
||||||
|
assert_eq!(
|
||||||
|
classify_line("###### Deep"),
|
||||||
|
LineType::Heading { level: 6, text: "Deep".to_string() },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn heading_too_many_hashes() {
|
||||||
|
// 7 hashes is not a valid heading.
|
||||||
|
assert_eq!(classify_line("####### TooDeep"), LineType::Expression);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn heading_expression_not_evaluated() {
|
||||||
|
assert_eq!(
|
||||||
|
classify_line("# 2 + 3"),
|
||||||
|
LineType::Heading { level: 1, text: "2 + 3".to_string() },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Comments ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn comment_basic() {
|
||||||
|
assert_eq!(
|
||||||
|
classify_line("// This is a note"),
|
||||||
|
LineType::Comment { text: " This is a note".to_string() },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn comment_no_space() {
|
||||||
|
assert_eq!(
|
||||||
|
classify_line("//note"),
|
||||||
|
LineType::Comment { text: "note".to_string() },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn comment_empty() {
|
||||||
|
assert_eq!(
|
||||||
|
classify_line("//"),
|
||||||
|
LineType::Comment { text: String::new() },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn comment_not_evaluated() {
|
||||||
|
assert_eq!(
|
||||||
|
classify_line("// 100 * 2"),
|
||||||
|
LineType::Comment { text: " 100 * 2".to_string() },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn comment_indented() {
|
||||||
|
assert_eq!(
|
||||||
|
classify_line(" // indented comment"),
|
||||||
|
LineType::Comment { text: " indented comment".to_string() },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Labels ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn label_simple() {
|
||||||
|
assert_eq!(
|
||||||
|
classify_line("Rent: 1500"),
|
||||||
|
LineType::Label {
|
||||||
|
label: "Rent:".to_string(),
|
||||||
|
expression: "1500".to_string(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn label_with_math() {
|
||||||
|
assert_eq!(
|
||||||
|
classify_line("Total: 100 + 200 + 300"),
|
||||||
|
LineType::Label {
|
||||||
|
label: "Total:".to_string(),
|
||||||
|
expression: "100 + 200 + 300".to_string(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn label_multiword() {
|
||||||
|
assert_eq!(
|
||||||
|
classify_line("Monthly Rent: 1500"),
|
||||||
|
LineType::Label {
|
||||||
|
label: "Monthly Rent:".to_string(),
|
||||||
|
expression: "1500".to_string(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn label_colon_at_end_is_not_label() {
|
||||||
|
// Nothing after the colon — not a label.
|
||||||
|
assert_eq!(classify_line("Rent:"), LineType::Expression);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_label_for_time_expression() {
|
||||||
|
// `12:30` has no alpha chars before the colon, so it's not a label.
|
||||||
|
assert_eq!(classify_line("12:30"), LineType::Expression);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Expression ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn expression_simple() {
|
||||||
|
assert_eq!(classify_line("2 + 3"), LineType::Expression);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn expression_complex() {
|
||||||
|
assert_eq!(classify_line("(10 + 5) * 2"), LineType::Expression);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn expression_single_number() {
|
||||||
|
assert_eq!(classify_line("42"), LineType::Expression);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── is_non_calculable ────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_calculable_blank() {
|
||||||
|
assert!(is_non_calculable(""));
|
||||||
|
assert!(is_non_calculable(" "));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_calculable_heading() {
|
||||||
|
assert!(is_non_calculable("# Title"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_calculable_comment() {
|
||||||
|
assert!(is_non_calculable("// note"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn calculable_expression() {
|
||||||
|
assert!(!is_non_calculable("2 + 3"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn calculable_label() {
|
||||||
|
assert!(!is_non_calculable("Price: 42"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Deterministic parsing (cross-platform consistency) ───────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deterministic_classification() {
|
||||||
|
let inputs = vec![
|
||||||
|
"# Budget",
|
||||||
|
"// notes here",
|
||||||
|
"Rent: 1500",
|
||||||
|
"2 + 3",
|
||||||
|
"",
|
||||||
|
];
|
||||||
|
let results_a: Vec<_> = inputs.iter().map(|l| classify_line(l)).collect();
|
||||||
|
let results_b: Vec<_> = inputs.iter().map(|l| classify_line(l)).collect();
|
||||||
|
assert_eq!(results_a, results_b);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mixed_sheet_scenario() {
|
||||||
|
let lines = vec![
|
||||||
|
"# Monthly Budget",
|
||||||
|
"// Income section",
|
||||||
|
"Salary: 5000",
|
||||||
|
"Bonus: 500",
|
||||||
|
"",
|
||||||
|
"# Expenses",
|
||||||
|
"Rent: 1500",
|
||||||
|
"Food: 300 + 200",
|
||||||
|
"// Utilities estimated",
|
||||||
|
"Utilities: 150",
|
||||||
|
"",
|
||||||
|
"5000 + 500 - 1500 - 500 - 150",
|
||||||
|
];
|
||||||
|
let types: Vec<_> = lines.iter().map(|l| classify_line(l)).collect();
|
||||||
|
|
||||||
|
assert!(matches!(&types[0], LineType::Heading { level: 1, text } if text == "Monthly Budget"));
|
||||||
|
assert!(matches!(&types[1], LineType::Comment { .. }));
|
||||||
|
assert!(matches!(&types[2], LineType::Label { label, .. } if label == "Salary:"));
|
||||||
|
assert!(matches!(&types[3], LineType::Label { label, .. } if label == "Bonus:"));
|
||||||
|
assert_eq!(types[4], LineType::Blank);
|
||||||
|
assert!(matches!(&types[5], LineType::Heading { level: 1, text } if text == "Expenses"));
|
||||||
|
assert!(matches!(&types[6], LineType::Label { label, .. } if label == "Rent:"));
|
||||||
|
assert!(matches!(&types[7], LineType::Label { label, expression } if label == "Food:" && expression == "300 + 200"));
|
||||||
|
assert!(matches!(&types[8], LineType::Comment { .. }));
|
||||||
|
assert!(matches!(&types[9], LineType::Label { label, .. } if label == "Utilities:"));
|
||||||
|
assert_eq!(types[10], LineType::Blank);
|
||||||
|
assert_eq!(types[11], LineType::Expression);
|
||||||
|
}
|
||||||
|
}
|
||||||
34
calcpad-engine/src/formatting/mod.rs
Normal file
34
calcpad-engine/src/formatting/mod.rs
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
//! Cross-platform UX formatting for CalcPad.
|
||||||
|
//!
|
||||||
|
//! This module extracts the notepad UX specs from Epics 10-1, 10-2, and 10-4
|
||||||
|
//! into the engine so that every platform (macOS, Windows, Web) renders
|
||||||
|
//! identical results.
|
||||||
|
//!
|
||||||
|
//! # Sub-modules
|
||||||
|
//!
|
||||||
|
//! * [`answer_format`] — Number formatting for the answer column: standard,
|
||||||
|
//! scientific, and SI notation with decimal places (0--10), thousands
|
||||||
|
//! separators, and currency symbol placement.
|
||||||
|
//! *(Epic 10-4: Answer Column Formatting)*
|
||||||
|
//!
|
||||||
|
//! * [`line_types`] — Line-type classification (blank, heading, comment,
|
||||||
|
//! label, expression) so the UI knows how to style each line and whether
|
||||||
|
//! to show an answer.
|
||||||
|
//! *(Epic 10-1: Headers, Comments & Labels)*
|
||||||
|
//!
|
||||||
|
//! * [`clipboard`] — Clipboard value extraction from a `CalcResult`:
|
||||||
|
//! raw numeric string (single-click) vs display string with unit
|
||||||
|
//! (double-click).
|
||||||
|
//! *(Epic 10-2: Click-to-Copy Answer)*
|
||||||
|
|
||||||
|
pub mod answer_format;
|
||||||
|
pub mod clipboard;
|
||||||
|
pub mod line_types;
|
||||||
|
|
||||||
|
// Re-export the most commonly used items for convenience.
|
||||||
|
pub use answer_format::{
|
||||||
|
format_number, resolve_formatting, CurrencyPosition, FormattingOptions, LineFormatOverride,
|
||||||
|
Notation, ThousandsSeparator,
|
||||||
|
};
|
||||||
|
pub use clipboard::{clipboard_values, ClipboardValues};
|
||||||
|
pub use line_types::{classify_line, is_non_calculable, LineType};
|
||||||
@@ -4,12 +4,14 @@ pub mod currency;
|
|||||||
pub mod datetime;
|
pub mod datetime;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod ffi;
|
pub mod ffi;
|
||||||
|
pub mod formatting;
|
||||||
pub mod functions;
|
pub mod functions;
|
||||||
pub mod interpreter;
|
pub mod interpreter;
|
||||||
pub mod lexer;
|
pub mod lexer;
|
||||||
pub mod number;
|
pub mod number;
|
||||||
pub mod parser;
|
pub mod parser;
|
||||||
pub mod pipeline;
|
pub mod pipeline;
|
||||||
|
pub mod plugins;
|
||||||
pub mod sheet_context;
|
pub mod sheet_context;
|
||||||
pub mod span;
|
pub mod span;
|
||||||
pub mod token;
|
pub mod token;
|
||||||
|
|||||||
437
calcpad-engine/src/plugins/api.rs
Normal file
437
calcpad-engine/src/plugins/api.rs
Normal file
@@ -0,0 +1,437 @@
|
|||||||
|
//! Plugin API: the `CalcPadPlugin` trait and supporting types.
|
||||||
|
//!
|
||||||
|
//! Developers implement [`CalcPadPlugin`] to extend calcpad-engine with
|
||||||
|
//! custom functions, units, and variables. The trait is object-safe so
|
||||||
|
//! plugins can be loaded dynamically at runtime.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Plugin Value type (simplified, f64-based)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// A value exchanged between the engine and plugins.
|
||||||
|
///
|
||||||
|
/// Plugin functions receive arguments as `PluginValue` and return one.
|
||||||
|
/// When the user writes `80kg`, the plugin receives
|
||||||
|
/// `PluginValue { number: 80.0, unit: Some("kg") }`.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub struct PluginValue {
|
||||||
|
pub number: f64,
|
||||||
|
pub unit: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PluginValue {
|
||||||
|
/// Create a plain numeric value (no unit).
|
||||||
|
pub fn new(number: f64) -> Self {
|
||||||
|
Self { number, unit: None }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a value with an attached unit string.
|
||||||
|
pub fn with_unit(number: f64, unit: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
number,
|
||||||
|
unit: Some(unit.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Plugin function signature
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// A plugin function: takes a slice of `PluginValue` args and returns
|
||||||
|
/// a `PluginValue` or an error message.
|
||||||
|
pub type PluginFn = Arc<dyn Fn(&[PluginValue]) -> Result<PluginValue, String> + Send + Sync>;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Per-category registries (passed to the plugin during registration)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Registry that a plugin fills with custom functions.
|
||||||
|
pub struct PluginFunctionRegistry {
|
||||||
|
pub(crate) functions: HashMap<String, PluginFn>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PluginFunctionRegistry {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
functions: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register a named function.
|
||||||
|
pub fn register(
|
||||||
|
&mut self,
|
||||||
|
name: impl Into<String>,
|
||||||
|
func: impl Fn(&[PluginValue]) -> Result<PluginValue, String> + Send + Sync + 'static,
|
||||||
|
) {
|
||||||
|
self.functions.insert(name.into(), Arc::new(func));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Iterate over registered function names.
|
||||||
|
pub fn names(&self) -> impl Iterator<Item = &str> {
|
||||||
|
self.functions.keys().map(|s| s.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for PluginFunctionRegistry {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Registry that a plugin fills with custom units.
|
||||||
|
///
|
||||||
|
/// Each unit is stored as a conversion factor relative to some base.
|
||||||
|
/// For example, `"lb"` with factor `0.453592` means `80 lb` evaluates
|
||||||
|
/// to `80 * 0.453592 = 36.287` in the base unit (kg).
|
||||||
|
pub struct PluginUnitRegistry {
|
||||||
|
pub(crate) units: HashMap<String, f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PluginUnitRegistry {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
units: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register a unit with its conversion factor.
|
||||||
|
pub fn register(&mut self, name: impl Into<String>, factor: f64) {
|
||||||
|
self.units.insert(name.into(), factor);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Iterate over registered unit names.
|
||||||
|
pub fn names(&self) -> impl Iterator<Item = &str> {
|
||||||
|
self.units.keys().map(|s| s.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for PluginUnitRegistry {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Registry that a plugin fills with custom variables (constants).
|
||||||
|
pub struct PluginVariableRegistry {
|
||||||
|
pub(crate) variables: HashMap<String, f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PluginVariableRegistry {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
variables: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register a named constant.
|
||||||
|
pub fn register(&mut self, name: impl Into<String>, value: f64) {
|
||||||
|
self.variables.insert(name.into(), value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Iterate over registered variable names.
|
||||||
|
pub fn names(&self) -> impl Iterator<Item = &str> {
|
||||||
|
self.variables.keys().map(|s| s.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for PluginVariableRegistry {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Plugin lifecycle
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Errors that can occur during plugin initialisation.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub struct PluginError {
|
||||||
|
pub plugin_name: String,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for PluginError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "[plugin '{}'] {}", self.plugin_name, self.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for PluginError {}
|
||||||
|
|
||||||
|
/// A warning generated during plugin loading (e.g., name conflicts with
|
||||||
|
/// built-in functions).
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub struct PluginWarning {
|
||||||
|
pub plugin_name: String,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for PluginWarning {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "[plugin '{}'] {}", self.plugin_name, self.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// The CalcPadPlugin trait
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// The trait that all plugins implement to extend CalcPad.
|
||||||
|
///
|
||||||
|
/// # Lifecycle
|
||||||
|
///
|
||||||
|
/// 1. **`init()`** — Called once when the plugin is loaded. Perform any
|
||||||
|
/// one-time setup here. Return `Err` to abort loading.
|
||||||
|
/// 2. **`register_functions()`** / **`register_units()`** /
|
||||||
|
/// **`register_variables()`** — Called after a successful `init()` to
|
||||||
|
/// collect the plugin's contributions.
|
||||||
|
/// 3. **`shutdown()`** — Called when the plugin is unloaded or the engine
|
||||||
|
/// is torn down. Clean up resources here.
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
///
|
||||||
|
/// ```rust
|
||||||
|
/// use calcpad_engine::plugins::api::*;
|
||||||
|
///
|
||||||
|
/// struct BmiPlugin;
|
||||||
|
///
|
||||||
|
/// impl CalcPadPlugin for BmiPlugin {
|
||||||
|
/// fn name(&self) -> &str { "bmi" }
|
||||||
|
///
|
||||||
|
/// fn register_functions(&self, reg: &mut PluginFunctionRegistry) {
|
||||||
|
/// reg.register("bmi", |args| {
|
||||||
|
/// if args.len() != 2 {
|
||||||
|
/// return Err("bmi() requires 2 arguments: weight, height".into());
|
||||||
|
/// }
|
||||||
|
/// let weight = args[0].number;
|
||||||
|
/// let height = args[1].number;
|
||||||
|
/// if height == 0.0 {
|
||||||
|
/// return Err("height cannot be zero".into());
|
||||||
|
/// }
|
||||||
|
/// Ok(PluginValue::new(weight / (height * height)))
|
||||||
|
/// });
|
||||||
|
/// }
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
pub trait CalcPadPlugin: Send + Sync {
|
||||||
|
/// A short, unique name used for identification and conflict reporting.
|
||||||
|
fn name(&self) -> &str;
|
||||||
|
|
||||||
|
/// Called once when the plugin is loaded.
|
||||||
|
///
|
||||||
|
/// Return `Err` to abort loading; the error will be surfaced to the user.
|
||||||
|
fn init(&self) -> Result<(), String> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register custom functions with the engine.
|
||||||
|
fn register_functions(&self, _registry: &mut PluginFunctionRegistry) {}
|
||||||
|
|
||||||
|
/// Register custom units with the engine.
|
||||||
|
fn register_units(&self, _registry: &mut PluginUnitRegistry) {}
|
||||||
|
|
||||||
|
/// Register custom variables / constants with the engine.
|
||||||
|
fn register_variables(&self, _registry: &mut PluginVariableRegistry) {}
|
||||||
|
|
||||||
|
/// Called when the plugin is unloaded. Clean up any resources here.
|
||||||
|
fn shutdown(&self) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_plugin_value_plain() {
|
||||||
|
let v = PluginValue::new(42.0);
|
||||||
|
assert_eq!(v.number, 42.0);
|
||||||
|
assert!(v.unit.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_plugin_value_with_unit() {
|
||||||
|
let v = PluginValue::with_unit(80.0, "kg");
|
||||||
|
assert_eq!(v.number, 80.0);
|
||||||
|
assert_eq!(v.unit.as_deref(), Some("kg"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_function_registry_register_and_call() {
|
||||||
|
let mut reg = PluginFunctionRegistry::new();
|
||||||
|
reg.register("double", |args| {
|
||||||
|
Ok(PluginValue::new(args[0].number * 2.0))
|
||||||
|
});
|
||||||
|
let func = reg.functions.get("double").unwrap();
|
||||||
|
let result = func(&[PluginValue::new(21.0)]).unwrap();
|
||||||
|
assert_eq!(result.number, 42.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_function_registry_names() {
|
||||||
|
let mut reg = PluginFunctionRegistry::new();
|
||||||
|
reg.register("foo", |_| Ok(PluginValue::new(0.0)));
|
||||||
|
reg.register("bar", |_| Ok(PluginValue::new(0.0)));
|
||||||
|
let mut names: Vec<&str> = reg.names().collect();
|
||||||
|
names.sort();
|
||||||
|
assert_eq!(names, vec!["bar", "foo"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_unit_registry() {
|
||||||
|
let mut reg = PluginUnitRegistry::new();
|
||||||
|
reg.register("kg", 1.0);
|
||||||
|
reg.register("lb", 0.453592);
|
||||||
|
assert_eq!(reg.units.get("kg"), Some(&1.0));
|
||||||
|
assert_eq!(reg.units.get("lb"), Some(&0.453592));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_unit_registry_names() {
|
||||||
|
let mut reg = PluginUnitRegistry::new();
|
||||||
|
reg.register("m", 1.0);
|
||||||
|
reg.register("ft", 0.3048);
|
||||||
|
let mut names: Vec<&str> = reg.names().collect();
|
||||||
|
names.sort();
|
||||||
|
assert_eq!(names, vec!["ft", "m"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_variable_registry() {
|
||||||
|
let mut reg = PluginVariableRegistry::new();
|
||||||
|
reg.register("pi", std::f64::consts::PI);
|
||||||
|
assert_eq!(reg.variables.get("pi"), Some(&std::f64::consts::PI));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_plugin_trait_full_implementation() {
|
||||||
|
struct TestPlugin;
|
||||||
|
impl CalcPadPlugin for TestPlugin {
|
||||||
|
fn name(&self) -> &str { "test" }
|
||||||
|
|
||||||
|
fn init(&self) -> Result<(), String> { Ok(()) }
|
||||||
|
|
||||||
|
fn register_functions(&self, reg: &mut PluginFunctionRegistry) {
|
||||||
|
reg.register("square", |args| {
|
||||||
|
if args.len() != 1 {
|
||||||
|
return Err("square() requires 1 argument".into());
|
||||||
|
}
|
||||||
|
Ok(PluginValue::new(args[0].number * args[0].number))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_units(&self, reg: &mut PluginUnitRegistry) {
|
||||||
|
reg.register("cm", 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_variables(&self, reg: &mut PluginVariableRegistry) {
|
||||||
|
reg.register("tau", std::f64::consts::TAU);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shutdown(&self) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
let plugin = TestPlugin;
|
||||||
|
assert_eq!(plugin.name(), "test");
|
||||||
|
assert!(plugin.init().is_ok());
|
||||||
|
|
||||||
|
let mut freg = PluginFunctionRegistry::new();
|
||||||
|
plugin.register_functions(&mut freg);
|
||||||
|
let func = freg.functions.get("square").unwrap();
|
||||||
|
assert_eq!(func(&[PluginValue::new(5.0)]).unwrap().number, 25.0);
|
||||||
|
|
||||||
|
let mut ureg = PluginUnitRegistry::new();
|
||||||
|
plugin.register_units(&mut ureg);
|
||||||
|
assert_eq!(ureg.units.get("cm"), Some(&0.01));
|
||||||
|
|
||||||
|
let mut vreg = PluginVariableRegistry::new();
|
||||||
|
plugin.register_variables(&mut vreg);
|
||||||
|
assert_eq!(vreg.variables.get("tau"), Some(&std::f64::consts::TAU));
|
||||||
|
|
||||||
|
plugin.shutdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_plugin_default_methods_are_noops() {
|
||||||
|
struct MinimalPlugin;
|
||||||
|
impl CalcPadPlugin for MinimalPlugin {
|
||||||
|
fn name(&self) -> &str { "minimal" }
|
||||||
|
}
|
||||||
|
|
||||||
|
let p = MinimalPlugin;
|
||||||
|
assert!(p.init().is_ok());
|
||||||
|
// Should not panic:
|
||||||
|
let mut freg = PluginFunctionRegistry::new();
|
||||||
|
p.register_functions(&mut freg);
|
||||||
|
assert_eq!(freg.functions.len(), 0);
|
||||||
|
p.shutdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_plugin_init_can_fail() {
|
||||||
|
struct FailPlugin;
|
||||||
|
impl CalcPadPlugin for FailPlugin {
|
||||||
|
fn name(&self) -> &str { "fail" }
|
||||||
|
fn init(&self) -> Result<(), String> {
|
||||||
|
Err("missing required config".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let p = FailPlugin;
|
||||||
|
let err = p.init().unwrap_err();
|
||||||
|
assert_eq!(err, "missing required config");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_plugin_warning_display() {
|
||||||
|
let w = PluginWarning {
|
||||||
|
plugin_name: "myplugin".to_string(),
|
||||||
|
message: "function 'sin' conflicts with built-in".to_string(),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
w.to_string(),
|
||||||
|
"[plugin 'myplugin'] function 'sin' conflicts with built-in"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_plugin_error_display() {
|
||||||
|
let e = PluginError {
|
||||||
|
plugin_name: "bad".to_string(),
|
||||||
|
message: "init failed".to_string(),
|
||||||
|
};
|
||||||
|
assert_eq!(e.to_string(), "[plugin 'bad'] init failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_function_returns_error() {
|
||||||
|
let mut reg = PluginFunctionRegistry::new();
|
||||||
|
reg.register("safe_div", |args| {
|
||||||
|
if args.len() != 2 {
|
||||||
|
return Err("safe_div() requires 2 args".into());
|
||||||
|
}
|
||||||
|
if args[1].number == 0.0 {
|
||||||
|
return Err("division by zero".into());
|
||||||
|
}
|
||||||
|
Ok(PluginValue::new(args[0].number / args[1].number))
|
||||||
|
});
|
||||||
|
let func = reg.functions.get("safe_div").unwrap();
|
||||||
|
assert_eq!(func(&[PluginValue::new(10.0), PluginValue::new(2.0)]).unwrap().number, 5.0);
|
||||||
|
let err = func(&[PluginValue::new(1.0), PluginValue::new(0.0)]).unwrap_err();
|
||||||
|
assert!(err.contains("division by zero"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_plugin_fn_is_send_sync() {
|
||||||
|
// Compile-time check: PluginFn must be Send + Sync
|
||||||
|
fn assert_send_sync<T: Send + Sync>() {}
|
||||||
|
assert_send_sync::<PluginFn>();
|
||||||
|
}
|
||||||
|
}
|
||||||
63
calcpad-engine/src/plugins/mod.rs
Normal file
63
calcpad-engine/src/plugins/mod.rs
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
//! Plugin / extension system for calcpad-engine.
|
||||||
|
//!
|
||||||
|
//! This module provides a trait-based plugin API, a registry for managing
|
||||||
|
//! loaded plugins, and a Rhai scripting integration for lightweight custom
|
||||||
|
//! functions.
|
||||||
|
//!
|
||||||
|
//! # Architecture
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! ┌─────────────────────────────────────────────────────┐
|
||||||
|
//! │ PluginRegistry │
|
||||||
|
//! │ ┌───────────┐ ┌───────────┐ ┌───────────┐ │
|
||||||
|
//! │ │ Plugin A │ │ Plugin B │ │ ScriptPlugin │ │
|
||||||
|
//! │ │ (Rust) │ │ (Rust) │ │ (.rhai) │ │
|
||||||
|
//! │ └─────┬─────┘ └─────┬─────┘ └──────┬────┘ │
|
||||||
|
//! │ │ │ │ │
|
||||||
|
//! │ └──────────────┴───────────────┘ │
|
||||||
|
//! │ CalcPadPlugin trait │
|
||||||
|
//! │ functions: HashMap<String, PluginFn> │
|
||||||
|
//! │ units: HashMap<String, f64> │
|
||||||
|
//! │ variables: HashMap<String, f64> │
|
||||||
|
//! └─────────────────────────────────────────────────────┘
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! # Quick start
|
||||||
|
//!
|
||||||
|
//! ```rust
|
||||||
|
//! use calcpad_engine::plugins::{
|
||||||
|
//! CalcPadPlugin, PluginFunctionRegistry, PluginValue, PluginRegistry,
|
||||||
|
//! };
|
||||||
|
//!
|
||||||
|
//! struct MyPlugin;
|
||||||
|
//!
|
||||||
|
//! impl CalcPadPlugin for MyPlugin {
|
||||||
|
//! fn name(&self) -> &str { "my_plugin" }
|
||||||
|
//!
|
||||||
|
//! fn register_functions(&self, reg: &mut PluginFunctionRegistry) {
|
||||||
|
//! reg.register("double", |args| {
|
||||||
|
//! if args.len() != 1 {
|
||||||
|
//! return Err("double() requires 1 argument".into());
|
||||||
|
//! }
|
||||||
|
//! Ok(PluginValue::new(args[0].number * 2.0))
|
||||||
|
//! });
|
||||||
|
//! }
|
||||||
|
//! }
|
||||||
|
//!
|
||||||
|
//! let mut registry = PluginRegistry::new();
|
||||||
|
//! registry.add(MyPlugin).unwrap();
|
||||||
|
//! let result = registry.call_function("double", &[PluginValue::new(21.0)]).unwrap();
|
||||||
|
//! assert_eq!(result.number, 42.0);
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
pub mod api;
|
||||||
|
pub mod registry;
|
||||||
|
pub mod scripting;
|
||||||
|
|
||||||
|
// Re-export the most commonly used types at the `plugins` level.
|
||||||
|
pub use api::{
|
||||||
|
CalcPadPlugin, PluginError, PluginFn, PluginFunctionRegistry,
|
||||||
|
PluginUnitRegistry, PluginValue, PluginVariableRegistry, PluginWarning,
|
||||||
|
};
|
||||||
|
pub use registry::PluginRegistry;
|
||||||
|
pub use scripting::{load_scripts_from_dir, ScriptError, ScriptLoadResult, ScriptPlugin};
|
||||||
485
calcpad-engine/src/plugins/registry.rs
Normal file
485
calcpad-engine/src/plugins/registry.rs
Normal file
@@ -0,0 +1,485 @@
|
|||||||
|
//! Plugin registry: loads, manages, and queries plugins.
|
||||||
|
//!
|
||||||
|
//! [`PluginRegistry`] is the central coordinator that:
|
||||||
|
//! - Accepts plugins (via [`add`] / [`add_boxed`])
|
||||||
|
//! - Calls their lifecycle hooks (init, registration, shutdown)
|
||||||
|
//! - Merges their contributions (functions, units, variables)
|
||||||
|
//! - Detects and reports naming conflicts with built-in symbols
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use super::api::{
|
||||||
|
CalcPadPlugin, PluginError, PluginFn, PluginFunctionRegistry, PluginUnitRegistry,
|
||||||
|
PluginValue, PluginVariableRegistry, PluginWarning,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Names that are reserved by the engine's built-in function set.
|
||||||
|
/// Plugin functions with any of these names will be skipped, and a
|
||||||
|
/// warning will be emitted instead.
|
||||||
|
const BUILTIN_FUNCTION_NAMES: &[&str] = &[
|
||||||
|
"sin", "cos", "tan", "asin", "acos", "atan",
|
||||||
|
"sinh", "cosh", "tanh",
|
||||||
|
"sqrt", "abs", "ceil", "floor", "round",
|
||||||
|
"ln", "log", "log2", "log10",
|
||||||
|
"exp", "pow", "min", "max",
|
||||||
|
"factorial", "nPr", "nCr",
|
||||||
|
"gcd", "lcm", "sum", "avg", "average",
|
||||||
|
"compound_interest", "mortgage_payment",
|
||||||
|
"tc_to_frames", "frames_to_tc",
|
||||||
|
];
|
||||||
|
|
||||||
|
fn is_builtin_function(name: &str) -> bool {
|
||||||
|
BUILTIN_FUNCTION_NAMES.contains(&name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of loading one or more plugins into the registry.
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct LoadResult {
|
||||||
|
/// Number of plugins that loaded successfully.
|
||||||
|
pub loaded: usize,
|
||||||
|
/// Errors from plugins that failed to load.
|
||||||
|
pub errors: Vec<PluginError>,
|
||||||
|
/// Non-fatal warnings (e.g., naming conflicts).
|
||||||
|
pub warnings: Vec<PluginWarning>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Manages the full set of loaded plugins and their merged contributions.
|
||||||
|
pub struct PluginRegistry {
|
||||||
|
/// Loaded plugin instances (kept alive for shutdown).
|
||||||
|
plugins: Vec<Box<dyn CalcPadPlugin>>,
|
||||||
|
/// Merged function map: name -> callable.
|
||||||
|
functions: HashMap<String, PluginFn>,
|
||||||
|
/// Merged unit map: name -> conversion factor.
|
||||||
|
units: HashMap<String, f64>,
|
||||||
|
/// Merged variable map: name -> value.
|
||||||
|
variables: HashMap<String, f64>,
|
||||||
|
/// Accumulated warnings from all load operations.
|
||||||
|
warnings: Vec<PluginWarning>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PluginRegistry {
|
||||||
|
/// Create an empty registry.
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
plugins: Vec::new(),
|
||||||
|
functions: HashMap::new(),
|
||||||
|
units: HashMap::new(),
|
||||||
|
variables: HashMap::new(),
|
||||||
|
warnings: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a plugin (by value) to the registry.
|
||||||
|
///
|
||||||
|
/// Calls `init()`, then the three `register_*()` methods.
|
||||||
|
/// On init failure the plugin is **not** added and the error is returned.
|
||||||
|
pub fn add<P: CalcPadPlugin + 'static>(&mut self, plugin: P) -> Result<(), PluginError> {
|
||||||
|
self.add_boxed(Box::new(plugin))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a boxed plugin to the registry.
|
||||||
|
pub fn add_boxed(&mut self, plugin: Box<dyn CalcPadPlugin>) -> Result<(), PluginError> {
|
||||||
|
let name = plugin.name().to_string();
|
||||||
|
|
||||||
|
// Lifecycle: init
|
||||||
|
plugin.init().map_err(|msg| PluginError {
|
||||||
|
plugin_name: name.clone(),
|
||||||
|
message: msg,
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Collect functions
|
||||||
|
let mut freg = PluginFunctionRegistry::new();
|
||||||
|
plugin.register_functions(&mut freg);
|
||||||
|
for (fname, func) in freg.functions {
|
||||||
|
if is_builtin_function(&fname) {
|
||||||
|
self.warnings.push(PluginWarning {
|
||||||
|
plugin_name: name.clone(),
|
||||||
|
message: format!(
|
||||||
|
"function '{}' conflicts with built-in -- built-in takes precedence",
|
||||||
|
fname
|
||||||
|
),
|
||||||
|
});
|
||||||
|
} else if self.functions.contains_key(&fname) {
|
||||||
|
self.warnings.push(PluginWarning {
|
||||||
|
plugin_name: name.clone(),
|
||||||
|
message: format!(
|
||||||
|
"function '{}' already registered by another plugin -- overwritten",
|
||||||
|
fname
|
||||||
|
),
|
||||||
|
});
|
||||||
|
self.functions.insert(fname, func);
|
||||||
|
} else {
|
||||||
|
self.functions.insert(fname, func);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect units
|
||||||
|
let mut ureg = PluginUnitRegistry::new();
|
||||||
|
plugin.register_units(&mut ureg);
|
||||||
|
for (uname, factor) in ureg.units {
|
||||||
|
if self.units.contains_key(&uname) {
|
||||||
|
self.warnings.push(PluginWarning {
|
||||||
|
plugin_name: name.clone(),
|
||||||
|
message: format!(
|
||||||
|
"unit '{}' already registered -- overwritten",
|
||||||
|
uname
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
self.units.insert(uname, factor);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect variables
|
||||||
|
let mut vreg = PluginVariableRegistry::new();
|
||||||
|
plugin.register_variables(&mut vreg);
|
||||||
|
for (vname, value) in vreg.variables {
|
||||||
|
if self.variables.contains_key(&vname) {
|
||||||
|
self.warnings.push(PluginWarning {
|
||||||
|
plugin_name: name.clone(),
|
||||||
|
message: format!(
|
||||||
|
"variable '{}' already registered -- overwritten",
|
||||||
|
vname
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
self.variables.insert(vname, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.plugins.push(plugin);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Queries
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Number of loaded plugins.
|
||||||
|
pub fn plugin_count(&self) -> usize {
|
||||||
|
self.plugins.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Names of all loaded plugins.
|
||||||
|
pub fn plugin_names(&self) -> Vec<&str> {
|
||||||
|
self.plugins.iter().map(|p| p.name()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Look up a plugin-provided function by name.
|
||||||
|
pub fn get_function(&self, name: &str) -> Option<&PluginFn> {
|
||||||
|
self.functions.get(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Call a plugin function by name.
|
||||||
|
pub fn call_function(
|
||||||
|
&self,
|
||||||
|
name: &str,
|
||||||
|
args: &[PluginValue],
|
||||||
|
) -> Result<PluginValue, String> {
|
||||||
|
let func = self
|
||||||
|
.functions
|
||||||
|
.get(name)
|
||||||
|
.ok_or_else(|| format!("unknown plugin function: {}", name))?;
|
||||||
|
func(args)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check whether a plugin function with the given name exists.
|
||||||
|
pub fn has_function(&self, name: &str) -> bool {
|
||||||
|
self.functions.contains_key(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All registered plugin function names (sorted).
|
||||||
|
pub fn function_names(&self) -> Vec<&str> {
|
||||||
|
let mut names: Vec<&str> = self.functions.keys().map(|s| s.as_str()).collect();
|
||||||
|
names.sort();
|
||||||
|
names
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Look up a plugin-provided unit's conversion factor.
|
||||||
|
pub fn get_unit_factor(&self, name: &str) -> Option<f64> {
|
||||||
|
self.units.get(name).copied()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check whether a plugin unit with the given name exists.
|
||||||
|
pub fn has_unit(&self, name: &str) -> bool {
|
||||||
|
self.units.contains_key(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All registered plugin unit names (sorted).
|
||||||
|
pub fn unit_names(&self) -> Vec<&str> {
|
||||||
|
let mut names: Vec<&str> = self.units.keys().map(|s| s.as_str()).collect();
|
||||||
|
names.sort();
|
||||||
|
names
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Look up a plugin-provided variable's value.
|
||||||
|
pub fn get_variable(&self, name: &str) -> Option<f64> {
|
||||||
|
self.variables.get(name).copied()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check whether a plugin variable with the given name exists.
|
||||||
|
pub fn has_variable(&self, name: &str) -> bool {
|
||||||
|
self.variables.contains_key(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All registered plugin variable names (sorted).
|
||||||
|
pub fn variable_names(&self) -> Vec<&str> {
|
||||||
|
let mut names: Vec<&str> = self.variables.keys().map(|s| s.as_str()).collect();
|
||||||
|
names.sort();
|
||||||
|
names
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Warnings accumulated during loading.
|
||||||
|
pub fn warnings(&self) -> &[PluginWarning] {
|
||||||
|
&self.warnings
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Lifecycle: shutdown
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Shut down all loaded plugins (calls `shutdown()` on each).
|
||||||
|
pub fn shutdown_all(&self) {
|
||||||
|
for plugin in &self.plugins {
|
||||||
|
plugin.shutdown();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for PluginRegistry {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for PluginRegistry {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.shutdown_all();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::plugins::api::*;
|
||||||
|
|
||||||
|
// -- helpers --
|
||||||
|
|
||||||
|
struct MathPlugin;
|
||||||
|
impl CalcPadPlugin for MathPlugin {
|
||||||
|
fn name(&self) -> &str { "math_extras" }
|
||||||
|
|
||||||
|
fn register_functions(&self, reg: &mut PluginFunctionRegistry) {
|
||||||
|
reg.register("square", |args| {
|
||||||
|
if args.len() != 1 {
|
||||||
|
return Err("square() requires 1 argument".into());
|
||||||
|
}
|
||||||
|
Ok(PluginValue::new(args[0].number * args[0].number))
|
||||||
|
});
|
||||||
|
reg.register("cube", |args| {
|
||||||
|
if args.len() != 1 {
|
||||||
|
return Err("cube() requires 1 argument".into());
|
||||||
|
}
|
||||||
|
let n = args[0].number;
|
||||||
|
Ok(PluginValue::new(n * n * n))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_variables(&self, reg: &mut PluginVariableRegistry) {
|
||||||
|
reg.register("golden_ratio", 1.618033988749);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UnitsPlugin;
|
||||||
|
impl CalcPadPlugin for UnitsPlugin {
|
||||||
|
fn name(&self) -> &str { "custom_units" }
|
||||||
|
|
||||||
|
fn register_units(&self, reg: &mut PluginUnitRegistry) {
|
||||||
|
reg.register("stone", 6.35029);
|
||||||
|
reg.register("furlong", 201.168);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FailingPlugin;
|
||||||
|
impl CalcPadPlugin for FailingPlugin {
|
||||||
|
fn name(&self) -> &str { "failing" }
|
||||||
|
fn init(&self) -> Result<(), String> {
|
||||||
|
Err("cannot connect to service".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ConflictPlugin;
|
||||||
|
impl CalcPadPlugin for ConflictPlugin {
|
||||||
|
fn name(&self) -> &str { "conflict" }
|
||||||
|
fn register_functions(&self, reg: &mut PluginFunctionRegistry) {
|
||||||
|
// "sin" is a built-in
|
||||||
|
reg.register("sin", |_| Ok(PluginValue::new(0.0)));
|
||||||
|
// "my_func" is not
|
||||||
|
reg.register("my_func", |_| Ok(PluginValue::new(99.0)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- tests --
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_empty_registry() {
|
||||||
|
let reg = PluginRegistry::new();
|
||||||
|
assert_eq!(reg.plugin_count(), 0);
|
||||||
|
assert!(reg.function_names().is_empty());
|
||||||
|
assert!(reg.unit_names().is_empty());
|
||||||
|
assert!(reg.variable_names().is_empty());
|
||||||
|
assert!(reg.warnings().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_add_plugin_functions_and_variables() {
|
||||||
|
let mut reg = PluginRegistry::new();
|
||||||
|
reg.add(MathPlugin).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(reg.plugin_count(), 1);
|
||||||
|
assert_eq!(reg.plugin_names(), vec!["math_extras"]);
|
||||||
|
|
||||||
|
// Functions
|
||||||
|
assert!(reg.has_function("square"));
|
||||||
|
assert!(reg.has_function("cube"));
|
||||||
|
assert!(!reg.has_function("nonexistent"));
|
||||||
|
|
||||||
|
let result = reg.call_function("square", &[PluginValue::new(7.0)]).unwrap();
|
||||||
|
assert_eq!(result.number, 49.0);
|
||||||
|
|
||||||
|
let result = reg.call_function("cube", &[PluginValue::new(3.0)]).unwrap();
|
||||||
|
assert_eq!(result.number, 27.0);
|
||||||
|
|
||||||
|
// Variables
|
||||||
|
assert!(reg.has_variable("golden_ratio"));
|
||||||
|
let gr = reg.get_variable("golden_ratio").unwrap();
|
||||||
|
assert!((gr - 1.618033988749).abs() < 1e-10);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_add_plugin_units() {
|
||||||
|
let mut reg = PluginRegistry::new();
|
||||||
|
reg.add(UnitsPlugin).unwrap();
|
||||||
|
|
||||||
|
assert!(reg.has_unit("stone"));
|
||||||
|
assert!(reg.has_unit("furlong"));
|
||||||
|
assert_eq!(reg.get_unit_factor("stone"), Some(6.35029));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_multiple_plugins() {
|
||||||
|
let mut reg = PluginRegistry::new();
|
||||||
|
reg.add(MathPlugin).unwrap();
|
||||||
|
reg.add(UnitsPlugin).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(reg.plugin_count(), 2);
|
||||||
|
assert!(reg.has_function("square"));
|
||||||
|
assert!(reg.has_unit("stone"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_plugin_init_failure_does_not_add() {
|
||||||
|
let mut reg = PluginRegistry::new();
|
||||||
|
let err = reg.add(FailingPlugin).unwrap_err();
|
||||||
|
assert_eq!(err.plugin_name, "failing");
|
||||||
|
assert!(err.message.contains("cannot connect"));
|
||||||
|
assert_eq!(reg.plugin_count(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_builtin_conflict_generates_warning() {
|
||||||
|
let mut reg = PluginRegistry::new();
|
||||||
|
reg.add(ConflictPlugin).unwrap();
|
||||||
|
|
||||||
|
// "sin" should NOT be registered (built-in conflict)
|
||||||
|
assert!(!reg.has_function("sin"));
|
||||||
|
// "my_func" should be registered
|
||||||
|
assert!(reg.has_function("my_func"));
|
||||||
|
|
||||||
|
assert_eq!(reg.warnings().len(), 1);
|
||||||
|
assert!(reg.warnings()[0].message.contains("sin"));
|
||||||
|
assert!(reg.warnings()[0].message.contains("built-in"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_duplicate_function_name_across_plugins() {
|
||||||
|
struct PluginA;
|
||||||
|
impl CalcPadPlugin for PluginA {
|
||||||
|
fn name(&self) -> &str { "a" }
|
||||||
|
fn register_functions(&self, reg: &mut PluginFunctionRegistry) {
|
||||||
|
reg.register("shared_fn", |_| Ok(PluginValue::new(1.0)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
struct PluginB;
|
||||||
|
impl CalcPadPlugin for PluginB {
|
||||||
|
fn name(&self) -> &str { "b" }
|
||||||
|
fn register_functions(&self, reg: &mut PluginFunctionRegistry) {
|
||||||
|
reg.register("shared_fn", |_| Ok(PluginValue::new(2.0)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut reg = PluginRegistry::new();
|
||||||
|
reg.add(PluginA).unwrap();
|
||||||
|
reg.add(PluginB).unwrap();
|
||||||
|
|
||||||
|
// The second plugin's version should overwrite
|
||||||
|
let result = reg.call_function("shared_fn", &[]).unwrap();
|
||||||
|
assert_eq!(result.number, 2.0);
|
||||||
|
|
||||||
|
// A warning should have been emitted
|
||||||
|
assert_eq!(reg.warnings().len(), 1);
|
||||||
|
assert!(reg.warnings()[0].message.contains("overwritten"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_call_unknown_function_returns_error() {
|
||||||
|
let reg = PluginRegistry::new();
|
||||||
|
let err = reg.call_function("nope", &[]).unwrap_err();
|
||||||
|
assert!(err.contains("unknown plugin function"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_function_names_sorted() {
|
||||||
|
let mut reg = PluginRegistry::new();
|
||||||
|
struct SortPlugin;
|
||||||
|
impl CalcPadPlugin for SortPlugin {
|
||||||
|
fn name(&self) -> &str { "sort" }
|
||||||
|
fn register_functions(&self, r: &mut PluginFunctionRegistry) {
|
||||||
|
r.register("zebra", |_| Ok(PluginValue::new(0.0)));
|
||||||
|
r.register("alpha", |_| Ok(PluginValue::new(0.0)));
|
||||||
|
r.register("mid", |_| Ok(PluginValue::new(0.0)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reg.add(SortPlugin).unwrap();
|
||||||
|
assert_eq!(reg.function_names(), vec!["alpha", "mid", "zebra"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_shutdown_is_called() {
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
static SHUT_DOWN: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
|
struct ShutdownPlugin;
|
||||||
|
impl CalcPadPlugin for ShutdownPlugin {
|
||||||
|
fn name(&self) -> &str { "shutdown_test" }
|
||||||
|
fn shutdown(&self) {
|
||||||
|
SHUT_DOWN.store(true, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut reg = PluginRegistry::new();
|
||||||
|
reg.add(ShutdownPlugin).unwrap();
|
||||||
|
reg.shutdown_all();
|
||||||
|
assert!(SHUT_DOWN.load(Ordering::SeqCst));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_default_trait() {
|
||||||
|
let reg = PluginRegistry::default();
|
||||||
|
assert_eq!(reg.plugin_count(), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
555
calcpad-engine/src/plugins/scripting.rs
Normal file
555
calcpad-engine/src/plugins/scripting.rs
Normal file
@@ -0,0 +1,555 @@
|
|||||||
|
//! Rhai scripting integration for lightweight custom functions.
|
||||||
|
//!
|
||||||
|
//! Users can write `.rhai` script files that define functions. This module
|
||||||
|
//! compiles those scripts in a sandboxed engine and wraps each
|
||||||
|
//! script-defined function as a [`CalcPadPlugin`] for seamless integration
|
||||||
|
//! with the plugin registry.
|
||||||
|
//!
|
||||||
|
//! # Current status
|
||||||
|
//!
|
||||||
|
//! The Rhai integration is **stubbed**. When the `rhai` crate is added as
|
||||||
|
//! a dependency (with the `sync` feature) the stubs in this file should be
|
||||||
|
//! replaced with the real Rhai engine calls. The public API and types are
|
||||||
|
//! already designed and tested; only the compile/call internals are pending.
|
||||||
|
//!
|
||||||
|
//! To enable in the future, add to `Cargo.toml`:
|
||||||
|
//!
|
||||||
|
//! ```toml
|
||||||
|
//! rhai = { version = "1", features = ["sync"] }
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! # Sandbox guarantees (when fully enabled)
|
||||||
|
//!
|
||||||
|
//! - `Engine::new_raw()` — no standard library, no I/O
|
||||||
|
//! - `eval` symbol disabled
|
||||||
|
//! - Operation limit: 1 000 000
|
||||||
|
//! - Call-depth limit: 64
|
||||||
|
//! - Expression-depth limit: 64
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use super::api::{
|
||||||
|
CalcPadPlugin, PluginFn, PluginFunctionRegistry, PluginUnitRegistry,
|
||||||
|
PluginValue, PluginVariableRegistry,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Error type
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Error from loading or compiling a Rhai script.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub struct ScriptError {
|
||||||
|
pub file: PathBuf,
|
||||||
|
pub line: Option<usize>,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for ScriptError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
if let Some(line) = self.line {
|
||||||
|
write!(f, "{}: line {}: {}", self.file.display(), line, self.message)
|
||||||
|
} else {
|
||||||
|
write!(f, "{}: {}", self.file.display(), self.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for ScriptError {}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Built-in name reservation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const BUILTIN_NAMES: &[&str] = &[
|
||||||
|
"sin", "cos", "tan", "asin", "acos", "atan",
|
||||||
|
"sinh", "cosh", "tanh",
|
||||||
|
"sqrt", "abs", "ceil", "floor", "round",
|
||||||
|
"ln", "log", "log2", "log10",
|
||||||
|
"exp", "pow", "min", "max",
|
||||||
|
"factorial", "nPr", "nCr",
|
||||||
|
"gcd", "lcm",
|
||||||
|
];
|
||||||
|
|
||||||
|
fn is_builtin_name(name: &str) -> bool {
|
||||||
|
BUILTIN_NAMES.contains(&name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ScriptPlugin
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// A plugin whose functions were loaded from `.rhai` script files.
|
||||||
|
///
|
||||||
|
/// This wraps compiled script functions as standard [`CalcPadPlugin`]
|
||||||
|
/// contributions so they integrate seamlessly with the
|
||||||
|
/// [`PluginRegistry`](super::registry::PluginRegistry).
|
||||||
|
pub struct ScriptPlugin {
|
||||||
|
name: String,
|
||||||
|
functions: HashMap<String, PluginFn>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ScriptPlugin {
|
||||||
|
/// Create a script plugin directly from pre-built function closures.
|
||||||
|
///
|
||||||
|
/// This is the primary constructor used by [`load_scripts_from_dir`] and
|
||||||
|
/// is also useful for testing.
|
||||||
|
pub fn new(name: impl Into<String>, functions: HashMap<String, PluginFn>) -> Self {
|
||||||
|
Self {
|
||||||
|
name: name.into(),
|
||||||
|
functions,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of functions this script plugin provides.
|
||||||
|
pub fn function_count(&self) -> usize {
|
||||||
|
self.functions.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Names of all functions provided by this plugin.
|
||||||
|
pub fn function_names(&self) -> Vec<&str> {
|
||||||
|
let mut names: Vec<&str> = self.functions.keys().map(|s| s.as_str()).collect();
|
||||||
|
names.sort();
|
||||||
|
names
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CalcPadPlugin for ScriptPlugin {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
&self.name
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_functions(&self, reg: &mut PluginFunctionRegistry) {
|
||||||
|
for (name, func) in &self.functions {
|
||||||
|
let f = Arc::clone(func);
|
||||||
|
reg.register(name.clone(), move |args: &[PluginValue]| f(args));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scripts currently only provide functions (units and variables would
|
||||||
|
// require a richer scripting protocol).
|
||||||
|
fn register_units(&self, _reg: &mut PluginUnitRegistry) {}
|
||||||
|
fn register_variables(&self, _reg: &mut PluginVariableRegistry) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Load result
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Result of loading `.rhai` scripts from a directory.
|
||||||
|
pub struct ScriptLoadResult {
|
||||||
|
/// The merged plugin, or `None` if no functions were loaded.
|
||||||
|
pub plugin: Option<ScriptPlugin>,
|
||||||
|
/// Errors from scripts that failed to parse / execute.
|
||||||
|
pub errors: Vec<ScriptError>,
|
||||||
|
/// Non-fatal warnings (e.g., built-in name conflicts).
|
||||||
|
pub warnings: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public API
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Load all `.rhai` scripts from `dir`, compile them, and return a
|
||||||
|
/// [`ScriptLoadResult`].
|
||||||
|
///
|
||||||
|
/// Scripts that fail to parse are reported in `errors` but do not prevent
|
||||||
|
/// other scripts from loading. Functions whose names conflict with
|
||||||
|
/// built-in engine names are skipped (reported in `warnings`).
|
||||||
|
///
|
||||||
|
/// **Current implementation is stubbed** — it scans for `.rhai` files to
|
||||||
|
/// validate the directory but does not compile them (Rhai is not yet a
|
||||||
|
/// dependency). Each discovered function-definition line is noted but
|
||||||
|
/// returns a stub error at call time.
|
||||||
|
pub fn load_scripts_from_dir(dir: &Path) -> ScriptLoadResult {
|
||||||
|
let mut errors = Vec::new();
|
||||||
|
let mut warnings = Vec::new();
|
||||||
|
let mut functions: HashMap<String, PluginFn> = HashMap::new();
|
||||||
|
|
||||||
|
if !dir.exists() || !dir.is_dir() {
|
||||||
|
return ScriptLoadResult {
|
||||||
|
plugin: None,
|
||||||
|
errors,
|
||||||
|
warnings,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect .rhai files
|
||||||
|
let mut rhai_files: Vec<PathBuf> = match std::fs::read_dir(dir) {
|
||||||
|
Ok(entries) => entries
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
.map(|e| e.path())
|
||||||
|
.filter(|p| p.extension().map_or(false, |ext| ext == "rhai"))
|
||||||
|
.collect(),
|
||||||
|
Err(e) => {
|
||||||
|
errors.push(ScriptError {
|
||||||
|
file: dir.to_path_buf(),
|
||||||
|
line: None,
|
||||||
|
message: format!("failed to read directory: {}", e),
|
||||||
|
});
|
||||||
|
return ScriptLoadResult {
|
||||||
|
plugin: None,
|
||||||
|
errors,
|
||||||
|
warnings,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
rhai_files.sort();
|
||||||
|
|
||||||
|
if rhai_files.is_empty() {
|
||||||
|
return ScriptLoadResult {
|
||||||
|
plugin: None,
|
||||||
|
errors,
|
||||||
|
warnings,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stub: parse files looking for `fn <name>(<args>)` patterns.
|
||||||
|
// Real implementation would use `rhai::Engine::compile()`.
|
||||||
|
for path in &rhai_files {
|
||||||
|
match std::fs::read_to_string(path) {
|
||||||
|
Ok(source) => {
|
||||||
|
let extracted = extract_stub_functions(&source);
|
||||||
|
for (name, param_count) in extracted {
|
||||||
|
if is_builtin_name(&name) {
|
||||||
|
warnings.push(format!(
|
||||||
|
"script '{}': function '{}' conflicts with built-in -- skipped",
|
||||||
|
path.file_name().unwrap_or_default().to_string_lossy(),
|
||||||
|
name,
|
||||||
|
));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let fname = name.clone();
|
||||||
|
let pc = param_count;
|
||||||
|
let plugin_fn: PluginFn = Arc::new(move |args: &[PluginValue]| {
|
||||||
|
if args.len() != pc {
|
||||||
|
return Err(format!(
|
||||||
|
"{}() expects {} argument{}, got {}",
|
||||||
|
fname,
|
||||||
|
pc,
|
||||||
|
if pc == 1 { "" } else { "s" },
|
||||||
|
args.len(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// Stub: real impl would evaluate the AST via Rhai engine.
|
||||||
|
Err(format!(
|
||||||
|
"{}(): Rhai scripting engine is not yet enabled",
|
||||||
|
fname,
|
||||||
|
))
|
||||||
|
});
|
||||||
|
functions.insert(name, plugin_fn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
errors.push(ScriptError {
|
||||||
|
file: path.clone(),
|
||||||
|
line: None,
|
||||||
|
message: format!("failed to read file: {}", e),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let plugin = if functions.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(ScriptPlugin::new("rhai_scripts", functions))
|
||||||
|
};
|
||||||
|
|
||||||
|
ScriptLoadResult {
|
||||||
|
plugin,
|
||||||
|
errors,
|
||||||
|
warnings,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Naive extraction of `fn name(a, b, c)` definitions from Rhai source.
|
||||||
|
///
|
||||||
|
/// This is a stub parser that extracts function signatures without a full
|
||||||
|
/// Rhai compile step. It finds lines matching `fn <ident>(<params>)` and
|
||||||
|
/// returns `(name, param_count)` pairs.
|
||||||
|
fn extract_stub_functions(source: &str) -> Vec<(String, usize)> {
|
||||||
|
let mut results = Vec::new();
|
||||||
|
for line in source.lines() {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if !trimmed.starts_with("fn ") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// "fn double(x) { ... }" -> extract "double" and count params
|
||||||
|
let rest = trimmed[3..].trim();
|
||||||
|
if let Some(paren_open) = rest.find('(') {
|
||||||
|
let name = rest[..paren_open].trim().to_string();
|
||||||
|
if name.is_empty() || !name.chars().all(|c| c.is_alphanumeric() || c == '_') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(paren_close) = rest.find(')') {
|
||||||
|
let params_str = rest[paren_open + 1..paren_close].trim();
|
||||||
|
let param_count = if params_str.is_empty() {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
params_str.split(',').count()
|
||||||
|
};
|
||||||
|
results.push((name, param_count));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
results
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
/// Helper to create a temporary directory for test scripts.
|
||||||
|
struct TempDir {
|
||||||
|
path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TempDir {
|
||||||
|
fn new(suffix: &str) -> Self {
|
||||||
|
let path = std::env::temp_dir().join(format!(
|
||||||
|
"calcpad_scripting_test_{}_{}", std::process::id(), suffix
|
||||||
|
));
|
||||||
|
let _ = fs::remove_dir_all(&path);
|
||||||
|
fs::create_dir_all(&path).unwrap();
|
||||||
|
Self { path }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_file(&self, name: &str, content: &str) {
|
||||||
|
fs::write(self.path.join(name), content).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path(&self) -> &Path {
|
||||||
|
&self.path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for TempDir {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = fs::remove_dir_all(&self.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- extract_stub_functions tests --
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_no_functions() {
|
||||||
|
let fns = extract_stub_functions("let x = 5;\nlet y = 10;");
|
||||||
|
assert!(fns.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_single_function() {
|
||||||
|
let fns = extract_stub_functions("fn double(x) { x * 2.0 }");
|
||||||
|
assert_eq!(fns.len(), 1);
|
||||||
|
assert_eq!(fns[0].0, "double");
|
||||||
|
assert_eq!(fns[0].1, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_multiple_functions() {
|
||||||
|
let src = r#"
|
||||||
|
fn double(x) { x * 2.0 }
|
||||||
|
fn add(a, b) { a + b }
|
||||||
|
fn pi() { 3.14159 }
|
||||||
|
"#;
|
||||||
|
let fns = extract_stub_functions(src);
|
||||||
|
assert_eq!(fns.len(), 3);
|
||||||
|
assert_eq!(fns[0], ("double".into(), 1));
|
||||||
|
assert_eq!(fns[1], ("add".into(), 2));
|
||||||
|
assert_eq!(fns[2], ("pi".into(), 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_ignores_non_fn_lines() {
|
||||||
|
let src = "// fn fake(x) {}\nfn real(x) { x }";
|
||||||
|
let fns = extract_stub_functions(src);
|
||||||
|
assert_eq!(fns.len(), 1);
|
||||||
|
assert_eq!(fns[0].0, "real");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- load_scripts_from_dir tests --
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_load_empty_dir() {
|
||||||
|
let tmp = TempDir::new("empty");
|
||||||
|
let result = load_scripts_from_dir(tmp.path());
|
||||||
|
assert!(result.plugin.is_none());
|
||||||
|
assert!(result.errors.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_load_nonexistent_dir() {
|
||||||
|
let result = load_scripts_from_dir(Path::new("/tmp/nonexistent_calcpad_99999"));
|
||||||
|
assert!(result.plugin.is_none());
|
||||||
|
assert!(result.errors.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_load_simple_function() {
|
||||||
|
let tmp = TempDir::new("simple");
|
||||||
|
tmp.write_file("math.rhai", "fn double(x) { x * 2.0 }");
|
||||||
|
|
||||||
|
let result = load_scripts_from_dir(tmp.path());
|
||||||
|
assert!(result.errors.is_empty());
|
||||||
|
assert!(result.warnings.is_empty());
|
||||||
|
let plugin = result.plugin.unwrap();
|
||||||
|
assert_eq!(plugin.function_count(), 1);
|
||||||
|
assert_eq!(plugin.function_names(), vec!["double"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_load_multiple_functions_one_file() {
|
||||||
|
let tmp = TempDir::new("multi_fn");
|
||||||
|
tmp.write_file("funcs.rhai", r#"
|
||||||
|
fn double(x) { x * 2.0 }
|
||||||
|
fn triple(x) { x * 3.0 }
|
||||||
|
fn add(a, b) { a + b }
|
||||||
|
"#);
|
||||||
|
|
||||||
|
let result = load_scripts_from_dir(tmp.path());
|
||||||
|
assert!(result.errors.is_empty());
|
||||||
|
let plugin = result.plugin.unwrap();
|
||||||
|
assert_eq!(plugin.function_count(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_load_multiple_files() {
|
||||||
|
let tmp = TempDir::new("multi_file");
|
||||||
|
tmp.write_file("a.rhai", "fn from_a(x) { x + 1.0 }");
|
||||||
|
tmp.write_file("b.rhai", "fn from_b(x) { x + 2.0 }");
|
||||||
|
|
||||||
|
let result = load_scripts_from_dir(tmp.path());
|
||||||
|
assert!(result.errors.is_empty());
|
||||||
|
let plugin = result.plugin.unwrap();
|
||||||
|
assert_eq!(plugin.function_count(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_non_rhai_files_ignored() {
|
||||||
|
let tmp = TempDir::new("ignore");
|
||||||
|
tmp.write_file("readme.txt", "not a script");
|
||||||
|
tmp.write_file("script.lua", "-- not rhai");
|
||||||
|
tmp.write_file("actual.rhai", "fn real_fn(x) { x }");
|
||||||
|
|
||||||
|
let result = load_scripts_from_dir(tmp.path());
|
||||||
|
assert!(result.errors.is_empty());
|
||||||
|
let plugin = result.plugin.unwrap();
|
||||||
|
assert_eq!(plugin.function_count(), 1);
|
||||||
|
assert_eq!(plugin.function_names(), vec!["real_fn"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_builtin_conflict_produces_warning() {
|
||||||
|
let tmp = TempDir::new("conflict");
|
||||||
|
tmp.write_file("math.rhai", r#"
|
||||||
|
fn sqrt(x) { x }
|
||||||
|
fn my_func(x) { x * 2.0 }
|
||||||
|
"#);
|
||||||
|
|
||||||
|
let result = load_scripts_from_dir(tmp.path());
|
||||||
|
let plugin = result.plugin.unwrap();
|
||||||
|
// sqrt should be skipped
|
||||||
|
assert_eq!(plugin.function_count(), 1);
|
||||||
|
assert_eq!(plugin.function_names(), vec!["my_func"]);
|
||||||
|
// warning about sqrt
|
||||||
|
assert_eq!(result.warnings.len(), 1);
|
||||||
|
assert!(result.warnings[0].contains("sqrt"));
|
||||||
|
assert!(result.warnings[0].contains("built-in"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_multiple_builtin_conflicts() {
|
||||||
|
let tmp = TempDir::new("multi_conflict");
|
||||||
|
tmp.write_file("bad.rhai", r#"
|
||||||
|
fn sin(x) { x }
|
||||||
|
fn cos(x) { x }
|
||||||
|
fn safe_fn(x) { x }
|
||||||
|
"#);
|
||||||
|
|
||||||
|
let result = load_scripts_from_dir(tmp.path());
|
||||||
|
assert_eq!(result.warnings.len(), 2);
|
||||||
|
let plugin = result.plugin.unwrap();
|
||||||
|
assert_eq!(plugin.function_count(), 1);
|
||||||
|
assert_eq!(plugin.function_names(), vec!["safe_fn"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_stub_function_arity_check() {
|
||||||
|
let tmp = TempDir::new("arity");
|
||||||
|
tmp.write_file("math.rhai", "fn double(x) { x * 2.0 }");
|
||||||
|
|
||||||
|
let result = load_scripts_from_dir(tmp.path());
|
||||||
|
let plugin = result.plugin.unwrap();
|
||||||
|
|
||||||
|
// Register into a PluginFunctionRegistry so we can call it.
|
||||||
|
let mut freg = PluginFunctionRegistry::new();
|
||||||
|
plugin.register_functions(&mut freg);
|
||||||
|
|
||||||
|
let func = freg.functions.get("double").unwrap();
|
||||||
|
// Wrong arity should produce an error.
|
||||||
|
let err = func(&[PluginValue::new(1.0), PluginValue::new(2.0)]).unwrap_err();
|
||||||
|
assert!(err.contains("expects 1 argument"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_stub_function_returns_engine_not_enabled() {
|
||||||
|
let tmp = TempDir::new("stub_err");
|
||||||
|
tmp.write_file("math.rhai", "fn double(x) { x * 2.0 }");
|
||||||
|
|
||||||
|
let result = load_scripts_from_dir(tmp.path());
|
||||||
|
let plugin = result.plugin.unwrap();
|
||||||
|
|
||||||
|
let mut freg = PluginFunctionRegistry::new();
|
||||||
|
plugin.register_functions(&mut freg);
|
||||||
|
|
||||||
|
let func = freg.functions.get("double").unwrap();
|
||||||
|
// Correct arity but stub should say engine is not enabled.
|
||||||
|
let err = func(&[PluginValue::new(5.0)]).unwrap_err();
|
||||||
|
assert!(err.contains("not yet enabled"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_zero_arg_function_extracted() {
|
||||||
|
let fns = extract_stub_functions("fn pi() { 3.14159 }");
|
||||||
|
assert_eq!(fns.len(), 1);
|
||||||
|
assert_eq!(fns[0].0, "pi");
|
||||||
|
assert_eq!(fns[0].1, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_script_plugin_implements_calcpad_plugin() {
|
||||||
|
let functions: HashMap<String, PluginFn> = HashMap::new();
|
||||||
|
let plugin = ScriptPlugin::new("test_scripts", functions);
|
||||||
|
// Verify it satisfies the trait
|
||||||
|
let p: &dyn CalcPadPlugin = &plugin;
|
||||||
|
assert_eq!(p.name(), "test_scripts");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_script_error_display_with_line() {
|
||||||
|
let e = ScriptError {
|
||||||
|
file: PathBuf::from("foo.rhai"),
|
||||||
|
line: Some(42),
|
||||||
|
message: "unexpected token".into(),
|
||||||
|
};
|
||||||
|
assert_eq!(e.to_string(), "foo.rhai: line 42: unexpected token");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_script_error_display_without_line() {
|
||||||
|
let e = ScriptError {
|
||||||
|
file: PathBuf::from("bar.rhai"),
|
||||||
|
line: None,
|
||||||
|
message: "file not found".into(),
|
||||||
|
};
|
||||||
|
assert_eq!(e.to_string(), "bar.rhai: file not found");
|
||||||
|
}
|
||||||
|
}
|
||||||
177
calcpad-engine/tests/proptest_fuzz.rs
Normal file
177
calcpad-engine/tests/proptest_fuzz.rs
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
//! Property-based fuzz tests for the evaluation engine.
|
||||||
|
//!
|
||||||
|
//! These use proptest to generate random inputs and verify invariants:
|
||||||
|
//! - The engine never panics on any input
|
||||||
|
//! - Algebraic properties (commutativity, identities) hold
|
||||||
|
//! - Division by zero always produces errors
|
||||||
|
|
||||||
|
use calcpad_engine::context::EvalContext;
|
||||||
|
use calcpad_engine::pipeline::eval_line;
|
||||||
|
use calcpad_engine::types::ResultType;
|
||||||
|
use proptest::prelude::*;
|
||||||
|
|
||||||
|
fn arb_small_int() -> impl Strategy<Value = i64> {
|
||||||
|
-10000i64..10000
|
||||||
|
}
|
||||||
|
|
||||||
|
fn arb_operator() -> impl Strategy<Value = &'static str> {
|
||||||
|
prop::sample::select(vec!["+", "-", "*"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// No-panic guarantees: valid expressions
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
proptest! {
|
||||||
|
#[test]
|
||||||
|
fn valid_number_never_panics(n in arb_small_int()) {
|
||||||
|
let input = format!("{}", n);
|
||||||
|
let mut ctx = EvalContext::new();
|
||||||
|
let _result = eval_line(&input, &mut ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn valid_binary_expr_never_panics(
|
||||||
|
a in arb_small_int(),
|
||||||
|
b in arb_small_int(),
|
||||||
|
op in arb_operator()
|
||||||
|
) {
|
||||||
|
let input = format!("{} {} {}", a, op, b);
|
||||||
|
let mut ctx = EvalContext::new();
|
||||||
|
let _result = eval_line(&input, &mut ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn valid_complex_expr_never_panics(
|
||||||
|
a in arb_small_int(),
|
||||||
|
b in 1i64..1000,
|
||||||
|
c in arb_small_int(),
|
||||||
|
op1 in arb_operator(),
|
||||||
|
op2 in arb_operator()
|
||||||
|
) {
|
||||||
|
let input = format!("({} {} {}) {} {}", a, op1, b, op2, c);
|
||||||
|
let mut ctx = EvalContext::new();
|
||||||
|
let _result = eval_line(&input, &mut ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// No-panic guarantees: malformed / garbage input
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
proptest! {
|
||||||
|
#[test]
|
||||||
|
fn malformed_garbage_never_panics(
|
||||||
|
s in "[a-zA-Z0-9!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>/?]{1,50}"
|
||||||
|
) {
|
||||||
|
let mut ctx = EvalContext::new();
|
||||||
|
let _result = eval_line(&s, &mut ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn malformed_unmatched_parens_never_panics(
|
||||||
|
s in "\\({0,5}[0-9]{1,4}[+\\-*/]{0,2}[0-9]{0,4}\\){0,5}"
|
||||||
|
) {
|
||||||
|
let mut ctx = EvalContext::new();
|
||||||
|
let _result = eval_line(&s, &mut ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_and_whitespace_never_panics(s in "\\s{0,20}") {
|
||||||
|
let mut ctx = EvalContext::new();
|
||||||
|
let _result = eval_line(&s, &mut ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn random_identifiers_never_panics(s in "[a-z]{1,10}") {
|
||||||
|
let mut ctx = EvalContext::new();
|
||||||
|
let _result = eval_line(&s, &mut ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Algebraic properties
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
proptest! {
|
||||||
|
#[test]
|
||||||
|
fn addition_commutativity(a in arb_small_int(), b in arb_small_int()) {
|
||||||
|
let mut ctx1 = EvalContext::new();
|
||||||
|
let mut ctx2 = EvalContext::new();
|
||||||
|
let r1 = eval_line(&format!("{} + {}", a, b), &mut ctx1);
|
||||||
|
let r2 = eval_line(&format!("{} + {}", b, a), &mut ctx2);
|
||||||
|
|
||||||
|
match (r1.result_type(), r2.result_type()) {
|
||||||
|
(ResultType::Number, ResultType::Number) => {
|
||||||
|
let v1 = r1.metadata.raw_value.unwrap();
|
||||||
|
let v2 = r2.metadata.raw_value.unwrap();
|
||||||
|
prop_assert!((v1 - v2).abs() < 1e-10,
|
||||||
|
"{} + {} = {} but {} + {} = {}", a, b, v1, b, a, v2);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
prop_assert_eq!(r1.result_type(), r2.result_type());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn multiplication_commutativity(a in arb_small_int(), b in arb_small_int()) {
|
||||||
|
let mut ctx1 = EvalContext::new();
|
||||||
|
let mut ctx2 = EvalContext::new();
|
||||||
|
let r1 = eval_line(&format!("{} * {}", a, b), &mut ctx1);
|
||||||
|
let r2 = eval_line(&format!("{} * {}", b, a), &mut ctx2);
|
||||||
|
|
||||||
|
match (r1.result_type(), r2.result_type()) {
|
||||||
|
(ResultType::Number, ResultType::Number) => {
|
||||||
|
let v1 = r1.metadata.raw_value.unwrap();
|
||||||
|
let v2 = r2.metadata.raw_value.unwrap();
|
||||||
|
prop_assert!((v1 - v2).abs() < 1e-6,
|
||||||
|
"{} * {} = {} but {} * {} = {}", a, b, v1, b, a, v2);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
prop_assert_eq!(r1.result_type(), r2.result_type());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Division by zero
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
proptest! {
|
||||||
|
#[test]
|
||||||
|
fn division_by_zero_always_error(a in arb_small_int()) {
|
||||||
|
let mut ctx = EvalContext::new();
|
||||||
|
let r = eval_line(&format!("{} / 0", a), &mut ctx);
|
||||||
|
prop_assert_eq!(r.result_type(), ResultType::Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Identity operations
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
proptest! {
|
||||||
|
#[test]
|
||||||
|
fn add_zero_identity(a in arb_small_int()) {
|
||||||
|
let mut ctx = EvalContext::new();
|
||||||
|
let r = eval_line(&format!("{} + 0", a), &mut ctx);
|
||||||
|
if r.result_type() == ResultType::Number {
|
||||||
|
let v = r.metadata.raw_value.unwrap();
|
||||||
|
prop_assert!((v - a as f64).abs() < 1e-10,
|
||||||
|
"{} + 0 should be {} but got {}", a, a, v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mul_one_identity(a in arb_small_int()) {
|
||||||
|
let mut ctx = EvalContext::new();
|
||||||
|
let r = eval_line(&format!("{} * 1", a), &mut ctx);
|
||||||
|
if r.result_type() == ResultType::Number {
|
||||||
|
let v = r.metadata.raw_value.unwrap();
|
||||||
|
prop_assert!((v - a as f64).abs() < 1e-10,
|
||||||
|
"{} * 1 should be {} but got {}", a, a, v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
313
calcpad-engine/tests/sheet_scenarios.rs
Normal file
313
calcpad-engine/tests/sheet_scenarios.rs
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
//! Integration tests covering real-world sheet scenarios.
|
||||||
|
//!
|
||||||
|
//! These exercise the full pipeline (lexer -> parser -> interpreter) through
|
||||||
|
//! the eval_line / eval_sheet / SheetContext public APIs, verifying end-to-end
|
||||||
|
//! behavior rather than individual module internals.
|
||||||
|
|
||||||
|
use calcpad_engine::context::EvalContext;
|
||||||
|
use calcpad_engine::pipeline::{eval_line, eval_sheet};
|
||||||
|
use calcpad_engine::types::ResultType;
|
||||||
|
use calcpad_engine::SheetContext;
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Variable assignment and reference across lines
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sheet_variable_assignment_and_reference() {
|
||||||
|
let mut ctx = EvalContext::new();
|
||||||
|
let results = eval_sheet(
|
||||||
|
&["price = 100", "quantity = 5", "total = price * quantity"],
|
||||||
|
&mut ctx,
|
||||||
|
);
|
||||||
|
assert_eq!(results.len(), 3);
|
||||||
|
assert_eq!(results[0].metadata.display, "100");
|
||||||
|
assert_eq!(results[1].metadata.display, "5");
|
||||||
|
assert_eq!(results[2].metadata.display, "500");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sheet_variable_used_in_multiple_lines() {
|
||||||
|
let mut ctx = EvalContext::new();
|
||||||
|
let results = eval_sheet(
|
||||||
|
&[
|
||||||
|
"rate = 25",
|
||||||
|
"hours = 8",
|
||||||
|
"daily = rate * hours",
|
||||||
|
"weekly = daily * 5",
|
||||||
|
],
|
||||||
|
&mut ctx,
|
||||||
|
);
|
||||||
|
assert_eq!(results[2].metadata.display, "200");
|
||||||
|
assert_eq!(results[3].metadata.display, "1000");
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Chained dependencies
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sheet_chained_dependencies() {
|
||||||
|
let mut ctx = EvalContext::new();
|
||||||
|
let results = eval_sheet(
|
||||||
|
&["a = 10", "b = a + 5", "c = b * 2", "d = c - a"],
|
||||||
|
&mut ctx,
|
||||||
|
);
|
||||||
|
assert_eq!(results[0].metadata.display, "10");
|
||||||
|
assert_eq!(results[1].metadata.display, "15");
|
||||||
|
assert_eq!(results[2].metadata.display, "30");
|
||||||
|
assert_eq!(results[3].metadata.display, "20");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sheet_deep_chain() {
|
||||||
|
let mut ctx = EvalContext::new();
|
||||||
|
let results = eval_sheet(
|
||||||
|
&[
|
||||||
|
"v1 = 1",
|
||||||
|
"v2 = v1 + 1",
|
||||||
|
"v3 = v2 + 1",
|
||||||
|
"v4 = v3 + 1",
|
||||||
|
"v5 = v4 + 1",
|
||||||
|
],
|
||||||
|
&mut ctx,
|
||||||
|
);
|
||||||
|
assert_eq!(results[4].metadata.display, "5");
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Forward references and undefined variables
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sheet_forward_reference_error() {
|
||||||
|
let mut ctx = EvalContext::new();
|
||||||
|
let results = eval_sheet(&["a = b + 1", "b = 10"], &mut ctx);
|
||||||
|
// eval_sheet processes sequentially; b isn't defined yet when line 0 runs
|
||||||
|
assert_eq!(results[0].result_type(), ResultType::Error);
|
||||||
|
assert_eq!(results[1].metadata.display, "10");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sheet_undefined_variable_in_chain() {
|
||||||
|
let mut ctx = EvalContext::new();
|
||||||
|
let results = eval_sheet(&["x = undefined_var + 1"], &mut ctx);
|
||||||
|
assert_eq!(results[0].result_type(), ResultType::Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Unit conversion across lines
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sheet_unit_arithmetic_same_unit() {
|
||||||
|
let mut ctx = EvalContext::new();
|
||||||
|
let results = eval_sheet(&["a = 5kg", "b = 3kg", "total = a + b"], &mut ctx);
|
||||||
|
assert_eq!(results[2].result_type(), ResultType::UnitValue);
|
||||||
|
assert_eq!(results[2].metadata.raw_value, Some(8.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Mixed expressions: comments, empty lines, text
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sheet_with_comments() {
|
||||||
|
let mut ctx = EvalContext::new();
|
||||||
|
let results = eval_sheet(
|
||||||
|
&[
|
||||||
|
"// Budget calculation",
|
||||||
|
"income = 5000",
|
||||||
|
"expenses = 3000",
|
||||||
|
"savings = income - expenses",
|
||||||
|
],
|
||||||
|
&mut ctx,
|
||||||
|
);
|
||||||
|
assert_eq!(results[0].result_type(), ResultType::Error); // comment
|
||||||
|
assert_eq!(results[3].metadata.display, "2000");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sheet_with_empty_lines() {
|
||||||
|
let mut ctx = EvalContext::new();
|
||||||
|
let results = eval_sheet(&["x = 10", "", "y = x + 5"], &mut ctx);
|
||||||
|
assert_eq!(results[0].metadata.display, "10");
|
||||||
|
assert_eq!(results[1].result_type(), ResultType::Error); // empty line
|
||||||
|
assert_eq!(results[2].metadata.display, "15");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sheet_mixed_comments_and_calcs() {
|
||||||
|
let mut ctx = EvalContext::new();
|
||||||
|
let results = eval_sheet(
|
||||||
|
&[
|
||||||
|
"// Shopping list",
|
||||||
|
"apples = 3 * 2",
|
||||||
|
"// bananas are on sale",
|
||||||
|
"bananas = 5 * 1",
|
||||||
|
"total = apples + bananas",
|
||||||
|
],
|
||||||
|
&mut ctx,
|
||||||
|
);
|
||||||
|
assert_eq!(results[1].metadata.display, "6");
|
||||||
|
assert_eq!(results[3].metadata.display, "5");
|
||||||
|
assert_eq!(results[4].metadata.display, "11");
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Variable reassignment / shadowing
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sheet_variable_reassignment() {
|
||||||
|
let mut ctx = EvalContext::new();
|
||||||
|
let results = eval_sheet(&["x = 5", "y = x * 2", "x = 20", "z = x * 2"], &mut ctx);
|
||||||
|
assert_eq!(results[0].metadata.display, "5");
|
||||||
|
assert_eq!(results[1].metadata.display, "10");
|
||||||
|
assert_eq!(results[2].metadata.display, "20");
|
||||||
|
assert_eq!(results[3].metadata.display, "40"); // uses reassigned x=20
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Real-world calculation scenarios
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sheet_invoice_calculation() {
|
||||||
|
let mut ctx = EvalContext::new();
|
||||||
|
let results = eval_sheet(
|
||||||
|
&[
|
||||||
|
"// Invoice",
|
||||||
|
"subtotal = 1500",
|
||||||
|
"tax_rate = 8.5",
|
||||||
|
"tax = subtotal * tax_rate / 100",
|
||||||
|
"total = subtotal + tax",
|
||||||
|
],
|
||||||
|
&mut ctx,
|
||||||
|
);
|
||||||
|
assert_eq!(results[1].metadata.display, "1500");
|
||||||
|
assert_eq!(results[3].metadata.display, "127.5");
|
||||||
|
assert_eq!(results[4].metadata.display, "1627.5");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sheet_tip_calculation() {
|
||||||
|
let mut ctx = EvalContext::new();
|
||||||
|
let results = eval_sheet(
|
||||||
|
&["bill = 85", "tip = bill + 20%", "per_person = tip / 4"],
|
||||||
|
&mut ctx,
|
||||||
|
);
|
||||||
|
assert_eq!(results[0].metadata.display, "85");
|
||||||
|
assert_eq!(results[1].metadata.display, "102");
|
||||||
|
assert!((results[2].metadata.raw_value.unwrap() - 25.5).abs() < 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sheet_accumulator_pattern() {
|
||||||
|
let mut ctx = EvalContext::new();
|
||||||
|
let lines: Vec<String> = (0..20)
|
||||||
|
.map(|i| {
|
||||||
|
if i == 0 {
|
||||||
|
"acc = 0".to_string()
|
||||||
|
} else {
|
||||||
|
format!("acc = acc + {}", i)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let refs: Vec<&str> = lines.iter().map(|s| s.as_str()).collect();
|
||||||
|
let results = eval_sheet(&refs, &mut ctx);
|
||||||
|
// Sum of 1..19 = 190
|
||||||
|
let last = results.last().unwrap();
|
||||||
|
assert_eq!(last.metadata.raw_value, Some(190.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Context isolation
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn context_variables_independent() {
|
||||||
|
let mut ctx1 = EvalContext::new();
|
||||||
|
let mut ctx2 = EvalContext::new();
|
||||||
|
eval_line("x = 100", &mut ctx1);
|
||||||
|
let r = eval_line("x", &mut ctx2);
|
||||||
|
assert_eq!(r.result_type(), ResultType::Error); // x not defined in ctx2
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// SheetContext: incremental evaluation
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sheet_context_incremental_independent_lines() {
|
||||||
|
let mut sheet = SheetContext::new();
|
||||||
|
sheet.set_line(0, "a = 10");
|
||||||
|
sheet.set_line(1, "b = 20");
|
||||||
|
sheet.set_line(2, "c = a + 5");
|
||||||
|
sheet.set_line(3, "d = b + 5");
|
||||||
|
let results = sheet.eval();
|
||||||
|
assert_eq!(results.len(), 4);
|
||||||
|
|
||||||
|
// Change only b: a and c should be unaffected
|
||||||
|
sheet.set_line(1, "b = 99");
|
||||||
|
let results = sheet.eval();
|
||||||
|
assert_eq!(results[0].metadata.raw_value, Some(10.0));
|
||||||
|
assert_eq!(results[1].metadata.raw_value, Some(99.0));
|
||||||
|
assert_eq!(results[2].metadata.raw_value, Some(15.0)); // a + 5, unchanged
|
||||||
|
assert_eq!(results[3].metadata.raw_value, Some(104.0)); // b + 5
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sheet_context_aggregator_invoice() {
|
||||||
|
let mut sheet = SheetContext::new();
|
||||||
|
sheet.set_line(0, "## Monthly Expenses");
|
||||||
|
sheet.set_line(1, "1200"); // rent
|
||||||
|
sheet.set_line(2, "150"); // utilities
|
||||||
|
sheet.set_line(3, "400"); // groceries
|
||||||
|
sheet.set_line(4, "subtotal");
|
||||||
|
sheet.set_line(5, "## One-Time Costs");
|
||||||
|
sheet.set_line(6, "500"); // furniture
|
||||||
|
sheet.set_line(7, "200"); // electronics
|
||||||
|
sheet.set_line(8, "subtotal");
|
||||||
|
sheet.set_line(9, "grand total");
|
||||||
|
|
||||||
|
let results = sheet.eval();
|
||||||
|
assert_eq!(results[4].metadata.raw_value, Some(1750.0));
|
||||||
|
assert_eq!(results[8].metadata.raw_value, Some(700.0));
|
||||||
|
assert_eq!(results[9].metadata.raw_value, Some(2450.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sheet_context_prev_through_sections() {
|
||||||
|
let mut sheet = SheetContext::new();
|
||||||
|
sheet.set_line(0, "100");
|
||||||
|
sheet.set_line(1, "prev + 50"); // 150
|
||||||
|
sheet.set_line(2, "prev * 2"); // 300
|
||||||
|
let results = sheet.eval();
|
||||||
|
assert_eq!(results[0].metadata.raw_value, Some(100.0));
|
||||||
|
assert_eq!(results[1].metadata.raw_value, Some(150.0));
|
||||||
|
assert_eq!(results[2].metadata.raw_value, Some(300.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sheet_context_comparison_result() {
|
||||||
|
let mut ctx = EvalContext::new();
|
||||||
|
let results = eval_sheet(
|
||||||
|
&["budget = 1000", "spent = 750", "budget - spent > 0"],
|
||||||
|
&mut ctx,
|
||||||
|
);
|
||||||
|
assert_eq!(results[2].result_type(), ResultType::Boolean);
|
||||||
|
assert_eq!(results[2].metadata.display, "true");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sheet_context_percentage_discount() {
|
||||||
|
let mut ctx = EvalContext::new();
|
||||||
|
let results = eval_sheet(
|
||||||
|
&["original = $200", "discounted = $200 - 30%"],
|
||||||
|
&mut ctx,
|
||||||
|
);
|
||||||
|
assert_eq!(results[0].result_type(), ResultType::CurrencyValue);
|
||||||
|
assert_eq!(results[1].result_type(), ResultType::CurrencyValue);
|
||||||
|
assert!((results[1].metadata.raw_value.unwrap() - 140.0).abs() < 0.01);
|
||||||
|
}
|
||||||
37
calcpad-macos/Package.swift
Normal file
37
calcpad-macos/Package.swift
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
// swift-tools-version: 5.9
|
||||||
|
|
||||||
|
import PackageDescription
|
||||||
|
|
||||||
|
// Path to the Rust static library built by `cargo build --release`
|
||||||
|
let rustLibPath = "../calcpad-engine/target/release"
|
||||||
|
|
||||||
|
let package = Package(
|
||||||
|
name: "CalcPad",
|
||||||
|
platforms: [
|
||||||
|
.macOS(.v14)
|
||||||
|
],
|
||||||
|
targets: [
|
||||||
|
.systemLibrary(
|
||||||
|
name: "CCalcPadEngine",
|
||||||
|
path: "Sources/CCalcPadEngine"
|
||||||
|
),
|
||||||
|
.executableTarget(
|
||||||
|
name: "CalcPad",
|
||||||
|
dependencies: ["CCalcPadEngine"],
|
||||||
|
path: "Sources/CalcPad",
|
||||||
|
linkerSettings: [
|
||||||
|
.unsafeFlags(["-L\(rustLibPath)"]),
|
||||||
|
.linkedLibrary("calcpad_engine"),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
.testTarget(
|
||||||
|
name: "CalcPadTests",
|
||||||
|
dependencies: ["CalcPad"],
|
||||||
|
path: "Tests/CalcPadTests",
|
||||||
|
linkerSettings: [
|
||||||
|
.unsafeFlags(["-L\(rustLibPath)"]),
|
||||||
|
.linkedLibrary("calcpad_engine"),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
90
calcpad-macos/Sources/CCalcPadEngine/calcpad.h
Normal file
90
calcpad-macos/Sources/CCalcPadEngine/calcpad.h
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
/**
|
||||||
|
* CalcPad Engine — C FFI Header
|
||||||
|
*
|
||||||
|
* This header declares the C-compatible interface for the CalcPad calculation
|
||||||
|
* engine, built in Rust. It is designed for consumption by Swift via a
|
||||||
|
* bridging header or a module map.
|
||||||
|
*
|
||||||
|
* All functions are safe to call from any thread. Panics in Rust are caught
|
||||||
|
* and converted to error results — they never unwind into the caller.
|
||||||
|
*
|
||||||
|
* Memory ownership:
|
||||||
|
* - Strings returned by calcpad_eval_line / calcpad_eval_sheet are
|
||||||
|
* heap-allocated by Rust and MUST be freed by calling calcpad_free_result.
|
||||||
|
* - Passing NULL to calcpad_free_result is a safe no-op.
|
||||||
|
*
|
||||||
|
* JSON schema (version "1.0"):
|
||||||
|
*
|
||||||
|
* Single-line result (calcpad_eval_line):
|
||||||
|
* {
|
||||||
|
* "schema_version": "1.0",
|
||||||
|
* "result": {
|
||||||
|
* "value": { "kind": "Number", "value": 42.0 },
|
||||||
|
* "metadata": {
|
||||||
|
* "span": { "start": 0, "end": 4 },
|
||||||
|
* "result_type": "Number",
|
||||||
|
* "display": "42",
|
||||||
|
* "raw_value": 42.0
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* Multi-line result (calcpad_eval_sheet):
|
||||||
|
* {
|
||||||
|
* "schema_version": "1.0",
|
||||||
|
* "results": [ ... ] // array of result objects as above
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef CALCPAD_H
|
||||||
|
#define CALCPAD_H
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluate a single line of CalcPad input.
|
||||||
|
*
|
||||||
|
* @param input A null-terminated UTF-8 string containing the expression.
|
||||||
|
* Passing NULL returns a JSON error result.
|
||||||
|
*
|
||||||
|
* @return A heap-allocated, null-terminated JSON string containing the
|
||||||
|
* versioned result. The caller MUST free this with
|
||||||
|
* calcpad_free_result(). Returns NULL only on catastrophic
|
||||||
|
* allocation failure.
|
||||||
|
*/
|
||||||
|
char *calcpad_eval_line(const char *input);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluate multiple lines of CalcPad input as a sheet.
|
||||||
|
*
|
||||||
|
* Variable assignments on earlier lines are visible to later lines
|
||||||
|
* (e.g., "x = 5" on line 1 makes x available on line 2).
|
||||||
|
*
|
||||||
|
* @param lines An array of null-terminated UTF-8 strings.
|
||||||
|
* NULL entries are treated as empty lines.
|
||||||
|
* @param count The number of elements in the lines array.
|
||||||
|
* Must be > 0.
|
||||||
|
*
|
||||||
|
* @return A heap-allocated, null-terminated JSON string containing the
|
||||||
|
* versioned results array. The caller MUST free this with
|
||||||
|
* calcpad_free_result(). Returns NULL only on catastrophic
|
||||||
|
* allocation failure.
|
||||||
|
*/
|
||||||
|
char *calcpad_eval_sheet(const char *const *lines, int count);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Free a result string previously returned by calcpad_eval_line or
|
||||||
|
* calcpad_eval_sheet.
|
||||||
|
*
|
||||||
|
* @param ptr The pointer to free. Passing NULL is safe (no-op).
|
||||||
|
* After this call the pointer is invalid.
|
||||||
|
*/
|
||||||
|
void calcpad_free_result(char *ptr);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#endif /* CALCPAD_H */
|
||||||
5
calcpad-macos/Sources/CCalcPadEngine/module.modulemap
Normal file
5
calcpad-macos/Sources/CCalcPadEngine/module.modulemap
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
module CCalcPadEngine {
|
||||||
|
header "calcpad.h"
|
||||||
|
link "calcpad_engine"
|
||||||
|
export *
|
||||||
|
}
|
||||||
11
calcpad-macos/Sources/CalcPad/App/CalcPadApp.swift
Normal file
11
calcpad-macos/Sources/CalcPad/App/CalcPadApp.swift
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct CalcPadApp: App {
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
ContentView()
|
||||||
|
}
|
||||||
|
.defaultSize(width: 800, height: 600)
|
||||||
|
}
|
||||||
|
}
|
||||||
7
calcpad-macos/Sources/CalcPad/App/ContentView.swift
Normal file
7
calcpad-macos/Sources/CalcPad/App/ContentView.swift
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ContentView: View {
|
||||||
|
var body: some View {
|
||||||
|
TwoColumnEditorView()
|
||||||
|
}
|
||||||
|
}
|
||||||
62
calcpad-macos/Sources/CalcPad/Engine/CalculationEngine.swift
Normal file
62
calcpad-macos/Sources/CalcPad/Engine/CalculationEngine.swift
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Protocol for a calculation engine that evaluates text expressions.
|
||||||
|
/// The primary implementation is RustCalculationEngine (FFI bridge to calcpad-engine).
|
||||||
|
/// StubCalculationEngine is provided for testing without the Rust library.
|
||||||
|
protocol CalculationEngine: Sendable {
|
||||||
|
/// Evaluate a single line expression and return the result string, or nil for blank/comment lines.
|
||||||
|
func evaluateLine(_ line: String) -> LineResult
|
||||||
|
|
||||||
|
/// Evaluate an entire sheet (multi-line text) and return results for each line.
|
||||||
|
func evaluateSheet(_ text: String) -> [LineResult]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stub engine for testing that handles basic arithmetic (+, -, *, /).
|
||||||
|
/// Used when the Rust engine library is not available.
|
||||||
|
final class StubCalculationEngine: CalculationEngine {
|
||||||
|
|
||||||
|
func evaluateLine(_ line: String) -> LineResult {
|
||||||
|
evaluateSingleLine(line, lineNumber: 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func evaluateSheet(_ text: String) -> [LineResult] {
|
||||||
|
let lines = text.components(separatedBy: "\n")
|
||||||
|
return lines.enumerated().map { index, line in
|
||||||
|
evaluateSingleLine(line, lineNumber: index + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func evaluateSingleLine(_ line: String, lineNumber: Int) -> LineResult {
|
||||||
|
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||||
|
|
||||||
|
// Blank lines produce no result
|
||||||
|
guard !trimmed.isEmpty else {
|
||||||
|
return LineResult(id: lineNumber, expression: line, result: nil, isError: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Comment lines (starting with // or #) produce no result
|
||||||
|
if trimmed.hasPrefix("//") || trimmed.hasPrefix("#") {
|
||||||
|
return LineResult(id: lineNumber, expression: line, result: nil, isError: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert integer literals to floating-point so division produces decimals.
|
||||||
|
// NSExpression does integer division for "7 / 2" -> 3, but we want 3.5.
|
||||||
|
let floatExpr = trimmed.replacingOccurrences(
|
||||||
|
of: #"\b(\d+)\b"#,
|
||||||
|
with: "$1.0",
|
||||||
|
options: .regularExpression
|
||||||
|
)
|
||||||
|
|
||||||
|
// Try to evaluate as a basic arithmetic expression
|
||||||
|
do {
|
||||||
|
let expr = NSExpression(format: floatExpr)
|
||||||
|
if let value = expr.expressionValue(with: nil, context: nil) as? NSNumber {
|
||||||
|
let doubleValue = value.doubleValue
|
||||||
|
let formatted = String(format: "%g", doubleValue)
|
||||||
|
return LineResult(id: lineNumber, expression: line, result: formatted, isError: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return LineResult(id: lineNumber, expression: line, result: "Error", isError: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
107
calcpad-macos/Sources/CalcPad/Engine/FFIModels.swift
Normal file
107
calcpad-macos/Sources/CalcPad/Engine/FFIModels.swift
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// JSON response from `calcpad_eval_line` — wraps a single `CalcResult`.
|
||||||
|
struct FFIResponse: Decodable {
|
||||||
|
let schemaVersion: String
|
||||||
|
let result: FFICalcResult
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case schemaVersion = "schema_version"
|
||||||
|
case result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// JSON response from `calcpad_eval_sheet` — wraps an array of `CalcResult`.
|
||||||
|
struct FFISheetResponse: Decodable {
|
||||||
|
let schemaVersion: String
|
||||||
|
let results: [FFICalcResult]
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case schemaVersion = "schema_version"
|
||||||
|
case results
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A complete calculation result returned by the Rust engine.
|
||||||
|
struct FFICalcResult: Decodable {
|
||||||
|
let value: FFICalcValue
|
||||||
|
let metadata: FFIResultMetadata
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Metadata attached to every evaluation result.
|
||||||
|
struct FFIResultMetadata: Decodable {
|
||||||
|
let span: FFISpan
|
||||||
|
let resultType: String
|
||||||
|
let display: String
|
||||||
|
let rawValue: Double?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case span
|
||||||
|
case resultType = "result_type"
|
||||||
|
case display
|
||||||
|
case rawValue = "raw_value"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Source span (byte offsets).
|
||||||
|
struct FFISpan: Decodable {
|
||||||
|
let start: Int
|
||||||
|
let end: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tagged union of possible calculation values.
|
||||||
|
/// Rust serializes with `#[serde(tag = "kind")]`.
|
||||||
|
enum FFICalcValue: Decodable {
|
||||||
|
case number(value: Double)
|
||||||
|
case unitValue(value: Double, unit: String)
|
||||||
|
case currencyValue(amount: Double, currency: String)
|
||||||
|
case dateTime(date: String)
|
||||||
|
case timeDelta(days: Int64, description: String)
|
||||||
|
case boolean(value: Bool)
|
||||||
|
case error(message: String)
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case kind
|
||||||
|
case value, unit, amount, currency, date, days, description, message, span
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
let kind = try container.decode(String.self, forKey: .kind)
|
||||||
|
|
||||||
|
switch kind {
|
||||||
|
case "Number":
|
||||||
|
let value = try container.decode(Double.self, forKey: .value)
|
||||||
|
self = .number(value: value)
|
||||||
|
case "UnitValue":
|
||||||
|
let value = try container.decode(Double.self, forKey: .value)
|
||||||
|
let unit = try container.decode(String.self, forKey: .unit)
|
||||||
|
self = .unitValue(value: value, unit: unit)
|
||||||
|
case "CurrencyValue":
|
||||||
|
let amount = try container.decode(Double.self, forKey: .amount)
|
||||||
|
let currency = try container.decode(String.self, forKey: .currency)
|
||||||
|
self = .currencyValue(amount: amount, currency: currency)
|
||||||
|
case "DateTime":
|
||||||
|
let date = try container.decode(String.self, forKey: .date)
|
||||||
|
self = .dateTime(date: date)
|
||||||
|
case "TimeDelta":
|
||||||
|
let days = try container.decode(Int64.self, forKey: .days)
|
||||||
|
let desc = try container.decode(String.self, forKey: .description)
|
||||||
|
self = .timeDelta(days: days, description: desc)
|
||||||
|
case "Boolean":
|
||||||
|
let value = try container.decode(Bool.self, forKey: .value)
|
||||||
|
self = .boolean(value: value)
|
||||||
|
case "Error":
|
||||||
|
let message = try container.decode(String.self, forKey: .message)
|
||||||
|
self = .error(message: message)
|
||||||
|
default:
|
||||||
|
self = .error(message: "Unknown result kind: \(kind)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this value represents an error from the Rust engine.
|
||||||
|
var isError: Bool {
|
||||||
|
if case .error = self { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import CCalcPadEngine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Calculation engine backed by the Rust CalcPad engine via C FFI.
|
||||||
|
///
|
||||||
|
/// Thread safety: Each call creates its own `EvalContext` on the Rust side,
|
||||||
|
/// so concurrent calls from different threads are safe. The C FFI functions
|
||||||
|
/// are documented as safe to call from any thread.
|
||||||
|
final class RustCalculationEngine: CalculationEngine, @unchecked Sendable {
|
||||||
|
|
||||||
|
private let decoder = JSONDecoder()
|
||||||
|
|
||||||
|
func evaluateLine(_ line: String) -> LineResult {
|
||||||
|
evaluateSingleLine(line, lineNumber: 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func evaluateSheet(_ text: String) -> [LineResult] {
|
||||||
|
let lines = text.components(separatedBy: "\n")
|
||||||
|
|
||||||
|
// Build C string array for the FFI call
|
||||||
|
let cStrings = lines.map { strdup($0) }
|
||||||
|
defer { cStrings.forEach { free($0) } }
|
||||||
|
|
||||||
|
var pointers = cStrings.map { UnsafePointer<CChar>($0) }
|
||||||
|
|
||||||
|
let resultPtr = pointers.withUnsafeMutableBufferPointer { buffer -> UnsafeMutablePointer<CChar>? in
|
||||||
|
calcpad_eval_sheet(buffer.baseAddress, Int32(lines.count))
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let resultPtr else {
|
||||||
|
return lines.enumerated().map { index, line in
|
||||||
|
LineResult(id: index + 1, expression: line, result: "Error", isError: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defer { calcpad_free_result(resultPtr) }
|
||||||
|
|
||||||
|
let jsonString = String(cString: resultPtr)
|
||||||
|
|
||||||
|
guard let jsonData = jsonString.data(using: .utf8),
|
||||||
|
let response = try? decoder.decode(FFISheetResponse.self, from: jsonData) else {
|
||||||
|
return lines.enumerated().map { index, line in
|
||||||
|
LineResult(id: index + 1, expression: line, result: "Error", isError: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.results.enumerated().map { index, calcResult in
|
||||||
|
mapToLineResult(calcResult, expression: lines[index], lineNumber: index + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private
|
||||||
|
|
||||||
|
private func evaluateSingleLine(_ line: String, lineNumber: Int) -> LineResult {
|
||||||
|
let resultPtr = calcpad_eval_line(line)
|
||||||
|
|
||||||
|
guard let resultPtr else {
|
||||||
|
return LineResult(id: lineNumber, expression: line, result: "Error", isError: true)
|
||||||
|
}
|
||||||
|
defer { calcpad_free_result(resultPtr) }
|
||||||
|
|
||||||
|
let jsonString = String(cString: resultPtr)
|
||||||
|
|
||||||
|
guard let jsonData = jsonString.data(using: .utf8),
|
||||||
|
let response = try? decoder.decode(FFIResponse.self, from: jsonData) else {
|
||||||
|
return LineResult(id: lineNumber, expression: line, result: "Error", isError: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapToLineResult(response.result, expression: line, lineNumber: lineNumber)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func mapToLineResult(_ calcResult: FFICalcResult, expression: String, lineNumber: Int) -> LineResult {
|
||||||
|
switch calcResult.value {
|
||||||
|
case .error(let message):
|
||||||
|
// Blank/comment lines produce "empty expression" errors in the Rust engine.
|
||||||
|
// Map these to nil result (matching the protocol's convention for non-evaluable lines).
|
||||||
|
if isEmptyExpressionError(message) {
|
||||||
|
return LineResult(id: lineNumber, expression: expression, result: nil, isError: false)
|
||||||
|
}
|
||||||
|
return LineResult(id: lineNumber, expression: expression, result: "Error: \(message)", isError: true)
|
||||||
|
|
||||||
|
default:
|
||||||
|
return LineResult(id: lineNumber, expression: expression, result: calcResult.metadata.display, isError: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Rust engine returns an error for blank lines, comments, and other
|
||||||
|
/// non-evaluable input. We detect these to return nil (matching protocol convention).
|
||||||
|
private func isEmptyExpressionError(_ message: String) -> Bool {
|
||||||
|
let lower = message.lowercased()
|
||||||
|
return lower.contains("empty") || lower.contains("no expression") || lower.contains("comment")
|
||||||
|
}
|
||||||
|
}
|
||||||
15
calcpad-macos/Sources/CalcPad/Models/LineResult.swift
Normal file
15
calcpad-macos/Sources/CalcPad/Models/LineResult.swift
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Represents the evaluation result for a single line in the editor.
|
||||||
|
struct LineResult: Identifiable, Equatable {
|
||||||
|
let id: Int
|
||||||
|
let expression: String
|
||||||
|
let result: String?
|
||||||
|
let isError: Bool
|
||||||
|
|
||||||
|
/// Line number (1-based) corresponding to this result.
|
||||||
|
var lineNumber: Int { id }
|
||||||
|
|
||||||
|
/// A blank/comment line that produces no result.
|
||||||
|
var isEmpty: Bool { result == nil && !isError }
|
||||||
|
}
|
||||||
111
calcpad-macos/Sources/CalcPad/Views/AnswerColumnView.swift
Normal file
111
calcpad-macos/Sources/CalcPad/Views/AnswerColumnView.swift
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import AppKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Displays calculation results in a vertical column, one result per line,
|
||||||
|
/// aligned to match the corresponding editor lines.
|
||||||
|
/// Uses NSViewRepresentable wrapping NSScrollView + NSTextView for pixel-perfect
|
||||||
|
/// line height alignment with the editor.
|
||||||
|
struct AnswerColumnView: NSViewRepresentable {
|
||||||
|
let results: [LineResult]
|
||||||
|
@Binding var scrollOffset: CGFloat
|
||||||
|
var font: NSFont
|
||||||
|
|
||||||
|
func makeCoordinator() -> Coordinator {
|
||||||
|
Coordinator()
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeNSView(context: Context) -> NSScrollView {
|
||||||
|
let scrollView = NSScrollView()
|
||||||
|
scrollView.hasVerticalScroller = false
|
||||||
|
scrollView.hasHorizontalScroller = false
|
||||||
|
scrollView.borderType = .noBorder
|
||||||
|
scrollView.drawsBackground = false
|
||||||
|
// Disable user scrolling — scroll is driven by the editor
|
||||||
|
scrollView.verticalScrollElasticity = .none
|
||||||
|
scrollView.horizontalScrollElasticity = .none
|
||||||
|
|
||||||
|
let textView = NSTextView()
|
||||||
|
textView.isEditable = false
|
||||||
|
textView.isSelectable = true
|
||||||
|
textView.isRichText = true
|
||||||
|
textView.usesFontPanel = false
|
||||||
|
textView.drawsBackground = false
|
||||||
|
textView.backgroundColor = .clear
|
||||||
|
|
||||||
|
// Match editor text container settings for alignment
|
||||||
|
textView.textContainer?.lineFragmentPadding = 4
|
||||||
|
textView.textContainerInset = NSSize(width: 8, height: 8)
|
||||||
|
|
||||||
|
// Disable line wrapping to match editor behavior
|
||||||
|
textView.isHorizontallyResizable = true
|
||||||
|
textView.textContainer?.widthTracksTextView = false
|
||||||
|
textView.textContainer?.containerSize = NSSize(
|
||||||
|
width: CGFloat.greatestFiniteMagnitude,
|
||||||
|
height: CGFloat.greatestFiniteMagnitude
|
||||||
|
)
|
||||||
|
textView.maxSize = NSSize(
|
||||||
|
width: CGFloat.greatestFiniteMagnitude,
|
||||||
|
height: CGFloat.greatestFiniteMagnitude
|
||||||
|
)
|
||||||
|
|
||||||
|
scrollView.documentView = textView
|
||||||
|
context.coordinator.textView = textView
|
||||||
|
context.coordinator.scrollView = scrollView
|
||||||
|
|
||||||
|
updateContent(textView: textView)
|
||||||
|
return scrollView
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateNSView(_ scrollView: NSScrollView, context: Context) {
|
||||||
|
guard let textView = scrollView.documentView as? NSTextView else { return }
|
||||||
|
|
||||||
|
updateContent(textView: textView)
|
||||||
|
|
||||||
|
// Sync scroll position from editor
|
||||||
|
let currentOffset = scrollView.contentView.bounds.origin.y
|
||||||
|
if abs(currentOffset - scrollOffset) > 0.5 {
|
||||||
|
scrollView.contentView.scroll(to: NSPoint(x: 0, y: scrollOffset))
|
||||||
|
scrollView.reflectScrolledClipView(scrollView.contentView)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateContent(textView: NSTextView) {
|
||||||
|
let attributedString = NSMutableAttributedString()
|
||||||
|
|
||||||
|
let paragraphStyle = NSMutableParagraphStyle()
|
||||||
|
paragraphStyle.alignment = .right
|
||||||
|
|
||||||
|
let resultColor = NSColor.secondaryLabelColor
|
||||||
|
let errorColor = NSColor.systemRed
|
||||||
|
|
||||||
|
for (index, lineResult) in results.enumerated() {
|
||||||
|
let displayText = lineResult.result ?? ""
|
||||||
|
let color = lineResult.isError ? errorColor : resultColor
|
||||||
|
|
||||||
|
let attributes: [NSAttributedString.Key: Any] = [
|
||||||
|
.font: font,
|
||||||
|
.foregroundColor: color,
|
||||||
|
.paragraphStyle: paragraphStyle,
|
||||||
|
]
|
||||||
|
|
||||||
|
let line = NSAttributedString(string: displayText, attributes: attributes)
|
||||||
|
attributedString.append(line)
|
||||||
|
|
||||||
|
// Add newline between lines (but not after the last)
|
||||||
|
if index < results.count - 1 {
|
||||||
|
let newline = NSAttributedString(
|
||||||
|
string: "\n",
|
||||||
|
attributes: [.font: font]
|
||||||
|
)
|
||||||
|
attributedString.append(newline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
textView.textStorage?.setAttributedString(attributedString)
|
||||||
|
}
|
||||||
|
|
||||||
|
final class Coordinator {
|
||||||
|
weak var textView: NSTextView?
|
||||||
|
weak var scrollView: NSScrollView?
|
||||||
|
}
|
||||||
|
}
|
||||||
137
calcpad-macos/Sources/CalcPad/Views/EditorTextView.swift
Normal file
137
calcpad-macos/Sources/CalcPad/Views/EditorTextView.swift
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import AppKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// A SwiftUI wrapper around NSTextView for the editor pane.
|
||||||
|
/// Uses NSViewRepresentable to bridge AppKit's NSTextView into SwiftUI,
|
||||||
|
/// providing line-level control, scroll position access, and performance
|
||||||
|
/// that SwiftUI's TextEditor cannot match.
|
||||||
|
struct EditorTextView: NSViewRepresentable {
|
||||||
|
@Binding var text: String
|
||||||
|
@Binding var scrollOffset: CGFloat
|
||||||
|
var font: NSFont
|
||||||
|
|
||||||
|
func makeCoordinator() -> Coordinator {
|
||||||
|
Coordinator(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeNSView(context: Context) -> NSScrollView {
|
||||||
|
let scrollView = NSScrollView()
|
||||||
|
scrollView.hasVerticalScroller = true
|
||||||
|
scrollView.hasHorizontalScroller = false
|
||||||
|
scrollView.autohidesScrollers = true
|
||||||
|
scrollView.borderType = .noBorder
|
||||||
|
scrollView.drawsBackground = false
|
||||||
|
|
||||||
|
let textView = NSTextView()
|
||||||
|
textView.isEditable = true
|
||||||
|
textView.isSelectable = true
|
||||||
|
textView.allowsUndo = true
|
||||||
|
textView.isRichText = false
|
||||||
|
textView.usesFontPanel = false
|
||||||
|
textView.isAutomaticQuoteSubstitutionEnabled = false
|
||||||
|
textView.isAutomaticDashSubstitutionEnabled = false
|
||||||
|
textView.isAutomaticTextReplacementEnabled = false
|
||||||
|
textView.isAutomaticSpellingCorrectionEnabled = false
|
||||||
|
|
||||||
|
// Disable line wrapping — horizontal scroll instead
|
||||||
|
textView.isHorizontallyResizable = true
|
||||||
|
textView.textContainer?.widthTracksTextView = false
|
||||||
|
textView.textContainer?.containerSize = NSSize(
|
||||||
|
width: CGFloat.greatestFiniteMagnitude,
|
||||||
|
height: CGFloat.greatestFiniteMagnitude
|
||||||
|
)
|
||||||
|
textView.maxSize = NSSize(
|
||||||
|
width: CGFloat.greatestFiniteMagnitude,
|
||||||
|
height: CGFloat.greatestFiniteMagnitude
|
||||||
|
)
|
||||||
|
|
||||||
|
// Configure font and text color
|
||||||
|
textView.font = font
|
||||||
|
textView.textColor = .textColor
|
||||||
|
textView.backgroundColor = .clear
|
||||||
|
textView.drawsBackground = false
|
||||||
|
textView.insertionPointColor = .textColor
|
||||||
|
|
||||||
|
// Set the text
|
||||||
|
textView.string = text
|
||||||
|
|
||||||
|
// Configure the text container for consistent line spacing
|
||||||
|
textView.textContainer?.lineFragmentPadding = 4
|
||||||
|
textView.textContainerInset = NSSize(width: 8, height: 8)
|
||||||
|
|
||||||
|
scrollView.documentView = textView
|
||||||
|
context.coordinator.textView = textView
|
||||||
|
context.coordinator.scrollView = scrollView
|
||||||
|
|
||||||
|
// Observe text changes
|
||||||
|
textView.delegate = context.coordinator
|
||||||
|
|
||||||
|
// Observe scroll changes
|
||||||
|
scrollView.contentView.postsBoundsChangedNotifications = true
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
context.coordinator,
|
||||||
|
selector: #selector(Coordinator.scrollViewDidScroll(_:)),
|
||||||
|
name: NSView.boundsDidChangeNotification,
|
||||||
|
object: scrollView.contentView
|
||||||
|
)
|
||||||
|
|
||||||
|
return scrollView
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateNSView(_ scrollView: NSScrollView, context: Context) {
|
||||||
|
guard let textView = scrollView.documentView as? NSTextView else { return }
|
||||||
|
|
||||||
|
// Update font if changed
|
||||||
|
if textView.font != font {
|
||||||
|
textView.font = font
|
||||||
|
// Reapply font to entire text storage
|
||||||
|
let range = NSRange(location: 0, length: textView.textStorage?.length ?? 0)
|
||||||
|
textView.textStorage?.addAttribute(.font, value: font, range: range)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update text only if it actually changed (avoid feedback loops)
|
||||||
|
if textView.string != text {
|
||||||
|
let selectedRanges = textView.selectedRanges
|
||||||
|
textView.string = text
|
||||||
|
textView.selectedRanges = selectedRanges
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update scroll position if driven externally
|
||||||
|
context.coordinator.isUpdatingScroll = true
|
||||||
|
let currentOffset = scrollView.contentView.bounds.origin.y
|
||||||
|
if abs(currentOffset - scrollOffset) > 0.5 {
|
||||||
|
scrollView.contentView.scroll(to: NSPoint(x: 0, y: scrollOffset))
|
||||||
|
scrollView.reflectScrolledClipView(scrollView.contentView)
|
||||||
|
}
|
||||||
|
context.coordinator.isUpdatingScroll = false
|
||||||
|
}
|
||||||
|
|
||||||
|
final class Coordinator: NSObject, NSTextViewDelegate {
|
||||||
|
var parent: EditorTextView
|
||||||
|
weak var textView: NSTextView?
|
||||||
|
weak var scrollView: NSScrollView?
|
||||||
|
var isUpdatingScroll = false
|
||||||
|
|
||||||
|
init(_ parent: EditorTextView) {
|
||||||
|
self.parent = parent
|
||||||
|
}
|
||||||
|
|
||||||
|
func textDidChange(_ notification: Notification) {
|
||||||
|
guard let textView = notification.object as? NSTextView else { return }
|
||||||
|
parent.text = textView.string
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func scrollViewDidScroll(_ notification: Notification) {
|
||||||
|
guard !isUpdatingScroll,
|
||||||
|
let scrollView = scrollView else { return }
|
||||||
|
let offset = scrollView.contentView.bounds.origin.y
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
self?.parent.scrollOffset = offset
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
NotificationCenter.default.removeObserver(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import Combine
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// The main two-column editor layout: text editor on the left, results on the right.
|
||||||
|
/// Scrolling is synchronized between both columns.
|
||||||
|
struct TwoColumnEditorView: View {
|
||||||
|
@State private var text: String = ""
|
||||||
|
@State private var scrollOffset: CGFloat = 0
|
||||||
|
@State private var results: [LineResult] = []
|
||||||
|
@State private var evaluationTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
/// Uses the Rust FFI engine. Falls back to StubCalculationEngine if the Rust
|
||||||
|
/// library is not linked (e.g., during UI-only development).
|
||||||
|
private let engine: CalculationEngine = RustCalculationEngine()
|
||||||
|
|
||||||
|
/// Debounce interval for re-evaluation after typing (seconds).
|
||||||
|
private let evaluationDebounce: TimeInterval = 0.05
|
||||||
|
|
||||||
|
/// Font that respects the user's accessibility / Dynamic Type settings.
|
||||||
|
private var editorFont: NSFont {
|
||||||
|
// Use the system's preferred monospaced font size, which scales with
|
||||||
|
// Accessibility > Display > Text Size in System Settings (macOS 14+).
|
||||||
|
let baseSize = NSFont.systemFontSize
|
||||||
|
// Scale with accessibility settings via the body text style size
|
||||||
|
let preferredSize = NSFont.preferredFont(forTextStyle: .body, options: [:]).pointSize
|
||||||
|
// Use the larger of system default or accessibility-preferred size
|
||||||
|
let size = max(baseSize, preferredSize)
|
||||||
|
return NSFont.monospacedSystemFont(ofSize: size, weight: .regular)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HSplitView {
|
||||||
|
// Left pane: Editor
|
||||||
|
EditorTextView(
|
||||||
|
text: $text,
|
||||||
|
scrollOffset: $scrollOffset,
|
||||||
|
font: editorFont
|
||||||
|
)
|
||||||
|
.frame(minWidth: 200)
|
||||||
|
|
||||||
|
// Divider is automatic with HSplitView
|
||||||
|
|
||||||
|
// Right pane: Answer column
|
||||||
|
AnswerColumnView(
|
||||||
|
results: results,
|
||||||
|
scrollOffset: $scrollOffset,
|
||||||
|
font: editorFont
|
||||||
|
)
|
||||||
|
.frame(minWidth: 120, idealWidth: 200)
|
||||||
|
}
|
||||||
|
.onChange(of: text) { _, newValue in
|
||||||
|
scheduleEvaluation(newValue)
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
evaluateText(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Debounce evaluation so rapid typing doesn't cause excessive recalculation.
|
||||||
|
private func scheduleEvaluation(_ newText: String) {
|
||||||
|
evaluationTask?.cancel()
|
||||||
|
evaluationTask = Task { @MainActor in
|
||||||
|
try? await Task.sleep(for: .milliseconds(Int(evaluationDebounce * 1000)))
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
evaluateText(newText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func evaluateText(_ newText: String) {
|
||||||
|
results = engine.evaluateSheet(newText)
|
||||||
|
}
|
||||||
|
}
|
||||||
227
calcpad-macos/Tests/CalcPadTests/FFIModelTests.swift
Normal file
227
calcpad-macos/Tests/CalcPadTests/FFIModelTests.swift
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
import Testing
|
||||||
|
import Foundation
|
||||||
|
@testable import CalcPad
|
||||||
|
|
||||||
|
@Suite("FFI JSON Model Decoding Tests")
|
||||||
|
struct FFIModelTests {
|
||||||
|
|
||||||
|
// MARK: - Single Line Response
|
||||||
|
|
||||||
|
@Test("Decode number result")
|
||||||
|
func decodeNumberResult() throws {
|
||||||
|
let json = """
|
||||||
|
{
|
||||||
|
"schema_version": "1.0",
|
||||||
|
"result": {
|
||||||
|
"value": { "kind": "Number", "value": 42.0 },
|
||||||
|
"metadata": {
|
||||||
|
"span": { "start": 0, "end": 5 },
|
||||||
|
"result_type": "Number",
|
||||||
|
"display": "42",
|
||||||
|
"raw_value": 42.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
let response = try JSONDecoder().decode(FFIResponse.self, from: Data(json.utf8))
|
||||||
|
#expect(response.schemaVersion == "1.0")
|
||||||
|
#expect(response.result.metadata.display == "42")
|
||||||
|
#expect(response.result.metadata.resultType == "Number")
|
||||||
|
#expect(response.result.metadata.rawValue == 42.0)
|
||||||
|
if case .number(let value) = response.result.value {
|
||||||
|
#expect(value == 42.0)
|
||||||
|
} else {
|
||||||
|
Issue.record("Expected .number variant")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Decode unit value result")
|
||||||
|
func decodeUnitValueResult() throws {
|
||||||
|
let json = """
|
||||||
|
{
|
||||||
|
"schema_version": "1.0",
|
||||||
|
"result": {
|
||||||
|
"value": { "kind": "UnitValue", "value": 5.0, "unit": "kg" },
|
||||||
|
"metadata": {
|
||||||
|
"span": { "start": 0, "end": 4 },
|
||||||
|
"result_type": "UnitValue",
|
||||||
|
"display": "5 kg",
|
||||||
|
"raw_value": 5.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
let response = try JSONDecoder().decode(FFIResponse.self, from: Data(json.utf8))
|
||||||
|
if case .unitValue(let value, let unit) = response.result.value {
|
||||||
|
#expect(value == 5.0)
|
||||||
|
#expect(unit == "kg")
|
||||||
|
} else {
|
||||||
|
Issue.record("Expected .unitValue variant")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Decode currency value result")
|
||||||
|
func decodeCurrencyValueResult() throws {
|
||||||
|
let json = """
|
||||||
|
{
|
||||||
|
"schema_version": "1.0",
|
||||||
|
"result": {
|
||||||
|
"value": { "kind": "CurrencyValue", "amount": 19.99, "currency": "USD" },
|
||||||
|
"metadata": {
|
||||||
|
"span": { "start": 0, "end": 6 },
|
||||||
|
"result_type": "CurrencyValue",
|
||||||
|
"display": "$19.99",
|
||||||
|
"raw_value": 19.99
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
let response = try JSONDecoder().decode(FFIResponse.self, from: Data(json.utf8))
|
||||||
|
if case .currencyValue(let amount, let currency) = response.result.value {
|
||||||
|
#expect(amount == 19.99)
|
||||||
|
#expect(currency == "USD")
|
||||||
|
} else {
|
||||||
|
Issue.record("Expected .currencyValue variant")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Decode datetime result")
|
||||||
|
func decodeDateTimeResult() throws {
|
||||||
|
let json = """
|
||||||
|
{
|
||||||
|
"schema_version": "1.0",
|
||||||
|
"result": {
|
||||||
|
"value": { "kind": "DateTime", "date": "2024-03-17" },
|
||||||
|
"metadata": {
|
||||||
|
"span": { "start": 0, "end": 10 },
|
||||||
|
"result_type": "DateTime",
|
||||||
|
"display": "2024-03-17",
|
||||||
|
"raw_value": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
let response = try JSONDecoder().decode(FFIResponse.self, from: Data(json.utf8))
|
||||||
|
if case .dateTime(let date) = response.result.value {
|
||||||
|
#expect(date == "2024-03-17")
|
||||||
|
} else {
|
||||||
|
Issue.record("Expected .dateTime variant")
|
||||||
|
}
|
||||||
|
#expect(response.result.metadata.rawValue == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Decode time delta result")
|
||||||
|
func decodeTimeDeltaResult() throws {
|
||||||
|
let json = """
|
||||||
|
{
|
||||||
|
"schema_version": "1.0",
|
||||||
|
"result": {
|
||||||
|
"value": { "kind": "TimeDelta", "days": 30, "description": "30 days" },
|
||||||
|
"metadata": {
|
||||||
|
"span": { "start": 0, "end": 8 },
|
||||||
|
"result_type": "TimeDelta",
|
||||||
|
"display": "30 days",
|
||||||
|
"raw_value": 30.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
let response = try JSONDecoder().decode(FFIResponse.self, from: Data(json.utf8))
|
||||||
|
if case .timeDelta(let days, let desc) = response.result.value {
|
||||||
|
#expect(days == 30)
|
||||||
|
#expect(desc == "30 days")
|
||||||
|
} else {
|
||||||
|
Issue.record("Expected .timeDelta variant")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Decode boolean result")
|
||||||
|
func decodeBooleanResult() throws {
|
||||||
|
let json = """
|
||||||
|
{
|
||||||
|
"schema_version": "1.0",
|
||||||
|
"result": {
|
||||||
|
"value": { "kind": "Boolean", "value": true },
|
||||||
|
"metadata": {
|
||||||
|
"span": { "start": 0, "end": 5 },
|
||||||
|
"result_type": "Boolean",
|
||||||
|
"display": "true",
|
||||||
|
"raw_value": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
let response = try JSONDecoder().decode(FFIResponse.self, from: Data(json.utf8))
|
||||||
|
if case .boolean(let value) = response.result.value {
|
||||||
|
#expect(value == true)
|
||||||
|
} else {
|
||||||
|
Issue.record("Expected .boolean variant")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Decode error result")
|
||||||
|
func decodeErrorResult() throws {
|
||||||
|
let json = """
|
||||||
|
{
|
||||||
|
"schema_version": "1.0",
|
||||||
|
"result": {
|
||||||
|
"value": { "kind": "Error", "message": "unexpected token", "span": { "start": 0, "end": 3 } },
|
||||||
|
"metadata": {
|
||||||
|
"span": { "start": 0, "end": 3 },
|
||||||
|
"result_type": "Error",
|
||||||
|
"display": "Error: unexpected token",
|
||||||
|
"raw_value": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
let response = try JSONDecoder().decode(FFIResponse.self, from: Data(json.utf8))
|
||||||
|
#expect(response.result.value.isError)
|
||||||
|
if case .error(let message) = response.result.value {
|
||||||
|
#expect(message == "unexpected token")
|
||||||
|
} else {
|
||||||
|
Issue.record("Expected .error variant")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sheet Response
|
||||||
|
|
||||||
|
@Test("Decode sheet response with multiple results")
|
||||||
|
func decodeSheetResponse() throws {
|
||||||
|
let json = """
|
||||||
|
{
|
||||||
|
"schema_version": "1.0",
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"value": { "kind": "Number", "value": 4.0 },
|
||||||
|
"metadata": { "span": { "start": 0, "end": 5 }, "result_type": "Number", "display": "4", "raw_value": 4.0 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": { "kind": "Error", "message": "empty expression", "span": { "start": 0, "end": 0 } },
|
||||||
|
"metadata": { "span": { "start": 0, "end": 0 }, "result_type": "Error", "display": "Error: empty expression", "raw_value": null }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": { "kind": "Number", "value": 30.0 },
|
||||||
|
"metadata": { "span": { "start": 0, "end": 6 }, "result_type": "Number", "display": "30", "raw_value": 30.0 }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
let response = try JSONDecoder().decode(FFISheetResponse.self, from: Data(json.utf8))
|
||||||
|
#expect(response.results.count == 3)
|
||||||
|
#expect(response.results[0].metadata.display == "4")
|
||||||
|
#expect(response.results[1].value.isError)
|
||||||
|
#expect(response.results[2].metadata.display == "30")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - FFICalcValue.isError
|
||||||
|
|
||||||
|
@Test("isError returns false for non-error values")
|
||||||
|
func isErrorFalseForNonError() throws {
|
||||||
|
let json = """
|
||||||
|
{ "kind": "Number", "value": 42.0 }
|
||||||
|
"""
|
||||||
|
let value = try JSONDecoder().decode(FFICalcValue.self, from: Data(json.utf8))
|
||||||
|
#expect(value.isError == false)
|
||||||
|
}
|
||||||
|
}
|
||||||
64
calcpad-macos/Tests/CalcPadTests/PerformanceTests.swift
Normal file
64
calcpad-macos/Tests/CalcPadTests/PerformanceTests.swift
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import Testing
|
||||||
|
@testable import CalcPad
|
||||||
|
|
||||||
|
@Suite("Performance Tests")
|
||||||
|
struct PerformanceTests {
|
||||||
|
let engine = StubCalculationEngine()
|
||||||
|
|
||||||
|
@Test("Evaluate 1000+ line sheet completes in under 1 second")
|
||||||
|
func largeSheetEvaluation() async throws {
|
||||||
|
// Generate a 1500-line document mixing expressions, blanks, and comments
|
||||||
|
var lines: [String] = []
|
||||||
|
for i in 1...1500 {
|
||||||
|
switch i % 5 {
|
||||||
|
case 0: lines.append("") // blank
|
||||||
|
case 1: lines.append("// Line \(i)") // comment
|
||||||
|
case 2: lines.append("\(i) + \(i * 2)")
|
||||||
|
case 3: lines.append("\(i) * 3")
|
||||||
|
case 4: lines.append("\(i) / 7")
|
||||||
|
default: lines.append("\(i)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let text = lines.joined(separator: "\n")
|
||||||
|
|
||||||
|
let start = ContinuousClock.now
|
||||||
|
let results = engine.evaluateSheet(text)
|
||||||
|
let elapsed = ContinuousClock.now - start
|
||||||
|
|
||||||
|
#expect(results.count == 1500)
|
||||||
|
#expect(elapsed < .seconds(1), "Sheet evaluation took \(elapsed), expected < 1 second")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Evaluate 5000 line sheet completes in under 3 seconds")
|
||||||
|
func veryLargeSheetEvaluation() async throws {
|
||||||
|
var lines: [String] = []
|
||||||
|
for i in 1...5000 {
|
||||||
|
lines.append("\(i) + \(i)")
|
||||||
|
}
|
||||||
|
let text = lines.joined(separator: "\n")
|
||||||
|
|
||||||
|
let start = ContinuousClock.now
|
||||||
|
let results = engine.evaluateSheet(text)
|
||||||
|
let elapsed = ContinuousClock.now - start
|
||||||
|
|
||||||
|
#expect(results.count == 5000)
|
||||||
|
#expect(elapsed < .seconds(3), "Sheet evaluation took \(elapsed), expected < 3 seconds")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("LineResult array construction is efficient for large documents")
|
||||||
|
func lineResultConstruction() {
|
||||||
|
// Verify we get correct line numbering for large documents
|
||||||
|
let lines = (1...1000).map { "\($0) + 1" }
|
||||||
|
let text = lines.joined(separator: "\n")
|
||||||
|
let results = engine.evaluateSheet(text)
|
||||||
|
|
||||||
|
#expect(results.count == 1000)
|
||||||
|
#expect(results.first?.lineNumber == 1)
|
||||||
|
#expect(results.last?.lineNumber == 1000)
|
||||||
|
|
||||||
|
// Spot-check results
|
||||||
|
#expect(results[0].result == "2") // 1 + 1
|
||||||
|
#expect(results[99].result == "101") // 100 + 1
|
||||||
|
#expect(results[999].result == "1001") // 1000 + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
164
calcpad-macos/Tests/CalcPadTests/RustEngineTests.swift
Normal file
164
calcpad-macos/Tests/CalcPadTests/RustEngineTests.swift
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import Testing
|
||||||
|
import Foundation
|
||||||
|
@testable import CalcPad
|
||||||
|
|
||||||
|
@Suite("RustCalculationEngine Tests")
|
||||||
|
struct RustEngineTests {
|
||||||
|
let engine = RustCalculationEngine()
|
||||||
|
|
||||||
|
// MARK: - AC: evaluateLine returns LineResult (not raw pointer)
|
||||||
|
|
||||||
|
@Test("evaluateLine 2+2 returns LineResult with result 4")
|
||||||
|
func evalLineBasicArithmetic() {
|
||||||
|
let result = engine.evaluateLine("2 + 2")
|
||||||
|
#expect(result.result == "4")
|
||||||
|
#expect(result.isError == false)
|
||||||
|
#expect(result.lineNumber == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("evaluateLine multiplication")
|
||||||
|
func evalLineMultiplication() {
|
||||||
|
let result = engine.evaluateLine("6 * 7")
|
||||||
|
#expect(result.result == "42")
|
||||||
|
#expect(result.isError == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("evaluateLine subtraction")
|
||||||
|
func evalLineSubtraction() {
|
||||||
|
let result = engine.evaluateLine("10 - 4")
|
||||||
|
#expect(result.result == "6")
|
||||||
|
#expect(result.isError == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("evaluateLine division")
|
||||||
|
func evalLineDivision() {
|
||||||
|
let result = engine.evaluateLine("15 / 3")
|
||||||
|
#expect(result.result == "5")
|
||||||
|
#expect(result.isError == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("evaluateLine decimal result")
|
||||||
|
func evalLineDecimal() {
|
||||||
|
let result = engine.evaluateLine("7 / 2")
|
||||||
|
#expect(result.result == "3.5")
|
||||||
|
#expect(result.isError == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - AC: evaluateSheet returns array of LineResult, one per line
|
||||||
|
|
||||||
|
@Test("evaluateSheet returns one result per line")
|
||||||
|
func evalSheetMultiLine() {
|
||||||
|
let text = "2 + 2\n\n10 * 3"
|
||||||
|
let results = engine.evaluateSheet(text)
|
||||||
|
#expect(results.count == 3)
|
||||||
|
#expect(results[0].lineNumber == 1)
|
||||||
|
#expect(results[0].result == "4")
|
||||||
|
#expect(results[1].lineNumber == 2)
|
||||||
|
#expect(results[1].result == nil) // blank line
|
||||||
|
#expect(results[2].lineNumber == 3)
|
||||||
|
#expect(results[2].result == "30")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("evaluateSheet with comments and blanks")
|
||||||
|
func evalSheetMixed() {
|
||||||
|
let text = "// Title\n5 + 5\n\n// Section\n3 * 3"
|
||||||
|
let results = engine.evaluateSheet(text)
|
||||||
|
#expect(results.count == 5)
|
||||||
|
#expect(results[0].result == nil) // comment
|
||||||
|
#expect(results[0].isError == false)
|
||||||
|
#expect(results[1].result == "10")
|
||||||
|
#expect(results[2].result == nil) // blank
|
||||||
|
#expect(results[3].result == nil) // comment
|
||||||
|
#expect(results[4].result == "9")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - AC: Error handling — errors become LineResult, no crash
|
||||||
|
|
||||||
|
@Test("Malformed expression returns error LineResult, not crash")
|
||||||
|
func evalLineError() {
|
||||||
|
let result = engine.evaluateLine("2 + + 3")
|
||||||
|
#expect(result.isError == true)
|
||||||
|
#expect(result.result != nil) // has error message
|
||||||
|
#expect(result.result?.starts(with: "Error") == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Completely invalid input returns error, not crash")
|
||||||
|
func evalLineInvalidInput() {
|
||||||
|
let result = engine.evaluateLine("@#$%^&")
|
||||||
|
// Should not crash — either error or some result
|
||||||
|
#expect(result.id == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - AC: Blank and comment lines
|
||||||
|
|
||||||
|
@Test("Blank line returns nil result")
|
||||||
|
func blankLine() {
|
||||||
|
let result = engine.evaluateLine("")
|
||||||
|
#expect(result.result == nil)
|
||||||
|
#expect(result.isError == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Comment with // returns nil result")
|
||||||
|
func commentLine() {
|
||||||
|
let result = engine.evaluateLine("// this is a comment")
|
||||||
|
#expect(result.result == nil)
|
||||||
|
#expect(result.isError == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("# is not a comment in Rust engine — treated as identifier lookup")
|
||||||
|
func hashNotComment() {
|
||||||
|
let result = engine.evaluateLine("# header")
|
||||||
|
// The Rust engine does not treat # as a comment. The # is skipped and
|
||||||
|
// "header" is parsed as an identifier, resulting in an undefined variable error.
|
||||||
|
#expect(result.isError == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - AC: Memory safety — no leaks with repeated calls
|
||||||
|
|
||||||
|
@Test("Repeated evaluateLine calls don't leak (1000 iterations)")
|
||||||
|
func memoryStressLine() {
|
||||||
|
for i in 0..<1000 {
|
||||||
|
let result = engine.evaluateLine("\(i) + 1")
|
||||||
|
#expect(result.isError == false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Repeated evaluateSheet calls don't leak (100 iterations)")
|
||||||
|
func memoryStressSheet() {
|
||||||
|
let text = (1...10).map { "\($0) * 2" }.joined(separator: "\n")
|
||||||
|
for _ in 0..<100 {
|
||||||
|
let results = engine.evaluateSheet(text)
|
||||||
|
#expect(results.count == 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - AC: Thread safety — concurrent calls
|
||||||
|
|
||||||
|
@Test("Concurrent evaluateLine calls from multiple threads")
|
||||||
|
func threadSafety() async {
|
||||||
|
await withTaskGroup(of: LineResult.self) { group in
|
||||||
|
for i in 0..<50 {
|
||||||
|
group.addTask {
|
||||||
|
engine.evaluateLine("\(i) + 1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var count = 0
|
||||||
|
for await result in group {
|
||||||
|
#expect(result.isError == false)
|
||||||
|
count += 1
|
||||||
|
}
|
||||||
|
#expect(count == 50)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - AC: Variables shared across sheet lines
|
||||||
|
|
||||||
|
@Test("evaluateSheet shares variables across lines")
|
||||||
|
func evalSheetVariables() {
|
||||||
|
let text = "x = 10\nx * 2"
|
||||||
|
let results = engine.evaluateSheet(text)
|
||||||
|
#expect(results.count == 2)
|
||||||
|
#expect(results[0].result == "10")
|
||||||
|
#expect(results[1].result == "20")
|
||||||
|
}
|
||||||
|
}
|
||||||
115
calcpad-macos/Tests/CalcPadTests/StubEngineTests.swift
Normal file
115
calcpad-macos/Tests/CalcPadTests/StubEngineTests.swift
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import Testing
|
||||||
|
@testable import CalcPad
|
||||||
|
|
||||||
|
@Suite("StubCalculationEngine Tests")
|
||||||
|
struct StubEngineTests {
|
||||||
|
let engine = StubCalculationEngine()
|
||||||
|
|
||||||
|
@Test("Blank lines produce nil result")
|
||||||
|
func blankLine() {
|
||||||
|
let result = engine.evaluateLine("")
|
||||||
|
#expect(result.result == nil)
|
||||||
|
#expect(result.isError == false)
|
||||||
|
#expect(result.isEmpty == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Whitespace-only lines produce nil result")
|
||||||
|
func whitespaceOnly() {
|
||||||
|
let result = engine.evaluateLine(" ")
|
||||||
|
#expect(result.result == nil)
|
||||||
|
#expect(result.isError == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Comment lines starting with // produce nil result")
|
||||||
|
func doubleSlashComment() {
|
||||||
|
let result = engine.evaluateLine("// this is a comment")
|
||||||
|
#expect(result.result == nil)
|
||||||
|
#expect(result.isError == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Comment lines starting with # produce nil result")
|
||||||
|
func hashComment() {
|
||||||
|
let result = engine.evaluateLine("# header")
|
||||||
|
#expect(result.result == nil)
|
||||||
|
#expect(result.isError == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Simple addition")
|
||||||
|
func addition() {
|
||||||
|
let result = engine.evaluateLine("2 + 3")
|
||||||
|
#expect(result.result == "5")
|
||||||
|
#expect(result.isError == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Simple subtraction")
|
||||||
|
func subtraction() {
|
||||||
|
let result = engine.evaluateLine("10 - 4")
|
||||||
|
#expect(result.result == "6")
|
||||||
|
#expect(result.isError == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Simple multiplication")
|
||||||
|
func multiplication() {
|
||||||
|
let result = engine.evaluateLine("6 * 7")
|
||||||
|
#expect(result.result == "42")
|
||||||
|
#expect(result.isError == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Simple division")
|
||||||
|
func division() {
|
||||||
|
let result = engine.evaluateLine("15 / 3")
|
||||||
|
#expect(result.result == "5")
|
||||||
|
#expect(result.isError == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Decimal result")
|
||||||
|
func decimalResult() {
|
||||||
|
let result = engine.evaluateLine("7 / 2")
|
||||||
|
#expect(result.result == "3.5")
|
||||||
|
#expect(result.isError == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("evaluateSheet returns one result per line")
|
||||||
|
func evaluateSheet() {
|
||||||
|
let text = "2 + 2\n\n10 * 3"
|
||||||
|
let results = engine.evaluateSheet(text)
|
||||||
|
#expect(results.count == 3)
|
||||||
|
#expect(results[0].lineNumber == 1)
|
||||||
|
#expect(results[0].result == "4")
|
||||||
|
#expect(results[1].lineNumber == 2)
|
||||||
|
#expect(results[1].result == nil)
|
||||||
|
#expect(results[2].lineNumber == 3)
|
||||||
|
#expect(results[2].result == "30")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("evaluateSheet with comments and blanks")
|
||||||
|
func evaluateSheetMixed() {
|
||||||
|
let text = "// Title\n5 + 5\n\n# Section\n3 * 3"
|
||||||
|
let results = engine.evaluateSheet(text)
|
||||||
|
#expect(results.count == 5)
|
||||||
|
#expect(results[0].result == nil) // comment
|
||||||
|
#expect(results[1].result == "10")
|
||||||
|
#expect(results[2].result == nil) // blank
|
||||||
|
#expect(results[3].result == nil) // comment
|
||||||
|
#expect(results[4].result == "9")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("LineResult id equals line number")
|
||||||
|
func lineResultId() {
|
||||||
|
let result = LineResult(id: 5, expression: "2+2", result: "4", isError: false)
|
||||||
|
#expect(result.lineNumber == 5)
|
||||||
|
#expect(result.id == 5)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("LineResult isEmpty for nil results")
|
||||||
|
func lineResultIsEmpty() {
|
||||||
|
let empty = LineResult(id: 1, expression: "", result: nil, isError: false)
|
||||||
|
#expect(empty.isEmpty == true)
|
||||||
|
|
||||||
|
let error = LineResult(id: 1, expression: "bad", result: nil, isError: true)
|
||||||
|
#expect(error.isEmpty == false)
|
||||||
|
|
||||||
|
let hasResult = LineResult(id: 1, expression: "2+2", result: "4", isError: false)
|
||||||
|
#expect(hasResult.isEmpty == false)
|
||||||
|
}
|
||||||
|
}
|
||||||
37
calcpad-macos/build-rust.sh
Normal file
37
calcpad-macos/build-rust.sh
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Build the Rust CalcPad engine for macOS.
|
||||||
|
#
|
||||||
|
# Produces a static library that the Swift Package Manager links against.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./build-rust.sh # Build release (default)
|
||||||
|
# ./build-rust.sh debug # Build debug
|
||||||
|
#
|
||||||
|
# Prerequisites:
|
||||||
|
# - Rust toolchain with targets: aarch64-apple-darwin, x86_64-apple-darwin
|
||||||
|
# - Install targets: rustup target add aarch64-apple-darwin x86_64-apple-darwin
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PROFILE="${1:-release}"
|
||||||
|
ENGINE_DIR="$(cd "$(dirname "$0")/../calcpad-engine" && pwd)"
|
||||||
|
OUTPUT_DIR="$ENGINE_DIR/target/$PROFILE"
|
||||||
|
|
||||||
|
if [ "$PROFILE" = "debug" ]; then
|
||||||
|
CARGO_FLAG=""
|
||||||
|
else
|
||||||
|
CARGO_FLAG="--release"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Building calcpad-engine ($PROFILE)..."
|
||||||
|
|
||||||
|
# Build for the current architecture
|
||||||
|
cd "$ENGINE_DIR"
|
||||||
|
cargo build $CARGO_FLAG
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Build complete!"
|
||||||
|
echo "Static library: $OUTPUT_DIR/libcalcpad_engine.a"
|
||||||
|
echo ""
|
||||||
|
echo "To build and test the Swift package:"
|
||||||
|
echo " cd calcpad-macos && swift test"
|
||||||
24
calcpad-web/.gitignore
vendored
Normal file
24
calcpad-web/.gitignore
vendored
Normal 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
19
calcpad-web/index.html
Normal 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
33
calcpad-web/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
4
calcpad-web/public/favicon.svg
Normal file
4
calcpad-web/public/favicon.svg
Normal 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 |
4
calcpad-web/public/icons/icon-192.svg
Normal file
4
calcpad-web/public/icons/icon-192.svg
Normal 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 |
4
calcpad-web/public/icons/icon-512.svg
Normal file
4
calcpad-web/public/icons/icon-512.svg
Normal 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 |
4
calcpad-web/public/icons/icon-maskable-512.svg
Normal file
4
calcpad-web/public/icons/icon-maskable-512.svg
Normal 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
89
calcpad-web/src/App.tsx
Normal 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
|
||||||
49
calcpad-web/src/components/AnswerColumn.tsx
Normal file
49
calcpad-web/src/components/AnswerColumn.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
45
calcpad-web/src/components/InstallPrompt.tsx
Normal file
45
calcpad-web/src/components/InstallPrompt.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
23
calcpad-web/src/components/OfflineBanner.tsx
Normal file
23
calcpad-web/src/components/OfflineBanner.tsx
Normal 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">
|
||||||
|
●
|
||||||
|
</span>
|
||||||
|
You are offline. Changes are saved locally.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
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',
|
||||||
|
},
|
||||||
|
})
|
||||||
104
calcpad-web/src/editor/answer-gutter.ts
Normal file
104
calcpad-web/src/editor/answer-gutter.ts
Normal 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]
|
||||||
|
}
|
||||||
145
calcpad-web/src/editor/calcpad-language.ts
Normal file
145
calcpad-web/src/editor/calcpad-language.ts
Normal 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)
|
||||||
|
}
|
||||||
142
calcpad-web/src/editor/error-display.ts
Normal file
142
calcpad-web/src/editor/error-display.ts
Normal 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,
|
||||||
|
]
|
||||||
|
}
|
||||||
28
calcpad-web/src/engine/types.ts
Normal file
28
calcpad-web/src/engine/types.ts
Normal 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 }
|
||||||
91
calcpad-web/src/engine/useEngine.ts
Normal file
91
calcpad-web/src/engine/useEngine.ts
Normal 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 }
|
||||||
|
}
|
||||||
133
calcpad-web/src/engine/worker.ts
Normal file
133
calcpad-web/src/engine/worker.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
69
calcpad-web/src/hooks/useInstallPrompt.ts
Normal file
69
calcpad-web/src/hooks/useInstallPrompt.ts
Normal 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 }
|
||||||
|
}
|
||||||
27
calcpad-web/src/hooks/useOnlineStatus.ts
Normal file
27
calcpad-web/src/hooks/useOnlineStatus.ts
Normal 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
31
calcpad-web/src/main.tsx
Normal 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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
47
calcpad-web/src/styles/answer-column.css
Normal file
47
calcpad-web/src/styles/answer-column.css
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
92
calcpad-web/src/styles/app.css
Normal file
92
calcpad-web/src/styles/app.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
60
calcpad-web/src/styles/index.css
Normal file
60
calcpad-web/src/styles/index.css
Normal 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);
|
||||||
|
}
|
||||||
69
calcpad-web/src/styles/install-prompt.css
Normal file
69
calcpad-web/src/styles/install-prompt.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
23
calcpad-web/src/styles/offline-banner.css
Normal file
23
calcpad-web/src/styles/offline-banner.css
Normal 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
14
calcpad-web/src/vite-env.d.ts
vendored
Normal 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>
|
||||||
|
}
|
||||||
26
calcpad-web/tsconfig.app.json
Normal file
26
calcpad-web/tsconfig.app.json
Normal 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"]
|
||||||
|
}
|
||||||
7
calcpad-web/tsconfig.json
Normal file
7
calcpad-web/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
24
calcpad-web/tsconfig.node.json
Normal file
24
calcpad-web/tsconfig.node.json
Normal 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"]
|
||||||
|
}
|
||||||
95
calcpad-web/vite.config.ts
Normal file
95
calcpad-web/vite.config.ts
Normal 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',
|
||||||
|
},
|
||||||
|
})
|
||||||
19
calcpad-windows/Cargo.toml
Normal file
19
calcpad-windows/Cargo.toml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
[package]
|
||||||
|
name = "calcpad-windows"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "CalcPad — native Windows app built with iced"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "calcpad-win"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
calcpad-engine = { path = "../calcpad-engine" }
|
||||||
|
iced = { version = "0.13", features = ["tokio"] }
|
||||||
|
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
opt-level = 3
|
||||||
|
lto = true
|
||||||
|
strip = true
|
||||||
210
calcpad-windows/src/evaluator.rs
Normal file
210
calcpad-windows/src/evaluator.rs
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
//! Thin wrapper around `calcpad_engine::SheetContext` for the iced UI.
|
||||||
|
//!
|
||||||
|
//! Exposes a simple `evaluate_sheet(text) -> Vec<LineResult>` interface so
|
||||||
|
//! the UI layer does not interact with the engine types directly.
|
||||||
|
|
||||||
|
use calcpad_engine::{CalcResult, CalcValue, ResultType, SheetContext};
|
||||||
|
|
||||||
|
/// A pre-formatted result for a single line, ready for display.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct LineResult {
|
||||||
|
/// The display string shown in the results column.
|
||||||
|
/// Empty string means the line produced no visible result (comment, blank, heading).
|
||||||
|
pub display: String,
|
||||||
|
/// Whether this result is an error.
|
||||||
|
pub is_error: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Evaluate a full sheet of text, returning one `LineResult` per line.
|
||||||
|
///
|
||||||
|
/// Uses `SheetContext` for full dependency resolution, variables,
|
||||||
|
/// aggregators, unit conversions, and all engine features.
|
||||||
|
pub fn evaluate_sheet(text: &str) -> Vec<LineResult> {
|
||||||
|
if text.is_empty() {
|
||||||
|
return vec![LineResult {
|
||||||
|
display: String::new(),
|
||||||
|
is_error: false,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
let lines: Vec<&str> = text.lines().collect();
|
||||||
|
let mut ctx = SheetContext::new();
|
||||||
|
|
||||||
|
for (i, line) in lines.iter().enumerate() {
|
||||||
|
ctx.set_line(i, line);
|
||||||
|
}
|
||||||
|
|
||||||
|
let results = ctx.eval();
|
||||||
|
|
||||||
|
let mut output: Vec<LineResult> = results.into_iter().map(|r| format_result(&r)).collect();
|
||||||
|
|
||||||
|
// Ensure we have at least as many results as lines
|
||||||
|
while output.len() < lines.len() {
|
||||||
|
output.push(LineResult {
|
||||||
|
display: String::new(),
|
||||||
|
is_error: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
output
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format a `CalcResult` into a `LineResult`.
|
||||||
|
fn format_result(result: &CalcResult) -> LineResult {
|
||||||
|
match &result.value {
|
||||||
|
CalcValue::Error { message, .. } => {
|
||||||
|
// Suppress "noise" errors from empty/comment lines
|
||||||
|
let suppressed = message == "Empty expression"
|
||||||
|
|| message == "no expression found"
|
||||||
|
|| message == "No result";
|
||||||
|
|
||||||
|
if suppressed {
|
||||||
|
LineResult {
|
||||||
|
display: String::new(),
|
||||||
|
is_error: false,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LineResult {
|
||||||
|
display: result.metadata.display.clone(),
|
||||||
|
is_error: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => LineResult {
|
||||||
|
display: result.metadata.display.clone(),
|
||||||
|
is_error: result.metadata.result_type == ResultType::Error,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_empty_input() {
|
||||||
|
let results = evaluate_sheet("");
|
||||||
|
assert_eq!(results.len(), 1);
|
||||||
|
assert_eq!(results[0].display, "");
|
||||||
|
assert!(!results[0].is_error);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_single_expression() {
|
||||||
|
let results = evaluate_sheet("2 + 2");
|
||||||
|
assert_eq!(results.len(), 1);
|
||||||
|
assert_eq!(results[0].display, "4");
|
||||||
|
assert!(!results[0].is_error);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_multiple_lines() {
|
||||||
|
let results = evaluate_sheet("10\n20\n30");
|
||||||
|
assert_eq!(results.len(), 3);
|
||||||
|
assert_eq!(results[0].display, "10");
|
||||||
|
assert_eq!(results[1].display, "20");
|
||||||
|
assert_eq!(results[2].display, "30");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_variables() {
|
||||||
|
let results = evaluate_sheet("x = 10\nx * 2");
|
||||||
|
assert_eq!(results.len(), 2);
|
||||||
|
assert_eq!(results[0].display, "10");
|
||||||
|
assert_eq!(results[1].display, "20");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_variable_dependency() {
|
||||||
|
let results = evaluate_sheet("price = 100\ntax = 15\nprice + tax");
|
||||||
|
assert_eq!(results.len(), 3);
|
||||||
|
assert_eq!(results[0].display, "100");
|
||||||
|
assert_eq!(results[1].display, "15");
|
||||||
|
assert_eq!(results[2].display, "115");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_aggregator_total() {
|
||||||
|
let results = evaluate_sheet("10\n20\n30\ntotal");
|
||||||
|
assert_eq!(results.len(), 4);
|
||||||
|
assert_eq!(results[3].display, "60");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_empty_lines_produce_empty_results() {
|
||||||
|
let results = evaluate_sheet("10\n\n20");
|
||||||
|
assert_eq!(results.len(), 3);
|
||||||
|
assert_eq!(results[0].display, "10");
|
||||||
|
assert_eq!(results[1].display, "");
|
||||||
|
assert_eq!(results[2].display, "20");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_comment_lines() {
|
||||||
|
let results = evaluate_sheet("// this is a comment\n42");
|
||||||
|
assert_eq!(results.len(), 2);
|
||||||
|
assert_eq!(results[0].display, "");
|
||||||
|
assert_eq!(results[1].display, "42");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_complex_expressions() {
|
||||||
|
let results = evaluate_sheet("(2 + 3) * 4");
|
||||||
|
assert_eq!(results.len(), 1);
|
||||||
|
assert_eq!(results[0].display, "20");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_non_math_text() {
|
||||||
|
let results = evaluate_sheet("hello world");
|
||||||
|
assert_eq!(results.len(), 1);
|
||||||
|
assert_eq!(results[0].display, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_native_types_no_serialization() {
|
||||||
|
// Verify results come back as native Rust types through SheetContext.
|
||||||
|
let mut ctx = SheetContext::new();
|
||||||
|
ctx.set_line(0, "2 + 2");
|
||||||
|
let results = ctx.eval();
|
||||||
|
assert_eq!(results.len(), 1);
|
||||||
|
assert_eq!(results[0].value, CalcValue::Number { value: 4.0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_full_sheet_with_dependencies() {
|
||||||
|
let mut ctx = SheetContext::new();
|
||||||
|
ctx.set_line(0, "base = 100");
|
||||||
|
ctx.set_line(1, "rate = 0.15");
|
||||||
|
ctx.set_line(2, "base * rate");
|
||||||
|
let results = ctx.eval();
|
||||||
|
assert_eq!(results.len(), 3);
|
||||||
|
assert_eq!(results[0].value, CalcValue::Number { value: 100.0 });
|
||||||
|
assert_eq!(results[1].value, CalcValue::Number { value: 0.15 });
|
||||||
|
assert_eq!(results[2].value, CalcValue::Number { value: 15.0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_performance_1000_lines() {
|
||||||
|
let lines: Vec<String> = (0..1000)
|
||||||
|
.map(|i| format!("{} + {} * {}", i, i + 1, i + 2))
|
||||||
|
.collect();
|
||||||
|
let sheet = lines.join("\n");
|
||||||
|
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
let results = evaluate_sheet(&sheet);
|
||||||
|
let elapsed = start.elapsed();
|
||||||
|
|
||||||
|
assert_eq!(results.len(), 1000);
|
||||||
|
for r in &results {
|
||||||
|
assert!(!r.display.is_empty(), "Every line should produce a result");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must complete 1000 evaluations within one 60fps frame budget (16ms).
|
||||||
|
assert!(
|
||||||
|
elapsed.as_millis() < 100, // generous for CI, expect <16ms locally
|
||||||
|
"1000 evaluations took {}ms",
|
||||||
|
elapsed.as_millis()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
117
calcpad-windows/src/main.rs
Normal file
117
calcpad-windows/src/main.rs
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
//! CalcPad — native Windows (and cross-platform) notepad calculator.
|
||||||
|
//!
|
||||||
|
//! Built with iced and the calcpad-engine. Two-column layout:
|
||||||
|
//! editable text on the left, live-evaluated results on the right.
|
||||||
|
//! GPU-accelerated rendering via wgpu with automatic tiny-skia fallback.
|
||||||
|
|
||||||
|
mod evaluator;
|
||||||
|
mod theme;
|
||||||
|
mod ui;
|
||||||
|
|
||||||
|
use evaluator::LineResult;
|
||||||
|
use iced::widget::{column, container, row, text_editor, vertical_rule};
|
||||||
|
use iced::{Element, Font, Length};
|
||||||
|
|
||||||
|
fn main() -> iced::Result {
|
||||||
|
iced::application("CalcPad", CalcPad::update, CalcPad::view)
|
||||||
|
.window_size((960.0, 640.0))
|
||||||
|
.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Application state
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Top-level application state.
|
||||||
|
struct CalcPad {
|
||||||
|
/// The editor content (left pane).
|
||||||
|
content: text_editor::Content,
|
||||||
|
/// Cached per-line results (right pane).
|
||||||
|
results: Vec<LineResult>,
|
||||||
|
/// Current line count (for the header status).
|
||||||
|
line_count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for CalcPad {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
content: text_editor::Content::new(),
|
||||||
|
results: vec![LineResult {
|
||||||
|
display: String::new(),
|
||||||
|
is_error: false,
|
||||||
|
}],
|
||||||
|
line_count: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Messages
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
enum Message {
|
||||||
|
/// The user interacted with the text editor.
|
||||||
|
EditorAction(text_editor::Action),
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Update & View
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
impl CalcPad {
|
||||||
|
fn update(&mut self, message: Message) {
|
||||||
|
match message {
|
||||||
|
Message::EditorAction(action) => {
|
||||||
|
let is_edit = action.is_edit();
|
||||||
|
self.content.perform(action);
|
||||||
|
|
||||||
|
if is_edit {
|
||||||
|
self.re_evaluate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view(&self) -> Element<'_, Message> {
|
||||||
|
// -- Header --
|
||||||
|
let header = ui::header::view::<Message>(self.line_count);
|
||||||
|
|
||||||
|
// -- Left: text editor --
|
||||||
|
let editor = text_editor(&self.content)
|
||||||
|
.on_action(Message::EditorAction)
|
||||||
|
.font(Font::MONOSPACE)
|
||||||
|
.height(Length::Fill)
|
||||||
|
.style(theme::editor_style);
|
||||||
|
|
||||||
|
let editor_pane = container(editor)
|
||||||
|
.width(Length::FillPortion(3))
|
||||||
|
.height(Length::Fill)
|
||||||
|
.style(theme::editor_pane);
|
||||||
|
|
||||||
|
// -- Right: results column --
|
||||||
|
let results_view = ui::results_column::view::<Message>(&self.results);
|
||||||
|
|
||||||
|
let results_pane = container(results_view)
|
||||||
|
.width(Length::FillPortion(2))
|
||||||
|
.height(Length::Fill)
|
||||||
|
.style(theme::results_pane);
|
||||||
|
|
||||||
|
// -- Assemble --
|
||||||
|
let body = row![editor_pane, vertical_rule(1), results_pane]
|
||||||
|
.height(Length::Fill);
|
||||||
|
|
||||||
|
column![header, body].height(Length::Fill).into()
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Re-evaluate all lines through the engine and cache results.
|
||||||
|
fn re_evaluate(&mut self) {
|
||||||
|
let source = self.content.text();
|
||||||
|
self.results = evaluator::evaluate_sheet(&source);
|
||||||
|
self.line_count = source.lines().count().max(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
88
calcpad-windows/src/theme.rs
Normal file
88
calcpad-windows/src/theme.rs
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
//! Windows 11 inspired theme for the CalcPad iced application.
|
||||||
|
//!
|
||||||
|
//! Provides color constants and styling functions that mirror the
|
||||||
|
//! WinUI 3 / Fluent Design aesthetic: clean backgrounds, subtle
|
||||||
|
//! separators, and the Segoe-like color palette.
|
||||||
|
|
||||||
|
use iced::widget::{container, text_editor};
|
||||||
|
use iced::{Border, Color, Theme};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Color palette — Windows 11 Light
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Main window background (WinUI "Layer" background).
|
||||||
|
pub const BACKGROUND: Color = Color::from_rgb(0.976, 0.976, 0.976); // #f9f9f9
|
||||||
|
|
||||||
|
/// Editor pane background — slightly lighter for contrast.
|
||||||
|
pub const EDITOR_BG: Color = Color::from_rgb(1.0, 1.0, 1.0); // #ffffff
|
||||||
|
|
||||||
|
/// Results pane background — very subtle gray tint.
|
||||||
|
pub const RESULTS_BG: Color = Color::from_rgb(0.965, 0.965, 0.969); // #f7f7f8
|
||||||
|
|
||||||
|
/// Result text color — muted dark gray, not pure black.
|
||||||
|
pub const RESULT_TEXT: Color = Color::from_rgb(0.247, 0.247, 0.247); // #3f3f3f
|
||||||
|
|
||||||
|
/// Error text color — Windows 11 system red.
|
||||||
|
pub const ERROR_TEXT: Color = Color::from_rgb(0.780, 0.173, 0.157); // #c72c28
|
||||||
|
|
||||||
|
/// Accent color — Windows 11 default blue.
|
||||||
|
pub const ACCENT: Color = Color::from_rgb(0.0, 0.471, 0.831); // #0078d4
|
||||||
|
|
||||||
|
/// Separator / rule color.
|
||||||
|
pub const SEPARATOR: Color = Color::from_rgb(0.878, 0.878, 0.878); // #e0e0e0
|
||||||
|
|
||||||
|
/// Title bar / header background.
|
||||||
|
pub const HEADER_BG: Color = Color::from_rgb(0.949, 0.949, 0.949); // #f2f2f2
|
||||||
|
|
||||||
|
/// Placeholder / dimmed text.
|
||||||
|
pub const PLACEHOLDER: Color = Color::from_rgb(0.600, 0.600, 0.600); // #999999
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Container styles
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Style for the editor (left) pane container.
|
||||||
|
pub fn editor_pane(_theme: &Theme) -> container::Style {
|
||||||
|
container::Style {
|
||||||
|
background: Some(iced::Background::Color(EDITOR_BG)),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Style for the results (right) pane container.
|
||||||
|
pub fn results_pane(_theme: &Theme) -> container::Style {
|
||||||
|
container::Style {
|
||||||
|
background: Some(iced::Background::Color(RESULTS_BG)),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Style for the top header bar.
|
||||||
|
pub fn header_bar(_theme: &Theme) -> container::Style {
|
||||||
|
container::Style {
|
||||||
|
background: Some(iced::Background::Color(HEADER_BG)),
|
||||||
|
border: Border {
|
||||||
|
color: SEPARATOR,
|
||||||
|
width: 0.0,
|
||||||
|
radius: 0.0.into(),
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Text editor style
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Clean, borderless text editor that blends with the editor pane.
|
||||||
|
pub fn editor_style(theme: &Theme, status: text_editor::Status) -> text_editor::Style {
|
||||||
|
let mut style = text_editor::default(theme, status);
|
||||||
|
style.background = iced::Background::Color(EDITOR_BG);
|
||||||
|
style.border = Border {
|
||||||
|
color: Color::TRANSPARENT,
|
||||||
|
width: 0.0,
|
||||||
|
radius: 0.0.into(),
|
||||||
|
};
|
||||||
|
style
|
||||||
|
}
|
||||||
22
calcpad-windows/src/ui/header.rs
Normal file
22
calcpad-windows/src/ui/header.rs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
//! Top header bar — displays the app title and basic status.
|
||||||
|
|
||||||
|
use crate::theme;
|
||||||
|
use iced::widget::{container, row, text};
|
||||||
|
use iced::{Element, Length};
|
||||||
|
|
||||||
|
/// Build the header bar element.
|
||||||
|
pub fn view<'a, Message: 'a>(line_count: usize) -> Element<'a, Message> {
|
||||||
|
let title = text("CalcPad").size(14);
|
||||||
|
let status = text(format!("{} lines", line_count))
|
||||||
|
.size(12)
|
||||||
|
.color(theme::PLACEHOLDER);
|
||||||
|
|
||||||
|
container(
|
||||||
|
row![title, iced::widget::horizontal_space(), status]
|
||||||
|
.padding([6, 12])
|
||||||
|
.align_y(iced::Alignment::Center),
|
||||||
|
)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.style(theme::header_bar)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
4
calcpad-windows/src/ui/mod.rs
Normal file
4
calcpad-windows/src/ui/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
//! UI components for the CalcPad Windows application.
|
||||||
|
|
||||||
|
pub mod header;
|
||||||
|
pub mod results_column;
|
||||||
38
calcpad-windows/src/ui/results_column.rs
Normal file
38
calcpad-windows/src/ui/results_column.rs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
//! Results column component — renders one text widget per editor line,
|
||||||
|
//! aligned vertically so each result sits beside its input line.
|
||||||
|
|
||||||
|
use crate::evaluator::LineResult;
|
||||||
|
use crate::theme;
|
||||||
|
use iced::widget::{column, scrollable, text, Column};
|
||||||
|
use iced::{Element, Font, Length};
|
||||||
|
|
||||||
|
/// Build the scrollable results column from a slice of `LineResult`s.
|
||||||
|
///
|
||||||
|
/// Each result occupies exactly one line-height so it aligns with the
|
||||||
|
/// corresponding editor line on the left.
|
||||||
|
pub fn view<'a, Message: 'a>(results: &[LineResult]) -> Element<'a, Message> {
|
||||||
|
let items: Vec<Element<'a, Message>> = if results.is_empty() {
|
||||||
|
vec![text("").font(Font::MONOSPACE).into()]
|
||||||
|
} else {
|
||||||
|
results
|
||||||
|
.iter()
|
||||||
|
.map(|r| {
|
||||||
|
let color = if r.is_error {
|
||||||
|
theme::ERROR_TEXT
|
||||||
|
} else {
|
||||||
|
theme::RESULT_TEXT
|
||||||
|
};
|
||||||
|
text(r.display.clone())
|
||||||
|
.font(Font::MONOSPACE)
|
||||||
|
.color(color)
|
||||||
|
.into()
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
scrollable(
|
||||||
|
Column::with_children(items).padding([7, 10]),
|
||||||
|
)
|
||||||
|
.height(Length::Fill)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user