From 806e2f1ec68a965be167efe1d41950914053a108 Mon Sep 17 00:00:00 2001 From: "C. Cassel" Date: Tue, 17 Mar 2026 09:46:40 -0400 Subject: [PATCH] feat: add platform shells, CLI, formatting, plugins, tests, and benchmarks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .claude/settings.local.json | 3 +- Cargo.lock | 4145 ++++++++++++++++- Cargo.toml | 2 + calcpad-cli/Cargo.toml | 15 + calcpad-cli/src/main.rs | 328 ++ calcpad-engine/Cargo.toml | 6 + calcpad-engine/benches/eval_benchmark.rs | 155 + .../src/formatting/answer_format.rs | 666 +++ calcpad-engine/src/formatting/clipboard.rs | 290 ++ calcpad-engine/src/formatting/line_types.rs | 471 ++ calcpad-engine/src/formatting/mod.rs | 34 + calcpad-engine/src/lib.rs | 2 + calcpad-engine/src/plugins/api.rs | 437 ++ calcpad-engine/src/plugins/mod.rs | 63 + calcpad-engine/src/plugins/registry.rs | 485 ++ calcpad-engine/src/plugins/scripting.rs | 555 +++ calcpad-engine/tests/proptest_fuzz.rs | 177 + calcpad-engine/tests/sheet_scenarios.rs | 313 ++ calcpad-macos/Package.swift | 37 + .../Sources/CCalcPadEngine/calcpad.h | 90 + .../Sources/CCalcPadEngine/module.modulemap | 5 + .../Sources/CalcPad/App/CalcPadApp.swift | 11 + .../Sources/CalcPad/App/ContentView.swift | 7 + .../CalcPad/Engine/CalculationEngine.swift | 62 + .../Sources/CalcPad/Engine/FFIModels.swift | 107 + .../Engine/RustCalculationEngine.swift | 92 + .../Sources/CalcPad/Models/LineResult.swift | 15 + .../CalcPad/Views/AnswerColumnView.swift | 111 + .../CalcPad/Views/EditorTextView.swift | 137 + .../CalcPad/Views/TwoColumnEditorView.swift | 72 + .../Tests/CalcPadTests/FFIModelTests.swift | 227 + .../Tests/CalcPadTests/PerformanceTests.swift | 64 + .../Tests/CalcPadTests/RustEngineTests.swift | 164 + .../Tests/CalcPadTests/StubEngineTests.swift | 115 + calcpad-macos/build-rust.sh | 37 + calcpad-web/.gitignore | 24 + calcpad-web/index.html | 19 + calcpad-web/package.json | 33 + calcpad-web/public/favicon.svg | 4 + calcpad-web/public/icons/icon-192.svg | 4 + calcpad-web/public/icons/icon-512.svg | 4 + .../public/icons/icon-maskable-512.svg | 4 + calcpad-web/src/App.tsx | 89 + calcpad-web/src/components/AnswerColumn.tsx | 49 + calcpad-web/src/components/InstallPrompt.tsx | 45 + calcpad-web/src/components/OfflineBanner.tsx | 23 + calcpad-web/src/editor/CalcEditor.tsx | 224 + calcpad-web/src/editor/answer-gutter.ts | 104 + calcpad-web/src/editor/calcpad-language.ts | 145 + calcpad-web/src/editor/error-display.ts | 142 + calcpad-web/src/engine/types.ts | 28 + calcpad-web/src/engine/useEngine.ts | 91 + calcpad-web/src/engine/worker.ts | 133 + calcpad-web/src/hooks/useInstallPrompt.ts | 69 + calcpad-web/src/hooks/useOnlineStatus.ts | 27 + calcpad-web/src/main.tsx | 31 + calcpad-web/src/styles/answer-column.css | 47 + calcpad-web/src/styles/app.css | 92 + calcpad-web/src/styles/index.css | 60 + calcpad-web/src/styles/install-prompt.css | 69 + calcpad-web/src/styles/offline-banner.css | 23 + calcpad-web/src/vite-env.d.ts | 14 + calcpad-web/tsconfig.app.json | 26 + calcpad-web/tsconfig.json | 7 + calcpad-web/tsconfig.node.json | 24 + calcpad-web/vite.config.ts | 95 + calcpad-windows/Cargo.toml | 19 + calcpad-windows/src/evaluator.rs | 210 + calcpad-windows/src/main.rs | 117 + calcpad-windows/src/theme.rs | 88 + calcpad-windows/src/ui/header.rs | 22 + calcpad-windows/src/ui/mod.rs | 4 + calcpad-windows/src/ui/results_column.rs | 38 + 73 files changed, 11715 insertions(+), 32 deletions(-) create mode 100644 calcpad-cli/Cargo.toml create mode 100644 calcpad-cli/src/main.rs create mode 100644 calcpad-engine/benches/eval_benchmark.rs create mode 100644 calcpad-engine/src/formatting/answer_format.rs create mode 100644 calcpad-engine/src/formatting/clipboard.rs create mode 100644 calcpad-engine/src/formatting/line_types.rs create mode 100644 calcpad-engine/src/formatting/mod.rs create mode 100644 calcpad-engine/src/plugins/api.rs create mode 100644 calcpad-engine/src/plugins/mod.rs create mode 100644 calcpad-engine/src/plugins/registry.rs create mode 100644 calcpad-engine/src/plugins/scripting.rs create mode 100644 calcpad-engine/tests/proptest_fuzz.rs create mode 100644 calcpad-engine/tests/sheet_scenarios.rs create mode 100644 calcpad-macos/Package.swift create mode 100644 calcpad-macos/Sources/CCalcPadEngine/calcpad.h create mode 100644 calcpad-macos/Sources/CCalcPadEngine/module.modulemap create mode 100644 calcpad-macos/Sources/CalcPad/App/CalcPadApp.swift create mode 100644 calcpad-macos/Sources/CalcPad/App/ContentView.swift create mode 100644 calcpad-macos/Sources/CalcPad/Engine/CalculationEngine.swift create mode 100644 calcpad-macos/Sources/CalcPad/Engine/FFIModels.swift create mode 100644 calcpad-macos/Sources/CalcPad/Engine/RustCalculationEngine.swift create mode 100644 calcpad-macos/Sources/CalcPad/Models/LineResult.swift create mode 100644 calcpad-macos/Sources/CalcPad/Views/AnswerColumnView.swift create mode 100644 calcpad-macos/Sources/CalcPad/Views/EditorTextView.swift create mode 100644 calcpad-macos/Sources/CalcPad/Views/TwoColumnEditorView.swift create mode 100644 calcpad-macos/Tests/CalcPadTests/FFIModelTests.swift create mode 100644 calcpad-macos/Tests/CalcPadTests/PerformanceTests.swift create mode 100644 calcpad-macos/Tests/CalcPadTests/RustEngineTests.swift create mode 100644 calcpad-macos/Tests/CalcPadTests/StubEngineTests.swift create mode 100644 calcpad-macos/build-rust.sh create mode 100644 calcpad-web/.gitignore create mode 100644 calcpad-web/index.html create mode 100644 calcpad-web/package.json create mode 100644 calcpad-web/public/favicon.svg create mode 100644 calcpad-web/public/icons/icon-192.svg create mode 100644 calcpad-web/public/icons/icon-512.svg create mode 100644 calcpad-web/public/icons/icon-maskable-512.svg create mode 100644 calcpad-web/src/App.tsx create mode 100644 calcpad-web/src/components/AnswerColumn.tsx create mode 100644 calcpad-web/src/components/InstallPrompt.tsx create mode 100644 calcpad-web/src/components/OfflineBanner.tsx create mode 100644 calcpad-web/src/editor/CalcEditor.tsx create mode 100644 calcpad-web/src/editor/answer-gutter.ts create mode 100644 calcpad-web/src/editor/calcpad-language.ts create mode 100644 calcpad-web/src/editor/error-display.ts create mode 100644 calcpad-web/src/engine/types.ts create mode 100644 calcpad-web/src/engine/useEngine.ts create mode 100644 calcpad-web/src/engine/worker.ts create mode 100644 calcpad-web/src/hooks/useInstallPrompt.ts create mode 100644 calcpad-web/src/hooks/useOnlineStatus.ts create mode 100644 calcpad-web/src/main.tsx create mode 100644 calcpad-web/src/styles/answer-column.css create mode 100644 calcpad-web/src/styles/app.css create mode 100644 calcpad-web/src/styles/index.css create mode 100644 calcpad-web/src/styles/install-prompt.css create mode 100644 calcpad-web/src/styles/offline-banner.css create mode 100644 calcpad-web/src/vite-env.d.ts create mode 100644 calcpad-web/tsconfig.app.json create mode 100644 calcpad-web/tsconfig.json create mode 100644 calcpad-web/tsconfig.node.json create mode 100644 calcpad-web/vite.config.ts create mode 100644 calcpad-windows/Cargo.toml create mode 100644 calcpad-windows/src/evaluator.rs create mode 100644 calcpad-windows/src/main.rs create mode 100644 calcpad-windows/src/theme.rs create mode 100644 calcpad-windows/src/ui/header.rs create mode 100644 calcpad-windows/src/ui/mod.rs create mode 100644 calcpad-windows/src/ui/results_column.rs diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 2be699d..f352098 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -23,7 +23,8 @@ "Bash(git commit:*)", "Bash(/Users/cassel/.cargo/bin/cargo test:*)", "Bash(tee /tmp/test-output.txt)", - "Bash(echo \"EXIT: $?\")" + "Bash(echo \"EXIT: $?\")", + "Bash(/Users/cassel/.cargo/bin/cargo build:*)" ] } } diff --git a/Cargo.lock b/Cargo.lock index ace1923..36b8039 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,12 +2,94 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + [[package]] name = "adler2" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "version_check", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android-activity" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef6978589202a00cd7e118380c448a08b6ed394c3a8df3a430d0898e3a42d046" +dependencies = [ + "android-properties", + "bitflags 2.11.0", + "cc", + "cesu8", + "jni", + "jni-sys", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys 0.6.0+11769913", + "num_enum", + "thiserror 1.0.69", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -17,12 +99,235 @@ dependencies = [ "libc", ] +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "ash" +version = "0.37.3+1.3.251" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39e9c3835d686b0a6084ab4234fcd1b07dbf6e4767dce60874b12356a25ecd4a" +dependencies = [ + "libloading 0.7.4", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-fs" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.4", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.4", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async-signal" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.4", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + [[package]] name = "async-trait" version = "0.1.89" @@ -31,9 +336,15 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "autocfg" version = "1.5.0" @@ -46,25 +357,142 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec 0.6.3", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec 0.8.0", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.2", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "bumpalo" version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "calcpad-cli" +version = "0.1.0" +dependencies = [ + "calcpad-engine", + "clap", + "rustyline", + "serde_json", +] + [[package]] name = "calcpad-engine" version = "0.1.0" dependencies = [ "chrono", "chrono-tz", + "criterion", "dashu", + "proptest", "serde", "serde_json", "tempfile", @@ -85,6 +513,66 @@ dependencies = [ "wasm-bindgen-test", ] +[[package]] +name = "calcpad-windows" +version = "0.1.0" +dependencies = [ + "calcpad-engine", + "iced", + "tokio", +] + +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.11.0", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "calloop" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" +dependencies = [ + "bitflags 2.11.0", + "polling", + "rustix 1.1.4", + "slab", + "tracing", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop 0.13.0", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" +dependencies = [ + "calloop 0.14.4", + "rustix 1.1.4", + "wayland-backend", + "wayland-client", +] + [[package]] name = "cast" version = "0.3.0" @@ -98,15 +586,35 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chrono" version = "0.4.44" @@ -128,7 +636,189 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" dependencies = [ "chrono", - "phf", + "phf 0.12.1", +] + +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "clap" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + +[[package]] +name = "clipboard_macos" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b7f4aaa047ba3c3630b080bb9860894732ff23e2aee290a418909aa6d5df38f" +dependencies = [ + "objc2 0.5.2", + "objc2-app-kit", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "clipboard_wayland" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "003f886bc4e2987729d10c1db3424e7f80809f3fc22dbc16c685738887cb37b8" +dependencies = [ + "smithay-clipboard", +] + +[[package]] +name = "clipboard_x11" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd63e33452ffdafd39924c4f05a5dd1e94db646c779c6bd59148a3d95fff5ad4" +dependencies = [ + "thiserror 2.0.18", + "x11rb", +] + +[[package]] +name = "codespan-reporting" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" +dependencies = [ + "termcolor", + "unicode-width 0.1.14", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "com" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e17887fd17353b65b1b2ef1c526c83e26cd72e74f598a8dc1bee13a48f3d9f6" +dependencies = [ + "com_macros", +] + +[[package]] +name = "com_macros" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d375883580a668c7481ea6631fc1a8863e33cc335bf56bfad8d7e6d4b04b13a5" +dependencies = [ + "com_macros_support", + "proc-macro2", + "syn 1.0.109", +] + +[[package]] +name = "com_macros_support" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad899a1087a9296d5644792d7cb72b8e34c1bec8e7d4fbc002230169a6e8710c" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", ] [[package]] @@ -137,6 +827,62 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "libc", +] + +[[package]] +name = "cosmic-text" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59fd57d82eb4bfe7ffa9b1cec0c05e2fd378155b47f255a67983cb4afe0e80c2" +dependencies = [ + "bitflags 2.11.0", + "fontdb", + "log", + "rangemap", + "rayon", + "rustc-hash 1.1.0", + "rustybuzz", + "self_cell", + "swash", + "sys-locale", + "ttf-parser 0.21.1", + "unicode-bidi", + "unicode-linebreak", + "unicode-script", + "unicode-segmentation", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -146,6 +892,122 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "criterion" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" +dependencies = [ + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "is-terminal", + "itertools", + "num-traits", + "once_cell", + "oorandom", + "plotters", + "rayon", + "regex", + "serde", + "serde_derive", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +dependencies = [ + "cast", + "itertools", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "ctor-lite" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e162d0c2e2068eb736b71e5597eff0b9944e6b973cd9f37b6a288ab9bf20e300" + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "d3d12" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e3d747f100290a1ca24b752186f61f6637e1deffe3bf6320de6fcb29510a307" +dependencies = [ + "bitflags 2.11.0", + "libloading 0.8.9", + "winapi", +] + +[[package]] +name = "dark-light" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a76fa97167fa740dcdbfe18e8895601e1bc36525f09b044e00916e717c03a3c" +dependencies = [ + "dconf_rs", + "detect-desktop-environment", + "dirs", + "objc", + "rust-ini", + "web-sys", + "winreg", + "zbus", +] + [[package]] name = "dashu" version = "0.4.2" @@ -224,6 +1086,64 @@ dependencies = [ "rustversion", ] +[[package]] +name = "dconf_rs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7046468a81e6a002061c01e6a7c83139daf91b11c30e66795b13217c2d885c8b" + +[[package]] +name = "detect-desktop-environment" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21d8ad60dd5b13a4ee6bd8fa2d5d88965c597c67bce32b5fc49c94f55cb50810" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.11.0", + "objc2 0.6.4", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -232,7 +1152,114 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading 0.8.9", +] + +[[package]] +name = "dlv-list" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0688c2a7f92e427f44895cd63841bff7b29f8d7a1648b9e7e07a4a365b2e1257" + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "drm" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5b71449a23fe79542d6527ca572844b2016abf9573c49e43144d546b1735aec" +dependencies = [ + "bitflags 2.11.0", + "bytemuck", + "bytemuck_derive", + "drm-ffi", + "drm-fourcc", + "libc", + "rustix 1.1.4", +] + +[[package]] +name = "drm-ffi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51a91c9b32ac4e8105dec255e849e0d66e27d7c34d184364fb93e469db08f690" +dependencies = [ + "drm-sys", + "rustix 1.1.4", +] + +[[package]] +name = "drm-fourcc" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aafbcdb8afc29c1a7ee5fbe53b5d62f4565b35a042a662ca9fecd0b54dae6f4" + +[[package]] +name = "drm-sys" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8e1361066d91f5ffccff060a3c3be9c3ecde15be2959c1937595f7a82a9f8" +dependencies = [ + "libc", + "linux-raw-sys 0.9.4", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "endian-type" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -251,12 +1278,84 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + +[[package]] +name = "etagere" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc89bf99e5dc15954a60f707c1e09d7540e5cd9af85fa75caa0b510bc08c5342" +dependencies = [ + "euclid", + "svg_fmt", +] + +[[package]] +name = "euclid" +version = "0.22.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df61bf483e837f88d5c2291dcf55c67be7e676b3a51acc48db3a7b163b91ed63" +dependencies = [ + "num-traits", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fast-srgb8" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" + [[package]] name = "fastrand" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "fd-lock" +version = "4.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" +dependencies = [ + "cfg-if", + "rustix 1.1.4", + "windows-sys 0.52.0", +] + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -273,12 +1372,77 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "font-types" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3971f9a5ca983419cdc386941ba3b9e1feba01a0ab888adf78739feb2798492" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +dependencies = [ + "roxmltree", +] + +[[package]] +name = "fontdb" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0299020c3ef3f60f526a4f64ab4a3d4ce116b1acbf24cdd22da0068e5d81dc3" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2", + "slotmap", + "tinyvec", + "ttf-parser 0.20.0", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -288,12 +1452,84 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + [[package]] name = "futures-core" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + [[package]] name = "futures-task" version = "0.3.32" @@ -306,12 +1542,37 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ + "futures-channel", "futures-core", + "futures-io", + "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.4", + "windows-link", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -323,6 +1584,18 @@ dependencies = [ "wasi", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + [[package]] name = "getrandom" version = "0.4.2" @@ -331,11 +1604,141 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", "wasip2", "wasip3", ] +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glam" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "151665d9be52f9bb40fc7966565d39666f2d1e69233571b71b87791c7e0528b3" + +[[package]] +name = "glow" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd348e04c43b32574f2de31c8bb397d96c9fcfa1371bd4ca6d8bdc464ab121b1" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8098adac955faa2d31079b65dc48841251f69efd3ac25477903fc424362ead" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gpu-alloc" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbcd2dba93594b227a1f57ee09b8b9da8892c34d55aa332e034a228d0fe6a171" +dependencies = [ + "bitflags 2.11.0", + "gpu-alloc-types", +] + +[[package]] +name = "gpu-alloc-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98ff03b468aa837d70984d55f5d3f846f6ec31fe34bbb97c4f85219caeee1ca4" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "gpu-allocator" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f56f6318968d03c18e1bcf4857ff88c61157e9da8e47c5f29055d60e1228884" +dependencies = [ + "log", + "presser", + "thiserror 1.0.69", + "winapi", + "windows", +] + +[[package]] +name = "gpu-descriptor" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc11df1ace8e7e564511f53af41f3e42ddc95b56fd07b3f4445d2a6048bc682c" +dependencies = [ + "bitflags 2.11.0", + "gpu-descriptor-types", + "hashbrown 0.14.5", +] + +[[package]] +name = "gpu-descriptor-types" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bf0b36e6f090b7e1d8a4b49c0cb81c1f8376f72198c65dd3ad9ff3556b8b78c" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "guillotiere" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b62d5865c036cb1393e23c50693df631d3f5d7bcca4c04fe4cc0fd592e74a782" +dependencies = [ + "euclid", + "svg_fmt", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash 0.7.8", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash 0.8.12", + "allocator-api2", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -351,12 +1754,54 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "hassle-rs" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af2a7e73e1f34c48da31fb668a907f250794837e08faa144fd24f0b8b741e890" +dependencies = [ + "bitflags 2.11.0", + "com", + "libc", + "libloading 0.8.9", + "thiserror 1.0.69", + "widestring", + "winapi", +] + [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hexf-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -369,7 +1814,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core", + "windows-core 0.62.2", ] [[package]] @@ -381,6 +1826,185 @@ dependencies = [ "cc", ] +[[package]] +name = "iced" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88acfabc84ec077eaf9ede3457ffa3a104626d79022a9bf7f296093b1d60c73f" +dependencies = [ + "iced_core", + "iced_futures", + "iced_renderer", + "iced_widget", + "iced_winit", + "thiserror 1.0.69", +] + +[[package]] +name = "iced_core" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0013a238275494641bf8f1732a23a808196540dc67b22ff97099c044ae4c8a1c" +dependencies = [ + "bitflags 2.11.0", + "bytes", + "dark-light", + "glam", + "log", + "num-traits", + "once_cell", + "palette", + "rustc-hash 2.1.1", + "smol_str", + "thiserror 1.0.69", + "web-time", +] + +[[package]] +name = "iced_futures" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c04a6745ba2e80f32cf01e034fd00d853aa4f4cd8b91888099cb7aaee0d5d7c" +dependencies = [ + "futures", + "iced_core", + "log", + "rustc-hash 2.1.1", + "tokio", + "wasm-bindgen-futures", + "wasm-timer", +] + +[[package]] +name = "iced_glyphon" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41c3bb56f1820ca252bc1d0994ece33d233a55657c0c263ea7cb16895adbde82" +dependencies = [ + "cosmic-text", + "etagere", + "lru", + "rustc-hash 2.1.1", + "wgpu", +] + +[[package]] +name = "iced_graphics" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba25a18cfa6d5cc160aca7e1b34f73ccdff21680fa8702168c09739767b6c66f" +dependencies = [ + "bitflags 2.11.0", + "bytemuck", + "cosmic-text", + "half", + "iced_core", + "iced_futures", + "log", + "once_cell", + "raw-window-handle", + "rustc-hash 2.1.1", + "thiserror 1.0.69", + "unicode-segmentation", +] + +[[package]] +name = "iced_renderer" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73558208059f9e622df2bf434e044ee2f838ce75201a023cf0ca3e1244f46c2a" +dependencies = [ + "iced_graphics", + "iced_tiny_skia", + "iced_wgpu", + "log", + "thiserror 1.0.69", +] + +[[package]] +name = "iced_runtime" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "348b5b2c61c934d88ca3b0ed1ed913291e923d086a66fa288ce9669da9ef62b5" +dependencies = [ + "bytes", + "iced_core", + "iced_futures", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "iced_tiny_skia" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c625d368284fcc43b0b36b176f76eff1abebe7959dd58bd8ce6897d641962a50" +dependencies = [ + "bytemuck", + "cosmic-text", + "iced_graphics", + "kurbo", + "log", + "rustc-hash 2.1.1", + "softbuffer", + "tiny-skia", +] + +[[package]] +name = "iced_wgpu" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15708887133671d2bcc6c1d01d1f176f43a64d6cdc3b2bf893396c3ee498295f" +dependencies = [ + "bitflags 2.11.0", + "bytemuck", + "futures", + "glam", + "guillotiere", + "iced_glyphon", + "iced_graphics", + "log", + "once_cell", + "rustc-hash 2.1.1", + "thiserror 1.0.69", + "wgpu", +] + +[[package]] +name = "iced_widget" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81429e1b950b0e4bca65be4c4278fea6678ea782030a411778f26fa9f8983e1d" +dependencies = [ + "iced_renderer", + "iced_runtime", + "num-traits", + "once_cell", + "rustc-hash 2.1.1", + "thiserror 1.0.69", + "unicode-segmentation", +] + +[[package]] +name = "iced_winit" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f44cd4e1c594b6334f409282937bf972ba14d31fedf03c23aa595d982a2fda28" +dependencies = [ + "iced_futures", + "iced_graphics", + "iced_runtime", + "log", + "rustc-hash 2.1.1", + "thiserror 1.0.69", + "tracing", + "wasm-bindgen-futures", + "web-sys", + "winapi", + "window_clipboard", + "winit", +] + [[package]] name = "icu_collections" version = "2.1.1" @@ -501,12 +2125,79 @@ dependencies = [ "serde_core", ] +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "js-sys" version = "0.3.91" @@ -517,6 +2208,33 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading 0.8.9", + "pkg-config", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "kurbo" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1618d4ebd923e97d67e7cd363d80aef35fe961005cbbbb3d2dad8bdd1bc63440" +dependencies = [ + "arrayvec", + "smallvec", +] + [[package]] name = "leb128fmt" version = "0.1.0" @@ -529,12 +2247,56 @@ version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + [[package]] name = "libm" version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "libredox" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" +dependencies = [ + "bitflags 2.11.0", + "libc", + "plain", + "redox_syscall 0.7.3", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -547,18 +2309,75 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + [[package]] name = "memchr" version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memmap2" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +dependencies = [ + "libc", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "metal" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c43f73953f8cbe511f021b58f18c3ce1c3d1ae13fe953293e13345bf83217f25" +dependencies = [ + "bitflags 2.11.0", + "block", + "core-graphics-types", + "foreign-types", + "log", + "objc", + "paste", +] + [[package]] name = "minicov" version = "0.3.8" @@ -579,6 +2398,87 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "naga" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e3524642f53d9af419ab5e8dd29d3ba155708267667c2f3f06c88c9e130843" +dependencies = [ + "bit-set 0.5.3", + "bitflags 2.11.0", + "codespan-reporting", + "hexf-parse", + "indexmap", + "log", + "num-traits", + "rustc-hash 1.1.0", + "spirv", + "termcolor", + "thiserror 1.0.69", + "unicode-xid", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.11.0", + "jni-sys", + "log", + "ndk-sys 0.6.0+11769913", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.5.0+25.2.9519653" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" +dependencies = [ + "jni-sys", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys", +] + +[[package]] +name = "nibble_vec" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43" +dependencies = [ + "smallvec", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "cfg_aliases 0.2.1", + "libc", + "memoffset", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -613,18 +2513,452 @@ dependencies = [ "libm", ] +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", + "objc_exception", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.11.0", + "block2", + "libc", + "objc2 0.5.2", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation 0.2.2", + "objc2-quartz-core 0.2.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.11.0", + "block2", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.11.0", + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.11.0", + "dispatch2", + "objc2 0.6.4", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.11.0", + "dispatch2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-contacts", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.11.0", + "block2", + "dispatch", + "libc", + "objc2 0.5.2", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.11.0", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.11.0", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-app-kit", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.11.0", + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.11.0", + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.11.0", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.11.0", + "block2", + "objc2 0.5.2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-image", + "objc2-core-location", + "objc2-foundation 0.2.2", + "objc2-link-presentation", + "objc2-quartz-core 0.2.2", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.11.0", + "block2", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc_exception" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad970fb455818ad6cba4c122ad012fae53ae8b4795f86378bce65e4f6bab2ca4" +dependencies = [ + "cc", +] + [[package]] name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "oorandom" version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" +[[package]] +name = "orbclient" +version = "0.3.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59aed3b33578edcfa1bc96a321d590d31832b6ad55a26f0313362ce687e9abd6" +dependencies = [ + "libc", + "libredox", +] + +[[package]] +name = "ordered-multimap" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccd746e37177e1711c20dd619a1620f34f5c8b569c53590a72dedd5344d8924a" +dependencies = [ + "dlv-list", + "hashbrown 0.12.3", +] + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser 0.25.1", +] + +[[package]] +name = "palette" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cbf71184cc5ecc2e4e1baccdb21026c20e5fc3dcf63028a086131b3ab00b6e6" +dependencies = [ + "approx", + "fast-srgb8", + "palette_derive", + "phf 0.11.3", +] + +[[package]] +name = "palette_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5030daf005bface118c096f510ffb781fc28f9ab6a32ab224d8631be6851d30" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99" +dependencies = [ + "instant", + "lock_api", + "parking_lot_core 0.8.6", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core 0.9.12", +] + +[[package]] +name = "parking_lot_core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a2cfe6f0ad2bfc16aefa463b497d5c7a5ecd44a23efa72aa342d90177356dc" +dependencies = [ + "cfg-if", + "instant", + "libc", + "redox_syscall 0.2.16", + "smallvec", + "winapi", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + [[package]] name = "paste" version = "1.0.15" @@ -637,13 +2971,55 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared 0.11.3", +] + [[package]] name = "phf" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" dependencies = [ - "phf_shared", + "phf_shared 0.12.1", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand 0.8.5", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", ] [[package]] @@ -655,12 +3031,116 @@ dependencies = [ "siphasher", ] +[[package]] +name = "pin-project" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -670,6 +3150,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + [[package]] name = "prettyplease" version = "0.2.37" @@ -677,7 +3172,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", ] [[package]] @@ -689,6 +3193,46 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "profiling" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" + +[[package]] +name = "proptest" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566cb3fdacef14c0737f9546df7cfeadbfbc9fef10991038bf5015d0c80532" +dependencies = [ + "bit-set 0.8.0", + "bit-vec 0.8.0", + "bitflags 2.11.0", + "num-traits", + "rand 0.9.2", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + +[[package]] +name = "quick-xml" +version = "0.39.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d" +dependencies = [ + "memchr", +] + [[package]] name = "quote" version = "1.0.45" @@ -698,12 +3242,226 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "radix_trie" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c069c179fcdc6a2fe24d8d18305cf085fdbd4f922c041943e203685d6a1c58fd" +dependencies = [ + "endian-type", + "nibble_vec", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + +[[package]] +name = "range-alloc" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" + +[[package]] +name = "rangemap" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "read-fonts" +version = "0.22.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69aacb76b5c29acfb7f90155d39759a29496aebb49395830e928a9703d2eec2f" +dependencies = [ + "bytemuck", + "font-types", +] + +[[package]] +name = "redox_syscall" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "redox_syscall" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + [[package]] name = "ring" version = "0.17.14" @@ -718,16 +3476,57 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + +[[package]] +name = "rust-ini" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6d5f2436026b4f6e79dc829837d467cc7e9a55ee40e750d716713540715a2df" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.52.0", +] + [[package]] name = "rustix" version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.11.0", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] @@ -772,6 +3571,57 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + +[[package]] +name = "rustybuzz" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfb9cf8877777222e4a3bc7eb247e398b56baba500c38c1c46842431adc8b55c" +dependencies = [ + "bitflags 2.11.0", + "bytemuck", + "libm", + "smallvec", + "ttf-parser 0.21.1", + "unicode-bidi-mirroring", + "unicode-ccc", + "unicode-properties", + "unicode-script", +] + +[[package]] +name = "rustyline" +version = "15.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee1e066dc922e513bda599c6ccb5f3bb2b0ea5870a579448f2622993f0a9a2f" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "clipboard-win", + "fd-lock", + "home", + "libc", + "log", + "memchr", + "nix", + "radix_trie", + "unicode-segmentation", + "unicode-width 0.2.2", + "utf8parse", + "windows-sys 0.59.0", +] + [[package]] name = "same-file" version = "1.0.6" @@ -781,6 +3631,37 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sctk-adwaita" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" +dependencies = [ + "ab_glyph", + "log", + "memmap2", + "smithay-client-toolkit 0.19.2", + "tiny-skia", +] + +[[package]] +name = "self_cell" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" + [[package]] name = "semver" version = "1.0.27" @@ -825,7 +3706,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -841,12 +3722,44 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "simd-adler32" version = "0.3.8" @@ -859,18 +3772,150 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" +[[package]] +name = "skrifa" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1c44ad1f6c5bdd4eefed8326711b7dbda9ea45dfd36068c427d332aa382cbe" +dependencies = [ + "bytemuck", + "read-fonts", +] + [[package]] name = "slab" version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + [[package]] name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.11.0", + "calloop 0.13.0", + "calloop-wayland-source 0.3.0", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-client-toolkit" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" +dependencies = [ + "bitflags 2.11.0", + "calloop 0.14.4", + "calloop-wayland-source 0.4.1", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 1.1.4", + "thiserror 2.0.18", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-experimental", + "wayland-protocols-misc", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-clipboard" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" +dependencies = [ + "libc", + "smithay-client-toolkit 0.20.0", + "wayland-backend", +] + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "as-raw-xcb-connection", + "bytemuck", + "drm", + "fastrand", + "js-sys", + "memmap2", + "ndk", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", + "raw-window-handle", + "redox_syscall 0.5.18", + "rustix 1.1.4", + "tiny-xlib", + "tracing", + "wasm-bindgen", + "wayland-backend", + "wayland-client", + "wayland-sys", + "web-sys", + "windows-sys 0.61.2", + "x11rb", +] + +[[package]] +name = "spirv" +version = "0.3.0+sdk-1.3.268.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" +dependencies = [ + "bitflags 2.11.0", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -883,12 +3928,52 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "subtle" version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "svg_fmt" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0193cc4331cfd2f3d2011ef287590868599a2f33c3e69bc22c1a3d3acf9e02fb" + +[[package]] +name = "swash" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbd59f3f359ddd2c95af4758c18270eddd9c730dde98598023cdabff472c2ca2" +dependencies = [ + "skrifa", + "yazi", + "zeno", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.117" @@ -908,7 +3993,16 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "libc", ] [[package]] @@ -920,10 +4014,98 @@ dependencies = [ "fastrand", "getrandom 0.4.2", "once_cell", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "png", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tiny-xlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0324504befd01cab6e0c994f34b2ffa257849ee019d3fb3b64fb2c858887d89e" +dependencies = [ + "as-raw-xcb-connection", + "ctor-lite", + "libloading 0.8.9", + "pkg-config", + "tracing", +] + [[package]] name = "tinystr" version = "0.8.2" @@ -934,12 +4116,215 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +dependencies = [ + "pin-project-lite", + "tokio-macros", +] + +[[package]] +name = "tokio-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "toml_datetime" +version = "1.0.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.4+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7193cbd0ce53dc966037f54351dbbcf0d5a642c7f0038c382ef9e677ce8c13f2" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.0.9+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" +dependencies = [ + "winnow", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "ttf-parser" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17f77d76d837a7830fe1d4f12b7b4ba4192c1888001c7164257e4bc6d21d96b4" + +[[package]] +name = "ttf-parser" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c591d83f69777866b9126b24c6dd9a18351f177e49d625920d19f989fd31cf8" + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-bidi-mirroring" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cb788ffebc92c5948d0e997106233eeb1d8b9512f93f41651f52b6c5f5af86" + +[[package]] +name = "unicode-ccc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1df77b101bcc4ea3d78dafc5ad7e4f58ceffe0b2b16bf446aeb50b6cb4157656" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -988,6 +4373,27 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -1068,7 +4474,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -1111,7 +4517,7 @@ checksum = "67008cdde4769831958536b0f11b3bdd0380bde882be17fff9c2f34bb4549abd" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1142,18 +4548,168 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wasm-timer" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be0ecb0db480561e9a7642b5d3e4187c128914e58aa84330b9493e3eb68c5e7f" +dependencies = [ + "futures", + "js-sys", + "parking_lot 0.11.2", + "pin-utils", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasmparser" version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags", + "bitflags 2.11.0", "hashbrown 0.15.5", "indexmap", "semver", ] +[[package]] +name = "wayland-backend" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa75f400b7f719bcd68b3f47cd939ba654cedeef690f486db71331eec4c6a406" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.4", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab51d9f7c071abeee76007e2b742499e535148035bb835f97aaed1338cf516c3" +dependencies = [ + "bitflags 2.11.0", + "rustix 1.1.4", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.11.0", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b3298683470fbdc6ca40151dfc48c8f2fd4c41a26e13042f801f85002384091" +dependencies = [ + "rustix 1.1.4", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b23b5df31ceff1328f06ac607591d5ba360cf58f90c8fad4ac8d3a55a3c4aec7" +dependencies = [ + "bitflags 2.11.0", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-experimental" +version = "20250721.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" +dependencies = [ + "bitflags 2.11.0", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-misc" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "429b99200febaf95d4f4e46deff6fe4382bcff3280ee16a41cf887b3c3364984" +dependencies = [ + "bitflags 2.11.0", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d392fc283a87774afc9beefcd6f931582bb97fe0e6ced0b306a62cb1d026527c" +dependencies = [ + "bitflags 2.11.0", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78248e4cc0eff8163370ba5c158630dcae1f3497a586b826eca2ef5f348d6235" +dependencies = [ + "bitflags 2.11.0", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c86287151a309799b821ca709b7345a048a2956af05957c89cb824ab919fa4e3" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374f6b70e8e0d6bf9461a32988fd553b59ff630964924dad6e4a4eb6bd538d17" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + [[package]] name = "web-sys" version = "0.3.91" @@ -1164,6 +4720,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webpki-roots" version = "0.26.11" @@ -1182,6 +4748,135 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "wgpu" +version = "0.19.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbd7311dbd2abcfebaabf1841a2824ed7c8be443a0f29166e5d3c6a53a762c01" +dependencies = [ + "arrayvec", + "cfg-if", + "cfg_aliases 0.1.1", + "js-sys", + "log", + "naga", + "parking_lot 0.12.5", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "0.19.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28b94525fc99ba9e5c9a9e24764f2bc29bad0911a7446c12f446a8277369bf3a" +dependencies = [ + "arrayvec", + "bit-vec 0.6.3", + "bitflags 2.11.0", + "cfg_aliases 0.1.1", + "codespan-reporting", + "indexmap", + "log", + "naga", + "once_cell", + "parking_lot 0.12.5", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 1.0.69", + "web-sys", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-hal" +version = "0.19.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfabcfc55fd86611a855816326b2d54c3b2fd7972c27ce414291562650552703" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bit-set 0.5.3", + "bitflags 2.11.0", + "block", + "cfg_aliases 0.1.1", + "core-graphics-types", + "d3d12", + "glow", + "glutin_wgl_sys", + "gpu-alloc", + "gpu-allocator", + "gpu-descriptor", + "hassle-rs", + "js-sys", + "khronos-egl", + "libc", + "libloading 0.8.9", + "log", + "metal", + "naga", + "ndk-sys 0.5.0+25.2.9519653", + "objc", + "once_cell", + "parking_lot 0.12.5", + "profiling", + "range-alloc", + "raw-window-handle", + "renderdoc-sys", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 1.0.69", + "wasm-bindgen", + "web-sys", + "wgpu-types", + "winapi", +] + +[[package]] +name = "wgpu-types" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b671ff9fb03f78b46ff176494ee1ebe7d603393f42664be55b64dc8d53969805" +dependencies = [ + "bitflags 2.11.0", + "js-sys", + "web-sys", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -1191,6 +4886,45 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window_clipboard" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6d692d46038c433f9daee7ad8757e002a4248c20b0a3fbc991d99521d3bcb6d" +dependencies = [ + "clipboard-win", + "clipboard_macos", + "clipboard_wayland", + "clipboard_x11", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "windows" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" +dependencies = [ + "windows-core 0.52.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -1212,7 +4946,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1223,7 +4957,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1250,13 +4984,31 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + [[package]] name = "windows-sys" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", ] [[package]] @@ -1268,34 +5020,67 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + [[package]] name = "windows-targets" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -1308,30 +5093,124 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winit" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +dependencies = [ + "ahash 0.8.12", + "android-activity", + "atomic-waker", + "bitflags 2.11.0", + "block2", + "bytemuck", + "calloop 0.13.0", + "cfg_aliases 0.2.1", + "concurrent-queue", + "core-foundation", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk", + "objc2 0.5.2", + "objc2-app-kit", + "objc2-foundation 0.2.2", + "objc2-ui-kit", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "sctk-adwaita", + "smithay-client-toolkit 0.19.2", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -1362,7 +5241,7 @@ dependencies = [ "heck", "indexmap", "prettyplease", - "syn", + "syn 2.0.117", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -1378,7 +5257,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -1390,7 +5269,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags", + "bitflags 2.11.0", "indexmap", "log", "serde", @@ -1426,6 +5305,85 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading 0.8.9", + "once_cell", + "rustix 1.1.4", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xcursor" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b" + +[[package]] +name = "xdg-home" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.11.0", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[package]] +name = "yazi" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c94451ac9513335b5e23d7a8a2b61a7102398b8cca5160829d313e84c9d98be1" + [[package]] name = "yoke" version = "0.8.1" @@ -1445,10 +5403,98 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] +[[package]] +name = "zbus" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" +dependencies = [ + "async-broadcast", + "async-executor", + "async-fs", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix", + "ordered-stream", + "rand 0.8.5", + "serde", + "serde_repr", + "sha1", + "static_assertions", + "tracing", + "uds_windows", + "windows-sys 0.52.0", + "xdg-home", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" +dependencies = [ + "serde", + "static_assertions", + "zvariant", +] + +[[package]] +name = "zeno" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd15f8e0dbb966fd9245e7498c7e9e5055d9e5c8b676b95bd67091cd11a1e697" + +[[package]] +name = "zerocopy" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "zerofrom" version = "0.1.6" @@ -1466,7 +5512,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -1506,7 +5552,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1514,3 +5560,40 @@ name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zvariant" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" +dependencies = [ + "endi", + "enumflags2", + "serde", + "static_assertions", + "zvariant_derive", +] + +[[package]] +name = "zvariant_derive" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] diff --git a/Cargo.toml b/Cargo.toml index 6115e58..4184653 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,8 @@ [workspace] members = [ "calcpad-engine", + "calcpad-cli", "calcpad-wasm", + "calcpad-windows", ] resolver = "2" diff --git a/calcpad-cli/Cargo.toml b/calcpad-cli/Cargo.toml new file mode 100644 index 0000000..bbc95e5 --- /dev/null +++ b/calcpad-cli/Cargo.toml @@ -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" diff --git a/calcpad-cli/src/main.rs b/calcpad-cli/src/main.rs new file mode 100644 index 0000000..4ca2471 --- /dev/null +++ b/calcpad-cli/src/main.rs @@ -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, + + /// 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 { + // 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 "); + eprintln!(" calcpad --repl"); + eprintln!(" echo \"100 + 200\" | calcpad"); + eprintln!(); + eprintln!("Run 'calcpad --help' for full usage information."); + ExitCode::from(1) +} diff --git a/calcpad-engine/Cargo.toml b/calcpad-engine/Cargo.toml index 5dd6617..68e12c3 100644 --- a/calcpad-engine/Cargo.toml +++ b/calcpad-engine/Cargo.toml @@ -16,3 +16,9 @@ ureq = { version = "2", features = ["json"] } [dev-dependencies] tempfile = "3" +criterion = { version = "0.5", features = ["html_reports"] } +proptest = "1" + +[[bench]] +name = "eval_benchmark" +harness = false diff --git a/calcpad-engine/benches/eval_benchmark.rs b/calcpad-engine/benches/eval_benchmark.rs new file mode 100644 index 0000000..7ac4f68 --- /dev/null +++ b/calcpad-engine/benches/eval_benchmark.rs @@ -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 = (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 = 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 = (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 = (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); diff --git a/calcpad-engine/src/formatting/answer_format.rs b/calcpad-engine/src/formatting/answer_format.rs new file mode 100644 index 0000000..f1d4afc --- /dev/null +++ b/calcpad-engine/src/formatting/answer_format.rs @@ -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, + pub notation: Option, + pub thousands_separator: Option, + pub currency_symbol: Option, + pub currency_position: Option, +} + +// --------------------------------------------------------------------------- +// 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); + } +} diff --git a/calcpad-engine/src/formatting/clipboard.rs b/calcpad-engine/src/formatting/clipboard.rs new file mode 100644 index 0000000..5f5e2ac --- /dev/null +++ b/calcpad-engine/src/formatting/clipboard.rs @@ -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 { + 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); + } +} diff --git a/calcpad-engine/src/formatting/line_types.rs b/calcpad-engine/src/formatting/line_types.rs new file mode 100644 index 0000000..12cf69a --- /dev/null +++ b/calcpad-engine/src/formatting/line_types.rs @@ -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 { + 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 { + 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); + } +} diff --git a/calcpad-engine/src/formatting/mod.rs b/calcpad-engine/src/formatting/mod.rs new file mode 100644 index 0000000..e47d0d2 --- /dev/null +++ b/calcpad-engine/src/formatting/mod.rs @@ -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}; diff --git a/calcpad-engine/src/lib.rs b/calcpad-engine/src/lib.rs index 450e995..b7e4a61 100644 --- a/calcpad-engine/src/lib.rs +++ b/calcpad-engine/src/lib.rs @@ -4,12 +4,14 @@ pub mod currency; pub mod datetime; pub mod error; pub mod ffi; +pub mod formatting; pub mod functions; pub mod interpreter; pub mod lexer; pub mod number; pub mod parser; pub mod pipeline; +pub mod plugins; pub mod sheet_context; pub mod span; pub mod token; diff --git a/calcpad-engine/src/plugins/api.rs b/calcpad-engine/src/plugins/api.rs new file mode 100644 index 0000000..611e195 --- /dev/null +++ b/calcpad-engine/src/plugins/api.rs @@ -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, +} + +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) -> 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 Result + 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, +} + +impl PluginFunctionRegistry { + pub fn new() -> Self { + Self { + functions: HashMap::new(), + } + } + + /// Register a named function. + pub fn register( + &mut self, + name: impl Into, + func: impl Fn(&[PluginValue]) -> Result + Send + Sync + 'static, + ) { + self.functions.insert(name.into(), Arc::new(func)); + } + + /// Iterate over registered function names. + pub fn names(&self) -> impl Iterator { + 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, +} + +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, factor: f64) { + self.units.insert(name.into(), factor); + } + + /// Iterate over registered unit names. + pub fn names(&self) -> impl Iterator { + 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, +} + +impl PluginVariableRegistry { + pub fn new() -> Self { + Self { + variables: HashMap::new(), + } + } + + /// Register a named constant. + pub fn register(&mut self, name: impl Into, value: f64) { + self.variables.insert(name.into(), value); + } + + /// Iterate over registered variable names. + pub fn names(&self) -> impl Iterator { + 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() {} + assert_send_sync::(); + } +} diff --git a/calcpad-engine/src/plugins/mod.rs b/calcpad-engine/src/plugins/mod.rs new file mode 100644 index 0000000..1c0e5fb --- /dev/null +++ b/calcpad-engine/src/plugins/mod.rs @@ -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 │ +//! │ units: HashMap │ +//! │ variables: HashMap │ +//! └─────────────────────────────────────────────────────┘ +//! ``` +//! +//! # 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}; diff --git a/calcpad-engine/src/plugins/registry.rs b/calcpad-engine/src/plugins/registry.rs new file mode 100644 index 0000000..fff540b --- /dev/null +++ b/calcpad-engine/src/plugins/registry.rs @@ -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, + /// Non-fatal warnings (e.g., naming conflicts). + pub warnings: Vec, +} + +/// Manages the full set of loaded plugins and their merged contributions. +pub struct PluginRegistry { + /// Loaded plugin instances (kept alive for shutdown). + plugins: Vec>, + /// Merged function map: name -> callable. + functions: HashMap, + /// Merged unit map: name -> conversion factor. + units: HashMap, + /// Merged variable map: name -> value. + variables: HashMap, + /// Accumulated warnings from all load operations. + warnings: Vec, +} + +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(&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) -> 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 { + 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 { + 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 { + 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); + } +} diff --git a/calcpad-engine/src/plugins/scripting.rs b/calcpad-engine/src/plugins/scripting.rs new file mode 100644 index 0000000..0876975 --- /dev/null +++ b/calcpad-engine/src/plugins/scripting.rs @@ -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, + 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, +} + +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, functions: HashMap) -> 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, + /// Errors from scripts that failed to parse / execute. + pub errors: Vec, + /// Non-fatal warnings (e.g., built-in name conflicts). + pub warnings: Vec, +} + +// --------------------------------------------------------------------------- +// 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 = HashMap::new(); + + if !dir.exists() || !dir.is_dir() { + return ScriptLoadResult { + plugin: None, + errors, + warnings, + }; + } + + // Collect .rhai files + let mut rhai_files: Vec = 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 ()` 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 ()` 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 = 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"); + } +} diff --git a/calcpad-engine/tests/proptest_fuzz.rs b/calcpad-engine/tests/proptest_fuzz.rs new file mode 100644 index 0000000..8d16652 --- /dev/null +++ b/calcpad-engine/tests/proptest_fuzz.rs @@ -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 { + -10000i64..10000 +} + +fn arb_operator() -> impl Strategy { + 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); + } + } +} diff --git a/calcpad-engine/tests/sheet_scenarios.rs b/calcpad-engine/tests/sheet_scenarios.rs new file mode 100644 index 0000000..77dab97 --- /dev/null +++ b/calcpad-engine/tests/sheet_scenarios.rs @@ -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 = (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); +} diff --git a/calcpad-macos/Package.swift b/calcpad-macos/Package.swift new file mode 100644 index 0000000..0326fd0 --- /dev/null +++ b/calcpad-macos/Package.swift @@ -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"), + ] + ), + ] +) diff --git a/calcpad-macos/Sources/CCalcPadEngine/calcpad.h b/calcpad-macos/Sources/CCalcPadEngine/calcpad.h new file mode 100644 index 0000000..ee018a3 --- /dev/null +++ b/calcpad-macos/Sources/CCalcPadEngine/calcpad.h @@ -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 */ diff --git a/calcpad-macos/Sources/CCalcPadEngine/module.modulemap b/calcpad-macos/Sources/CCalcPadEngine/module.modulemap new file mode 100644 index 0000000..f40e499 --- /dev/null +++ b/calcpad-macos/Sources/CCalcPadEngine/module.modulemap @@ -0,0 +1,5 @@ +module CCalcPadEngine { + header "calcpad.h" + link "calcpad_engine" + export * +} diff --git a/calcpad-macos/Sources/CalcPad/App/CalcPadApp.swift b/calcpad-macos/Sources/CalcPad/App/CalcPadApp.swift new file mode 100644 index 0000000..3f5f3b5 --- /dev/null +++ b/calcpad-macos/Sources/CalcPad/App/CalcPadApp.swift @@ -0,0 +1,11 @@ +import SwiftUI + +@main +struct CalcPadApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + .defaultSize(width: 800, height: 600) + } +} diff --git a/calcpad-macos/Sources/CalcPad/App/ContentView.swift b/calcpad-macos/Sources/CalcPad/App/ContentView.swift new file mode 100644 index 0000000..d6dc37b --- /dev/null +++ b/calcpad-macos/Sources/CalcPad/App/ContentView.swift @@ -0,0 +1,7 @@ +import SwiftUI + +struct ContentView: View { + var body: some View { + TwoColumnEditorView() + } +} diff --git a/calcpad-macos/Sources/CalcPad/Engine/CalculationEngine.swift b/calcpad-macos/Sources/CalcPad/Engine/CalculationEngine.swift new file mode 100644 index 0000000..4be7584 --- /dev/null +++ b/calcpad-macos/Sources/CalcPad/Engine/CalculationEngine.swift @@ -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) + } +} diff --git a/calcpad-macos/Sources/CalcPad/Engine/FFIModels.swift b/calcpad-macos/Sources/CalcPad/Engine/FFIModels.swift new file mode 100644 index 0000000..cb9d6ab --- /dev/null +++ b/calcpad-macos/Sources/CalcPad/Engine/FFIModels.swift @@ -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 + } +} diff --git a/calcpad-macos/Sources/CalcPad/Engine/RustCalculationEngine.swift b/calcpad-macos/Sources/CalcPad/Engine/RustCalculationEngine.swift new file mode 100644 index 0000000..3486d7d --- /dev/null +++ b/calcpad-macos/Sources/CalcPad/Engine/RustCalculationEngine.swift @@ -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($0) } + + let resultPtr = pointers.withUnsafeMutableBufferPointer { buffer -> UnsafeMutablePointer? 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") + } +} diff --git a/calcpad-macos/Sources/CalcPad/Models/LineResult.swift b/calcpad-macos/Sources/CalcPad/Models/LineResult.swift new file mode 100644 index 0000000..64c3590 --- /dev/null +++ b/calcpad-macos/Sources/CalcPad/Models/LineResult.swift @@ -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 } +} diff --git a/calcpad-macos/Sources/CalcPad/Views/AnswerColumnView.swift b/calcpad-macos/Sources/CalcPad/Views/AnswerColumnView.swift new file mode 100644 index 0000000..248b4b5 --- /dev/null +++ b/calcpad-macos/Sources/CalcPad/Views/AnswerColumnView.swift @@ -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? + } +} diff --git a/calcpad-macos/Sources/CalcPad/Views/EditorTextView.swift b/calcpad-macos/Sources/CalcPad/Views/EditorTextView.swift new file mode 100644 index 0000000..3c24093 --- /dev/null +++ b/calcpad-macos/Sources/CalcPad/Views/EditorTextView.swift @@ -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) + } + } +} diff --git a/calcpad-macos/Sources/CalcPad/Views/TwoColumnEditorView.swift b/calcpad-macos/Sources/CalcPad/Views/TwoColumnEditorView.swift new file mode 100644 index 0000000..b364be5 --- /dev/null +++ b/calcpad-macos/Sources/CalcPad/Views/TwoColumnEditorView.swift @@ -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? + + /// 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) + } +} diff --git a/calcpad-macos/Tests/CalcPadTests/FFIModelTests.swift b/calcpad-macos/Tests/CalcPadTests/FFIModelTests.swift new file mode 100644 index 0000000..90f37cd --- /dev/null +++ b/calcpad-macos/Tests/CalcPadTests/FFIModelTests.swift @@ -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) + } +} diff --git a/calcpad-macos/Tests/CalcPadTests/PerformanceTests.swift b/calcpad-macos/Tests/CalcPadTests/PerformanceTests.swift new file mode 100644 index 0000000..915c900 --- /dev/null +++ b/calcpad-macos/Tests/CalcPadTests/PerformanceTests.swift @@ -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 + } +} diff --git a/calcpad-macos/Tests/CalcPadTests/RustEngineTests.swift b/calcpad-macos/Tests/CalcPadTests/RustEngineTests.swift new file mode 100644 index 0000000..22d9910 --- /dev/null +++ b/calcpad-macos/Tests/CalcPadTests/RustEngineTests.swift @@ -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") + } +} diff --git a/calcpad-macos/Tests/CalcPadTests/StubEngineTests.swift b/calcpad-macos/Tests/CalcPadTests/StubEngineTests.swift new file mode 100644 index 0000000..cbb042c --- /dev/null +++ b/calcpad-macos/Tests/CalcPadTests/StubEngineTests.swift @@ -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) + } +} diff --git a/calcpad-macos/build-rust.sh b/calcpad-macos/build-rust.sh new file mode 100644 index 0000000..820b370 --- /dev/null +++ b/calcpad-macos/build-rust.sh @@ -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" diff --git a/calcpad-web/.gitignore b/calcpad-web/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/calcpad-web/.gitignore @@ -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? diff --git a/calcpad-web/index.html b/calcpad-web/index.html new file mode 100644 index 0000000..08da036 --- /dev/null +++ b/calcpad-web/index.html @@ -0,0 +1,19 @@ + + + + + + + + + + + + + CalcPad + + +
+ + + diff --git a/calcpad-web/package.json b/calcpad-web/package.json new file mode 100644 index 0000000..546455b --- /dev/null +++ b/calcpad-web/package.json @@ -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" + } +} diff --git a/calcpad-web/public/favicon.svg b/calcpad-web/public/favicon.svg new file mode 100644 index 0000000..a0b77f9 --- /dev/null +++ b/calcpad-web/public/favicon.svg @@ -0,0 +1,4 @@ + + + = + diff --git a/calcpad-web/public/icons/icon-192.svg b/calcpad-web/public/icons/icon-192.svg new file mode 100644 index 0000000..2bc1a04 --- /dev/null +++ b/calcpad-web/public/icons/icon-192.svg @@ -0,0 +1,4 @@ + + + = + diff --git a/calcpad-web/public/icons/icon-512.svg b/calcpad-web/public/icons/icon-512.svg new file mode 100644 index 0000000..49d3af6 --- /dev/null +++ b/calcpad-web/public/icons/icon-512.svg @@ -0,0 +1,4 @@ + + + = + diff --git a/calcpad-web/public/icons/icon-maskable-512.svg b/calcpad-web/public/icons/icon-maskable-512.svg new file mode 100644 index 0000000..3ba916c --- /dev/null +++ b/calcpad-web/public/icons/icon-maskable-512.svg @@ -0,0 +1,4 @@ + + + = + diff --git a/calcpad-web/src/App.tsx b/calcpad-web/src/App.tsx new file mode 100644 index 0000000..716208d --- /dev/null +++ b/calcpad-web/src/App.tsx @@ -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 ( +
+ + +
+

CalcPad

+

Notepad Calculator

+
+ + {engine.ready ? 'Engine ready' : 'Loading engine...'} +
+
+ +
+
+ +
+
+ + +
+ ) +} + +export default App diff --git a/calcpad-web/src/components/AnswerColumn.tsx b/calcpad-web/src/components/AnswerColumn.tsx new file mode 100644 index 0000000..bece72a --- /dev/null +++ b/calcpad-web/src/components/AnswerColumn.tsx @@ -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 ( +
+ {results.map((result, i) => ( +
+ {result.type === 'error' + ? 'Error' + : result.display || '\u00A0'} +
+ ))} +
+ ) +} diff --git a/calcpad-web/src/components/InstallPrompt.tsx b/calcpad-web/src/components/InstallPrompt.tsx new file mode 100644 index 0000000..614336b --- /dev/null +++ b/calcpad-web/src/components/InstallPrompt.tsx @@ -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 + userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }> +} + +interface InstallPromptProps { + promptEvent: BeforeInstallPromptEvent | null + isInstalled: boolean + onInstall: () => Promise + onDismiss: () => void +} + +export function InstallPrompt({ + promptEvent, + isInstalled, + onInstall, + onDismiss, +}: InstallPromptProps) { + if (isInstalled || !promptEvent) return null + + return ( +
+
+

+ Install CalcPad for offline access and a native app experience. +

+
+ + +
+
+
+ ) +} diff --git a/calcpad-web/src/components/OfflineBanner.tsx b/calcpad-web/src/components/OfflineBanner.tsx new file mode 100644 index 0000000..fa41f23 --- /dev/null +++ b/calcpad-web/src/components/OfflineBanner.tsx @@ -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 ( +
+ + You are offline. Changes are saved locally. +
+ ) +} diff --git a/calcpad-web/src/editor/CalcEditor.tsx b/calcpad-web/src/editor/CalcEditor.tsx new file mode 100644 index 0000000..81837ef --- /dev/null +++ b/calcpad-web/src/editor/CalcEditor.tsx @@ -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(null) + const viewRef = useRef(null) + const timerRef = useRef | 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
+} + +/** + * 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', + }, +}) diff --git a/calcpad-web/src/editor/answer-gutter.ts b/calcpad-web/src/editor/answer-gutter.ts new file mode 100644 index 0000000..2f7a61d --- /dev/null +++ b/calcpad-web/src/editor/answer-gutter.ts @@ -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() + +// --- 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>({ + create() { + return new Map() + }, + + update(answers, tr) { + for (const effect of tr.effects) { + if (effect.is(setAnswersEffect)) { + const newAnswers = new Map() + 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] +} diff --git a/calcpad-web/src/editor/calcpad-language.ts b/calcpad-web/src/editor/calcpad-language.ts new file mode 100644 index 0000000..6edd66c --- /dev/null +++ b/calcpad-web/src/editor/calcpad-language.ts @@ -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(calcpadStreamParser) + +/** + * Tag mapping for CalcPad token types to CodeMirror highlight tags. + */ +export const calcpadHighlightTags: Record = { + 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) +} diff --git a/calcpad-web/src/editor/error-display.ts b/calcpad-web/src/editor/error-display.ts new file mode 100644 index 0000000..b04755c --- /dev/null +++ b/calcpad-web/src/editor/error-display.ts @@ -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() + +// --- 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({ + 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>({ + create() { + return new Set() + }, + + update(lines, tr) { + for (const effect of tr.effects) { + if (effect.is(setErrorsEffect)) { + const newLines = new Set() + 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, + ] +} diff --git a/calcpad-web/src/engine/types.ts b/calcpad-web/src/engine/types.ts new file mode 100644 index 0000000..e7c3195 --- /dev/null +++ b/calcpad-web/src/engine/types.ts @@ -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 } diff --git a/calcpad-web/src/engine/useEngine.ts b/calcpad-web/src/engine/useEngine.ts new file mode 100644 index 0000000..a1fb5e8 --- /dev/null +++ b/calcpad-web/src/engine/useEngine.ts @@ -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(null) + const requestIdRef = useRef(0) + const latestIdRef = useRef(0) + const [ready, setReady] = useState(false) + const [results, setResults] = useState([]) + const [error, setError] = useState(null) + + useEffect(() => { + const worker = new Worker( + new URL('./worker.ts', import.meta.url), + { type: 'module' }, + ) + workerRef.current = worker + + worker.onmessage = (event: MessageEvent) => { + 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 } +} diff --git a/calcpad-web/src/engine/worker.ts b/calcpad-web/src/engine/worker.ts new file mode 100644 index 0000000..427c7fe --- /dev/null +++ b/calcpad-web/src/engine/worker.ts @@ -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 { + try { + // Try to load the wasm-pack output + const wasmModule = await import(/* @vite-ignore */ '/wasm/calcpad_wasm.js') as { + default: () => Promise + 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) => { + 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 + } + } +} diff --git a/calcpad-web/src/hooks/useInstallPrompt.ts b/calcpad-web/src/hooks/useInstallPrompt.ts new file mode 100644 index 0000000..d97ff66 --- /dev/null +++ b/calcpad-web/src/hooks/useInstallPrompt.ts @@ -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 + userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }> +} + +export interface InstallPromptState { + promptEvent: BeforeInstallPromptEvent | null + isInstalled: boolean + handleInstall: () => Promise + handleDismiss: () => void +} + +export function useInstallPrompt(): InstallPromptState { + const [promptEvent, setPromptEvent] = useState(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 } +} diff --git a/calcpad-web/src/hooks/useOnlineStatus.ts b/calcpad-web/src/hooks/useOnlineStatus.ts new file mode 100644 index 0000000..173ab98 --- /dev/null +++ b/calcpad-web/src/hooks/useOnlineStatus.ts @@ -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 +} diff --git a/calcpad-web/src/main.tsx b/calcpad-web/src/main.tsx new file mode 100644 index 0000000..dc25e58 --- /dev/null +++ b/calcpad-web/src/main.tsx @@ -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( + + + , +) + +// 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 + } + }) +} diff --git a/calcpad-web/src/styles/answer-column.css b/calcpad-web/src/styles/answer-column.css new file mode 100644 index 0000000..fe49613 --- /dev/null +++ b/calcpad-web/src/styles/answer-column.css @@ -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); + } +} diff --git a/calcpad-web/src/styles/app.css b/calcpad-web/src/styles/app.css new file mode 100644 index 0000000..e8a0490 --- /dev/null +++ b/calcpad-web/src/styles/app.css @@ -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; + } +} diff --git a/calcpad-web/src/styles/index.css b/calcpad-web/src/styles/index.css new file mode 100644 index 0000000..3c71baf --- /dev/null +++ b/calcpad-web/src/styles/index.css @@ -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); +} diff --git a/calcpad-web/src/styles/install-prompt.css b/calcpad-web/src/styles/install-prompt.css new file mode 100644 index 0000000..df591ff --- /dev/null +++ b/calcpad-web/src/styles/install-prompt.css @@ -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; + } +} diff --git a/calcpad-web/src/styles/offline-banner.css b/calcpad-web/src/styles/offline-banner.css new file mode 100644 index 0000000..ea5ab6d --- /dev/null +++ b/calcpad-web/src/styles/offline-banner.css @@ -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; } +} diff --git a/calcpad-web/src/vite-env.d.ts b/calcpad-web/src/vite-env.d.ts new file mode 100644 index 0000000..20343fd --- /dev/null +++ b/calcpad-web/src/vite-env.d.ts @@ -0,0 +1,14 @@ +/// + +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 +} diff --git a/calcpad-web/tsconfig.app.json b/calcpad-web/tsconfig.app.json new file mode 100644 index 0000000..33ae861 --- /dev/null +++ b/calcpad-web/tsconfig.app.json @@ -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"] +} diff --git a/calcpad-web/tsconfig.json b/calcpad-web/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/calcpad-web/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/calcpad-web/tsconfig.node.json b/calcpad-web/tsconfig.node.json new file mode 100644 index 0000000..a96b3e5 --- /dev/null +++ b/calcpad-web/tsconfig.node.json @@ -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"] +} diff --git a/calcpad-web/vite.config.ts b/calcpad-web/vite.config.ts new file mode 100644 index 0000000..7086c5e --- /dev/null +++ b/calcpad-web/vite.config.ts @@ -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', + }, +}) diff --git a/calcpad-windows/Cargo.toml b/calcpad-windows/Cargo.toml new file mode 100644 index 0000000..01bc884 --- /dev/null +++ b/calcpad-windows/Cargo.toml @@ -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 diff --git a/calcpad-windows/src/evaluator.rs b/calcpad-windows/src/evaluator.rs new file mode 100644 index 0000000..d2f27f5 --- /dev/null +++ b/calcpad-windows/src/evaluator.rs @@ -0,0 +1,210 @@ +//! Thin wrapper around `calcpad_engine::SheetContext` for the iced UI. +//! +//! Exposes a simple `evaluate_sheet(text) -> Vec` 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 { + 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 = 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 = (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() + ); + } +} diff --git a/calcpad-windows/src/main.rs b/calcpad-windows/src/main.rs new file mode 100644 index 0000000..30a5278 --- /dev/null +++ b/calcpad-windows/src/main.rs @@ -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, + /// 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::(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::(&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); + } +} diff --git a/calcpad-windows/src/theme.rs b/calcpad-windows/src/theme.rs new file mode 100644 index 0000000..1029e16 --- /dev/null +++ b/calcpad-windows/src/theme.rs @@ -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 +} diff --git a/calcpad-windows/src/ui/header.rs b/calcpad-windows/src/ui/header.rs new file mode 100644 index 0000000..dc2068e --- /dev/null +++ b/calcpad-windows/src/ui/header.rs @@ -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() +} diff --git a/calcpad-windows/src/ui/mod.rs b/calcpad-windows/src/ui/mod.rs new file mode 100644 index 0000000..8cfbbc8 --- /dev/null +++ b/calcpad-windows/src/ui/mod.rs @@ -0,0 +1,4 @@ +//! UI components for the CalcPad Windows application. + +pub mod header; +pub mod results_column; diff --git a/calcpad-windows/src/ui/results_column.rs b/calcpad-windows/src/ui/results_column.rs new file mode 100644 index 0000000..5728103 --- /dev/null +++ b/calcpad-windows/src/ui/results_column.rs @@ -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> = 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() +}