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:
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) =====
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user