feat(web): implement complete workspace with themes, tabs, sidebar, and mobile

Transform CalcText from a single-document calculator into a full workspace
application with multi-document support, theming, and responsive mobile experience.

- Theme system: 5 presets (Light, Dark, Matrix, Midnight, Warm) + accent colors
- Document model with localStorage persistence and auto-save
- Tab bar with keyboard shortcuts (Ctrl+N/W/Tab/1-9), rename, close
- Sidebar with search, recent, favorites, folders, templates, drag-and-drop
- 5 templates: Budget, Invoice, Unit Converter, Trip Planner, Loan Calculator
- Status bar with cursor position, engine status, dedication to Igor Cassel
- Results panel: type-specific colors, click-to-copy, error hints
- Format toolbar: H, B, I, //, color labels with live preview toggle
- Syntax highlighting using theme CSS variables
- Error hover tooltips
- Mobile: bottom results tray, sidebar drawer, touch targets, safe areas
- Docker multi-stage build (Rust WASM + Vite + Nginx)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-18 09:12:05 -04:00
parent 806e2f1ec6
commit 0d38bd3108
78 changed files with 8175 additions and 421 deletions

View File

@@ -6,13 +6,17 @@ edition = "2021"
[lib]
crate-type = ["cdylib", "staticlib", "rlib"]
[features]
default = ["network"]
network = ["ureq"]
[dependencies]
chrono = { version = "0.4", features = ["serde"] }
chrono-tz = "0.10"
dashu = "0.4"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
ureq = { version = "2", features = ["json"] }
ureq = { version = "2", features = ["json"], optional = true }
[dev-dependencies]
tempfile = "3"

View File

@@ -137,6 +137,7 @@ impl CryptoProvider {
/// Refresh rates from the CoinGecko API.
/// Returns Ok(()) on success, Err with message on failure.
/// Falls back to cached rates if the API call fails.
#[cfg(feature = "network")]
pub fn refresh(&mut self) -> Result<(), String> {
if !self.is_stale() {
return Ok(());
@@ -163,7 +164,18 @@ impl CryptoProvider {
}
}
/// Refresh is unavailable without the `network` feature.
#[cfg(not(feature = "network"))]
pub fn refresh(&mut self) -> Result<(), String> {
if self.cache.is_some() {
Ok(())
} else {
Err("Network support not available".to_string())
}
}
/// Fetch rates from CoinGecko API.
#[cfg(feature = "network")]
fn fetch_from_api(&self) -> Result<HashMap<String, CryptoRate>, String> {
let url = format!(
"{}/coins/markets?vs_currency=usd&order=market_cap_desc&per_page=100&page=1&sparkline=false",

View File

@@ -16,6 +16,7 @@ use std::collections::HashMap;
// Provider implementations
// ---------------------------------------------------------------------------
#[cfg(feature = "network")]
/// Open Exchange Rates API provider (<https://openexchangerates.org>).
///
/// Requires an API key. Free tier provides 1,000 requests/month with USD base.
@@ -24,6 +25,7 @@ pub struct OpenExchangeRatesProvider {
api_key: String,
}
#[cfg(feature = "network")]
impl OpenExchangeRatesProvider {
pub fn new(api_key: &str) -> Self {
Self {
@@ -32,6 +34,7 @@ impl OpenExchangeRatesProvider {
}
}
#[cfg(feature = "network")]
impl CurrencyProvider for OpenExchangeRatesProvider {
fn fetch_rates(&self) -> Result<ExchangeRates, CurrencyError> {
let url = format!(
@@ -73,6 +76,7 @@ impl CurrencyProvider for OpenExchangeRatesProvider {
}
}
#[cfg(feature = "network")]
/// exchangerate.host API provider (<https://exchangerate.host>).
///
/// Free tier available; no API key required for basic usage.
@@ -80,6 +84,7 @@ pub struct ExchangeRateHostProvider {
api_key: Option<String>,
}
#[cfg(feature = "network")]
impl ExchangeRateHostProvider {
pub fn new(api_key: Option<&str>) -> Self {
Self {
@@ -88,6 +93,7 @@ impl ExchangeRateHostProvider {
}
}
#[cfg(feature = "network")]
impl CurrencyProvider for ExchangeRateHostProvider {
fn fetch_rates(&self) -> Result<ExchangeRates, CurrencyError> {
let mut url = "https://api.exchangerate.host/live?source=USD".to_string();

View File

@@ -13,9 +13,9 @@ pub mod rates;
pub mod symbols;
pub use crypto::{CryptoProvider, CryptoProviderConfig, CryptoRate};
pub use fiat::{
ExchangeRateHostProvider, FiatCurrencyProvider, OpenExchangeRatesProvider, fallback_rates,
};
#[cfg(feature = "network")]
pub use fiat::{ExchangeRateHostProvider, OpenExchangeRatesProvider};
pub use fiat::{FiatCurrencyProvider, fallback_rates};
pub use rates::{ExchangeRateCache, ExchangeRates, ProviderConfig, RateMetadata, RateSource};
pub use symbols::{is_currency_code, is_crypto_symbol, resolve_currency};

View File

@@ -215,6 +215,14 @@ impl Parser {
Ok(Spanned::new(ExprKind::PrevRef, span))
}
TokenKind::Percent(val) => {
// Standalone percentage: 8% = 0.08
let val = *val;
let span = tok.span;
self.advance();
Ok(Spanned::new(ExprKind::Number(val / 100.0), span))
}
_ => Err(ParseError::new(
format!("unexpected token: {:?}", tok.kind),
tok.span,

View File

@@ -4,12 +4,19 @@ use crate::lexer::tokenize;
use crate::parser::parse;
use crate::span::Span;
use crate::types::CalcResult;
use crate::variables::aggregators;
/// Evaluate a single line of input and return the result.
pub fn eval_line(input: &str, ctx: &mut EvalContext) -> CalcResult {
let trimmed = input.trim();
if trimmed.is_empty() {
return CalcResult::error("empty input", Span::new(0, 0));
return CalcResult::empty(Span::new(0, 0));
}
// Detect headings (# Title) and aggregator keywords (sum, total, etc.)
// before tokenizing — the lexer misinterprets `#` as a line reference prefix.
if aggregators::is_heading(trimmed) || aggregators::detect_aggregator(trimmed).is_some() {
return CalcResult::non_calculable(Span::new(0, trimmed.len()));
}
let tokens = tokenize(trimmed);
@@ -24,7 +31,7 @@ pub fn eval_line(input: &str, ctx: &mut EvalContext) -> CalcResult {
)
});
if !has_expr {
return CalcResult::error("no expression found", Span::new(0, trimmed.len()));
return CalcResult::non_calculable(Span::new(0, trimmed.len()));
}
match parse(tokens) {

View File

@@ -217,7 +217,7 @@ impl SheetContext {
// Heading lines produce no result -- skip them
if entry_is_heading {
let result = CalcResult::error("no expression found", Span::new(0, entry_source.len()));
let result = CalcResult::non_calculable(Span::new(0, entry_source.len()));
ordered_results.push(result.clone());
ordered_sources.push(entry_source);
self.results.insert(idx, result);
@@ -251,7 +251,7 @@ impl SheetContext {
// Store as __line_N for line reference support
ctx.set_variable(&format!("__line_{}", idx + 1), result.clone());
// Update __prev for prev/ans support
if result.result_type() != crate::types::ResultType::Error {
if result.is_calculable() {
ctx.set_variable("__prev", result.clone());
}
ordered_results.push(result);
@@ -261,7 +261,11 @@ impl SheetContext {
// Text/empty lines produce no result -- skip them
if entry_is_text || entry_source.trim().is_empty() {
let result = CalcResult::error("no expression found", Span::new(0, entry_source.len()));
let result = if entry_source.trim().is_empty() {
CalcResult::empty(Span::new(0, 0))
} else {
CalcResult::non_calculable(Span::new(0, entry_source.len()))
};
ordered_results.push(result.clone());
ordered_sources.push(entry_source);
self.results.insert(idx, result);
@@ -276,7 +280,7 @@ impl SheetContext {
// Store as __line_N for line reference support (1-indexed)
ctx.set_variable(&format!("__line_{}", idx + 1), result.clone());
// Update __prev for prev/ans support (only for non-error results)
if result.result_type() != crate::types::ResultType::Error {
if result.is_calculable() {
ctx.set_variable("__prev", result.clone());
}
ordered_results.push(result);
@@ -299,7 +303,7 @@ impl SheetContext {
}
// Replay line ref and prev for cached results too
ctx.set_variable(&format!("__line_{}", idx + 1), cached.clone());
if cached.result_type() != crate::types::ResultType::Error {
if cached.is_calculable() {
ctx.set_variable("__prev", cached.clone());
}
ordered_results.push(cached);

View File

@@ -12,6 +12,8 @@ pub enum ResultType {
DateTime,
TimeDelta,
Boolean,
Empty,
NonCalculable,
Error,
}
@@ -24,6 +26,8 @@ impl fmt::Display for ResultType {
ResultType::DateTime => write!(f, "DateTime"),
ResultType::TimeDelta => write!(f, "TimeDelta"),
ResultType::Boolean => write!(f, "Boolean"),
ResultType::Empty => write!(f, "Empty"),
ResultType::NonCalculable => write!(f, "NonCalculable"),
ResultType::Error => write!(f, "Error"),
}
}
@@ -168,6 +172,36 @@ impl CalcResult {
}
}
pub fn empty(span: Span) -> Self {
CalcResult {
value: CalcValue::Error {
message: "empty input".to_string(),
span: span.into(),
},
metadata: ResultMetadata {
span: span.into(),
result_type: ResultType::Empty,
display: String::new(),
raw_value: None,
},
}
}
pub fn non_calculable(span: Span) -> Self {
CalcResult {
value: CalcValue::Error {
message: "no expression found".to_string(),
span: span.into(),
},
metadata: ResultMetadata {
span: span.into(),
result_type: ResultType::NonCalculable,
display: String::new(),
raw_value: None,
},
}
}
pub fn error(message: &str, span: Span) -> Self {
CalcResult {
value: CalcValue::Error {
@@ -186,13 +220,34 @@ impl CalcResult {
pub fn result_type(&self) -> ResultType {
self.metadata.result_type
}
/// Returns true if this result is a computed value (not empty, non-calculable, or error).
pub fn is_calculable(&self) -> bool {
!matches!(
self.metadata.result_type,
ResultType::Empty | ResultType::NonCalculable | ResultType::Error
)
}
}
fn format_number(val: f64) -> String {
if val == val.floor() && val.abs() < 1e15 {
format!("{}", val as i64)
} else {
format!("{}", val)
// Round to 10 significant figures to avoid f64 noise
let abs = val.abs();
let magnitude = if abs > 0.0 { abs.log10().floor() as i32 } else { 0 };
let precision = (10 - magnitude - 1).max(0) as usize;
let formatted = format!("{:.prec$}", val, prec = precision);
// Strip trailing zeros after decimal point
if formatted.contains('.') {
formatted
.trim_end_matches('0')
.trim_end_matches('.')
.to_string()
} else {
formatted
}
}
}

View File

@@ -101,14 +101,14 @@ fn pipeline_eval_division_by_zero() {
fn pipeline_eval_empty_input() {
let mut ctx = EvalContext::new();
let result = eval_line("", &mut ctx);
assert_eq!(result.result_type(), ResultType::Error);
assert_eq!(result.result_type(), ResultType::Empty);
}
#[test]
fn pipeline_eval_comment_only() {
let mut ctx = EvalContext::new();
let result = eval_line("// this is a comment", &mut ctx);
assert_eq!(result.result_type(), ResultType::Error);
assert_eq!(result.result_type(), ResultType::NonCalculable);
}
// ===== Variable assignment tests (eval_sheet) =====

View File

@@ -123,7 +123,7 @@ fn sheet_with_comments() {
],
&mut ctx,
);
assert_eq!(results[0].result_type(), ResultType::Error); // comment
assert_eq!(results[0].result_type(), ResultType::NonCalculable); // comment
assert_eq!(results[3].metadata.display, "2000");
}
@@ -132,7 +132,7 @@ 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[1].result_type(), ResultType::Empty); // empty line
assert_eq!(results[2].metadata.display, "15");
}