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:
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