diff --git a/core/src/interp.rs b/core/src/interp.rs index 956df42..1f404b4 100644 --- a/core/src/interp.rs +++ b/core/src/interp.rs @@ -19,10 +19,6 @@ impl Value { Value::Bool(b) => b.to_string(), Value::Str(s) => s.clone(), Value::Array(items) => { - // Spice-shape detection: exactly [Number, Str] → render in - // SPICE notation. Any other 2-array (numbers, strings, etc. - // that happen to have two elements) falls through to the - // generic array display. if items.len() == 2 { if let (Value::Number(n), Value::Str(u)) = (&items[0], &items[1]) { return format_spice(*n, u); @@ -66,9 +62,7 @@ fn format_number(n: f64) -> String { } } -/// Render a spice-typed value (scalar + unit label) in SPICE notation. -/// Picks the closest SI prefix from {none, m, u, n, p} so the mantissa -/// lands in [1, 1000), formats to 3 sig figs, uppercases the unit. +/// renders a scalar+unit value in SPICE notation. fn format_spice(n: f64, unit: &str) -> String { if n == 0.0 { return format!("0{}", unit); @@ -91,27 +85,18 @@ fn format_spice(n: f64, unit: &str) -> String { let mantissa = n / scale; let mag = mantissa.abs().log10().floor() as i32; let decimals = (2 - mag).max(0) as usize; - // 3 sig figs, but trailing zeros after the decimal are cosmetic — - // trim them so `10` doesn't display as `10.0`, matching the house - // number formatter. let raw = format!("{:.*}", decimals, mantissa); let trimmed: &str = if raw.contains('.') { raw.trim_end_matches('0').trim_end_matches('.') } else { raw.as_str() }; - // Simple units (F, H, HZ) sit flush (`100NF`). Compound labels from the - // unit algebra (`F/H`, `1/F`, `F·H`, `F²`) get a separating space so - // `707M F/H` doesn't read as `707MF/H` — the `/` or `·` would merge - // into the prefix letter otherwise. let compound = unit.chars().any(|c| !c.is_ascii_alphabetic()); let sep = if compound && !unit.is_empty() { " " } else { "" }; format!("{}{}{}{}", trimmed, prefix, sep, unit) } -/// Peel a spice-shaped value down to (scalar, unit). Anything that isn't -/// `Array([Number, Str])` returns (value, None) — unit is only added to -/// the result when at least one operand was spice. +/// peels a spice-shaped value down to (scalar, unit). fn unwrap_spice(v: &Value) -> (Value, Option) { if let Value::Array(a) = v { if a.len() == 2 { @@ -123,9 +108,7 @@ fn unwrap_spice(v: &Value) -> (Value, Option) { (v.clone(), None) } -/// Re-wrap a numeric result with a unit carried from an operand. Non-number -/// results (Bool from comparison, Str from concatenation) drop the unit — -/// they're no longer a measurable quantity. +/// re-wraps a numeric result with a carried unit, or drops the tag for non-numbers. fn retag_spice(v: Value, unit: Option) -> Value { match (&v, unit) { (Value::Number(_), Some(u)) => Value::Array(vec![v, Value::Str(u)]), @@ -133,15 +116,6 @@ fn retag_spice(v: Value, unit: Option) -> Value { } } -/// Combine two unit labels under an operation. Returns `None` when the -/// result shouldn't carry a label — either because addition of distinct -/// labels can't be meaningfully preserved, or because division cancels -/// matching labels to dimensionless. `Some(String::new())` never occurs: -/// an empty label means "drop the spice tag", so we use None for that. -/// -/// All four helpers are pure label algebra. No dimensional analysis, no -/// SI knowledge — just literal rewriting. - fn combine_unit_mul(a: &str, b: &str) -> Option { match (a.is_empty(), b.is_empty()) { (true, true) => None, @@ -153,7 +127,6 @@ fn combine_unit_mul(a: &str, b: &str) -> Option { } fn combine_unit_div(a: &str, b: &str) -> Option { - // Same label on both sides → cancellation → plain number. if a == b { return None; } if b.is_empty() { return if a.is_empty() { None } else { Some(a.to_string()) }; @@ -175,11 +148,6 @@ fn combine_unit_pow(a: &str, exp: f64) -> Option { } fn combine_unit_additive(a: &str, b: &str) -> Option { - // Additive ops need compatible labels to keep the tag. Matching labels - // pass through; one-sided labels absorb the untagged operand (an H - // plus a bare number is still H). Distinct non-empty labels strip — - // `F + H` has no clean algebraic answer, so return a plain number - // rather than pretend the sum is meaningful in either unit. if a == b { if a.is_empty() { None } else { Some(a.to_string()) } } else if a.is_empty() { @@ -191,10 +159,7 @@ fn combine_unit_additive(a: &str, b: &str) -> Option { } } -/// Parse `"A1"`, `"AA12"`, etc. into 0-based `(col, row)`. Case-insensitive. -/// Letters must precede digits; both must be non-empty. Returns None for -/// anything else (e.g. `"x1"` when x isn't a plain letter sequence, `"1A"`, -/// `"A0"`). +/// parses a cell address like `A1` into 0-based `(col, row)`. pub fn parse_cell_address(s: &str) -> Option<(u32, u32)> { let s = s.trim(); if s.is_empty() { @@ -237,7 +202,7 @@ fn col_letters_to_index(s: &str) -> Option { Some(result - 1) } -/// Render a 0-based (col, row) back to spreadsheet notation for error messages. +/// renders a 0-based (col, row) as a spreadsheet-style address. pub fn display_addr(col: u32, row: u32) -> String { let mut letters = String::new(); let mut c = col as i64; @@ -252,8 +217,7 @@ pub fn display_addr(col: u32, row: u32) -> String { format!("{}{}", letters, row + 1) } -/// Interpret a cell's raw string. Number-parseable strings promote to -/// `Value::Number`; empty stays as empty string; anything else is Str. +/// coerces a raw cell string into a Value. fn coerce_cell_value(s: &str) -> Value { let trimmed = s.trim(); if trimmed.is_empty() { @@ -278,9 +242,6 @@ fn rows_to_value(rows: &[Vec]) -> Value { #[derive(Debug, Clone, PartialEq)] enum Token { Number(f64), - /// Spice-notation literal. Value is already scaled by the prefix - /// (e.g. `100nF` → (1e-7, "F")). Empty unit is valid — `100n` is - /// (1e-7, ""). Only emitted when spice mode is active. Spice(f64, String), Str(String), Bool(bool), @@ -328,10 +289,7 @@ enum Token { Eof, } -/// Pre-scan the source for `use spice` / `use spice::…`. Activates spice -/// notation for the whole tokenize pass — gating postfix parsing on runtime -/// module state would require threading `use` through the tokenizer, which -/// is out of proportion for a documentary import. +/// returns true if the source declares `use spice` or a `use spice::…` import. fn source_enables_spice(src: &str) -> bool { src.lines().any(|l| { let t = l.trim(); @@ -344,13 +302,8 @@ fn source_enables_spice(src: &str) -> bool { }) } -/// Known SPICE unit strings, stored uppercase. Matched case-insensitively -/// against the alpha run after a number. `"OHM"` is the long form; `"R"` -/// is the short ASCII alias some users prefer. const SPICE_UNITS: &[&str] = &["F", "H", "HZ", "V", "A", "W", "R", "OHM", "S", "J"]; -/// Scaling factor for a one-char SPICE prefix (lowercase). Returns None for -/// anything else. Accepts `µ` (both U+00B5 micro sign and U+03BC Greek mu). fn spice_prefix_scale(c: char) -> Option { match c { 'm' | 'M' => Some(1e-3), @@ -361,12 +314,7 @@ fn spice_prefix_scale(c: char) -> Option { } } -/// Parse a post-number alpha run as a SPICE suffix. Returns -/// `(scale, unit_uppercase)`. The run must match exactly one of: -/// - lone prefix (`m`, `u`, `µ`, `n`, `p`) -/// - lone unit (`F`, `H`, `Hz`, …) -/// - prefix + unit (`mF`, `uH`, `nF`, …) -/// A miss returns None so the caller can fall back to implicit-mul. +/// parses a post-number alpha run as `(scale, unit_uppercase)`. fn parse_spice_suffix(alpha: &str) -> Option<(f64, String)> { if alpha.is_empty() { return None; @@ -376,18 +324,15 @@ fn parse_spice_suffix(alpha: &str) -> Option<(f64, String)> { c => c.to_ascii_uppercase(), }).collect(); - // lone prefix if normalized.len() == 1 { let first = alpha.chars().next().unwrap(); if let Some(scale) = spice_prefix_scale(first) { return Some((scale, String::new())); } } - // lone unit if SPICE_UNITS.iter().any(|u| *u == normalized) { return Some((1.0, normalized)); } - // prefix + unit let first = alpha.chars().next().unwrap(); if let Some(scale) = spice_prefix_scale(first) { let rest: String = normalized.chars().skip(1).collect(); @@ -398,10 +343,7 @@ fn parse_spice_suffix(alpha: &str) -> Option<(f64, String)> { None } -/// After a number's digits are consumed up through position `i`, consume -/// an optional scientific exponent (`e[+-]?DIGITS`). Returns the final -/// multiplier and advances `i`. A malformed exponent (`e` with no digits) -/// is left untouched. +/// consumes an optional `e[+-]?DIGITS` and returns the multiplier. fn try_consume_exponent(chars: &[char], i: &mut usize) -> f64 { let len = chars.len(); if *i >= len { return 1.0; } @@ -416,9 +358,7 @@ fn try_consume_exponent(chars: &[char], i: &mut usize) -> f64 { 10f64.powi(exp) } -/// Attach exponent / spice-suffix / implicit-mul tail to a freshly-parsed -/// number. Pushes either `Number` or `Spice`, and may follow it with a -/// `Star` when the next char is ident-like or `(`. +/// pushes a Number or Spice token plus any implicit-multiplication `Star`. fn finalize_number( tokens: &mut Vec, mut value: f64, @@ -429,7 +369,6 @@ fn finalize_number( value *= try_consume_exponent(chars, i); let len = chars.len(); - // Greedy alpha run (including µ) — consumed as a whole or not at all. let run_start = *i; let mut run_end = run_start; while run_end < len && (chars[run_end].is_alphabetic() || chars[run_end] == 'µ' || chars[run_end] == 'μ') { @@ -445,8 +384,6 @@ fn finalize_number( } } tokens.push(Token::Number(value)); - // Implicit multiplication: Number directly adjacent to an ident-start - // char or `(`. Whitespace between kills the rule (skipped separately). if *i < len { let c = chars[*i]; if c.is_alphabetic() || c == '_' || c == '(' || c == 'µ' || c == 'μ' { @@ -468,15 +405,11 @@ fn tokenize(input: &str, spice: bool) -> Result, String> { '\n' => { tokens.push(Token::Newline); i += 1; } '+' => { tokens.push(Token::Plus); i += 1; } '-' => { - // `->` is the function-return-type separator; takes precedence - // over negative-number detection since `-` adjacent to `>` is - // never a numeric sign. if i + 1 < len && chars[i + 1] == '>' { tokens.push(Token::Arrow); i += 2; continue; } - // negative number literal: only if preceded by operator/open/start/newline if i + 1 < len && (chars[i + 1].is_ascii_digit() || chars[i + 1] == '.') { let can_be_neg = if tokens.is_empty() { true @@ -601,7 +534,7 @@ fn tokenize(input: &str, spice: bool) -> Result, String> { if i >= len { return Err("unterminated string".into()); } - i += 1; // closing quote + i += 1; tokens.push(Token::Str(s)); } _ if c.is_ascii_digit() || (c == '.' && i + 1 < len && chars[i + 1].is_ascii_digit()) => { @@ -630,9 +563,6 @@ fn tokenize(input: &str, spice: bool) -> Result, String> { "return" => tokens.push(Token::Return), "true" => tokens.push(Token::Bool(true)), "false" => tokens.push(Token::Bool(false)), - // Keyword forms of the logical operators. Coexist with - // `&&` / `||` / `!` and emit the same tokens, so the - // parser doesn't need to learn anything new. "and" => tokens.push(Token::And), "or" => tokens.push(Token::Or), "not" => tokens.push(Token::Bang), @@ -657,9 +587,6 @@ enum Op { Add, Sub, Mul, Div, Mod, Pow, Eq, Neq, Lt, Gt, Lte, Gte, And, Or, Not, Neg, - /// `~expr` — strip the type from a value, demoting Bool to its numeric - /// representation (false→0, true→1) so structurally-similar values can - /// be compared across declared types. Strip, } @@ -670,10 +597,6 @@ enum Stmt { While(Expr, Vec), IfElse(Expr, Vec, Option>), ForLoop(String, Expr, Vec), - /// `fn name(p [: T], …) [-> R] { body }`. Each param optionally carries - /// a type annotation (value type like `int` or a unit label like `F`); - /// `return_type` similarly may be a value type or a unit label that - /// overrides whatever unit the body's last expression produced. FnDef { name: String, params: Vec<(String, Option)>, @@ -681,22 +604,13 @@ enum Stmt { body: Vec, }, Return(Expr), - /// `use module_name` or `use module_name::item`. - /// (module, optional specific item) Use(String, Option), - /// `@[block::]table:A1 = expr` — write a cell in a live table. Only - /// valid at text-block scope; cell formulas are expressions and never - /// produce this variant. CellAssign { block: Option, table: String, cell: (u32, u32), value: Expr, }, - /// Math-form function inversion: - /// `let NAME(params…) = target_var where source_fn(args…) = result_var`. - /// Both params and source_args are bare identifiers; result_var must - /// equal params[0]; target_var must be in the source fn's param list. SolveDef { name: String, params: Vec, @@ -708,14 +622,10 @@ enum Stmt { ExprStmt(Expr), } -/// Target shape of a cell reference. Cell indices are 0-based (`A1` = (0,0)). #[derive(Debug, Clone, PartialEq)] pub enum CellRefTarget { - /// `@Table` — the whole table, coerces to Array>. Whole, - /// `@Table:A1` — a single cell (col, row). Cell(u32, u32), - /// `@Table:A1:B4` or `@Table[A1:B4]` — rectangular range (col0, row0, col1, row1). Range(u32, u32, u32, u32), } @@ -731,22 +641,12 @@ enum Expr { Array(Vec), Index(Box, Box), Range(Box, Box), - /// `expr is type_name`. Right side is a type identifier (literal token), - /// not a regular sub-expression — kept as a string for the evaluator to - /// match against the value's runtime kind. IsCheck(Box, String), - /// `@[block::]table[:cell[:cell] | [cell:cell]]` or bare `A1`/`A1:B4` - /// inside a cell formula (both names None → resolved against - /// `Interpreter::current_table`). CellRef { block: Option, table: Option, target: CellRefTarget, }, - /// `solve!(target_var, source_fn)` / `solve!(target_var from source_fn)`. - /// Only valid as the RHS of a `let`; `exec_stmt` intercepts this shape - /// and registers a `SolvedFnDef`. Evaluating it in any other position - /// is an error — there's no runtime value for the macro itself. SolveMacro { var: String, source_fn: String, @@ -850,7 +750,6 @@ impl Parser { Token::Ident(_) => { let saved = self.pos; if let Token::Ident(name) = self.advance() { - // name(params) = expr (legacy cord-expr function syntax) if self.peek() == &Token::LParen { let paren_saved = self.pos; self.advance(); @@ -890,7 +789,6 @@ impl Parser { } } self.pos = paren_saved; - // fall through: not a function def, might be assignment } if self.peek() == &Token::Eq { self.advance(); @@ -918,9 +816,6 @@ impl Parser { Token::Ident(n) => n, t => return Err(format!("expected identifier after 'let', got {:?}", t)), }; - // `let NAME(params) = …` — function definition. Body is either a - // single expression (legacy `f(x) = expr` form, lifted under `let`) - // or an inversion clause `target where source(args) = result`. if self.peek() == &Token::LParen { return self.parse_let_with_params(name); } @@ -939,8 +834,6 @@ impl Parser { Ok(Stmt::Let(name, type_ann, expr)) } - /// `let NAME ( PARAMS ) = …` — function def with optional `where` clause. - /// Called after the NAME has been consumed; `(` is the next token. fn parse_let_with_params(&mut self, name: String) -> Result { self.expect(&Token::LParen)?; let mut params = Vec::new(); @@ -961,16 +854,13 @@ impl Parser { self.expect(&Token::Eq)?; let rhs = self.parse_expr()?; - // Math-form inversion: RHS is a bare ident followed by `where`. - // `where` isn't a reserved keyword at the token layer, so it arrives - // as `Ident("where")` — check textually. let is_where = matches!(self.peek(), Token::Ident(w) if w == "where"); if is_where { let target_var = match rhs { Expr::Ident(s) => s, _ => return Err("expected a single target variable before 'where'".into()), }; - self.advance(); // consume `where` + self.advance(); let source_fn = match self.advance() { Token::Ident(n) => n, t => return Err(format!("expected source function name after 'where', got {:?}", t)), @@ -1007,9 +897,6 @@ impl Parser { }); } - // Plain function def: `let f(x) = expr` ≡ bare `f(x) = expr`. - // No type annotations at this entry point — params come in as - // bare idents from parse_let_with_params. self.skip_newlines(); let typed_params: Vec<(String, Option)> = params.into_iter().map(|p| (p, None)).collect(); @@ -1090,7 +977,6 @@ impl Parser { Ok(Stmt::Return(expr)) } - /// `use module_name` or `use module_name::item` fn parse_use(&mut self) -> Result { self.expect(&Token::Use)?; let module = match self.advance() { @@ -1098,7 +984,7 @@ impl Parser { other => return Err(format!("expected module name after 'use', got {:?}", other)), }; let item = if self.peek() == &Token::ColonColon { - self.advance(); // consume :: + self.advance(); match self.advance() { Token::Ident(name) => Some(name), Token::Star => Some("*".to_string()), @@ -1111,9 +997,6 @@ impl Parser { Ok(Stmt::Use(module, item)) } - /// Parse `@[block::]table[:cell[:cell] | [cell:cell]]`. Assumes the - /// leading `@` is the next token. Names are lowercased for - /// case-insensitive lookup. fn parse_cell_ref(&mut self) -> Result { self.expect(&Token::At)?; let first = match self.advance() { @@ -1156,12 +1039,7 @@ impl Parser { Ok(Expr::CellRef { block, table, target }) } - /// `solve ! ( IDENT [,|from] IDENT )`. Caller has verified the first - /// three tokens match `solve!(` — this just consumes and validates the - /// interior. Either `,` or the bare word `from` separates the target - /// variable from the source function name. fn parse_solve_macro(&mut self) -> Result { - // `solve` match self.advance() { Token::Ident(n) if n == "solve" => {} t => return Err(format!("expected 'solve', got {:?}", t)), @@ -1209,7 +1087,6 @@ impl Parser { } } self.expect(&Token::RParen)?; - // Optional `-> T` return-type annotation. let return_type = if self.peek() == &Token::Arrow { self.advance(); match self.advance() { @@ -1224,9 +1101,6 @@ impl Parser { Ok(Stmt::FnDef { name, params, return_type, body }) } - /// `ident` or `ident : type` — a function parameter's name with an - /// optional type annotation. The type is either a value-type (`int`, - /// `float`, …) or a unit label (`F`, `H`, `Ω`, …). fn parse_typed_param(&mut self) -> Result<(String, Option), String> { let name = match self.advance() { Token::Ident(p) => p, @@ -1251,10 +1125,6 @@ impl Parser { let right = self.parse_or()?; return Ok(Expr::Range(Box::new(left), Box::new(right))); } - // `A1:B4` bare cell range — only produces a CellRef when both sides - // are plain idents parseable as cell addresses. Any other `:` here - // would be a parse error anyway (Colon has no other use in - // expression position), so rewinding is safe. if self.peek() == &Token::Colon { if let Expr::Ident(ref start_name) = left { if let Some((c0, r0)) = parse_cell_address(start_name) { @@ -1300,8 +1170,6 @@ impl Parser { fn parse_comparison(&mut self) -> Result { let mut left = self.parse_additive()?; loop { - // `is` is a comparison-precedence keyword whose right side is a - // type identifier (literal name), not a sub-expression. if self.peek() == &Token::Is { self.advance(); let type_name = match self.advance() { @@ -1367,7 +1235,7 @@ impl Parser { let base = self.parse_unary()?; if self.peek() == &Token::Caret { self.advance(); - let exp = self.parse_power()?; // right-associative + let exp = self.parse_power()?; Ok(Expr::BinOp(Op::Pow, Box::new(base), Box::new(exp))) } else { Ok(base) @@ -1425,9 +1293,6 @@ impl Parser { match self.peek().clone() { Token::Number(n) => { self.advance(); Ok(Expr::Num(n)) } Token::Spice(n, unit) => { - // Lower to the 2-array the interpreter recognizes as spice: - // [scalar, unit]. No new AST variant — existing arithmetic - // handles arrays and the strip/retag helpers key off shape. self.advance(); Ok(Expr::Array(vec![Expr::Num(n), Expr::Str(unit)])) } @@ -1435,9 +1300,6 @@ impl Parser { Token::Bool(b) => { self.advance(); Ok(Expr::Bool(b)) } Token::At => self.parse_cell_ref(), Token::Ident(name) => { - // `solve!(VAR[, |from] SOURCE_FN)` — inversion macro. Detected - // here so any identifier followed by `!` still errors cleanly - // (no other macros exist yet; keep the check narrow). if name == "solve" && self.tokens.get(self.pos + 1) == Some(&Token::Bang) && self.tokens.get(self.pos + 2) == Some(&Token::LParen) @@ -1480,59 +1342,27 @@ pub struct FnDef { body: Vec, } -/// Lowered form of both `solve!(var, fn)` and the `where` clause. Reached by -/// two parse paths that converge on the same shape: pick a parameter of an -/// existing fn, make the fn's return the new first parameter, numerically -/// invert on call. #[derive(Clone, Debug)] pub struct SolvedFnDef { source_fn: String, - /// Index of the solved-for parameter within `source_fn`'s param list. solve_param_idx: usize, - /// Parameter names of the new (inverted) function. Slot 0 is the - /// target-value name (what the source fn would have returned). new_params: Vec, } pub struct Interpreter { vars: HashMap, - /// Sticky declared types. A `let x: T = ...` records `T` here; subsequent - /// `x = ...` reassignments must coerce to `T` via the round-trip rule. - /// Re-declaring `let x` (with or without a type) replaces the entry. var_types: HashMap, fns: HashMap, - /// Inverted functions built from `solve!(…)` / `let … where …`. Queried - /// by `eval_call` between the user-fn lookup and the builtin dispatch. solved_fns: HashMap, - /// Sticky flag set by `exec_line` when its input includes `use spice`. - /// Gates postfix SPICE notation in the tokenizer. Once on, stays on for - /// the interpreter's lifetime — modules are short-lived enough that - /// per-block granularity isn't worth the plumbing. spice_enabled: bool, - /// Tables registered from the viewport before eval. Keyed by fully - /// qualified lowercase name: a bare global name (e.g. `"budget"`), a - /// positional `"table_N"`, or a cross-block `"block_N::table_N"` / - /// `"blockname::tablename"`. The same table may be registered under - /// multiple keys (heading name, positional name, cross-block alias). tables: HashMap>>, - /// Cell formulas' scope anchor. Set to `Some(lowercased_table_name)` - /// while a formula inside that table is being evaluated, so bare `A1` - /// refs resolve against the right table. None in text-block scope. current_table: Option, - /// Current-block scope for resolving unqualified H4 (block-scoped) - /// table names. Lowercased block name. Set per-module by the eval - /// driver. current_block: Option, - /// Log of cell writes that happened during this eval pass. Drained by - /// the viewport after eval to apply mutations back to live TableBlocks. table_writes: Vec, } #[derive(Debug, Clone)] pub struct TableWrite { - /// Fully-resolved registry key — matches one of the strings the - /// viewport passed to `register_table`. The viewport's own name→id - /// map resolves this back to a `TableBlock`. pub table_key: String, pub cell: (u32, u32), pub value: String, @@ -1556,37 +1386,27 @@ impl Interpreter { } } - /// Register a table's current cell contents under `name` (lowercased). - /// Overwrites any prior registration under the same key. Called before - /// eval by the viewport for every table in the focused block's scope. + /// registers a table's contents under `name` (lowercased). pub fn register_table(&mut self, name: &str, rows: Vec>) { self.tables.insert(name.to_lowercase(), rows); } - /// Set the current-table anchor for bare cell refs in cell formulas. - /// Passing None restores text-block scope. + /// sets the table anchor for bare cell refs. pub fn set_current_table(&mut self, name: Option<&str>) { self.current_table = name.map(|s| s.to_lowercase()); } - /// Set the current-block anchor for resolving H4 (block-scoped) table - /// names without an explicit `block::` prefix. + /// sets the block anchor for unqualified block-scoped table names. pub fn set_current_block(&mut self, name: Option<&str>) { self.current_block = name.map(|s| s.to_lowercase()); } - /// Consume cell writes accumulated during the last eval. Viewport - /// applies each to the matching TableBlock after eval returns. + /// drains cell writes accumulated during the last eval. pub fn drain_table_writes(&mut self) -> Vec { std::mem::take(&mut self.table_writes) } - /// Overwrite a cell's raw string in the registered table without - /// logging a write. Used by the viewport's cell-formula loop to - /// thread a formula's computed value back into the table registry - /// so downstream reads (from text blocks, other formulas) see the - /// computed result instead of the `/=...` string. `name` is an - /// already-registered key; no-op if the table isn't registered. + /// overwrites a cell's raw string in the registered table without logging a write. pub fn write_cell_raw(&mut self, name: &str, col: u32, row: u32, value: &str) { let key = name.to_lowercase(); if let Some(rows) = self.tables.get_mut(&key) { @@ -1598,11 +1418,7 @@ impl Interpreter { } } - /// Build the HashMap key under which a table was registered. Bare refs - /// (no block qualifier) first try the name directly (H3 global or - /// positional `table_N`), then fall back to `current_block::name` (H4 - /// local to the caller's module). Returns None for refs that don't - /// resolve to any registered table. + /// resolves a (block, table) pair to a registered HashMap key. fn resolve_table_key(&self, block: Option<&str>, table: Option<&str>) -> Option { match (block, table) { (Some(b), Some(t)) => { @@ -1625,10 +1441,7 @@ impl Interpreter { } } - /// Same as `resolve_table_key` but returns the key even when the table - /// isn't registered — used by the write path so a `@Table:A1 = ...` - /// still logs a write in a predictable location (the canonical bare - /// form) even if the viewport hadn't registered it yet. + /// resolves a (block, table) pair, returning a synthesized key when unregistered. fn resolve_table_key_lenient(&self, block: Option<&str>, table: Option<&str>) -> Option { match (block, table) { (Some(b), Some(t)) => Some(format!("{}::{}", b.to_lowercase(), t.to_lowercase())), @@ -1688,8 +1501,6 @@ impl Interpreter { if trimmed.is_empty() { return Ok(None); } - // Pre-scan for `use spice` so the tokenizer sees spice mode even - // when the import is later in the block than the first literal. if !self.spice_enabled && source_enables_spice(trimmed) { self.spice_enabled = true; } @@ -1706,9 +1517,7 @@ impl Interpreter { } } - /// Evaluate a pre-parsed cell formula. Caller is responsible for having - /// called `set_current_table(Some(owning_table))` first, so bare - /// `A1`-style refs resolve to the right table. + /// evaluates a pre-parsed cell formula. pub fn eval_formula(&mut self, f: &ParsedFormula) -> Result { self.eval_expr(&f.ast, 0) } @@ -1724,24 +1533,16 @@ impl Interpreter { self.eval_expr(&e, 0) } - /// Explicitly enable SPICE notation. Used by the viewport when a - /// sibling block has `use spice` — the formula parser doesn't run - /// through `exec_line`, so the caller has to flip the flag itself. pub fn set_spice_enabled(&mut self, on: bool) { self.spice_enabled = on; } - /// Query whether SPICE notation is currently active. pub fn spice_enabled(&self) -> bool { self.spice_enabled } fn exec_stmt(&mut self, stmt: &Stmt, depth: u32) -> Result { match stmt { - // `let name = solve!(var, source_fn)` — short-circuit before the - // generic Let path so the macro never has to produce a runtime - // Value. Every other Expr reaches `eval_expr`; SolveMacro is the - // one shape that only makes sense as a binding target. Stmt::Let(name, _type_ann, Expr::SolveMacro { var, source_fn }) => { let def = self.build_solved_fn_def(source_fn, var, None)?; self.fns.remove(name); @@ -1751,11 +1552,6 @@ impl Interpreter { Ok(Value::Void) } Stmt::SolveDef { name, params, target_var, source_fn, source_args, result_var } => { - // Math-form validation: result_var must be slot 0 of the new - // fn's params, and the remaining source_args must align with - // the source fn's params modulo target_var. We check the - // params-shape here and delegate the target-in-source check - // to `build_solved_fn_def`. if params.first().map(|s| s.as_str()) != Some(result_var.as_str()) { return Err(format!( "inversion: result variable '{}' must be the first parameter of '{}'", @@ -1787,9 +1583,6 @@ impl Interpreter { )); } }; - // `let` always overwrites any prior type stickiness — this is - // the only path that can change a binding's type. Untyped - // `let x = ...` removes a previously sticky type as well. if let Some(t) = type_ann { self.var_types.insert(name.clone(), t.clone()); } else { @@ -1800,11 +1593,6 @@ impl Interpreter { } Stmt::Assign(name, expr) => { let val = self.eval_expr(expr, depth)?; - // Reassignment respects the binding's sticky annotation. - // Value-types enforce lossy-round-trip; unit-types rewrap - // the new value with the declared label (overriding whatever - // unit the RHS algebra produced). On failure the previous - // binding is preserved and the error says so explicitly. if let Some(t) = self.var_types.get(name).cloned() { match apply_type_annotation(&val, Some(&t)) { Ok(v) => { @@ -1886,8 +1674,6 @@ impl Interpreter { Err(format!("\x00return:{}", encode_return(&val))) } Stmt::Use(_, _) => { - // No-op at exec time. Use declarations are resolved - // externally by the module evaluation pipeline. Ok(Value::Void) } Stmt::CellAssign { block, table, cell, value } => { @@ -1921,17 +1707,12 @@ impl Interpreter { Expr::Str(s) => Ok(Value::Str(s.clone())), Expr::Bool(b) => Ok(Value::Bool(*b)), Expr::Ident(name) => { - // Local bindings shadow built-ins (standard scope rule), so a - // user `let pi = 3` would still hide the constant. Built-ins - // are the fallback when no binding exists. if let Some(v) = self.vars.get(name) { return Ok(v.clone()); } if let Some(v) = builtin_constant(name) { return Ok(v); } - // Cell-formula context fallback: inside a cell, bare `A1` - // resolves to the current table's (col, row). if self.current_table.is_some() { if let Some((col, row)) = parse_cell_address(name) { return self.read_cell(None, None, col, row); @@ -1974,10 +1755,6 @@ impl Interpreter { } } Expr::UnaryOp(Op::Strip, inner) => { - // `~expr` demotes a typed value to its raw form for loose - // structural comparison: bool→number (false=0, true=1), - // a spice-shaped [Number, Str] array → its scalar, other - // arrays pass through untouched. let v = self.eval_expr(inner, depth)?; Ok(match v { Value::Bool(b) => Value::Number(if b { 1.0 } else { 0.0 }), @@ -2037,7 +1814,6 @@ impl Interpreter { } fn eval_binop(&mut self, op: &Op, lhs: &Expr, rhs: &Expr, depth: u32) -> Result { - // short-circuit for logical ops if matches!(op, Op::And) { let l = self.eval_expr(lhs, depth)?; if !l.truthy() { return Ok(Value::Bool(false)); } @@ -2053,17 +1829,11 @@ impl Interpreter { let l_raw = self.eval_expr(lhs, depth)?; let r_raw = self.eval_expr(rhs, depth)?; - // Peel the index-0 scalar off each spice-tagged operand; the index-1 - // unit label is carried through algebraically below. Plain (non- - // spice) values return unit = None. let (l, l_unit) = unwrap_spice(&l_raw); let (r, r_unit) = unwrap_spice(&r_raw); let had_unit = l_unit.is_some() || r_unit.is_some(); let la = l_unit.unwrap_or_default(); let ra = r_unit.unwrap_or_default(); - // Operation-specific label algebra. Non-arithmetic ops (&&, ||, - // comparisons, equality) drop the unit since the result isn't a - // measurable quantity anyway. let unit_after: Option = if !had_unit { None } else { @@ -2072,8 +1842,6 @@ impl Interpreter { Op::Mul => combine_unit_mul(&la, &ra), Op::Div => combine_unit_div(&la, &ra), Op::Pow => { - // The exponent is conventionally unitless (`F^2`, not - // `F^(second)`), so only the base's unit propagates. if let Value::Number(e) = r { combine_unit_pow(&la, e) } else if la.is_empty() { @@ -2087,7 +1855,6 @@ impl Interpreter { }; let result = match (op, &l, &r) { - // number arithmetic (Op::Add, Value::Number(a), Value::Number(b)) => Ok(Value::Number(a + b)), (Op::Sub, Value::Number(a), Value::Number(b)) => Ok(Value::Number(a - b)), (Op::Mul, Value::Number(a), Value::Number(b)) => Ok(Value::Number(a * b)), @@ -2096,20 +1863,17 @@ impl Interpreter { (Op::Mod, Value::Number(a), Value::Number(b)) => Ok(Value::Number(a % b)), (Op::Pow, Value::Number(a), Value::Number(b)) => Ok(Value::Number(a.powf(*b))), - // string concatenation (Op::Add, Value::Str(a), Value::Str(b)) => Ok(Value::Str(format!("{}{}", a, b))), (Op::Add, Value::Str(a), Value::Number(b)) => Ok(Value::Str(format!("{}{}", a, format_number(*b)))), (Op::Add, Value::Number(a), Value::Str(b)) => Ok(Value::Str(format!("{}{}", format_number(*a), b))), (Op::Add, Value::Str(a), Value::Bool(b)) => Ok(Value::Str(format!("{}{}", a, b))), (Op::Add, Value::Bool(a), Value::Str(b)) => Ok(Value::Str(format!("{}{}", a, b))), - // number comparisons (Op::Lt, Value::Number(a), Value::Number(b)) => Ok(Value::Bool(a < b)), (Op::Gt, Value::Number(a), Value::Number(b)) => Ok(Value::Bool(a > b)), (Op::Lte, Value::Number(a), Value::Number(b)) => Ok(Value::Bool(a <= b)), (Op::Gte, Value::Number(a), Value::Number(b)) => Ok(Value::Bool(a >= b)), - // equality (Op::Eq, Value::Number(a), Value::Number(b)) => Ok(Value::Bool(a == b)), (Op::Eq, Value::Str(a), Value::Str(b)) => Ok(Value::Bool(a == b)), (Op::Eq, Value::Bool(a), Value::Bool(b)) => Ok(Value::Bool(a == b)), @@ -2130,10 +1894,6 @@ impl Interpreter { return Err("maximum call depth exceeded".into()); } - // User-defined functions win over built-ins — same shadow rule as - // variables shadowing builtin constants (`let pi = 3` overrides - // the `pi` constant). A note can define `fn max(a, b) { ... }` - // without being blocked by the aggregate `max` builtin below. if self.fns.contains_key(name) { return self.call_user_fn(name, args, depth); } @@ -2141,10 +1901,6 @@ impl Interpreter { return self.call_solved_fn(name, args, depth); } - // Math builtins are unit-transparent: unwrap the index-0 scalar, - // compute, retag with the SAME unit label. Unit is notation, not - // physics — sqrt(F) stays F, sin(V) stays V, log(A) stays A. The - // user has `~` if they need a scalar. match name { "sin" | "cos" | "tan" | "asin" | "acos" | "atan" | "sqrt" | "abs" | "ln" | "log" => { @@ -2202,11 +1958,6 @@ impl Interpreter { }; return Ok(retag_spice(Value::Number(result), unit)); } - // Aggregates over a flattened numeric view of the argument. - // Accept anything — cell ranges arrive as 2D arrays, literal - // arrays as 1D, a bare number as a length-1 sequence. Non- - // numeric cells (strings, bools, voids) are skipped so mixed - // tables Just Work. "sum" | "avg" | "min" | "max" | "count" | "std_devp" | "std_devs" => { if args.len() != 1 { return Err(format!("{}() expects 1 argument", name)); @@ -2274,15 +2025,8 @@ impl Interpreter { arg_vals.push(self.eval_expr(arg, depth)?); } - // save current scope (vars + sticky types), set up function scope. - // Function-local `let x: T = ...` must NOT leak its type stickiness - // back to the caller's `x`, so we restore var_types alongside vars. let saved_vars = self.vars.clone(); let saved_types = self.var_types.clone(); - // Bind each arg to its param name. If the param carries a type - // annotation, apply it first — value-types coerce, unit-labels - // rewrap the arg as spice with the declared label. Also stash the - // type in var_types so reassignments in the body respect it. for ((pname, pty), val) in fdef.params.iter().zip(arg_vals) { let bound = match pty { Some(t) => apply_type_annotation(&val, Some(t)) @@ -2320,12 +2064,6 @@ impl Interpreter { apply_fn_return_type(&fdef.return_type, result, name) } - /// Validate the pieces of an inversion (from either the macro or the math - /// form) and assemble the lowered `SolvedFnDef`. For the macro form, - /// `math_form` is None and `new_params` is derived from the source fn. - /// For the math form, `math_form = Some((new_params, source_args))` and - /// we cross-check that source_args line up with the source fn's params, - /// and that new_params[1..] matches source_args minus the target. fn build_solved_fn_def( &self, source_fn: &str, @@ -2392,9 +2130,6 @@ impl Interpreter { }) } - /// Dispatch a call to an inverted function. Arg 0 is the target value - /// (what the source fn would have returned); args 1..n are passed - /// through as the source fn's non-solved parameters. fn call_solved_fn(&mut self, name: &str, args: &[Expr], depth: u32) -> Result { let def = self.solved_fns.get(name).cloned() .ok_or_else(|| format!("undefined function '{}'", name))?; @@ -2408,8 +2143,6 @@ impl Interpreter { for a in args { arg_vals.push(self.eval_expr(a, depth)?); } - // Peel spice wrappers off both the target and the fixed args. The - // solver only cares about the scalar; units are a display concern. let (target_val, _) = unwrap_spice(&arg_vals[0]); let target = match target_val { Value::Number(n) => n, @@ -2434,11 +2167,7 @@ impl Interpreter { Ok(Value::Number(result)) } - /// Damped Newton's method with a secant-approximated derivative. The - /// damping (step halving when a probe yields NaN/Inf/error) keeps Newton - /// from shooting out of a domain boundary — e.g. a target requiring - /// sqrt(negative) because the initial guess was on the wrong side of - /// the well. + /// runs damped Newton's method with a secant-approximated derivative. fn numerical_solve( &mut self, def: &SolvedFnDef, @@ -2462,8 +2191,6 @@ impl Interpreter { )); } Err(e) => { - // Preserve the source fn's error — it usually says - // exactly what's wrong (wrong arity, bad unit, etc.). return Err(format!( "solve: '{}' at iteration {}: {}", def.source_fn, iter, e @@ -2473,14 +2200,10 @@ impl Interpreter { if fx.abs() < EPSILON { return Ok(x); } - // Secant-probe for the derivative. Step size scales with x so - // the probe stays meaningful across orders of magnitude. let h = (x.abs() * 1e-6).max(1e-9); let fx_h = self.probe_finite(def, x + h, fixed, depth) .or_else(|_| self.probe_finite(def, x - h, fixed, depth).map(|v| { - // If forward probe failed but backward works, flip the - // sign so the derivative still makes sense. - 2.0 * fx - v // produces same slope as (v - fx) / -h via (fx - v)/h + 2.0 * fx - v })) .map_err(|_| format!( "solve: could not probe derivative of '{}' near x={}", @@ -2494,8 +2217,6 @@ impl Interpreter { )); } let step = fx / dfx; - // Line search with step halving. Accept the first alpha where - // the source fn is finite at x - alpha * step. let mut alpha: f64 = 1.0; loop { let candidate = x - alpha * step; @@ -2522,9 +2243,6 @@ impl Interpreter { )) } - /// Evaluate the source fn at `x`, returning an error if the result isn't - /// a finite number. Separate from `eval_source_at` for use in the probe - /// path where we want to recover by trying a different direction. fn probe_finite( &mut self, def: &SolvedFnDef, @@ -2536,10 +2254,6 @@ impl Interpreter { if v.is_finite() { Ok(v) } else { Err("non-finite".into()) } } - /// Invoke the source fn with `x` spliced into the solved-for slot. - /// If the source fn is annotated (typed params or a return type), its - /// result comes back spice-tagged — unwrap that before the solver sees - /// it, since Newton only cares about the scalar. fn eval_source_at( &mut self, def: &SolvedFnDef, @@ -2579,9 +2293,6 @@ fn encode_return(val: &Value) -> String { Value::Bool(b) => format!("b:{}", b), Value::Str(s) => format!("s:{}", s), Value::Void => "v:".into(), - // Spice-shaped array preserves its structure through the return - // sentinel. Units are always uppercase ASCII so `|` is a safe - // separator. Value::Array(a) if a.len() == 2 => { if let (Value::Number(n), Value::Str(u)) = (&a[0], &a[1]) { return format!("q:{}|{}", n, u); @@ -2622,8 +2333,6 @@ fn type_name(v: &Value) -> &'static str { } } -/// Built-in mathematical constants. Looked up after local bindings, so a -/// user-defined `pi` shadows the built-in (standard scope rule). fn builtin_constant(name: &str) -> Option { match name { "pi" => Some(Value::Number(std::f64::consts::PI)), @@ -2631,10 +2340,6 @@ fn builtin_constant(name: &str) -> Option { } } -/// Runtime type-of test for the `is` keyword. Numbers match both `float` -/// (always) and `int` (only when integer-valued and finite) — int is treated -/// as a subset of float so a literal `1.0` is `is int` true and `is float` -/// true. Bool, str, and array match their own kind name only. fn value_is_kind(v: &Value, kind: &str) -> bool { match (kind, v) { ("int", Value::Number(n)) => *n == n.trunc() && n.is_finite(), @@ -2647,12 +2352,8 @@ fn value_is_kind(v: &Value, kind: &str) -> bool { } } -/// Cast `v` into the named target type, returning `Some(converted)` if the -/// conversion is exact for that single direction. Used as the primitive in -/// `coerce_to`'s round-trip rule. fn try_cast(v: &Value, target: &str) -> Option { match (target, v) { - // Identity (already the right type). ("int", Value::Number(n)) if *n == n.trunc() && n.is_finite() => { Some(Value::Number(*n)) } @@ -2660,7 +2361,6 @@ fn try_cast(v: &Value, target: &str) -> Option { ("bool", Value::Bool(_)) => Some(v.clone()), ("str", Value::Str(_)) => Some(v.clone()), - // bool <-> number: 0 and 1 only. ("bool", Value::Number(n)) => { if *n == 0.0 { Some(Value::Bool(false)) @@ -2673,7 +2373,6 @@ fn try_cast(v: &Value, target: &str) -> Option { ("int", Value::Bool(b)) => Some(Value::Number(if *b { 1.0 } else { 0.0 })), ("float", Value::Bool(b)) => Some(Value::Number(if *b { 1.0 } else { 0.0 })), - // bool <-> str: only the literals. ("str", Value::Bool(b)) => Some(Value::Str(b.to_string())), ("bool", Value::Str(s)) => match s.as_str() { "true" => Some(Value::Bool(true)), @@ -2681,7 +2380,6 @@ fn try_cast(v: &Value, target: &str) -> Option { _ => None, }, - // number <-> str: parseable, exact representation. ("str", Value::Number(n)) => Some(Value::Str(format_number(*n))), ("int", Value::Str(s)) => s .parse::() @@ -2694,10 +2392,6 @@ fn try_cast(v: &Value, target: &str) -> Option { } } -/// Round-trip clean coercion: cast to target, cast back into the value's -/// original variant, then forward to target again. Accepts iff both forward -/// casts agree AND the round-tripped value equals the original. Lossy -/// conversions (3.7 → int, 2.1 → bool, -1 → bool) fail. fn coerce_to(val: &Value, target: &str) -> Result { if !is_known_type(target) { return Err(format!("unknown type annotation: {}", target)); @@ -2715,15 +2409,10 @@ fn coerce_to(val: &Value, target: &str) -> Result { } }; - // Identity (already-the-target) shortcut: if the forward cast is a - // structural no-op, no round-trip is needed. if values_equal(&t1, val) { return Ok(t1); } - // The "source type" for the back-cast is the broadest target try_cast - // knows for the value's discriminant. For Number we use "float" since - // that's an unconstrained f64; for Bool/Str their own name. let back_target = match val { Value::Number(_) => "float", Value::Bool(_) => "bool", @@ -2770,11 +2459,6 @@ fn values_equal(a: &Value, b: &Value) -> bool { } } -/// Apply the declared type to a value being bound. Value-types (int, float, -/// bool, str) enforce round-trip coercion; any other identifier is treated -/// as a unit label and spice-wraps the value with that label. `let x: F = -/// 22n` discards whatever unit `22n` already had and tags x as F — the -/// declared output wins, as the user spec'd. fn apply_type_annotation(val: &Value, ann: Option<&str>) -> Result { match ann { Some(a) if is_known_type(a) => coerce_to(val, a), @@ -2796,9 +2480,6 @@ fn apply_unit_annotation(val: &Value, unit: &str) -> Result { } } -/// Apply a function's declared return type to its result. Void returns -/// pass through untouched — a declared return type only makes sense when -/// the body actually produced a value. fn apply_fn_return_type(ret_ty: &Option, val: Value, fn_name: &str) -> Result { match ret_ty { Some(t) if !matches!(val, Value::Void) => apply_type_annotation(&val, Some(t)) @@ -2824,7 +2505,6 @@ pub enum EvalFormat { // --- Module support --- -/// Collected top-level bindings from a module after evaluation. #[derive(Debug, Clone, Default)] pub struct ModuleExports { pub vars: HashMap, @@ -2832,16 +2512,12 @@ pub struct ModuleExports { pub solved_fns: HashMap, } -/// A parsed `use` declaration: module name and optional specific item. #[derive(Debug, Clone, PartialEq)] pub struct UseDecl { pub module: String, pub item: Option, } -/// A direct cell reference surfaced by a formula — used by the viewport's -/// dependency graph. Bare refs inside a cell formula are resolved against -/// the owning table before the ref is emitted. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct FormulaRef { pub block: Option, @@ -2849,23 +2525,14 @@ pub struct FormulaRef { pub cell: (u32, u32), } -/// Opaque pre-parsed cell formula. Viewport parses each `/=...` cell once, -/// harvests `refs()` to build a dep graph, then evaluates in topo order via -/// `Interpreter::eval_formula`. pub struct ParsedFormula { ast: Expr, } -/// Parse a cell formula body (the text AFTER `/=`). Produces a ParsedFormula -/// that can be evaluated later against any interpreter with the owning -/// table set as current_table. SPICE notation is off by default — call -/// `parse_formula_with_spice` from a context that knows the block state. pub fn parse_formula(text: &str) -> Result { parse_formula_with_spice(text, false) } -/// Parse a cell formula with explicit SPICE-mode gating. The viewport -/// passes `true` when the formula's owning block imports `spice`. pub fn parse_formula_with_spice(text: &str, spice: bool) -> Result { let trimmed = text.trim(); if trimmed.is_empty() { @@ -2878,10 +2545,6 @@ pub fn parse_formula_with_spice(text: &str, spice: bool) -> Result Vec { let mut out = Vec::new(); collect_formula_refs(&self.ast, current_table, &mut out); @@ -2958,7 +2621,6 @@ fn collect_formula_refs(expr: &Expr, current_table: &str, out: &mut Vec ModuleExports { ModuleExports { vars: self.vars.clone(), @@ -2967,9 +2629,7 @@ impl Interpreter { } } - /// Pre-populate scope with another module's exports. All bindings - /// are imported flat (as if written locally). Existing bindings - /// with the same name are overwritten. + /// imports every binding from `exports` into the current scope. pub fn import_all(&mut self, exports: &ModuleExports) { for (name, val) in &exports.vars { self.vars.insert(name.clone(), val.clone()); @@ -2982,8 +2642,6 @@ impl Interpreter { } } - /// Import a single named binding from exports. Returns false if - /// the name doesn't exist in the exports. pub fn import_item(&mut self, exports: &ModuleExports, item: &str) -> bool { let mut found = false; if let Some(val) = exports.vars.get(item) { @@ -3002,9 +2660,6 @@ impl Interpreter { } } -/// Scan text for `use` declarations without executing anything. -/// Returns the list of UseDecls found. Lines that fail to parse -/// are silently skipped (they'll be treated as prose). pub fn extract_use_declarations(text: &str) -> Vec { let mut decls = Vec::new(); for line in text.lines() { @@ -3026,13 +2681,9 @@ pub fn interpret_document(lines: &[(usize, &str, bool)]) -> Vec { interpret_document_with(&mut interp, lines) } -/// Like `interpret_document`, but uses an existing interpreter (with -/// pre-populated scope from module imports). pub fn interpret_document_with(interp: &mut Interpreter, lines: &[(usize, &str, bool)]) -> Vec { let mut results = Vec::new(); - // First pass: collect the entire program from cordial lines - // and evaluate line by line, recording results for eval lines let mut brace_depth: i32 = 0; let mut block_acc: Vec = Vec::new(); @@ -3069,7 +2720,6 @@ pub fn interpret_document_with(interp: &mut Interpreter, lines: &[(usize, &str, } } else { let trimmed = content.trim(); - // track brace depth for multi-line blocks let opens = trimmed.matches('{').count() as i32; let closes = trimmed.matches('}').count() as i32; @@ -3098,12 +2748,6 @@ pub fn interpret_document_with(interp: &mut Interpreter, lines: &[(usize, &str, results } -/// Flatten a value into the sequence of numbers an aggregate sees. Cell -/// ranges arrive as nested `Value::Array`s (rows of cells); literal -/// arrays may also be 1D. Strings that happen to be number-parseable DO -/// count — matching how cell reads auto-promote. Non-numeric strings, -/// booleans, voids, and errors are skipped silently so a `sum` over a -/// column with a header row still does the right thing. fn flatten_numbers(v: &Value) -> Vec { let mut out = Vec::new(); walk(v, &mut out); @@ -3127,10 +2771,6 @@ fn flatten_numbers(v: &Value) -> Vec { } } -/// Dispatch the numeric aggregation after the argument has been flattened. -/// Kept separate from the call-site so the same core is used from any -/// future aggregate (median, mode, variance, …) without rewriting the -/// unpacking. fn aggregate(name: &str, nums: &[f64]) -> Result { match name { "sum" => Ok(Value::Number(nums.iter().sum())), @@ -3252,7 +2892,6 @@ mod tests { #[test] fn logical_operators_keyword_forms() { - // and/or/not are interchangeable with &&/||/!. assert_eq!(eval_one("true and false"), "false"); assert_eq!(eval_one("true or false"), "true"); assert_eq!(eval_one("not true"), "false"); @@ -3261,31 +2900,26 @@ mod tests { #[test] fn pi_constant() { - // pi resolves to π without parens. let r = eval_one("pi"); assert!(r.starts_with("3.14159"), "expected pi, got: {}", r); - // Usable in expressions. let r2 = eval_one("pi * 2"); assert!(r2.starts_with("6.283185"), "expected 2π, got: {}", r2); } #[test] fn pi_can_be_shadowed_by_let() { - // Standard scope rule: a local binding hides the built-in. let input = "let pi = 3\n/= pi"; assert_eq!(eval(input), "3"); } #[test] fn strip_operator_bool_to_number() { - // ~true and ~false demote to their numeric form. assert_eq!(eval_one("~true"), "1"); assert_eq!(eval_one("~false"), "0"); } #[test] fn strip_operator_bridges_bool_and_number() { - // The motivating use case: comparing a typed bool to a typed int. let input = "let this: bool = 0\nlet that: int = 0\n/= ~this == ~that"; assert_eq!(eval(input), "true"); } @@ -3308,14 +2942,11 @@ mod tests { assert_eq!(eval_one("false is bool"), "true"); assert_eq!(eval_one("\"hello\" is str"), "true"); assert_eq!(eval_one("[1, 2, 3] is array"), "true"); - // int and float overlap for whole-valued numbers (int ⊂ float). assert_eq!(eval_one("1 is int"), "true"); assert_eq!(eval_one("1 is float"), "true"); assert_eq!(eval_one("1.0 is int"), "true"); - // Non-integer floats are NOT int. assert_eq!(eval_one("1.5 is int"), "false"); assert_eq!(eval_one("1.5 is float"), "true"); - // Wrong-kind checks. assert_eq!(eval_one("1 is bool"), "false"); assert_eq!(eval_one("true is int"), "false"); assert_eq!(eval_one("\"42\" is int"), "false"); @@ -3329,7 +2960,6 @@ mod tests { #[test] fn is_keyword_combines_with_logic() { - // is at comparison precedence: parses as `(x is int) and (x > 0)`. let input = "let x = 5\n/= x is int and x > 0"; assert_eq!(eval(input), "true"); let input2 = "let x = 5\n/= x is bool or x is int"; @@ -3338,10 +2968,8 @@ mod tests { #[test] fn logical_operators_mixed_forms() { - // Symbolic and keyword forms in the same expression. assert_eq!(eval_one("true and not false"), "true"); assert_eq!(eval_one("(true or false) and not false"), "true"); - // !or composition gives nand semantics: not(a or b) assert_eq!(eval_one("!(true or true)"), "false"); assert_eq!(eval_one("not (false or false)"), "true"); } @@ -3392,8 +3020,6 @@ mod tests { #[test] fn type_annotation_int_lossy_rejected() { - // Round-trip rule: 3.7 -> int -> float != 3.7, so this is lossy and - // must error. let input = "let x: int = 3.7\n/= x"; let result = eval(input); assert!(result.contains("error") || result.contains("lossy"), "should reject lossy: {}", result); @@ -3401,15 +3027,12 @@ mod tests { #[test] fn type_annotation_int_exact_accepted() { - // 3.0 -> int -> float -> int is exact, so this passes. let input = "let x: int = 3.0\n/= x"; assert_eq!(eval(input), "3"); } #[test] fn type_stickiness_reassign_lossy_rejected() { - // boolFlag is bool; assigning 0.1 to it must fail the round-trip - // (0.1 -> bool fails because 0.1 isn't 0 or 1). let input = "let f: bool = 0\nf = 0.1\n/= f"; let result = eval(input); assert!(result.contains("error") || result.contains("lossy") || result.contains("false"), @@ -3418,14 +3041,12 @@ mod tests { #[test] fn type_stickiness_reassign_clean_accepted() { - // 1 cleanly coerces to true. let input = "let f: bool = 0\nf = 1\n/= f"; assert_eq!(eval(input), "true"); } #[test] fn type_stickiness_redeclare_changes_type() { - // `let` overrides any prior type stickiness. let input = "let x: int = 3\nlet x: bool = 1\n/= x"; assert_eq!(eval(input), "true"); } @@ -3457,7 +3078,6 @@ mod tests { #[test] fn type_annotation_str_from_int_clean() { - // Round-trip: 42 -> "42" -> 42 is exact, so this coerces cleanly. let input = "let x: str = 42\n/= x"; assert_eq!(eval(input), "42"); } @@ -3492,7 +3112,6 @@ mod tests { #[test] fn error_recovery() { let input = "let x = bad_var\nlet y = 5\n/= y"; - // x assignment fails, but y should still work let result = eval(input); assert!(result.contains("5"), "should recover and eval y: {}", result); } @@ -3701,7 +3320,6 @@ fn find(arr, target) { #[test] fn use_statement_parses() { - // use is a no-op at exec time — just check it doesn't error let mut interp = Interpreter::new(); assert!(interp.exec_line("use budget").is_ok()); assert!(interp.exec_line("use budget::ramp").is_ok()); @@ -3756,7 +3374,6 @@ fn find(arr, target) { assert!(module_b.eval_expr_str("y").is_err()); } - // --- Cell references --- #[test] fn cell_address_parses_A1() { @@ -3988,14 +3605,12 @@ fn find(arr, target) { #[test] fn cell_address_case_insensitive_parse() { - // `@BUDGET:a1` should work identically to `@budget:A1`. let mut i = Interpreter::new(); i.register_table("budget", vec![vec!["7".into()]]); let v = i.eval_expr_str("@BUDGET:a1").unwrap(); assert!(matches!(v, Value::Number(n) if n == 7.0)); } - // --- Aggregate fns --- fn approx(a: f64, b: f64) -> bool { (a - b).abs() < 1e-9 @@ -4060,14 +3675,11 @@ fn find(arr, target) { vec!["3".into(), "".into(), "4".into()], ]); let v = i.eval_expr_str("count(@t)").unwrap(); - // Four parseable numbers in the flattened view. assert!(matches!(v, Value::Number(n) if n == 4.0)); } #[test] fn std_devp_matches_formula() { - // Population std-dev of {2,4,4,4,5,5,7,9}: - // mean = 5, variance = 4, stddev = 2. let mut i = Interpreter::new(); let v = i.eval_expr_str("std_devp([2, 4, 4, 4, 5, 5, 7, 9])").unwrap(); match v { @@ -4078,7 +3690,6 @@ fn find(arr, target) { #[test] fn std_devs_differs_from_std_devp() { - // Sample stddev uses (n-1) in the denominator. let mut i = Interpreter::new(); let p = match i.eval_expr_str("std_devp([1, 2, 3, 4])").unwrap() { Value::Number(n) => n, @@ -4104,7 +3715,6 @@ fn find(arr, target) { match v { Value::Number(n) => assert!(approx(n, 3.14)), _ => panic!() } let v = i.eval_expr_str("round(3.14159, 4)").unwrap(); match v { Value::Number(n) => assert!(approx(n, 3.1416)), _ => panic!() } - // Default digits (0) still works. let v = i.eval_expr_str("round(3.7)").unwrap(); assert!(matches!(v, Value::Number(n) if n == 4.0)); } @@ -4140,10 +3750,7 @@ fn find(arr, target) { assert!(i.eval_expr_str("avg(1, 2)").is_err()); } - // --- Function inversion (solve! / where) --- - fn solve_interp() -> Interpreter { - // Reusable setup: square fn on one param. Easy to verify by hand. let mut i = Interpreter::new(); i.exec_line("fn square(x) { x * x }").unwrap(); i @@ -4157,7 +3764,7 @@ fn find(arr, target) { let def = &i.solved_fns["invsq"]; assert_eq!(def.source_fn, "square"); assert_eq!(def.solve_param_idx, 0); - assert_eq!(def.new_params.len(), 1); // just the target slot + assert_eq!(def.new_params.len(), 1); } #[test] @@ -4196,8 +3803,6 @@ fn find(arr, target) { #[test] fn math_form_result_not_first_errors() { let mut i = solve_interp(); - // `x` is the target but also listed as the result position — the - // first param has to be the result variable, not the target. let err = i.exec_line("let bad(x) = x where square(x) = out").unwrap_err(); assert!(err.contains("first parameter"), "error was: {}", err); } @@ -4206,15 +3811,12 @@ fn find(arr, target) { fn math_form_mismatched_params_errors() { let mut i = Interpreter::new(); i.exec_line("fn f(a, b) { a + b }").unwrap(); - // Declared params are [out, c] but source_args minus target are [b]. let err = i.exec_line("let bad(out, c) = a where f(a, b) = out").unwrap_err(); assert!(err.contains("parameters"), "error was: {}", err); } #[test] fn lc_tank_inversion() { - // Define f0(l, c) = 1 / (2π√(lc)), create lfreq via solve!, compare - // the inverted result against the analytical closed-form. let mut i = Interpreter::new(); i.exec_line("fn f0(l, c) { 1 / (2 * pi * sqrt(l * c)) }").unwrap(); i.exec_line("let lfreq = solve!(l, f0)").unwrap(); @@ -4244,7 +3846,6 @@ fn find(arr, target) { #[test] fn non_convergent_errors() { - // Constant function has zero derivative everywhere. let mut i = Interpreter::new(); i.exec_line("fn flat(x) { 42 }").unwrap(); i.exec_line("let inv = solve!(x, flat)").unwrap(); @@ -4264,9 +3865,6 @@ fn find(arr, target) { #[test] fn let_with_params_is_regular_fn_def() { - // `let f(x) = expr` without a `where` clause is equivalent to the - // bare `f(x) = expr` form. Covered here to make sure the parser - // extension didn't break that path. let mut i = Interpreter::new(); i.exec_line("let double(x) = x * 2").unwrap(); assert!(i.fns.contains_key("double")); @@ -4274,8 +3872,6 @@ fn find(arr, target) { assert!(matches!(v, Value::Number(n) if n == 42.0)); } - // --- Implicit multiplication (juxtaposition) --- - #[test] fn implicit_mul_number_times_ident() { let mut i = Interpreter::new(); @@ -4300,9 +3896,6 @@ fn find(arr, target) { #[test] fn implicit_mul_only_fires_adjacent() { - // `2pi` inserts the Star; `2 pi` does not — locks in the adjacency - // rule. Whitespace between the number and ident keeps `pi` as a - // leftover token, which the parser drops, so the result is just `2`. let mut i = Interpreter::new(); let v_adj = i.eval_expr_str("2pi").unwrap(); let v_space = i.eval_expr_str("2 pi").unwrap(); @@ -4336,14 +3929,8 @@ fn find(arr, target) { assert!(matches!(v, Value::Number(n) if n == -1000.0)); } - // --- SPICE notation (gated on `use spice`) --- - #[test] fn spice_off_by_default() { - // Without `use spice`, `100n` falls back to implicit mul of 100 and n. - // When n isn't defined, that's an undefined-variable error — which is - // the behavior we want, so a user who hasn't opted in sees a clean - // error instead of a silent reinterpretation. let mut i = Interpreter::new(); assert!(i.eval_expr_str("100n").is_err()); } @@ -4393,7 +3980,6 @@ fn find(arr, target) { let mut i = Interpreter::new(); i.exec_line("use spice").unwrap(); let v = i.eval_expr_str("100nF + 1nF").unwrap(); - // 101e-9 → renormalized 101NF. assert_eq!(v.display(), "101NF"); } @@ -4401,7 +3987,6 @@ fn find(arr, target) { fn spice_cross_magnitude_renormalization() { let mut i = Interpreter::new(); i.exec_line("use spice").unwrap(); - // 2500nF = 2.5uF; rendered with closest prefix. let v = i.eval_expr_str("2500nF").unwrap(); assert_eq!(v.display(), "2.5UF"); } @@ -4416,9 +4001,6 @@ fn find(arr, target) { #[test] fn spice_unrecognized_suffix_falls_back_to_implicit_mul() { - // `2pi` under spice mode: `pi` isn't a valid suffix, so we fall - // back to implicit multiplication. This keeps math-style input - // working even after `use spice`. let mut i = Interpreter::new(); i.exec_line("use spice").unwrap(); let v = i.eval_expr_str("2pi").unwrap(); @@ -4429,7 +4011,6 @@ fn find(arr, target) { fn spice_display_small_value() { let mut i = Interpreter::new(); i.exec_line("use spice").unwrap(); - // 0.5nF = 500pF. let v = i.eval_expr_str("0.5nF").unwrap(); assert_eq!(v.display(), "500PF"); } @@ -4444,17 +4025,12 @@ fn find(arr, target) { #[test] fn spice_plain_number_display_unchanged() { - // A pure float result (no unit) should still use the plain - // number formatter, not the SPICE path. let mut i = Interpreter::new(); i.exec_line("use spice").unwrap(); let v = i.eval_expr_str("1 + 1").unwrap(); assert_eq!(v.display(), "2"); } - // Unit-label algebra (mul · , div /, cancellation, additive strip on - // mismatch) plus the declaration-overrides-algebra rules. - #[test] fn unit_mul_different_labels_concatenates() { let mut i = Interpreter::new(); @@ -4470,7 +4046,6 @@ fn find(arr, target) { let mut i = Interpreter::new(); i.exec_line("use spice").unwrap(); let v = i.eval_expr_str("6F / 3F").unwrap(); - // Same label on both sides → dimensionless → plain number. assert!(matches!(v, Value::Number(n) if n == 2.0)); } @@ -4484,8 +4059,6 @@ fn find(arr, target) { #[test] fn unit_add_mismatched_strips() { - // F + H has no clean algebraic answer, so the result drops the - // spice wrapper entirely rather than picking one side. let mut i = Interpreter::new(); i.exec_line("use spice").unwrap(); let v = i.eval_expr_str("1F + 2H").unwrap(); @@ -4506,13 +4079,11 @@ fn find(arr, target) { i.exec_line("use spice").unwrap(); i.exec_line("let x: F = 22n").unwrap(); let v = i.eval_expr_str("x").unwrap(); - // 22n = 22e-9 → tagged F → display as nanofarads. assert_eq!(v.display(), "22NF"); } #[test] fn unit_annotation_overrides_rhs_unit() { - // `let x: F = 22nH` — declared F wins, the H label is dropped. let mut i = Interpreter::new(); i.exec_line("use spice").unwrap(); i.exec_line("let x: F = 22nH").unwrap(); @@ -4528,8 +4099,6 @@ fn find(arr, target) { #[test] fn fn_param_type_wraps_arg_on_entry() { - // f receives a raw number; the param's `: F` annotation tags it - // inside the body. let mut i = Interpreter::new(); i.exec_line("fn f(c: F) { return c }").unwrap(); let v = i.eval_expr_str("f(5)").unwrap(); @@ -4538,8 +4107,6 @@ fn find(arr, target) { #[test] fn fn_return_type_overrides_algebra() { - // The algebra inside would produce `F·H`, but the declared return - // type replaces whatever label comes out. let mut i = Interpreter::new(); i.exec_line("use spice").unwrap(); i.exec_line("fn ry(c: F, l: H) -> ohm { return l * c }").unwrap(); @@ -4557,9 +4124,6 @@ fn find(arr, target) { #[test] fn solve_through_typed_source_fn() { - // When the source fn has typed params and return type, its result - // comes back spice-tagged. The solver unwraps that layer before - // doing Newton steps — otherwise it'd reject every iteration. let mut i = Interpreter::new(); i.exec_line("use spice").unwrap(); i.exec_line("fn f0(l: H, c: F) -> Hz {\n return 1 / ((2 * pi) * (sqrt(l * c)))\n}").unwrap(); @@ -4580,15 +4144,10 @@ fn find(arr, target) { #[test] fn spice_lc_tank_use_case() { - // End-to-end reproduction of the Freq note. let mut i = Interpreter::new(); i.exec_line("use spice").unwrap(); i.exec_line("fn L(f, c) {\n let b = (2 * pi * f)\n return 1 / ((b*b) * c)\n}").unwrap(); let v = i.eval_expr_str("L(2600, 1nF)").unwrap(); - // Closed-form: 1 / (4π²·2600²·1e-9) ≈ 3.747e-3 H. The body does its - // arithmetic in F (the unit that propagates from `c`), so the result - // comes back as a spice-tagged value with label `1/F`. Numerically - // it's still the right henry value — only the label is symbolic. let n = match v { Value::Number(n) => n, Value::Array(ref a) if a.len() == 2 => match &a[0] { diff --git a/linux/src/app.rs b/linux/src/app.rs index 02f3d27..92a1925 100644 --- a/linux/src/app.rs +++ b/linux/src/app.rs @@ -17,6 +17,7 @@ use acord_viewport::{ viewport_send_command, viewport_free_string, ViewportHandle, }; +use acord_viewport::browser::{self, BrowserHandle}; use crate::config::Config; use crate::shortcuts::{match_shortcut, MenuAction}; @@ -31,6 +32,11 @@ pub struct App { current_file: Option, last_autosave_attempt: Instant, last_autosaved_hash: Option, + + browser_window: Option, + browser_handle: Option, + browser_cursor: PhysicalPosition, + browser_scale: f32, } impl App { @@ -45,6 +51,10 @@ impl App { current_file: None, last_autosave_attempt: Instant::now(), last_autosaved_hash: None, + browser_window: None, + browser_handle: None, + browser_cursor: PhysicalPosition::new(0.0, 0.0), + browser_scale: 1.0, } } @@ -93,6 +103,154 @@ impl App { } } MenuAction::ExportCrate => { /* TODO: wire crate export */ } + MenuAction::ToggleBrowser => self.toggle_browser(event_loop), + } + } + + fn toggle_browser(&mut self, event_loop: &ActiveEventLoop) { + if self.browser_window.is_some() { + self.close_browser(); + } else { + self.open_browser(event_loop); + } + } + + fn open_browser(&mut self, event_loop: &ActiveEventLoop) { + let mut attrs = WindowAttributes::default() + .with_title("Documents - Acord") + .with_inner_size(LogicalSize::new(900.0, 650.0)); + if let Some(icon) = load_window_icon() { + attrs = attrs.with_window_icon(Some(icon)); + } + let window = match event_loop.create_window(attrs) { + Ok(w) => w, + Err(_) => return, + }; + self.browser_scale = window.scale_factor() as f32; + let size = window.inner_size(); + let w = size.width as f32 / self.browser_scale; + let h = size.height as f32 / self.browser_scale; + + use raw_window_handle::{HasDisplayHandle, HasWindowHandle}; + let display = match window.display_handle() { + Ok(d) => d.as_raw(), + Err(_) => return, + }; + let win_handle = match window.window_handle() { + Ok(w) => w.as_raw(), + Err(_) => return, + }; + + let notes_dir = self.config.notes_dir(); + let _ = std::fs::create_dir_all(¬es_dir); + + match browser::handle::create(display, win_handle, w, h, self.browser_scale, notes_dir) { + Some(handle) => { + self.browser_handle = Some(handle); + self.browser_window = Some(window); + } + None => drop(window), + } + } + + fn close_browser(&mut self) { + self.browser_handle = None; + self.browser_window = None; + } + + fn drain_browser_open(&mut self) { + let Some(handle) = self.browser_handle.as_mut() else { return }; + let Some(path) = browser::handle::take_pending_open(handle) else { return }; + if let Ok(text) = std::fs::read_to_string(&path) { + let c = CString::new(text).unwrap_or_default(); + viewport_set_text(self.handle, c.as_ptr()); + let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("md"); + let c_ext = CString::new(ext).unwrap(); + viewport_set_lang(self.handle, c_ext.as_ptr()); + if let Some(w) = &self.window { + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("Acord"); + w.set_title(&format!("{name} - Acord")); + w.focus_window(); + } + self.current_file = Some(path); + self.last_autosaved_hash = None; + } + self.close_browser(); + } + + fn handle_browser_event(&mut self, event: WindowEvent) { + let Some(handle) = self.browser_handle.as_mut() else { return }; + + match event { + WindowEvent::CloseRequested => { + self.close_browser(); + } + WindowEvent::Resized(size) => { + let w = size.width as f32 / self.browser_scale; + let h = size.height as f32 / self.browser_scale; + browser::handle::resize(handle, w, h, self.browser_scale); + } + WindowEvent::ScaleFactorChanged { scale_factor, .. } => { + self.browser_scale = scale_factor as f32; + if let Some(win) = &self.browser_window { + let size = win.inner_size(); + let w = size.width as f32 / self.browser_scale; + let h = size.height as f32 / self.browser_scale; + browser::handle::resize(handle, w, h, self.browser_scale); + } + } + WindowEvent::RedrawRequested => { + browser::handle::render(handle); + } + WindowEvent::CursorMoved { position, .. } => { + self.browser_cursor = position; + let x = position.x as f32 / self.browser_scale; + let y = position.y as f32 / self.browser_scale; + browser::handle::push_mouse_move(handle, x, y); + } + WindowEvent::MouseInput { state, button, .. } => { + let pressed = state == ElementState::Pressed; + browser::handle::push_mouse_button(handle, Self::winit_button(button), pressed); + } + WindowEvent::MouseWheel { delta, .. } => { + let (dx, dy) = match delta { + MouseScrollDelta::LineDelta(dx, dy) => (dx * 20.0, dy * 20.0), + MouseScrollDelta::PixelDelta(d) => (d.x as f32, d.y as f32), + }; + browser::handle::push_scroll(handle, dx, -dy); + } + WindowEvent::KeyboardInput { event, .. } => { + use iced_wgpu::core::keyboard; + use iced_wgpu::core::Event as IcedEvent; + let pressed = event.state == ElementState::Pressed; + let modifiers = decode_winit_modifiers(self.modifiers); + let key = winit_key_to_iced(&event.logical_key); + let text = event.text.as_ref().map(|s| iced_wgpu::core::SmolStr::new(s.as_str())); + let physical_key = keyboard::key::Physical::Unidentified(keyboard::key::NativeCode::Unidentified); + let location = keyboard::Location::Standard; + let modified_key = key.clone(); + let ev = if pressed { + keyboard::Event::KeyPressed { + key, modified_key, physical_key, location, modifiers, text, + repeat: event.repeat, + } + } else { + keyboard::Event::KeyReleased { + key, modified_key, physical_key, location, modifiers, + } + }; + browser::handle::push_event(handle, IcedEvent::Keyboard(ev)); + } + WindowEvent::ModifiersChanged(mods) => { + self.modifiers = mods.state(); + use iced_wgpu::core::keyboard; + use iced_wgpu::core::Event as IcedEvent; + browser::handle::push_event( + handle, + IcedEvent::Keyboard(keyboard::Event::ModifiersChanged(decode_winit_modifiers(mods.state()))), + ); + } + _ => {} } } @@ -232,7 +390,13 @@ impl ApplicationHandler for App { self.window = Some(window); } - fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) { + fn window_event(&mut self, event_loop: &ActiveEventLoop, id: WindowId, event: WindowEvent) { + let is_browser = self.browser_window.as_ref().map(|w| w.id() == id).unwrap_or(false); + if is_browser { + self.handle_browser_event(event); + return; + } + if self.handle.is_null() { return; } match event { @@ -340,11 +504,15 @@ impl ApplicationHandler for App { self.last_autosave_attempt = Instant::now(); self.try_autosave(); } + self.drain_browser_open(); if let Some(w) = &self.window { if !self.handle.is_null() { w.request_redraw(); } } + if let Some(w) = &self.browser_window { + w.request_redraw(); + } } } @@ -355,6 +523,34 @@ fn text_hash(s: &str) -> u64 { h.finish() } +/// Translates winit logical keys into iced keyboard keys for direct iced +/// event push (used by the second browser window, which speaks iced +/// directly rather than through the C bridge). +fn winit_key_to_iced(key: &Key) -> iced_wgpu::core::keyboard::Key { + use iced_wgpu::core::keyboard::{key as ikey, Key as IKey}; + match key { + Key::Named(n) => match n { + NamedKey::Enter => IKey::Named(ikey::Named::Enter), + NamedKey::Tab => IKey::Named(ikey::Named::Tab), + NamedKey::Backspace => IKey::Named(ikey::Named::Backspace), + NamedKey::Escape => IKey::Named(ikey::Named::Escape), + NamedKey::Delete => IKey::Named(ikey::Named::Delete), + NamedKey::ArrowLeft => IKey::Named(ikey::Named::ArrowLeft), + NamedKey::ArrowRight => IKey::Named(ikey::Named::ArrowRight), + NamedKey::ArrowUp => IKey::Named(ikey::Named::ArrowUp), + NamedKey::ArrowDown => IKey::Named(ikey::Named::ArrowDown), + NamedKey::Home => IKey::Named(ikey::Named::Home), + NamedKey::End => IKey::Named(ikey::Named::End), + NamedKey::PageUp => IKey::Named(ikey::Named::PageUp), + NamedKey::PageDown => IKey::Named(ikey::Named::PageDown), + NamedKey::Space => IKey::Named(ikey::Named::Space), + _ => IKey::Unidentified, + }, + Key::Character(s) => IKey::Character(iced_wgpu::core::SmolStr::new(s.as_str())), + _ => IKey::Unidentified, + } +} + /// Maps winit logical keys to the macOS-style virtual keycodes the bridge /// expects. Character keys go through `text` instead, so 0 is fine for those. fn winit_key_to_code(key: &Key) -> u32 { diff --git a/linux/src/shortcuts.rs b/linux/src/shortcuts.rs index bab8d6d..ca2fb5a 100644 --- a/linux/src/shortcuts.rs +++ b/linux/src/shortcuts.rs @@ -24,6 +24,7 @@ pub enum MenuAction { Find, Settings, ExportCrate, + ToggleBrowser, } /// Matches an app-level shortcut. Returns Some(action) for combos that should @@ -31,6 +32,16 @@ pub enum MenuAction { /// viewport (cut/copy/paste/undo/redo/select-all are handled inside iced via /// the Ctrl→LOGO modifier alias, plain typing, navigation, etc.). pub fn match_shortcut(modifiers: ModifiersState, key: &Key) -> Option { + // Alt+B mirrors macOS Ctrl+B for the document browser. Mac-Cmd maps to + // Ctrl on Linux/Windows, so Mac-Ctrl gets bumped to Alt to avoid collision. + if modifiers.alt_key() && !modifiers.control_key() && !modifiers.super_key() { + if let Key::Character(s) = key { + if ascii_lower(s) == 'b' { + return Some(MenuAction::ToggleBrowser); + } + } + } + if !modifiers.control_key() { return None; } diff --git a/viewport/Cargo.toml b/viewport/Cargo.toml index f70997a..263d363 100644 --- a/viewport/Cargo.toml +++ b/viewport/Cargo.toml @@ -26,6 +26,7 @@ zip = { version = "2", default-features = false, features = ["deflate"] } base64 = "0.22" arboard = "3" ureq = "3" +trash = "5" [build-dependencies] cbindgen = "0.29" diff --git a/viewport/src/browser/handle.rs b/viewport/src/browser/handle.rs new file mode 100644 index 0000000..5c8dda6 --- /dev/null +++ b/viewport/src/browser/handle.rs @@ -0,0 +1,262 @@ +use std::path::PathBuf; + +use iced_graphics::{Shell, Viewport}; +use iced_runtime::user_interface::{self, UserInterface}; +use iced_wgpu::core::renderer::Style; +use iced_wgpu::core::{ + clipboard, mouse, window, Color, Event, Font, Pixels, Point, Size, Theme, +}; +use iced_wgpu::Engine; +use raw_window_handle::{RawDisplayHandle, RawWindowHandle}; + +use crate::palette; +use super::state::{BrowserMessage, BrowserState}; +use super::ui; + +/// Owns the browser window's wgpu surface, iced renderer, and BrowserState. +pub struct BrowserHandle { + pub surface: wgpu::Surface<'static>, + pub device: wgpu::Device, + pub queue: wgpu::Queue, + pub format: wgpu::TextureFormat, + pub width: u32, + pub height: u32, + pub scale: f32, + + pub renderer: iced_wgpu::Renderer, + pub viewport: Viewport, + pub cache: user_interface::Cache, + pub state: BrowserState, + pub events: Vec, + pub cursor: mouse::Cursor, + pub needs_redraw: bool, +} + +/// The browser doesn't read or write the system clipboard. +struct NoopClipboard; + +impl clipboard::Clipboard for NoopClipboard { + fn read(&self, _kind: clipboard::Kind) -> Option { None } + fn write(&mut self, _kind: clipboard::Kind, _contents: String) {} +} + +/// Caller must keep the underlying winit Window alive for the surface's lifetime. +pub fn create( + raw_display: RawDisplayHandle, + raw_window: RawWindowHandle, + width: f32, + height: f32, + scale: f32, + notes_dir: PathBuf, +) -> Option { + #[cfg(target_os = "macos")] + let backends = wgpu::Backends::METAL; + #[cfg(target_os = "windows")] + let backends = wgpu::Backends::DX12; + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + let backends = wgpu::Backends::VULKAN; + + let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor { + backends, + ..Default::default() + }); + + let target = wgpu::SurfaceTargetUnsafe::RawHandle { + raw_display_handle: raw_display, + raw_window_handle: raw_window, + }; + + let surface = unsafe { instance.create_surface_unsafe(target).ok()? }; + + let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { + power_preference: wgpu::PowerPreference::HighPerformance, + compatible_surface: Some(&surface), + force_fallback_adapter: false, + })) + .ok()?; + + let (device, queue) = + pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())).ok()?; + + let phys_w = (width * scale) as u32; + let phys_h = (height * scale) as u32; + + let caps = surface.get_capabilities(&adapter); + let format = caps.formats.first().copied()?; + + surface.configure( + &device, + &wgpu::SurfaceConfiguration { + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + format, + width: phys_w.max(1), + height: phys_h.max(1), + present_mode: wgpu::PresentMode::AutoVsync, + alpha_mode: caps + .alpha_modes + .first() + .copied() + .unwrap_or(wgpu::CompositeAlphaMode::Auto), + view_formats: vec![], + desired_maximum_frame_latency: 2, + }, + ); + + let engine = Engine::new(&adapter, device.clone(), queue.clone(), format, None, Shell::headless()); + let renderer = iced_wgpu::Renderer::new(engine, Font::DEFAULT, Pixels(13.0)); + let viewport = Viewport::with_physical_size(Size::new(phys_w.max(1), phys_h.max(1)), scale); + + Some(BrowserHandle { + surface, + device, + queue, + format, + width: phys_w, + height: phys_h, + scale, + renderer, + viewport, + cache: user_interface::Cache::new(), + state: BrowserState::new(notes_dir), + events: Vec::new(), + cursor: mouse::Cursor::Available(Point::new(0.0, 0.0)), + needs_redraw: true, + }) +} + +/// One frame: drains pending events into messages, applies them, then redraws. +pub fn render(handle: &mut BrowserHandle) { + let pending = !handle.events.is_empty(); + if !handle.needs_redraw && !pending { + return; + } + + let frame = match handle.surface.get_current_texture() { + Ok(f) => f, + Err(_) => return, + }; + let view = frame.texture.create_view(&Default::default()); + let logical_size = handle.viewport.logical_size(); + + handle + .events + .push(Event::Window(window::Event::RedrawRequested(iced_wgpu::core::time::Instant::now()))); + + // First UI build receives input events and emits messages. + let cache = std::mem::take(&mut handle.cache); + let mut ui = UserInterface::build( + ui::view(&handle.state), + Size::new(logical_size.width, logical_size.height), + cache, + &mut handle.renderer, + ); + + let mut clipboard = NoopClipboard; + let mut messages: Vec = Vec::new(); + + let _ = ui.update( + &handle.events, + handle.cursor, + &mut handle.renderer, + &mut clipboard, + &mut messages, + ); + handle.events.clear(); + + let cache = ui.into_cache(); + + for msg in messages.drain(..) { + handle.state.update(msg); + } + + // Second UI build draws against post-message state. + let mut ui = UserInterface::build( + ui::view(&handle.state), + Size::new(logical_size.width, logical_size.height), + cache, + &mut handle.renderer, + ); + + let theme = Theme::Dark; + let style = Style { text_color: Color::WHITE }; + + ui.draw(&mut handle.renderer, &theme, &style, handle.cursor); + handle.cache = ui.into_cache(); + + handle + .renderer + .present(Some(palette::current().base), handle.format, &view, &handle.viewport); + + frame.present(); + handle.needs_redraw = false; +} + +pub fn resize(handle: &mut BrowserHandle, width: f32, height: f32, scale: f32) { + let phys_w = (width * scale) as u32; + let phys_h = (height * scale) as u32; + if phys_w == 0 || phys_h == 0 { return; } + + handle.width = phys_w; + handle.height = phys_h; + handle.scale = scale; + handle.viewport = Viewport::with_physical_size(Size::new(phys_w, phys_h), scale); + + handle.surface.configure( + &handle.device, + &wgpu::SurfaceConfiguration { + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + format: handle.format, + width: phys_w, + height: phys_h, + present_mode: wgpu::PresentMode::AutoVsync, + alpha_mode: wgpu::CompositeAlphaMode::Auto, + view_formats: vec![], + desired_maximum_frame_latency: 2, + }, + ); + handle.needs_redraw = true; +} + +pub fn push_mouse_move(handle: &mut BrowserHandle, x: f32, y: f32) { + let position = Point::new(x, y); + handle.cursor = mouse::Cursor::Available(position); + handle.events.push(Event::Mouse(mouse::Event::CursorMoved { position })); + handle.needs_redraw = true; +} + +pub fn push_mouse_button(handle: &mut BrowserHandle, button: u8, pressed: bool) { + let btn = match button { + 0 => mouse::Button::Left, + 1 => mouse::Button::Right, + 2 => mouse::Button::Middle, + n => mouse::Button::Other(n as u16), + }; + let ev = if pressed { + mouse::Event::ButtonPressed(btn) + } else { + mouse::Event::ButtonReleased(btn) + }; + handle.events.push(Event::Mouse(ev)); + handle.needs_redraw = true; +} + +pub fn push_scroll(handle: &mut BrowserHandle, delta_x: f32, delta_y: f32) { + handle.events.push(Event::Mouse(mouse::Event::WheelScrolled { + delta: mouse::ScrollDelta::Pixels { x: delta_x, y: delta_y }, + })); + handle.needs_redraw = true; +} + +pub fn push_event(handle: &mut BrowserHandle, event: Event) { + handle.events.push(event); + handle.needs_redraw = true; +} + +pub fn take_pending_open(handle: &mut BrowserHandle) -> Option { + handle.state.take_pending_open() +} + +pub fn refresh(handle: &mut BrowserHandle) { + handle.state.refresh(); + handle.needs_redraw = true; +} diff --git a/viewport/src/browser/mod.rs b/viewport/src/browser/mod.rs new file mode 100644 index 0000000..b67bb1b --- /dev/null +++ b/viewport/src/browser/mod.rs @@ -0,0 +1,8 @@ +pub mod model; +pub mod state; +pub mod ui; +pub mod handle; + +pub use model::{BrowserItem, BrowserItemKind}; +pub use state::{BrowserState, BrowserMessage}; +pub use handle::BrowserHandle; diff --git a/viewport/src/browser/model.rs b/viewport/src/browser/model.rs new file mode 100644 index 0000000..a442721 --- /dev/null +++ b/viewport/src/browser/model.rs @@ -0,0 +1,264 @@ +use std::path::{Path, PathBuf}; +use std::time::SystemTime; + +const SUPPORTED_EXTS: &[&str] = &["md", "txt", "markdown", "mdown"]; +const PREVIEW_LINES: usize = 20; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BrowserItemKind { + File, + Folder, +} + +#[derive(Debug, Clone)] +pub struct BrowserItem { + pub path: PathBuf, + pub name: String, + pub kind: BrowserItemKind, + pub modified: SystemTime, + pub preview: String, +} + +/// Folders first, then files; both in date-modified descending order. +pub fn scan_directory(dir: &Path) -> Vec { + let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new() }; + + let mut folders: Vec = Vec::new(); + let mut files: Vec = Vec::new(); + + for entry in entries.flatten() { + let path = entry.path(); + let name = entry.file_name().to_string_lossy().to_string(); + if name.starts_with('.') { continue; } + + let Ok(meta) = entry.metadata() else { continue }; + let modified = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH); + + if meta.is_dir() { + folders.push(BrowserItem { + path: path.clone(), + name, + kind: BrowserItemKind::Folder, + modified, + preview: folder_summary(&path), + }); + } else { + let ext = path + .extension() + .and_then(|e| e.to_str()) + .map(str::to_ascii_lowercase) + .unwrap_or_default(); + if !SUPPORTED_EXTS.iter().any(|e| *e == ext) { continue; } + + let display = path + .file_stem() + .and_then(|s| s.to_str()) + .map(str::to_string) + .unwrap_or(name); + + files.push(BrowserItem { + path: path.clone(), + name: display, + kind: BrowserItemKind::File, + modified, + preview: file_preview(&path), + }); + } + } + + folders.sort_by(|a, b| b.modified.cmp(&a.modified)); + files.sort_by(|a, b| b.modified.cmp(&a.modified)); + folders.extend(files); + folders +} + +pub fn file_preview(path: &Path) -> String { + let Ok(text) = std::fs::read_to_string(path) else { return String::new() }; + let body = strip_sidecar_archive(&text); + if body_looks_blank(body) { + return "(empty note)".to_string(); + } + body.lines().take(PREVIEW_LINES).collect::>().join("\n") +} + +pub fn folder_summary(dir: &Path) -> String { + let Ok(entries) = std::fs::read_dir(dir) else { return "Empty".to_string() }; + let mut files = 0usize; + let mut folders = 0usize; + for entry in entries.flatten() { + let name = entry.file_name(); + let s = name.to_string_lossy(); + if s.starts_with('.') { continue; } + let Ok(meta) = entry.metadata() else { continue }; + if meta.is_dir() { + folders += 1; + } else { + let ext = entry.path() + .extension() + .and_then(|e| e.to_str()) + .map(str::to_ascii_lowercase) + .unwrap_or_default(); + if SUPPORTED_EXTS.iter().any(|e| *e == ext) { files += 1; } + } + } + let mut parts: Vec = Vec::new(); + if files > 0 { + parts.push(format!("{} file{}", files, if files == 1 { "" } else { "s" })); + } + if folders > 0 { + parts.push(format!("{} folder{}", folders, if folders == 1 { "" } else { "s" })); + } + if parts.is_empty() { "Empty".to_string() } else { parts.join(", ") } +} + +/// Cuts the file at the start of the embedded base64 archive comment. +fn strip_sidecar_archive(text: &str) -> &str { + match text.find("\n"; + assert_eq!(strip_sidecar_archive(text), "body line\n\n"); + assert_eq!(strip_sidecar_archive("plain"), "plain"); + } +} diff --git a/viewport/src/browser/state.rs b/viewport/src/browser/state.rs new file mode 100644 index 0000000..7b92c3c --- /dev/null +++ b/viewport/src/browser/state.rs @@ -0,0 +1,161 @@ +use std::path::PathBuf; + +use super::model::{self, BrowserItem, BrowserItemKind}; + +pub struct BrowserState { + pub root: PathBuf, + pub current: PathBuf, + pub items: Vec, + pub selected: Option, + pub scale: f32, + pub renaming: Option, + pub rename_text: String, + /// Set when an item should be opened; the host shell drains this each frame. + pub pending_open: Option, + pub context_menu: Option, +} + +#[derive(Debug, Clone)] +pub struct ContextMenu { + pub anchor: iced_wgpu::core::Point, + pub item_path: PathBuf, + pub is_file: bool, +} + +#[derive(Debug, Clone)] +pub enum BrowserMessage { + NavigateTo(PathBuf), + Open(PathBuf), + Select(PathBuf), + StartRename(PathBuf), + UpdateRename(String), + CommitRename, + CancelRename, + Duplicate(PathBuf), + Trash(PathBuf), + NewFolder, + ScaleUp, + ScaleDown, + Refresh, + ShowContextMenu { anchor: iced_wgpu::core::Point, path: PathBuf, is_file: bool }, + HideContextMenu, +} + +impl BrowserState { + pub fn new(root: PathBuf) -> Self { + let current = root.clone(); + let items = model::scan_directory(¤t); + Self { + root, + current, + items, + selected: None, + scale: 1.0, + renaming: None, + rename_text: String::new(), + pending_open: None, + context_menu: None, + } + } + + pub fn refresh(&mut self) { + self.items = model::scan_directory(&self.current); + } + + pub fn update(&mut self, msg: BrowserMessage) { + match msg { + BrowserMessage::NavigateTo(path) => { + self.current = path; + self.selected = None; + self.renaming = None; + self.context_menu = None; + self.refresh(); + } + BrowserMessage::Open(path) => { + self.pending_open = Some(path); + self.context_menu = None; + } + BrowserMessage::Select(path) => { + self.selected = Some(path); + self.context_menu = None; + } + BrowserMessage::StartRename(path) => { + let stem = path + .file_stem() + .and_then(|s| s.to_str()) + .map(str::to_string) + .unwrap_or_default(); + self.rename_text = stem; + self.renaming = Some(path); + self.context_menu = None; + } + BrowserMessage::UpdateRename(text) => { + self.rename_text = text; + } + BrowserMessage::CommitRename => { + if let Some(path) = self.renaming.take() { + let is_file = path.is_file(); + let _ = model::rename(&path, &self.rename_text, is_file); + self.rename_text.clear(); + self.refresh(); + } + } + BrowserMessage::CancelRename => { + self.renaming = None; + self.rename_text.clear(); + } + BrowserMessage::Duplicate(path) => { + let _ = model::duplicate(&path); + self.context_menu = None; + self.refresh(); + } + BrowserMessage::Trash(path) => { + let _ = model::trash(&path); + if self.selected.as_deref() == Some(&path) { + self.selected = None; + } + self.context_menu = None; + self.refresh(); + } + BrowserMessage::NewFolder => { + let _ = model::create_folder(&self.current); + self.refresh(); + } + BrowserMessage::ScaleUp => { + self.scale = (self.scale + 0.1).min(3.0); + } + BrowserMessage::ScaleDown => { + self.scale = (self.scale - 0.1).max(0.4); + } + BrowserMessage::Refresh => { + self.refresh(); + } + BrowserMessage::ShowContextMenu { anchor, path, is_file } => { + self.context_menu = Some(ContextMenu { anchor, item_path: path, is_file }); + } + BrowserMessage::HideContextMenu => { + self.context_menu = None; + } + } + } + + pub fn take_pending_open(&mut self) -> Option { + self.pending_open.take() + } + + pub fn path_segments(&self) -> Vec<(String, PathBuf)> { + model::path_segments(&self.current, &self.root) + } + + pub fn is_renaming(&self, item: &BrowserItem) -> bool { + self.renaming.as_deref() == Some(&item.path) + } + + pub fn is_selected(&self, item: &BrowserItem) -> bool { + self.selected.as_deref() == Some(&item.path) + } + + pub fn item_kind_is_file(item: &BrowserItem) -> bool { + item.kind == BrowserItemKind::File + } +} diff --git a/viewport/src/browser/ui.rs b/viewport/src/browser/ui.rs new file mode 100644 index 0000000..b3f3fdc --- /dev/null +++ b/viewport/src/browser/ui.rs @@ -0,0 +1,237 @@ +use iced_wgpu::core::{Background, Border, Color, Element, Length, Padding, Theme}; +use iced_widget::{button, column, container, mouse_area, row, scrollable, text, text_input, Space}; + +use crate::palette; +use super::model::{BrowserItem, BrowserItemKind}; +use super::state::{BrowserMessage, BrowserState}; + +const CARDS_PER_ROW: usize = 3; +const CARD_BASE_W: f32 = 240.0; + +pub fn view(state: &BrowserState) -> Element<'_, BrowserMessage, Theme, iced_wgpu::Renderer> { + let p = palette::current(); + + let body: Element<_, _, _> = if state.items.is_empty() { + empty_state() + } else { + scrollable(grid(state)).height(Length::Fill).into() + }; + + let main = column![ + breadcrumb(state), + rule(p.surface1), + body, + ] + .height(Length::Fill); + + container(main) + .style(move |_t: &Theme| container::Style { + background: Some(Background::Color(p.base)), + border: Border::default(), + text_color: Some(p.text), + shadow: Default::default(), + snap: false, + }) + .width(Length::Fill) + .height(Length::Fill) + .into() +} + +fn breadcrumb(state: &BrowserState) -> Element<'_, BrowserMessage, Theme, iced_wgpu::Renderer> { + let p = palette::current(); + let segments = state.path_segments(); + let last_idx = segments.len().saturating_sub(1); + + let mut row_items: Vec> = Vec::new(); + for (i, (name, path)) in segments.into_iter().enumerate() { + if i > 0 { + row_items.push( + text(">").size(11.0).color(p.overlay0).into() + ); + } + let is_last = i == last_idx; + let label = text(name).size(12.0).color(if is_last { p.text } else { p.subtext0 }); + let btn = button(label) + .padding(Padding { top: 2.0, right: 4.0, bottom: 2.0, left: 4.0 }) + .style(move |_t: &Theme, _s| button::Style { + background: None, + text_color: if is_last { p.text } else { p.subtext0 }, + border: Border::default(), + shadow: Default::default(), + snap: false, + }) + .on_press(BrowserMessage::NavigateTo(path)); + row_items.push(btn.into()); + } + + container(row(row_items).spacing(2.0)) + .padding(Padding { top: 8.0, right: 16.0, bottom: 8.0, left: 16.0 }) + .style(move |_t: &Theme| container::Style { + background: Some(Background::Color(p.mantle)), + border: Border::default(), + text_color: Some(p.text), + shadow: Default::default(), + snap: false, + }) + .width(Length::Fill) + .into() +} + +fn rule(color: Color) -> Element<'static, BrowserMessage, Theme, iced_wgpu::Renderer> { + container(text("")) + .width(Length::Fill) + .height(Length::Fixed(1.0)) + .style(move |_t: &Theme| container::Style { + background: Some(Background::Color(color)), + border: Border::default(), + text_color: None, + shadow: Default::default(), + snap: false, + }) + .into() +} + +fn empty_state() -> Element<'static, BrowserMessage, Theme, iced_wgpu::Renderer> { + let p = palette::current(); + container( + column![ + text("No documents").size(16.0).color(p.subtext0), + text("Create a new note or add files to this folder").size(12.0).color(p.overlay0), + ] + .spacing(8.0) + ) + .width(Length::Fill) + .height(Length::Fill) + .padding(Padding { top: 100.0, right: 0.0, bottom: 0.0, left: 0.0 }) + .center_x(Length::Fill) + .into() +} + +fn grid(state: &BrowserState) -> Element<'_, BrowserMessage, Theme, iced_wgpu::Renderer> { + let scale = state.scale; + let mut rows: Vec> = Vec::new(); + let chunk_size = CARDS_PER_ROW; + + for chunk in state.items.chunks(chunk_size) { + let mut row_items: Vec> = Vec::new(); + for item in chunk { + row_items.push(card(item, state, scale)); + } + // Pad short final row so cards keep their fixed width instead of stretching. + while row_items.len() < chunk_size { + row_items.push( + Space::new() + .width(Length::Fill) + .height(Length::Shrink) + .into() + ); + } + rows.push( + row(row_items) + .spacing(16.0 * scale) + .into() + ); + } + + container( + column(rows) + .spacing(16.0 * scale) + .width(Length::Fill) + ) + .padding(16.0 * scale) + .width(Length::Fill) + .into() +} + +fn card<'a>( + item: &'a BrowserItem, + state: &'a BrowserState, + scale: f32, +) -> Element<'a, BrowserMessage, Theme, iced_wgpu::Renderer> { + let p = palette::current(); + let selected = state.is_selected(item); + let renaming = state.is_renaming(item); + + let preview_h = (CARD_BASE_W * scale) * 0.55; + let card_w = CARD_BASE_W * scale; + + let preview: Element<_, _, _> = match item.kind { + BrowserItemKind::Folder => container( + row![ + text("\u{1F4C1}").size(24.0 * scale).color(p.blue), + text(item.preview.clone()).size(10.0 * scale).color(p.subtext0), + ] + .spacing(8.0 * scale) + ) + .width(Length::Fill) + .height(Length::Fixed(preview_h)) + .padding(8.0 * scale) + .style(move |_t: &Theme| container::Style { + background: Some(Background::Color(p.mantle)), + border: Border { color: Color::TRANSPARENT, width: 0.0, radius: (4.0 * scale).into() }, + text_color: Some(p.text), + shadow: Default::default(), + snap: false, + }) + .into(), + BrowserItemKind::File => container( + text(item.preview.clone()).size(10.0 * scale).color(p.subtext0) + ) + .width(Length::Fill) + .height(Length::Fixed(preview_h)) + .padding(8.0 * scale) + .style(move |_t: &Theme| container::Style { + background: Some(Background::Color(p.mantle)), + border: Border { color: Color::TRANSPARENT, width: 0.0, radius: (4.0 * scale).into() }, + text_color: Some(p.subtext0), + shadow: Default::default(), + snap: false, + }) + .into(), + }; + + let title: Element<_, _, _> = if renaming { + text_input("Name", &state.rename_text) + .on_input(BrowserMessage::UpdateRename) + .on_submit(BrowserMessage::CommitRename) + .size(12.0 * scale) + .padding(Padding { top: 2.0, right: 4.0, bottom: 2.0, left: 4.0 }) + .into() + } else { + text(item.name.clone()).size(12.0 * scale).color(p.text).into() + }; + + let content = column![preview, title].spacing(6.0 * scale); + + let item_path = item.path.clone(); + let is_file = item.kind == BrowserItemKind::File; + + let body = container(content) + .width(Length::Fixed(card_w)) + .padding(10.0 * scale) + .style(move |_t: &Theme| container::Style { + background: Some(Background::Color(if selected { p.surface1 } else { p.surface0 })), + border: Border { + color: if selected { p.blue } else { Color::TRANSPARENT }, + width: if selected { 2.0 } else { 0.0 }, + radius: (8.0 * scale).into(), + }, + text_color: Some(p.text), + shadow: Default::default(), + snap: false, + }); + + let click_msg = match item.kind { + BrowserItemKind::Folder => BrowserMessage::NavigateTo(item_path.clone()), + BrowserItemKind::File => BrowserMessage::Open(item_path.clone()), + }; + + mouse_area(body) + .on_press(click_msg) + .on_right_press(BrowserMessage::ShowContextMenu { + anchor: iced_wgpu::core::Point::new(0.0, 0.0), + path: item_path, + is_file, + }) + .into() +} diff --git a/viewport/src/editor.rs b/viewport/src/editor.rs index 78a39e6..7dddf53 100644 --- a/viewport/src/editor.rs +++ b/viewport/src/editor.rs @@ -59,24 +59,22 @@ use crate::tree_block::TreeBlock; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RenderMode { - /// Blocks rendered, eval runs, tables interactive. + /// blocks rendered, eval runs, tables interactive Live, - /// Raw markdown in a single text_editor, no eval, no block splitting. + /// raw markdown in one text_editor, no eval, no block splitting Editor, - /// Read-only rendered view. Press `i` for Editor, `/` for Live. + /// read-only rendered view View, } -/// User-facing line-number gutter / cursorline behavior. +/// gutter line-number and cursorline display mode #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LineIndicator { - /// Absolute line numbers, full-row cursorline band. + /// absolute line numbers with full-row cursorline band On, - /// Hidden — no line numbers, no cursorline band. The gutter strip - /// stays at its layout width so the editor doesn't reflow. + /// no line numbers and no cursorline band Off, - /// Vim-style: relative line numbers (cursor line shows its absolute - /// number, others show signed distance), cursorline band on. + /// vim-style relative line numbers with cursorline band Vim, } @@ -104,22 +102,14 @@ pub enum Message { ToggleStrike, ToggleUnderline, ToggleBlockquote, - /// Wrap the selection in matching delimiters; if the selection is - /// already wrapped (markers immediately surround it, with or without - /// being included in the selection), unwrap it. + /// wraps the selection in matching delimiters, or unwraps an existing pair WrapWith(&'static str, &'static str), - /// Insert a paired `[]` / `{}` and place the cursor between them. - /// Only applied to `[` and `{`; quotes/parens deliberately do NOT pair - /// on type — use Cmd+"/'/9 to wrap a selection. + /// inserts paired `[]` or `{}` and places the cursor between them AutoPair(&'static str, &'static str), - /// Cmd+0: incremental scope exit. Each press closes the innermost - /// unclosed pair within the current block; once everything is closed, - /// jumps the cursor past the next outer scope's closing delimiter; once - /// fully at block scope, ensures a "newline sandwich" (cursor on a - /// blank line with one blank line of padding above and below). + /// incremental scope exit, then newline-sandwich placement FixUp, Evaluate, - /// Full-document ordered eval: every module evaluated in sequence. + /// evaluates every module in document order EvalAll, SmartEval, ZoomIn, @@ -141,74 +131,53 @@ pub enum Message { TableTab, TableShiftTab, TableEnter, - /// Up arrow on the top row of a table. Find the text block immediately - /// above and focus its end; synthesize a fresh text block if none exists. + /// up arrow on a table's top row escapes upward EscapeTableUp(usize), - /// Down arrow on the last row of a table. Mirror of `EscapeTableUp`. + /// down arrow on a table's last row escapes downward EscapeTableDown(usize), - /// Move the focused cell up by one row, staying inside the same table. + /// moves the focused cell up by one row, staying inside the same table TableMoveUp, - /// Move the focused cell down by one row, staying inside the same table. + /// moves the focused cell down by one row, staying inside the same table TableMoveDown, - /// Move the focused cell left by one column. + /// moves the focused cell left by one column TableMoveLeft, - /// Move the focused cell right by one column. + /// moves the focused cell right by one column TableMoveRight, - /// Backspace / Delete on a selected (not editing) cell. Empties the cell - /// without removing the row — Excel/Numbers semantics. + /// backspace or delete on a selected (not editing) cell ClearSelectedCell, - /// Second Cmd+A press — escalate to whole-document selection. Every block - /// renders highlighted; Backspace clears all content; Cmd+Backspace wipes - /// the document down to a single empty text block. + /// second cmd+a press escalates to whole-document selection SelectAllBlocks, - /// Plain Backspace/Delete with `all_blocks_selected == true`. Empties - /// every block's content but keeps the structure (block count, block - /// types, table row/col counts). + /// backspace or delete while all blocks are selected ClearAllBlocks, - /// Cmd+Backspace with `all_blocks_selected == true`. Wipes the document - /// down to a single empty text block. + /// cmd+backspace while all blocks are selected DeleteAllBlocks, - /// Right-click on a table cell. Opens the context menu anchored at the - /// current cursor position. Only block_idx is needed — the menu acts on - /// the existing selection, not on the right-clicked cell. + /// right-click on a table cell ShowContextMenu { block_idx: usize }, - /// Explicitly close the context menu (Escape key, etc.). Most other - /// messages auto-close it via `update()`'s top-of-loop drop logic. + /// explicitly closes the context menu HideContextMenu, - /// Push a literal string into the clipboard out-channel. Used by the - /// table spillover popup's copy button and by Cmd+C-on-selected-cell - /// where the value is already in hand at dispatch time. + /// pushes a literal string into the clipboard out-channel CopyLiteral(String), - /// Cmd+C while the focused block is a table — copy the current selection - /// (or the spillover cell, if open) as TSV. Dispatched from handle.rs - /// when the keyboard event would otherwise reach a non-cell-edit context. + /// copies the current table selection as TSV CopyFocusedTableSelection, - /// Escape from cell edit mode. The cell stays selected (highlighted) but - /// goes back to the static-text rendering — same as the Excel/Numbers - /// gesture for "stop editing this cell". + /// escape from cell edit mode ExitCellEdit, - /// User pressed a printable character with a cell selected but not yet - /// editing. Replace the cell's content with that single character and - /// enter edit mode — Excel/Numbers "start typing into the selection". + /// replaces the selected cell with one character and enters edit mode EnterCellEditWithChar(char), - /// Tab key inside a text block. iced's default `Binding::from_key_press` - /// returns None for Tab, so without our own binding the key does nothing. + /// tab key indents the current line IndentTab, OutdentTab, SetRenderMode(RenderMode), - /// Mouse pressed on an inline `/=` result. Starts the long-press timer. + /// mouse pressed on an inline result, arms the long-press timer InlineResultPress { block_id: crate::selection::BlockId, after_line: usize }, - /// Mouse released anywhere after pressing on an inline result. Cancels - /// any pending long-press that hasn't fired yet. + /// mouse released anywhere, cancels a pending long-press InlineResultRelease, - /// Double-clicked an inline `/=` result. Copies the source line + result - /// to clipboard AND drops a `let = result` template two lines down. + /// double-click on an inline result InlineResultDoubleClick { block_id: crate::selection::BlockId, after_line: usize }, } pub const RESULT_PREFIX: &str = "→ "; -/// Long-press / double-click state for the click-and-hold-on-result gesture. +/// long-press and double-click state for inline eval results #[derive(Debug, Clone)] pub struct InlinePressState { pub block_id: crate::selection::BlockId, @@ -223,18 +192,15 @@ pub const ERROR_PREFIX: &str = "⚠ "; const EVAL_DEBOUNCE_MS: u128 = 300; -// ── Document layers ───────────────────────────────────────────────── -// Layer 0 = registry + layout (user-authored structure). -// Layers 1-3 hold computed eval artifacts, independently invalidated. -/// Attachment point linking a computed item to a layer-0 text block. +/// anchor linking a computed item to a text block #[derive(Debug, Clone)] pub struct Anchor { pub block_id: crate::selection::BlockId, pub after_line: usize, } -/// Layer 1: inline eval result (→ value / ⚠ error). +/// inline eval result text or error message #[derive(Debug, Clone)] pub struct InlineResult { pub anchor: Anchor, @@ -246,7 +212,7 @@ impl InlineResult { pub fn element_height(&self, line_h: f32) -> f32 { line_h } } -/// Layer 2: computed table from `/=|` evaluation. +/// computed table produced by `/=|` evaluation #[derive(Debug, Clone)] pub struct ComputedTable { pub anchor: Anchor, @@ -262,7 +228,7 @@ impl ComputedTable { } } -/// Layer 3: computed tree from `/=\` evaluation. +/// computed tree produced by `/=\` evaluation #[derive(Debug, Clone)] pub struct ComputedTree { pub anchor: Anchor, @@ -275,19 +241,17 @@ impl ComputedTree { } } -/// Layer 4: embedded image from `![alt](src)`. +/// embedded image referenced by `![alt](src)` #[derive(Debug, Clone)] pub struct ComputedImage { pub anchor: Anchor, pub src: String, pub alt: String, - /// Pre-computed display height based on image aspect ratio and editor - /// width. Falls back to a placeholder height while loading. + /// pre-computed display height, or a placeholder while loading pub display_height: f32, } -/// Cached image data keyed by source path/URL. Handle must be built once -/// and reused — `Handle::from_bytes` mints a fresh Id every call. +/// cached image data keyed by source path or URL pub struct ImageCacheEntry { pub handle: iced_widget::image::Handle, pub width: u32, @@ -299,7 +263,7 @@ const IMAGE_MAX_H: f32 = 600.0; const IMAGE_PADDING: f32 = 48.0; const IMAGE_VPAD: f32 = 4.0; -/// Ref to a layer item for interleaved rendering. +/// reference to a computed layer item for interleaved rendering enum LayerItem<'a> { Inline(&'a InlineResult), Table(&'a ComputedTable), @@ -320,10 +284,7 @@ impl LayerItem<'_> { pub const FIND_INPUT_ID: &str = "find_input"; pub const REPLACE_INPUT_ID: &str = "replace_input"; -/// Stable id for the multi-block document scrollable. handle.rs targets this -/// via `iced_core::widget::operation::scrollable::scroll_by` to forward -/// wheel-scroll deltas captured by an inner `text_editor` (which would -/// otherwise swallow them when the cursor is over the editor's bounds). +/// stable widget id for the document scrollable pub const DOC_SCROLLABLE_ID: &str = "doc_scrollable"; const UNDO_MAX: usize = 200; const COALESCE_MS: u128 = 500; @@ -386,104 +347,58 @@ pub struct EditorState { pub find: FindState, pub pending_focus: Option, - /// Stand-in `Content` returned by `content()` when the focused block isn't - /// text-bearing AND there are no text blocks anywhere in the document - /// (e.g. a heading-only file). Always empty; never written to. fallback_text: text_widget::Content, - /// Live keyboard modifier state. Updated by handle.rs from - /// `Event::Keyboard(ModifiersChanged)`. Drives modifier-aware click - /// translation (Cmd → Toggle, Shift → Extend, etc.). + /// live keyboard modifier state pub mods: Modifiers, - /// Single source of truth for selection. Mirrored from `focused_block` - /// changes via `set_focused_block`; the compositor reads this for - /// cursorline / cell tint / cross-block range visuals. pub(crate) selection: crate::selection::Selection, - /// The path keys are routed to. A single point even when `selection` is a - /// range or set. + /// the single path that keys are routed to pub(crate) focus: Option, - /// Path currently in text-input edit mode (cell static-vs-edit). + /// path of the cell currently in text-input edit mode #[allow(dead_code)] pub(crate) editing: Option, - /// Cmd+A escalation flag. Set after a first Cmd+A (block-local select); - /// a second Cmd+A while still armed escalates to whole-document - /// selection. Cleared on any other input. handle.rs owns the - /// arm/disarm logic; the editor only reads it. + /// cmd+a escalation flag for whole-document selection pub cmd_a_armed: bool, - /// Whole-document selection mode — every block renders highlighted, - /// plain Backspace clears all block content, Cmd+Backspace wipes the - /// document. Set by `Message::SelectAllBlocks`, cleared by any click - /// or any single-block selection change. + /// whole-document selection mode flag pub all_blocks_selected: bool, - /// Latest mouse cursor position in viewport coordinates. handle.rs - /// updates this from `handle.cursor` BEFORE draining messages, so the - /// `Message::TableMsg(_, ContextMenu)` handler can read the position - /// to anchor the context menu overlay. + /// latest cursor position in viewport coordinates pub cursor_pos: Point, - /// Pending pixel scroll delta to apply to the document scrollable on - /// the next render frame. Captured here when iced's `text_editor` - /// swallows a wheel-scroll event (it captures `Action::Scroll` when - /// the cursor is over the editor's bounds), and forwarded to the outer - /// scrollable via `iced_core::widget::operation::scrollable::scroll_by` - /// in handle.rs::render. Accumulates if multiple scroll events land - /// in the same frame. + /// pending pixel scroll delta forwarded to the document scrollable pub pending_scroll: f32, - /// Active context menu, if any. Set by right-clicking a cell; - /// auto-cleared by `update()` whenever a message arrives that isn't - /// itself a context-menu operation. So clicking a menu button - /// dispatches the action AND clears the menu in one shot, and clicking - /// anywhere outside the menu also dismisses it. + /// active context menu state, if any pub context_menu: Option, - // ── Document layers (computed eval artifacts) ── pub eval_results: Vec, pub computed_tables: Vec, pub computed_trees: Vec, - /// Per-cell evaluated formula results. Keyed by (table block id, col, row). - /// Cells whose raw text starts with `/=` and are not being edited render - /// the computed value instead; anything not in this map renders raw. + /// per-cell evaluated formula results, keyed by (block_id, col, row) pub computed_cells: HashMap<(crate::selection::BlockId, u32, u32), acord_core::interp::Value>, - /// Active long-press / pending-result-gesture state. Set by - /// `InlineResultPress`, cleared by `InlineResultRelease` / - /// `InlineResultDoubleClick`. `tick()` checks the elapsed time to fire - /// the copy when it crosses `LONG_PRESS_MS`. + /// active long-press state for the result-copy gesture pub inline_press: Option, - /// Line-indicator preference: controls cursorline band + relative-vs- - /// absolute line numbers. Pushed in from Swift via FFI. + /// gutter line-indicator mode pub line_indicator: LineIndicator, - /// Whether the gutter line numbers cycle through the rainbow palette - /// based on distance from the cursor. Independent of `line_indicator`. + /// whether the gutter line numbers cycle through the rainbow palette pub gutter_rainbow: bool, - /// Cross-platform clipboard out-channel. Editor logic writes here; - /// the shell drains it after each frame via `viewport_take_clipboard` - /// and pushes the text to the system clipboard. + /// pending clipboard text, drained by the shell each frame pub pending_clipboard: Option, - // ── Images ── pub computed_images: Vec, pub image_cache: HashMap, - /// Previous global cursor line (block start_line + intra-block line). - /// Used by `tick()` to detect cursor-line changes and trigger eval. + /// previous global cursor line, used to detect line changes prev_cursor_line: usize, } -/// Per-eval table name→id bookkeeping. `keys` is every alias a table is -/// reachable by (heading name, positional `table_N`, qualified `mod::name`); -/// `canonical` is the preferred key for each BlockId, used as the -/// `current_table` anchor when evaluating formulas inside that table. +/// per-eval table name to id bookkeeping pub struct TableIndex { pub keys: HashMap, pub canonical: HashMap, } -/// Mirror of `Interpreter::resolve_table_key` for use during dep-graph -/// building, when we don't have the live interpreter handy but do have -/// the full alias→id map from `register_visible_tables`. fn resolve_ref_key( r: &acord_core::interp::FormulaRef, table_index: &TableIndex, @@ -500,11 +415,7 @@ fn resolve_ref_key( } } -/// State for the on-screen context menu overlay. Anchored at viewport -/// (x, y) — the position the user right-clicked. Carries the table's -/// block index so menu items targeting "this table" know which one. -/// Notably does NOT carry the right-clicked row/col — right-click is -/// purely a menu trigger and doesn't make the clicked cell "current." +/// on-screen context menu state anchored at viewport coordinates #[derive(Debug, Clone)] pub struct ContextMenuState { pub block_idx: usize, @@ -576,7 +487,6 @@ impl EditorState { } } - // ── registry + layout helpers ────────────────────────────────── fn vec_to_registry(blocks: Vec) -> (HashMap, Vec) { let mut registry = HashMap::with_capacity(blocks.len()); @@ -651,7 +561,6 @@ impl EditorState { } } - // ── Layer helpers ───────────────────────────────────────────── fn clear_layers_for_blocks(&mut self, ids: &[crate::selection::BlockId]) { self.eval_results.retain(|r| !ids.contains(&r.anchor.block_id)); @@ -659,8 +568,7 @@ impl EditorState { self.computed_trees.retain(|t| !ids.contains(&t.anchor.block_id)); } - /// Map a line number in concatenated module source back to a per-block anchor. - /// `boundaries` is a sorted vec of (cumulative_line_start, block_id). + /// maps a line number in concatenated module source back to a per-block anchor fn map_line_to_anchor( boundaries: &[(usize, crate::selection::BlockId)], global_line: usize, @@ -681,10 +589,7 @@ impl EditorState { } } - /// Scan text blocks for `![alt](src)` image references and populate - /// `computed_images`. Loads image bytes into `image_cache` on first - /// encounter (sync for local files). Replaces previous images for the - /// given block set — unchanged sources keep their cache entry. + /// scans text blocks for image references and populates the image cache fn scan_images( &mut self, boundaries: &[(usize, crate::selection::BlockId)], @@ -711,11 +616,9 @@ impl EditorState { } } - // Editor width estimate for aspect-ratio scaling. let editor_w = 800.0f32; // approximate; TODO: pass actual width for (anchor, src, alt) in new_srcs { - // Load into cache if absent. if !self.image_cache.contains_key(&src) { if let Some(entry) = load_image_from_path(&src) { self.image_cache.insert(src.clone(), entry); @@ -751,10 +654,7 @@ impl EditorState { None } - /// Update the focused block index AND mirror it into the central - /// `selection` / `focus` fields. Clears any active cell edit mode — - /// changing the focused block always exits whatever cell was being edited. - /// Also drops any per-table whole-table selection, since focus is moving. + /// updates the focused block index and mirrors it into the selection state fn set_focused_block(&mut self, idx: usize) { self.focused_block = idx; self.editing = None; @@ -770,9 +670,7 @@ impl EditorState { } } - /// Mark a specific cell of a table block as selected (highlighted but not - /// in edit mode). Clears any active edit. Used by single-click and by - /// Escape-from-edit. + /// marks a cell as selected without entering edit mode fn set_selected_cell(&mut self, idx: usize, row: usize, col: usize) { self.focused_block = idx; self.editing = None; @@ -783,10 +681,7 @@ impl EditorState { } } - /// Mark a specific cell as in edit mode (renders as text_input + takes - /// iced focus). Used by double-click, by printable-key entry from - /// selection, and by Tab/Enter navigation that wants to keep editing - /// rolling forward into the next cell. + /// marks a cell as in edit mode and gives it iced focus fn set_editing_cell(&mut self, idx: usize, row: usize, col: usize) { self.focused_block = idx; let bid = self.block_at(idx).map(|b| b.id()); @@ -799,9 +694,7 @@ impl EditorState { } } - /// Up arrow on the top row of a table at `table_idx`. If the immediately- - /// previous block is a text block, focus its end. Otherwise insert a fresh - /// empty text block just before the table and focus it. + /// escapes the table at `table_idx` upward into the previous text block fn escape_table_up(&mut self, table_idx: usize) { if table_idx > 0 { if let Some(tb) = self.text_block_at(table_idx - 1) { @@ -821,7 +714,6 @@ impl EditorState { return; } } - // No text block immediately above — synthesize one. self.push_undo_snapshot(); let lang = self.lang_str(); let new_id = blocks::next_id(); @@ -834,9 +726,7 @@ impl EditorState { self.reparse(); } - /// Down arrow on the last row of a table at `table_idx`. Mirror of - /// `escape_table_up`: focus the immediately-following text block if any, - /// otherwise synthesize a fresh empty text block right after the table. + /// escapes the table at `table_idx` downward into the next text block fn escape_table_down(&mut self, table_idx: usize) { let next_idx = table_idx + 1; if next_idx < self.block_count() { @@ -867,9 +757,7 @@ impl EditorState { self.lang.clone().unwrap_or_default() } - /// Tab width in spaces. Per-language for now defaults to 4 (matches Python - /// and most house-styles); a lookup table can be added when other languages - /// want narrower indents. + /// returns the tab width in spaces fn tab_width(&self) -> usize { 4 } @@ -951,10 +839,7 @@ impl EditorState { self.font_size * 1.3 } - /// Move the focused content's cursor to `target`, clamping line and column - /// into the current text so we never hand cosmic-text an out-of-bounds index. - /// Defends against an iced text_editor bug where `Content::move_to` passes - /// the caller's `column` straight to cosmic-text without validation. + /// moves the focused content's cursor to `target`, clamping line and column fn safe_move_to(&mut self, mut cursor: Cursor) { { let content = self.content(); @@ -995,8 +880,7 @@ impl EditorState { self.content_mut().move_to(cursor); } - /// Handle arrow/backspace/delete at block boundaries. - /// Returns true if the action was consumed (focus change or merge). + /// handles arrow, backspace, and delete at block boundaries fn handle_block_boundary(&mut self, action: &text_widget::Action) -> bool { let idx = self.focused_block; if !self.text_block_at(idx).is_some() { @@ -1064,7 +948,6 @@ impl EditorState { } let prev_idx = idx - 1; if !self.text_block_at(prev_idx).is_some() { - // Previous is non-text (HR, heading) -- remove it instead self.remove_block(prev_idx); let new_focus = prev_idx.min(self.block_count().saturating_sub(1)); self.set_focused_block(new_focus); @@ -1124,15 +1007,8 @@ impl EditorState { true } - /// Load a document from raw file bytes. Pulls any embedded sidecar - /// archive out of the markdown, sets the text body, then applies the - /// sidecar metadata to the parsed table blocks. Used by the FFI - /// `viewport_set_text` entrypoint so callers don't have to know the - /// archive format exists. + /// loads a document from raw file bytes pub fn load_doc(&mut self, text: &str) { - // In editor mode, loading text should preserve the single-block state. - // The save→observe→setText round-trip calls load_doc; if we reparse - // here we'd silently exit editor mode and corrupt the block structure. if self.render_mode == RenderMode::Editor { let loaded = sidecar::extract_archive(text); let clean = strip_result_lines(&loaded.markdown); @@ -1148,37 +1024,21 @@ impl EditorState { if let Some(sc) = loaded.sidecar { self.apply_sidecar(&sc); } - // Trigger full eval when loading into Live or View mode. if self.render_mode == RenderMode::Live || self.render_mode == RenderMode::View { self.run_eval_all(); } } - /// Save the document to raw file bytes: assign sidecar ids to any tables - /// that need them, build the sidecar from current block metadata, and - /// embed it as a base64 zip in an HTML comment at the end of the file. - /// Tables with no rich metadata produce no archive — the file stays a - /// pure plain `.md`. Used by the FFI `viewport_get_text` entrypoint. + /// saves the document to file bytes, embedding the sidecar archive pub fn save_doc(&mut self) -> String { let body = self.get_clean_text(); - // build_block_files reads self.modules; make sure it reflects the - // current document. rebuild_modules is idempotent and cheap. self.rebuild_modules(); let sidecar = self.build_sidecar(); let block_files = self.build_block_files(); sidecar::embed_archive(&body, &sidecar, &block_files) } - /// Build the per-block source files for the archive. One `.cord` file - /// per logical block — a logical block is a group of parser spans - /// bounded by H1/H2 headings or `---` (the same grouping `self.modules` - /// already maintains for `use`-resolution). Tables and prose are not - /// boundaries; they live inside whichever block contains them. - /// - /// Filename: heading-led blocks get `.cord`; HR-led - /// (anonymous) blocks get `block_N.cord` where N is the positional - /// index across all logical blocks (0-based). Collisions get `_2`, - /// `_3`, … suffixes. + /// builds the per-block `.cord` source files for the sidecar archive pub fn build_block_files(&self) -> Vec { use std::collections::HashSet; let mut files = Vec::with_capacity(self.modules.len()); @@ -1233,9 +1093,7 @@ impl EditorState { candidate } - /// Build a `Sidecar` snapshot from the current block tree, keyed by the - /// positional index of each non-eval table in layout order ("0", "1", ...). - /// Only tables with persistent metadata produce entries. + /// builds a `Sidecar` snapshot from the current block tree fn build_sidecar(&self) -> Sidecar { let mut sc = Sidecar::default(); sc.version = 1; @@ -1274,10 +1132,6 @@ impl EditorState { sc } - /// Apply a previously-loaded `Sidecar` to the current block tree, matching - /// entries to tables by positional index in layout order. Non-eval tables - /// count; eval-result tables are skipped. Missing entries leave tables - /// unchanged. fn apply_sidecar(&mut self, sc: &Sidecar) { let mut position: usize = 0; let layout = self.layout.clone(); @@ -1318,10 +1172,6 @@ impl EditorState { } pub fn set_text(&mut self, text: &str) { - // Snapshot undo before any wholesale text replacement so undo can - // recover the prior state. Identity-skip when nothing actually - // changes — Swift's observe loop can call set_text with the text we - // just emitted, and we don't want round-trips piling up phantom undos. let current = self.get_clean_text(); if current != text { self.push_undo_snapshot(); @@ -1330,13 +1180,8 @@ impl EditorState { self.replace_text_no_undo(text); } - /// Wholesale text replacement WITHOUT pushing an undo snapshot. Used by - /// `restore_snapshot` (the undo/redo path) where touching the undo stack - /// would loop. + /// replaces all text without pushing an undo snapshot fn replace_text_no_undo(&mut self, text: &str) { - // In editor mode, the document is a single raw text block. Don't - // reparse into structured blocks — that would silently exit editor - // mode while the render_mode flag still says Editor. if self.render_mode == RenderMode::Editor { let lang = self.lang_str(); self.clear_blocks(); @@ -1360,10 +1205,7 @@ impl EditorState { self.reparse(); } - /// Per-frame focus sync. Walks tables and sets `is_active`/`focused_cell` - /// based on iced's currently-focused widget id. `focused_cell` is preserved - /// across blur (keyboard shortcuts need it); `is_active` flips off every - /// frame and only flips on for the table whose cell matches. + /// per-frame focus synchronization with iced pub fn sync_focused_cell(&mut self, focused_id: Option<&WidgetId>) { for block in self.registry.values_mut() { if let Some(tb) = block.as_any_mut().downcast_mut::() { @@ -1400,21 +1242,12 @@ impl EditorState { } } - /// A non-eval table currently has a selected cell. Used by handle.rs to - /// gate keyboard interception of arrow keys, Tab, Enter, Backspace etc. - /// Keys off `focused_cell` (logical selection, preserved across blur), - /// NOT `is_active` (which only tracks whether iced widget focus is in the - /// cell text_input — true only during edit mode). + /// returns true when a non-eval table has a selected cell pub(crate) fn active_table_index(&self) -> Option { self.focused_table_index() } - /// True iff the editor's *currently focused block* is a non-eval table - /// that has a selected cell. This is the right gate for any table-specific - /// keybinding — `focused_table_index()` would also return Some for a - /// table whose selection was set on a previous click but where focus has - /// since moved to a text block, which would cause the table to silently - /// steal arrow keys / Backspace / Cmd+Backspace from the text block. + /// returns true when the focused block is a non-eval table pub(crate) fn table_is_focused_block(&self) -> bool { if let Some(block) = self.block_at(self.focused_block) { if let Some(tb) = block.as_any().downcast_ref::() { @@ -1424,9 +1257,7 @@ impl EditorState { false } - /// True iff the focused block is a table currently in whole-table - /// select-all mode. handle.rs uses this to route plain Backspace to - /// "clear all cells" and Cmd+Backspace to "delete the entire table." + /// returns true when the focused block is a table in whole-table-select mode pub(crate) fn focused_table_is_select_all(&self) -> bool { if let Some(block) = self.block_at(self.focused_block) { if let Some(tb) = block.as_any().downcast_ref::() { @@ -1436,9 +1267,7 @@ impl EditorState { false } - /// Returns (block_idx, row, total_rows) for the currently active table's - /// focused cell, or None if no table is active. handle.rs uses the - /// total_rows to detect "Down arrow on the last row" for edge-escape. + /// returns (block_idx, row, total_rows) for the focused cell's table pub(crate) fn active_table_focused_row(&self) -> Option<(usize, usize, usize)> { let idx = self.active_table_index()?; let tb = self.table_block_at(idx)?; @@ -1446,17 +1275,7 @@ impl EditorState { Some((idx, r, tb.rows.len())) } - /// Returns the index of the editor's currently focused block IF it's a - /// table with a selected cell. Returns None if focus is on a text block, - /// heading, etc. — even if some other table somewhere in the document - /// has `focused_cell` set (which is common since `focused_cell` is - /// preserved across blur so users can click back into a prior table and - /// have their selection restored). - /// - /// All table-targeted operations (DeleteCurrentTable, FocusedTableOp, - /// TableTab/Enter/Move*, EnterCellEditWithChar, ClearSelectedCell) use - /// this — so they all consistently mean "the table the user is in right - /// now," not "the topmost table that has ever been touched." + /// returns the focused block index when it's a table pub(crate) fn focused_table_index(&self) -> Option { let block = self.block_at(self.focused_block)?; let tb = block.as_any().downcast_ref::()?; @@ -1467,14 +1286,7 @@ impl EditorState { } } - /// True iff the editor's *currently focused block* is a table with a - /// selected cell that's not in edit mode. handle.rs uses this to decide - /// whether to intercept printable keys for "type to enter edit mode." - /// - /// MUST check `focused_block` rather than "any table has focused_cell" — - /// `focused_cell` is intentionally preserved across blur so clicking - /// back into a table restores selection, which means it can't double as - /// a "currently active" signal. + /// returns true when the focused block is a table with a focused cell pub(crate) fn has_selected_cell_not_editing(&self) -> bool { if self.editing.is_some() { return false; @@ -1488,11 +1300,7 @@ impl EditorState { !tb.is_eval_result && tb.focused_cell.is_some() } - /// True when handle.rs should intercept Cmd+C and route it to the - /// table-cell copy path instead of letting iced's text widget handle it. - /// Conditions: focused block is a table; not currently editing a cell - /// (cell-edit mode delegates to text_input's own copy); and either a - /// selection is non-empty or a spillover popup is open. + /// returns true when Cmd+C should copy the table selection instead of cell text pub(crate) fn should_intercept_table_copy(&self) -> bool { if self.editing.is_some() { return false; } let Some(block) = self.block_at(self.focused_block) else { return false; }; @@ -1500,9 +1308,7 @@ impl EditorState { !tb.selection.is_empty() || tb.spillover.is_some() } - /// Build the clipboard payload from the focused table — selection takes - /// precedence over spillover; spillover provides the single-cell payload - /// when no explicit selection exists. None if neither applies. + /// builds the clipboard payload from the focused table fn copy_focused_table_selection(&self) -> Option { let block = self.block_at(self.focused_block)?; let tb = block.as_any().downcast_ref::()?; @@ -1523,8 +1329,6 @@ impl EditorState { self.eval_dirty = false; self.run_eval(); } - // Cursor-line-change trigger: when the cursor moves to a different - // line (arrow keys, click, etc.) without an edit, re-evaluate. { let block_start = self.layout.get(self.focused_block) .and_then(|id| self.registry.get(id)) @@ -1539,9 +1343,6 @@ impl EditorState { } } } - // Fire the long-press copy at the threshold — if the user is still - // holding past LONG_PRESS_MS without having released, double-clicked, - // or moved off, drop the result onto the clipboard. let due = self.inline_press.as_ref().is_some_and(|s| { !s.fired_long_press && s.started_at.elapsed().as_millis() >= LONG_PRESS_MS }); @@ -1553,8 +1354,6 @@ impl EditorState { self.copy_inline_result(bid, line); } } - // Table hover-to-spillover dwell: each table polls its own armed - // timer and opens the popup once the 3s threshold passes. let block_ids: Vec = self.layout.clone(); for id in block_ids { if let Some(block) = self.registry.get_mut(&id) { @@ -1565,9 +1364,7 @@ impl EditorState { } } - /// True if an eval debounce is still pending. Used by handle::render to keep - /// the vsync loop ticking through the debounce window even when no new input - /// is arriving, so tick() eventually fires run_eval. + /// returns true while an eval debounce is pending pub fn has_pending_eval(&self) -> bool { self.eval_dirty || self.inline_press.as_ref().is_some_and(|s| !s.fired_long_press) @@ -1584,8 +1381,6 @@ impl EditorState { self.rebuild_modules(); } - /// Build the BlockInfo slice used by module/table detection. - /// Shared between `rebuild_modules` and `register_visible_tables`. fn build_block_infos(&self) -> Vec { use crate::heading_block::HeadingBlock; use crate::module::BlockInfo; @@ -1602,7 +1397,7 @@ impl EditorState { }).collect() } - /// Rebuild the module list and apply table naming from headings. + /// rebuilds the module list and applies heading-based table names fn rebuild_modules(&mut self) { use crate::module::{compute_modules, detect_table_names}; @@ -1619,10 +1414,7 @@ impl EditorState { } } - /// Register every non-eval-result table in the document on the - /// interpreter under all names it's reachable by from the focused - /// block's module. Also sets `current_block` on the interp so bare - /// H4 refs resolve correctly. + /// registers every non-eval-result table on the interpreter and returns the alias index fn register_visible_tables( &self, interp: &mut acord_core::interp::Interpreter, @@ -1659,9 +1451,6 @@ impl EditorState { let heading = table_names.iter().find(|a| a.table_id == *table_id); let module_name = block_to_module.get(table_id).cloned(); - // Canonical key (used as `current_table` anchor when evaluating - // formulas inside this table): heading name when global/present, - // `module::heading` for H4, positional as final fallback. let canonical_key = match heading { Some(h) => { let hname = normalize_name(&h.name); @@ -1680,15 +1469,10 @@ impl EditorState { }; canonical.insert(*table_id, canonical_key.clone()); - // Build the full set of keys this table is reachable by. let mut keys: Vec = vec![pos_name.to_lowercase(), canonical_key.clone()]; if let Some(h) = heading { let hname = normalize_name(&h.name); if h.scope == TableNameScope::BlockScoped { - // Also expose bare heading for refs FROM inside the - // owning module (resolve_table_key_fallback also handles - // this, but registering explicitly avoids the fallback - // hop and disambiguates collisions between modules). if module_name.as_deref() == focused_module_name.as_deref() { keys.push(hname); } @@ -1709,9 +1493,7 @@ impl EditorState { TableIndex { keys: keys_map, canonical } } - /// True if any non-eval-result table in the document has at least one - /// cell whose text starts with `/=`. Used to early-out `run_eval` when - /// neither text blocks nor tables have anything to evaluate. + /// returns true if any visible table contains a `/=` formula cell fn any_visible_cell_formulas(&self) -> bool { for block in self.registry.values() { if let Some(tb) = block.as_any().downcast_ref::() { @@ -1724,11 +1506,7 @@ impl EditorState { false } - /// Parse, topo-sort, and evaluate every cell formula across visible - /// tables. Results land in `self.computed_cells`; cycles yield - /// `Value::Error("cycle")`. Also threads computed values back into - /// `interp`'s table registry so subsequent text-block reads see the - /// formula result rather than the raw `/=...` string. + /// parses, topo-sorts, and evaluates every visible cell formula fn evaluate_cell_formulas( &mut self, interp: &mut acord_core::interp::Interpreter, @@ -1761,9 +1539,6 @@ impl EditorState { for (c, cell) in row.iter().enumerate() { let trimmed = cell.trim_start(); let Some(body) = trimmed.strip_prefix("/=") else { continue }; - // The interpreter's spice flag reflects any `use spice` - // already executed in the code blocks for this module. - // Formulas inside tables inherit that flag. match parse_formula_with_spice(body, interp.spice_enabled()) { Ok(ast) => formulas.push(Cell { table_key: canonical.clone(), @@ -1778,9 +1553,6 @@ impl EditorState { } } - // Clear prior computed values for visible tables only — tables - // outside the focused module's scope keep their stale results so - // their cells don't flash blank between cross-module evals. self.computed_cells.retain(|k, _| !seen_blocks.contains(&k.0)); for (bid, c, r, e) in parse_errors { @@ -1791,9 +1563,6 @@ impl EditorState { return; } - // Build dep graph. Node i is formulas[i]. Edge dep_idx → i means - // formula i reads the cell that formula dep_idx computes — so - // dep_idx must evaluate first. let node_key: HashMap<(String, u32, u32), usize> = formulas.iter().enumerate() .map(|(i, f)| ((f.table_key.clone(), f.col, f.row), i)) .collect(); @@ -1841,12 +1610,8 @@ impl EditorState { }; interp.set_current_table(None); - // Thread the computed value back into the interpreter's table - // registry so subsequent formulas AND text-block reads see it - // instead of the raw `/=...` string. if !result.is_error() { let display = result.display(); - // Write into every alias of this table. for (alias_key, &bid) in &table_index.keys { if bid == f.block_id { interp.write_cell_raw(alias_key, f.col, f.row, &display); @@ -1863,9 +1628,7 @@ impl EditorState { } } - /// Apply cell writes logged by the interpreter to the live TableBlocks. - /// Writes land in `rows[r][c]` and grow the table as needed (strict - /// bounds would discourage using formulas to populate empty cells). + /// applies cell writes logged by the interpreter to live tables fn apply_table_writes( &mut self, writes: Vec, @@ -1885,9 +1648,7 @@ impl EditorState { } } - /// Check if block structure changed after an edit. Serializes current - /// blocks, re-parses, applies an incremental diff, then re-seats the - /// focused block index against the post-reparse layout. + /// returns true when an edit changed the block structure fn check_block_structure(&mut self) { let cursor = self.content().cursor(); let full = self.full_text(); @@ -1909,18 +1670,7 @@ impl EditorState { self.rebuild_modules(); } - /// Wrap a selection in matching delimiters or unwrap an existing pair. - /// Used by Cmd+B (`**`), Cmd+I (`*`), Cmd+~ (`~~`), Cmd+", etc. - /// - /// Unwrap detection looks at characters IMMEDIATELY outside the - /// selection, not just inside it — so the selection can be the inner - /// text (without markers) and Cmd+B still toggles off. - /// - /// Star-marker parity rule: bold (`**`) unwraps when the surrounding - /// star count on each side is >= 2 AND even (2 → 0, 4 → 2, …). - /// Italic (`*`) unwraps when the count is odd (1, 3, 5 …). This - /// keeps `**bold**` + Cmd+I → wraps to `***bold***` (bold-italic), - /// not destructive; and `***both***` + Cmd+B → `*both*` (strips bold). + /// wraps a selection in matching delimiters or unwraps an existing pair fn toggle_wrap(&mut self, open: &str, close: &str) { let text = self.content().text(); let cursor = self.content().cursor(); @@ -1928,7 +1678,6 @@ impl EditorState { let (start, end) = match self.selection_byte_range(&text, pos) { Some(range) => range, None => { - // No selection: insert paired markers and park cursor between. let s = format!("{open}{close}"); self.content_mut().perform(text_widget::Action::Edit( text_widget::Edit::Paste(Arc::new(s)), @@ -1948,7 +1697,6 @@ impl EditorState { let star_marker = open.chars().all(|c| c == '*') && close == open; if star_marker { let mlen = open.len(); - // Sym-strip when markers are inside the selection itself. if selected.starts_with(open) && selected.ends_with(close) && selected.len() >= mlen * 2 { let inner = &selected[mlen..selected.len() - mlen]; self.content_mut().perform(text_widget::Action::Edit( @@ -1968,7 +1716,6 @@ impl EditorState { return; } } else { - // Non-star markers: simple symmetric strip. let olen = open.len(); let clen = close.len(); if selected.starts_with(open) && selected.ends_with(close) && selected.len() >= olen + clen { @@ -1985,7 +1732,6 @@ impl EditorState { } } - // Default: wrap. let wrapped = format!("{open}{selected}{close}"); self.content_mut().perform(text_widget::Action::Edit( text_widget::Edit::Paste(Arc::new(wrapped)), @@ -1993,9 +1739,7 @@ impl EditorState { self.reparse(); } - /// Replace a byte range in the current content with `replacement`. Used - /// by toggle_wrap's unwrap path so we can rewrite text that sits OUTSIDE - /// the selection (the surrounding markers). + /// replaces a byte range in the current content with `replacement` fn replace_range(&mut self, start: usize, end: usize, replacement: &str) { let text = self.content().text(); if start > end || end > text.len() { return; } @@ -2003,16 +1747,12 @@ impl EditorState { new_text.push_str(&text[..start]); new_text.push_str(replacement); new_text.push_str(&text[end..]); - // Rebuild the content with the new text and place cursor at end of - // replacement so successive toggles continue to operate at the - // same logical spot. let cursor_byte = start + replacement.len(); self.content_mut().perform(text_widget::Action::Move(Motion::DocumentStart)); self.content_mut().perform(text_widget::Action::Select(Motion::DocumentEnd)); self.content_mut().perform(text_widget::Action::Edit( text_widget::Edit::Paste(Arc::new(new_text.clone())), )); - // Position cursor at byte offset cursor_byte by walking from start. let target = line_col_for_byte(&new_text, cursor_byte); self.content_mut().perform(text_widget::Action::Move(Motion::DocumentStart)); for _ in 0..target.0 { @@ -2025,35 +1765,22 @@ impl EditorState { self.reparse(); } - /// Compute the byte range of the current selection (start, end) or None - /// when no selection is active. + /// returns the byte range of the current selection, or None fn selection_byte_range(&self, text: &str, _cursor_pos: usize) -> Option<(usize, usize)> { let sel = self.content().selection()?; - // We need the start position; use cursor + selection length to - // bracket. Selection is the text between selection-start and cursor; - // search both directions in the buffer to find a unique location. - // For toggle_wrap's purposes, we use the cursor position as the END - // and walk back by sel.len() to find start. This is correct when - // selection extends backward from the cursor; otherwise we fall - // back to a forward search. let cursor = self.content().cursor(); let cursor_byte = byte_offset_for_cursor(text, &cursor.position); let len = sel.len(); - // Try cursor at end of selection. if cursor_byte >= len && &text[cursor_byte - len..cursor_byte] == sel.as_str() { return Some((cursor_byte - len, cursor_byte)); } - // Try cursor at start of selection. if cursor_byte + len <= text.len() && &text[cursor_byte..cursor_byte + len] == sel.as_str() { return Some((cursor_byte, cursor_byte + len)); } - // Fall back to searching the doc. text.find(sel.as_str()).map(|s| (s, s + len)) } - /// Insert paired delimiters at the cursor and place the caret between - /// them. Used for `[` → `[|]` and `{` → `{|}`. Quotes/parens are - /// deliberately NOT auto-paired. + /// inserts paired delimiters and places the caret between them fn auto_pair(&mut self, open: &str, close: &str) { let combined = format!("{open}{close}"); self.content_mut().perform(text_widget::Action::Edit( @@ -2064,15 +1791,12 @@ impl EditorState { } } - /// Toggle blockquote prefix on the current line(s). With a selection - /// spanning multiple lines, prefix `> ` to each; if every line already - /// has `> `, strip it. + /// toggles the `> ` blockquote prefix on the current line fn toggle_blockquote(&mut self) { let text = self.content().text(); let cursor = self.content().cursor(); let lines: Vec<&str> = text.lines().collect(); let cur_line = cursor.position.line.min(lines.len().saturating_sub(1)); - // Single-line toggle: simplest meaningful form. if cur_line >= lines.len() { return; } let line = lines[cur_line]; let mut new_lines: Vec = lines.iter().map(|l| l.to_string()).collect(); @@ -2090,7 +1814,7 @@ impl EditorState { self.reparse(); } - /// Cmd+0 catch-all. See `Message::FixUp` for the spec. + /// incremental scope-exit and newline-sandwich placement fn fix_up(&mut self) { let text = self.content().text(); let cursor = self.content().cursor(); @@ -2119,20 +1843,16 @@ impl EditorState { self.ensure_newline_sandwich(); } - /// Move the cursor onto its own line with exactly one blank line of - /// padding above and below (3 newlines total around the caret), or up - /// to EOF on either side. + /// places the cursor on its own line with one blank line of padding above and below fn ensure_newline_sandwich(&mut self) { let text = self.content().text(); let cursor = self.content().cursor(); let pos = byte_offset_for_cursor(&text, &cursor.position); - // Walk back: collapse trailing whitespace/newlines before pos to "\n\n". let mut left = pos; while left > 0 { let c = text[..left].chars().rev().next().unwrap(); if c == '\n' || c.is_whitespace() { left -= c.len_utf8(); } else { break; } } - // Walk forward: collapse leading whitespace/newlines after pos to "\n\n". let mut right = pos; while right < text.len() { let c = text[right..].chars().next().unwrap(); @@ -2165,9 +1885,7 @@ impl EditorState { self.full_text() } - /// Switch to editor mode: collapse all blocks into a single text block - /// containing the raw markdown. The single-block view path renders it - /// as a full-page text editor. Cmd+A then selects all text naturally. + /// switches to editor mode by collapsing all blocks into one text buffer pub fn enter_editor_mode(&mut self) { if self.render_mode == RenderMode::Editor { return; } self.push_undo_snapshot(); @@ -2184,8 +1902,6 @@ impl EditorState { self.computed_tables.clear(); self.computed_trees.clear(); self.computed_cells.clear(); - // Select all text in the single editor so the user can immediately - // delete or type over it. self.content_mut().perform(Action::Move(Motion::DocumentStart)); self.content_mut().perform(Action::Select(Motion::DocumentEnd)); if let Some(tb) = self.text_block_at(0) { @@ -2193,8 +1909,7 @@ impl EditorState { } } - /// Switch back to live mode: reparse the single text block into - /// structured blocks (headings, tables, HRs, etc.). + /// switches back to live mode and reparses the buffer into blocks pub fn exit_editor_mode(&mut self) { if self.render_mode != RenderMode::Editor { return; } let text = self.content().text(); @@ -2208,11 +1923,9 @@ impl EditorState { self.reparse(); } - /// Switch to view mode: read-only rendered view. Press `i` for Editor, - /// `/` for Live. + /// switches to view mode pub fn enter_view_mode(&mut self) { if self.render_mode == RenderMode::View { return; } - // If coming from editor mode, reparse back to blocks first if self.render_mode == RenderMode::Editor { let text = self.content().text(); let lang = self.lang_str(); @@ -2226,7 +1939,7 @@ impl EditorState { self.render_mode = RenderMode::View; } - /// Collect the concatenated text of all text blocks within a module. + /// returns the concatenated text of all text blocks in a module fn module_source_text(&self, module: &crate::module::Module) -> String { let mut parts = Vec::new(); for &bid in &module.block_ids { @@ -2239,8 +1952,7 @@ impl EditorState { parts.join("\n") } - /// Build an interpreter pre-populated with root module exports and - /// any `use`'d module exports for the block at `block_idx`. + /// builds an interpreter pre-populated with root and `use`'d module exports fn build_eval_interpreter(&self, block_idx: usize) -> acord_core::interp::Interpreter { use acord_core::interp; @@ -2291,7 +2003,7 @@ impl EditorState { eval_interp } - /// Recursively evaluate a module with its `use` declarations resolved. + /// recursively evaluates a module with its `use` declarations resolved fn resolve_module_exports( &self, module: &crate::module::Module, @@ -2334,7 +2046,6 @@ impl EditorState { fn run_eval(&mut self) { self.rebuild_modules(); - // Find which module the focused block belongs to. let focused_id = match self.layout.get(self.focused_block) { Some(&id) => id, None => return, @@ -2344,8 +2055,6 @@ impl EditorState { None => return, }; - // Collect source text from the module's text blocks, tracking block - // boundaries so eval result line numbers can be mapped back to anchors. let mut source_parts: Vec = Vec::new(); let mut boundaries: Vec<(usize, crate::selection::BlockId)> = Vec::new(); let mut cumulative = 0usize; @@ -2364,7 +2073,6 @@ impl EditorState { } let source = source_parts.join("\n"); - // Image scan runs regardless of eval content. self.scan_images(&boundaries, &block_ids); let has_text_eval = source.lines().any(|l| l.trim_start().starts_with("/=")); @@ -2378,22 +2086,15 @@ impl EditorState { let mut interp = self.build_eval_interpreter(self.focused_block); let table_keys = self.register_visible_tables(&mut interp, self.focused_block); - // Phase 1: evaluate cell formulas (reads current raw cell values). - // Formulas override their cell's registered value in the interpreter - // so phase 2 text-block reads see computed values, not /=... strings. self.evaluate_cell_formulas(&mut interp, &table_keys); - // Phase 2: evaluate text-block document. let doc = crate::eval::evaluate_document_with_interp(&mut interp, &source); - // Phase 3: apply text-block cell writes back to live TableBlocks. let writes = interp.drain_table_writes(); self.apply_table_writes(writes, &table_keys); - // Clear previous results for this module's blocks. self.clear_layers_for_blocks(&block_ids); - // Distribute results to the appropriate layers. for r in &doc.results { let anchor = Self::map_line_to_anchor(&boundaries, r.line); if r.format == "table" { @@ -2418,7 +2119,6 @@ impl EditorState { } _ => {} } - // Table parse failed — fall through to inline result self.eval_results.push(InlineResult { anchor, text: format!("{}{}", RESULT_PREFIX, r.result), @@ -2456,20 +2156,14 @@ impl EditorState { } - /// Evaluate every module in document order. Each module gets a fresh - /// interpreter seeded with root exports (and its own `use` imports). - /// Used by Cmd+R, mode switches to Live/View, and file loads. + /// evaluates every module in document order fn run_eval_all(&mut self) { self.rebuild_modules(); - // Clear all computed layers up front. self.eval_results.clear(); self.computed_tables.clear(); self.computed_trees.clear(); self.computed_cells.clear(); - // Evaluate each module in order by temporarily pointing focused_block - // at a text block within it, then calling run_eval() which already - // handles cross-module imports (root exports + `use` declarations). let saved = self.focused_block; let modules: Vec = self.modules.clone(); for module in &modules { @@ -2487,11 +2181,7 @@ impl EditorState { self.pending_focus.take() } - /// Drain the accumulated wheel-scroll delta. handle.rs::render calls - /// this each frame and, if non-zero, runs a `scroll_by` operation - /// against the document scrollable. Returns None when no scroll has - /// been queued (so handle.rs can skip the operation entirely on idle - /// frames). + /// drains the accumulated wheel-scroll delta pub fn take_pending_scroll(&mut self) -> Option { if self.pending_scroll.abs() < f32::EPSILON { self.pending_scroll = 0.0; @@ -2551,8 +2241,6 @@ impl EditorState { } fn restore_snapshot(&mut self, snap: &UndoSnapshot) { - // Bypass the undo-recording branch in set_text — we're in the middle of - // an undo/redo operation and don't want to pile new entries onto the stack. self.replace_text_no_undo(&snap.text); self.run_eval(); self.safe_move_to(Cursor { @@ -2625,11 +2313,7 @@ impl EditorState { }); } - /// Whether `message` is safe to dispatch while the editor is in - /// `RenderMode::View`. Allowlist: scroll, click/drag selection, - /// find, copy, zoom, focus, navigation — anything that doesn't - /// touch document content. Edit-shaped `text_widget::Action`s and - /// every mutating top-level `Message` get dropped at the gate. + /// returns true when `message` is safe to dispatch in view mode fn message_is_view_safe(message: &Message) -> bool { match message { Message::SetRenderMode(_) => true, @@ -2659,18 +2343,10 @@ impl EditorState { } pub fn update(&mut self, message: Message) { - // View mode: drop anything that would change the document. Mode - // switches, focus, scroll, click/drag selection, find, copy, - // navigation — all pass through. Allowlist; new mutating - // messages should fall through to the default `false` arm and - // get dropped. if self.render_mode == RenderMode::View && !Self::message_is_view_safe(&message) { return; } - // Drop whole-document selection on any message that isn't itself an - // operation on that selection. Click, key press, table action — all - // collapse the doc-wide selection back to single-block / single-cell. let preserve_doc_selection = matches!( &message, Message::SelectAllBlocks @@ -2681,11 +2357,6 @@ impl EditorState { self.all_blocks_selected = false; } - // Drop the context menu on any message that isn't itself a context - // menu operation. Includes button clicks INSIDE the menu — they - // dispatch the action AND auto-clear in one shot. Clicking outside - // the menu (which generates a SelectCell or BlockAction) also - // dismisses for free. let preserve_context_menu = matches!( &message, Message::ShowContextMenu { .. } @@ -2706,11 +2377,6 @@ impl EditorState { if let Action::Scroll { lines } = &action { let lh = self.line_height(); - // Single-block-mode gutter still uses scroll_offset for - // its own scroll tracking. Keep this update so the gutter - // doesn't desync. The multi-block path ignores this and - // uses `pending_scroll` (forwarded to the outer - // scrollable in handle.rs::render). self.scroll_offset += *lines as f32 * lh; self.scroll_offset = self.scroll_offset.max(0.0); let focused_id = self.layout.get(self.focused_block).copied(); @@ -2719,17 +2385,9 @@ impl EditorState { .unwrap_or(0.0); let max = (self.content().line_count() as f32 - 1.0) * lh + items_h; self.scroll_offset = self.scroll_offset.min(max.max(0.0)); - // Accumulate the pixel delta for the outer scrollable. - // text_editor's `Action::Scroll` carries lines, not pixels — - // multiply by line height. Multiple scroll events in one - // frame stack up here. self.pending_scroll += *lines as f32 * lh; } - // Smart-backspace inside leading whitespace: with no - // selection, delete back to the previous tab stop in a - // single user-visible step. Mutually exclusive with - // handle_block_boundary's col-0 merge case (col > 0 here). let smart_backspace_count: Option = if matches!(&action, Action::Edit(text_widget::Edit::Backspace)) { let cursor = self.content().cursor(); @@ -2785,9 +2443,6 @@ impl EditorState { } } - // Auto-indent on Enter. Compute AFTER perform(Enter), reading - // the line that was just split — that's the line whose - // indentation we want to inherit on the new line below it. if is_enter && !handled_boundary { let cursor = self.content().cursor(); if cursor.position.line > 0 { @@ -2851,21 +2506,14 @@ impl EditorState { ]; let new_id = blocks::next_id(); let mut new_table = TableBlock::new(new_id, rows, 0); - // Park focus on the first data cell (skip the header row) so - // the user lands ready to type values, not to edit headers. new_table.focused_cell = Some((1, 0)); let new_block: BoxedBlock = Box::new(new_table); let insert_at = (self.focused_block + 1).min(self.block_count()); self.insert_block(insert_at, new_block); self.recount_block_lines(); - // Land in edit mode on the first data cell so the user can - // type immediately. set_editing_cell handles index, central - // selection, and pending_focus in one shot. self.set_editing_cell(insert_at, 1, 0); self.reparse(); - // Intentionally NOT calling run_eval() — see eval_segment_range - // for the destruction-class bug this avoids. } Message::ToggleBold => self.toggle_wrap("**", "**"), Message::ToggleItalic => self.toggle_wrap("*", "*"), @@ -2971,8 +2619,6 @@ impl EditorState { let mut lines: Vec = clean.lines().map(|l| l.to_string()).collect(); if match_line < lines.len() { let line = &lines[match_line]; - // Case-fold per-substring at the match column to avoid - // byte-index divergence between line and line.to_lowercase(). let chars: Vec<(usize, char)> = line.char_indices().collect(); if match_col < chars.len() { let window: String = chars[match_col..] @@ -3011,10 +2657,6 @@ impl EditorState { self.push_undo_snapshot(); self.redo_stack.clear(); - // Case-fold per-substring via char iteration. Never index - // into a pre-lowercased copy — the byte layout can diverge - // for characters whose lowercase changes byte length (Turkish - // İ → "i\u{307}", German ß → "ss", etc.). let clean = self.get_clean_text(); let query_lower = self.find.query.to_lowercase(); let query_char_count = query_lower.chars().count(); @@ -3054,7 +2696,6 @@ impl EditorState { | TableMessage::AddRow | TableMessage::AddColumn ); - // DeleteCol on a single-column table collapses to DeleteTable. if matches!(&tmsg, TableMessage::DeleteCol) { if let Some(tb) = self.table_block_at(idx) { if !tb.is_eval_result @@ -3065,10 +2706,6 @@ impl EditorState { } } } - // The corner-cell delete affordance promotes straight to the - // top-level DeleteCurrentTable handler. We need to ensure the - // target table is the one focused before that runs — the - // click on the affordance counts as touching the table. if matches!(&tmsg, TableMessage::DeleteTable) { if let Some(tb) = self.table_block_at_mut(idx) { if tb.focused_cell.is_none() { @@ -3079,14 +2716,6 @@ impl EditorState { self.update(Message::DeleteCurrentTable); return; } - // Right-click → ShowContextMenu. Before opening the menu, - // hoist focus to the right-clicked table+cell so subsequent - // `FocusedTableOp` actions (Insert/Delete row/col) target - // this table and so iced's focus machinery doesn't snap the - // scroll position back to whatever the prior focused block - // was. Pre-existing multi-cell selection IS preserved — - // only `focused_block` and the right-clicked cell's - // `focused_cell` are updated, not the selection HashSet. if let TableMessage::ContextMenu(r, c) = &tmsg { let (r, c) = (*r, *c); if let Some(tb) = self.table_block_at_mut(idx) { @@ -3097,9 +2726,6 @@ impl EditorState { self.update(Message::ShowContextMenu { block_idx: idx }); return; } - // SelectAll/ClearAll need editor-level housekeeping: SelectAll - // also marks the table as the focused block so the keyboard - // gates pick it up; ClearAll snapshots undo and re-runs eval. let select_all = matches!(&tmsg, TableMessage::SelectAll); let clear_all = matches!(&tmsg, TableMessage::ClearAll); if clear_all { @@ -3110,9 +2736,6 @@ impl EditorState { self.push_undo_snapshot(); } - // SelectCell / EditCell are click-driven and need to also - // mutate the editor-level `editing` / `selection` / focus. - // Capture before tb.handle so we can call the helpers after. let select_target = if let TableMessage::SelectCell(r, c) = &tmsg { Some((*r, *c)) } else { @@ -3124,8 +2747,6 @@ impl EditorState { None }; - // Capture mods BEFORE the borrow so the click can be - // resolved into a SelectionMode without aliasing. let mods = self.mods; if let Some(tb) = self.table_block_at_mut(idx) { @@ -3133,13 +2754,6 @@ impl EditorState { } if let Some((r, c)) = select_target { - // Resolve the modifier-aware selection mode and apply it - // to the table's HashSet selection. Click affects ONE - // cell — rectangular range comes from drag, not click. - // no mod → Replace (selection becomes just this cell) - // Cmd → Toggle (invert this cell's membership) - // Shift → Add (add this cell, never remove) - // Cmd+Shift → Remove (remove this cell, never add) let mode = if mods.logo() && mods.shift() { crate::table_block::SelectionMode::Subtract } else if mods.logo() { @@ -3158,9 +2772,6 @@ impl EditorState { self.set_editing_cell(idx, r, c); } if select_all { - // Whole-table selection — focused block is this table, - // editing is cleared. The table_selected flag was already - // set by tb.handle above. self.focused_block = idx; self.editing = None; if let Some(block) = self.block_at(idx) { @@ -3216,8 +2827,6 @@ impl EditorState { if cur_c + 1 >= col_count { self.update(Message::TableMsg(idx, TableMessage::AddColumn)); } - // Tab keeps edit mode rolling forward so the user can type - // straight into the next cell without a second click. self.set_editing_cell(idx, cur_r, cur_c + 1); } Message::TableShiftTab => { @@ -3244,14 +2853,9 @@ impl EditorState { self.escape_table_down(table_idx); } Message::ExitCellEdit => { - // Exit edit mode but keep the cell selected — same as - // Excel/Numbers' Escape behavior. The cell flips back to - // its static-text rendering on the next frame. if let Some(path) = self.editing.clone() { self.editing = None; if let crate::selection::InnerPath::Cell { row, col } = path.inner { - // Locate the table by id and re-park focus on the - // (now selected, not editing) cell. for i in 0..self.block_count() { if let Some(tb) = self.block_at(i).and_then(|b| b.as_any().downcast_ref::()) { if tb.id == path.block_id { @@ -3267,8 +2871,6 @@ impl EditorState { let Some(idx) = self.focused_table_index() else { return }; let Some(tb) = self.table_block_at(idx) else { return }; let Some((r, col)) = tb.focused_cell else { return }; - // Replace the cell content with just the typed character — - // Excel/Numbers "start typing into the selection" semantics. if let Some(tb) = self.table_block_at_mut(idx) { if r < tb.rows.len() && col < tb.rows[r].len() { tb.rows[r][col] = c.to_string(); @@ -3347,11 +2949,6 @@ impl EditorState { self.pending_focus = Some(table_block::cell_id(block_id, cur_r, cur_c + 1)); } Message::ClearSelectedCell => { - // Empty every selected cell. Does nothing if there's no - // selection or if a cell is currently being edited (the - // text_input handles its own backspace). Honors the multi-cell - // selection HashSet first; falls back to single focused_cell - // if the HashSet is empty. if self.editing.is_some() { return; } @@ -3391,7 +2988,6 @@ impl EditorState { } else if self.render_mode == RenderMode::View { self.render_mode = RenderMode::Live; self.reparse(); - // Restore keyboard focus to the focused text block. if let Some(tb) = self.text_block_at(self.focused_block) { self.pending_focus = Some(block_editor_id(tb.id)); } @@ -3406,9 +3002,6 @@ impl EditorState { } } Message::ClearAllBlocks => { - // Plain Backspace/Delete with the whole document selected — - // wipe to a single empty text block, matching standard editor - // select-all + delete behavior. self.push_undo_snapshot(); self.redo_stack.clear(); self.clear_blocks(); @@ -3427,10 +3020,6 @@ impl EditorState { self.reparse(); } Message::ShowContextMenu { block_idx } => { - // Anchor at the current cursor position. handle.rs writes - // self.cursor_pos before draining messages so this read is - // current. The position is in viewport coordinates — view_blocks - // uses it directly via container padding inside an iced stack. self.context_menu = Some(ContextMenuState { block_idx, x: self.cursor_pos.x, @@ -3449,9 +3038,6 @@ impl EditorState { } } Message::DeleteAllBlocks => { - // Cmd+Backspace with the whole document selected — wipe to a - // single empty text block. Same destructive scope as - // selecting all in a regular editor and hitting Delete. self.push_undo_snapshot(); self.redo_stack.clear(); self.clear_blocks(); @@ -3547,9 +3133,7 @@ impl EditorState { } } - /// Look up the inline result for `(block_id, after_line)` and return its - /// raw value text (the part after the `→ ` prefix). `None` if no result - /// is attached or the result is an error. + /// returns the inline result text for a given anchor fn inline_result_value(&self, block_id: crate::selection::BlockId, after_line: usize) -> Option { let r = self.eval_results.iter().find(|r| { r.anchor.block_id == block_id && r.anchor.after_line == after_line && !r.is_error @@ -3557,15 +3141,14 @@ impl EditorState { Some(r.text.trim_start_matches(RESULT_PREFIX).trim().to_string()) } - /// Read line `line_idx` from the TextBlock with the given id, if any. + /// reads line `line_idx` from the text block with the given id fn read_line_at(&self, block_id: crate::selection::BlockId, line_idx: usize) -> Option { let block = self.registry.get(&block_id)?; let tb = block.as_any().downcast_ref::()?; tb.content.line(line_idx).map(|l| l.text.to_string()) } - /// Copy `{line} → {value}` to clipboard. Used by both long-press (just - /// copy) and double-click (copy then insert template). + /// copies `{source} → {value}` to the clipboard fn copy_inline_result(&mut self, block_id: crate::selection::BlockId, after_line: usize) { let value = match self.inline_result_value(block_id, after_line) { Some(v) => v, @@ -3576,9 +3159,7 @@ impl EditorState { self.pending_clipboard = Some(format!("{trimmed} {RESULT_PREFIX}{value}")); } - /// Double-click on a result: copy + drop a `let = value` line two lines - /// below the source `/=`. Cursor lands right after `let ` so the user can - /// type the variable name. + /// copies the result and drops a `let _ = value` line below the source fn handle_result_extract(&mut self, block_id: crate::selection::BlockId, after_line: usize) { let value = match self.inline_result_value(block_id, after_line) { Some(v) => v, @@ -3590,14 +3171,12 @@ impl EditorState { Some(i) => i, None => return, }; - // Only TextBlocks accept text-buffer mutations through this path. if self.text_block_at(block_idx).is_none() { return; } self.push_undo_snapshot(); self.redo_stack.clear(); self.set_focused_block(block_idx); - // Move cursor to end of the source `/=` line. let content = self.content_mut(); content.perform(Action::Move(Motion::DocumentStart)); for _ in 0..after_line { @@ -3605,13 +3184,9 @@ impl EditorState { } content.perform(Action::Move(Motion::End)); - // Drop a blank line then `let = value`. Two spaces between `let` and - // `=` — the user types the variable name into the gap. let paste = format!("\n\nlet = {value}"); content.perform(Action::Edit(text_widget::Edit::Paste(Arc::new(paste)))); - // Cursor is at the end of `value`. Walk back past `value`, the `=`, - // and the two flanking spaces — landing right after `let `. let back = 3 + value.chars().count(); for _ in 0..back { content.perform(Action::Move(Motion::Left)); @@ -3802,11 +3377,6 @@ impl EditorState { global_line += line_count; let _ = line_h; // text_widget::layout owns the height now - // Length::Shrink lets text_widget::layout publish the - // actual rendered height (visual_rows × line_h + items - // + padding). Computing it here from logical line count - // undercounts when wrap fires, which leaves the next - // block sitting on top of this block's tail. let editor = text_widget::TextEditor::new(&tb.content) .id(block_editor_id(tb.id)) .on_action(move |action| Message::BlockAction(block_idx, action)) @@ -3856,8 +3426,6 @@ impl EditorState { if let Some(tab) = any.downcast_ref::() { let block_idx = bi; - // Translate the central `editing` path into a (row, col) for - // this specific table, so the renderer can branch each cell. let editing_cell = match self.editing.as_ref() { Some(path) if path.block_id == tab.id => match &path.inner { crate::selection::InnerPath::Cell { row, col } => Some((*row, *col)), @@ -3878,11 +3446,6 @@ impl EditorState { continue; } - // Heading / HR / Tree go through the trait `view` method via a - // per-iteration ViewCtx. The new trait signature decouples the - // returned LayeredView's lifetime from `ctx`, so a stack-local - // ViewCtx is fine — implementations must read what they need from - // ctx into Copy locals and not capture ctx into the element. let ctx: ViewCtx<'_, Message> = ViewCtx { block_index: bi, selection: &self.selection, @@ -3934,10 +3497,6 @@ impl EditorState { .into() }; - // Whole-document selection visual: tint the entire content area blue. - // The container only paints background — it doesn't intercept clicks, - // so the underlying blocks remain interactive (which is intentional: - // a click anywhere drops the selection back to single-block scope). let inner: Element<'_, Message, Theme, iced_wgpu::Renderer> = if self.all_blocks_selected { let p = palette::current(); iced_widget::container(inner) @@ -3955,11 +3514,6 @@ impl EditorState { inner }; - // Context menu overlay. Stacked above the main content; positioned - // at the cursor anchor via container padding from top-left. Clicks - // anywhere outside the menu hit the main content (still alive on - // the layer below) AND auto-clear the menu via update()'s top-of-loop - // drop logic. let with_ctx: Element<'_, Message, Theme, iced_wgpu::Renderer> = if let Some(menu_state) = &self.context_menu { iced_widget::stack![inner, self.context_menu_view(menu_state)].into() @@ -3967,8 +3521,6 @@ impl EditorState { inner }; - // Spillover popup overlay — opens when wrap is off and the user - // clicks a clipped cell. Only one is open at a time per editor. if let Some(popup) = self.spillover_view() { iced_widget::stack![with_ctx, popup].into() } else { @@ -3976,10 +3528,7 @@ impl EditorState { } } - /// Find the first table block with an open spillover and render its - /// popup. Returns None when no spillover is active. The popup is - /// fixed-positioned at the top-center of the viewport — close enough - /// for now; cell-anchored positioning is a polish pass away. + /// renders the spillover popup of the first table that has one open fn spillover_view(&self) -> Option> { let p = palette::current(); let cell_text = self.layout.iter() @@ -4045,9 +3594,6 @@ impl EditorState { snap: false, }); - // Position via Shrink-sized leading spacers (same trick as the - // context menu overlay) — Fill+padding triggers a viewport-wide - // re-layout on every popup open and steals events. let popup_el: Element<'_, Message, Theme, iced_wgpu::Renderer> = popup.into(); let v_spacer = iced_widget::Space::new() .width(Length::Shrink) @@ -4064,7 +3610,7 @@ impl EditorState { ) } - /// Get (after_line, height) offset pairs for a block's anchored items. + /// returns (after_line, height) offset pairs for a block's anchored items fn item_offsets(&self, block_id: crate::selection::BlockId) -> Vec<(usize, f32)> { let lh = self.line_height(); self.collect_layer_items(block_id) @@ -4075,7 +3621,7 @@ impl EditorState { - /// Collect all layer items for a block into a sorted vec of (after_line, item). + /// returns layer items for a block sorted by anchor line fn collect_layer_items(&self, block_id: crate::selection::BlockId) -> Vec<(usize, LayerItem<'_>)> { let mut items: Vec<(usize, LayerItem<'_>)> = Vec::new(); for r in &self.eval_results { @@ -4102,8 +3648,7 @@ impl EditorState { items } - /// Build anchored child Elements for the text widget compositor. - /// Converts layer items into AnchoredItem structs with pre-built Elements. + /// builds anchored child elements for the text widget compositor fn build_anchored_items<'a>( &'a self, block_id: crate::selection::BlockId, @@ -4116,17 +3661,45 @@ impl EditorState { for (after_line, item) in &items { match item { LayerItem::Inline(r) => { - let color = if r.is_error { p.red } else { p.green }; - let inner = iced_widget::container( - iced_widget::text(&r.text) - .font(syntax::EDITOR_FONT) - .size(self.font_size) - .color(oklab::lighten_for_size(color, self.font_size)) - ) - .padding(Padding { top: 0.0, right: 8.0, bottom: 0.0, left: 40.0 }) - .width(Length::Fill); - // Errors don't carry a copyable result value, so they - // don't get the gesture wrapper. + let inner = if r.is_error { + iced_widget::container( + iced_widget::text(&r.text) + .font(syntax::EDITOR_FONT) + .size(self.font_size) + .color(oklab::lighten_for_size(p.red, self.font_size)) + ) + .padding(Padding { top: 0.0, right: 8.0, bottom: 0.0, left: 40.0 }) + .width(Length::Fill) + } else { + let value = r.text + .strip_prefix(RESULT_PREFIX) + .unwrap_or(&r.text) + .to_string(); + let arrow_color = oklab::lighten_for_size(palette::eval_arrow_color(), self.font_size); + let value_color = oklab::lighten_for_size(palette::eval_value_color(), self.font_size); + let bold = Font { + weight: iced_wgpu::core::font::Weight::Bold, + ..syntax::EDITOR_FONT + }; + let row = iced_widget::row![ + iced_widget::text("→ ") + .font(syntax::EDITOR_FONT) + .size(self.font_size) + .color(arrow_color), + iced_widget::text(value) + .font(bold) + .size(self.font_size) + .color(value_color), + iced_widget::text(" ←") + .font(syntax::EDITOR_FONT) + .size(self.font_size) + .color(arrow_color), + ] + .spacing(0.0); + iced_widget::container(row) + .padding(Padding { top: 0.0, right: 8.0, bottom: 0.0, left: 40.0 }) + .width(Length::Fill) + }; let el: Element<'a, Message, Theme, iced_wgpu::Renderer> = if r.is_error { inner.into() } else { @@ -4208,7 +3781,6 @@ impl EditorState { .width(Length::Fill) .into() } else { - // Placeholder while loading or on failure. iced_widget::container( iced_widget::text(format!("[image: {}]", img.alt)) .font(syntax::EDITOR_FONT) @@ -4231,9 +3803,7 @@ impl EditorState { anchored } - /// Build the context menu overlay for a right-clicked cell. Returns a - /// fill container that holds the actual menu in the top-left corner of - /// a padded region — padding (top: y, left: x) anchors it to the click. + /// builds the context menu overlay for a right-clicked cell fn context_menu_view( &self, state: &ContextMenuState, @@ -4332,11 +3902,6 @@ impl EditorState { snap: false, }); - // Position via shrink-sized leading spacers, NOT a Fill container with - // padding. Fill+padding triggers a viewport-wide re-layout on every - // menu open, which jumps the scrollable and swallows events on the - // overlay layer. Shrink sizing keeps the overlay limited to its own - // bounds, so clicks outside pass through to the scrollable beneath. let menu_element: Element<'_, Message, Theme, iced_wgpu::Renderer> = menu.into(); let v_spacer = iced_widget::Space::new() .width(Length::Shrink) @@ -4484,8 +4049,6 @@ fn context_menu_item_style( } } -// Strip obsolete inline-result lines from documents saved before eval -// results moved into anchored child elements. fn is_result_line(line: &str) -> bool { let trimmed = line.trim_start(); @@ -4543,7 +4106,6 @@ fn macos_key_binding(key_press: KeyPress) -> Option> { keyboard::Key::Character("-") if modifiers.logo() => { Some(Binding::Custom(Message::ZoomOut)) } - // Cmd+0 lives in handle.rs now (FixUp); Cmd+Shift+0 resets zoom. keyboard::Key::Character("[") if !modifiers.logo() && !modifiers.alt() && !modifiers.control() && auto_pair::enabled(auto_pair::BRACKET) => { Some(Binding::Custom(Message::AutoPair("[", "]"))) } @@ -4655,18 +4217,17 @@ fn leading_whitespace(line: &str) -> &str { &line[..end] } -/// Count consecutive trailing occurrences of `c` at the end of `s`. +/// counts consecutive trailing occurrences of `c` in `s` fn count_trailing_char(s: &str, c: char) -> usize { s.chars().rev().take_while(|&x| x == c).count() } -/// Count consecutive leading occurrences of `c` at the start of `s`. +/// counts consecutive leading occurrences of `c` in `s` fn count_leading_char(s: &str, c: char) -> usize { s.chars().take_while(|&x| x == c).count() } -/// Convert an iced `Position { line, column }` to a byte offset within -/// `text`. column is interpreted as char count (cosmic-text convention). +/// converts a line/column position to a byte offset in `text` fn byte_offset_for_cursor(text: &str, pos: &text_widget::Position) -> usize { let mut byte = 0usize; for (line_idx, line) in text.split_inclusive('\n').enumerate() { @@ -4681,7 +4242,7 @@ fn byte_offset_for_cursor(text: &str, pos: &text_widget::Position) -> usize { text.len() } -/// Inverse of `byte_offset_for_cursor`. Returns (line, column). +/// inverse of `byte_offset_for_cursor` fn line_col_for_byte(text: &str, byte: usize) -> (usize, usize) { let mut acc = 0usize; let mut line_idx = 0usize; @@ -4697,10 +4258,7 @@ fn line_col_for_byte(text: &str, byte: usize) -> (usize, usize) { (last_line, text.lines().last().map(|l| l.chars().count()).unwrap_or(0)) } -/// Walk `text` left-to-right tracking a delimiter stack. Return the -/// `close` char of the innermost still-open pair, or None if balanced. -/// Pairs tracked: `()`, `[]`, `{}`. (Quotes/HTML are intentionally out -/// of scope — too ambiguous in markdown.) +/// walks `text` left-to-right tracking a delimiter stack fn innermost_unclosed_delim(text: &str) -> Option { let mut stack: Vec = Vec::new(); for c in text.chars() { @@ -4717,8 +4275,7 @@ fn innermost_unclosed_delim(text: &str) -> Option { stack.last().copied() } -/// Find the byte offset of the next outer scope's CLOSING delimiter -/// after `pos`. Used by FixUp to step the cursor out one scope. +/// returns the byte offset of the next outer scope's closing delimiter fn next_closing_delim_after(text: &str, pos: usize) -> Option { let mut depth: i32 = 0; let bytes = text.as_bytes(); @@ -4735,10 +4292,7 @@ fn next_closing_delim_after(text: &str, pos: usize) -> Option { None } -/// Parse a markdown image reference `![alt](src)` from a line. Returns -/// `(alt, src)` if found. Only matches if the `![` is the first -/// non-whitespace on the line (inline images inside text are not rendered -/// as block-level anchored items). +/// parses a markdown image reference `![alt](src)` from a line fn parse_image_ref(line: &str) -> Option<(String, String)> { let trimmed = line.trim_start(); if !trimmed.starts_with("![") { return None; } @@ -4753,8 +4307,7 @@ fn parse_image_ref(line: &str) -> Option<(String, String)> { Some((alt, src)) } -/// Load an image. `src` may be `http(s)://`, `~/…`, or a filesystem path. -/// Result is always PNG bytes regardless of source format. +/// loads an image from a local path or http(s) URL fn load_image_from_path(src: &str) -> Option { let raw = if src.starts_with("http://") || src.starts_with("https://") { let agent: ureq::Agent = ureq::Agent::config_builder() @@ -4779,10 +4332,7 @@ fn load_image_from_path(src: &str) -> Option { Some(ImageCacheEntry { handle, width, height }) } -/// Encode a clipboard image (RGBA from `arboard`) to PNG and write it into -/// `~/.acord/cache/images/{hash}.png`. Returns the absolute path as a -/// String suitable for embedding in a `![]( … )` markdown reference. -/// Content-addressed: re-pasting the same pixels reuses the same file. +/// encodes a clipboard image to PNG and writes it into the on-disk cache pub fn write_clipboard_image_to_cache(img: &arboard::ImageData) -> Option { let dir = dirs::home_dir()?.join(".acord").join("cache").join("images"); std::fs::create_dir_all(&dir).ok()?; diff --git a/viewport/src/lib.rs b/viewport/src/lib.rs index 9ddb09f..27c911e 100644 --- a/viewport/src/lib.rs +++ b/viewport/src/lib.rs @@ -3,6 +3,7 @@ use std::ffi::{c_char, c_void, CStr, CString}; pub mod block; pub mod blocks; mod bridge; +pub mod browser; mod editor; pub mod export; mod handle; diff --git a/viewport/src/palette.rs b/viewport/src/palette.rs index b3a7219..022c9c0 100644 --- a/viewport/src/palette.rs +++ b/viewport/src/palette.rs @@ -152,17 +152,34 @@ pub struct WidgetSurface { pub border: Color, pub header_accent: Color, pub body_text: Color, + pub eval_fill: Color, + pub eval_border: Color, + pub eval_accent: Color, } pub fn widget_surface() -> WidgetSurface { let p = current(); - // Dark: fill lifts above base (surface0) for a frosted-lighter card. - // Light: fill recedes below base (mantle) for a frosted-cooler card. let fill = if is_dark() { p.surface0 } else { p.mantle }; + let eval_fill = if is_dark() { p.surface1 } else { p.crust }; WidgetSurface { fill, border: p.surface2, header_accent: p.teal, body_text: p.text, + eval_fill, + eval_border: p.overlay0, + eval_accent: p.teal, } } + +pub fn eval_value_color() -> Color { + if is_dark() { + Color::from_rgb(0.30, 0.95, 0.50) + } else { + Color::from_rgb(0.10, 0.55, 0.18) + } +} + +pub fn eval_arrow_color() -> Color { + current().red +} diff --git a/viewport/src/table_block.rs b/viewport/src/table_block.rs index 5ae6db4..6d0e728 100644 --- a/viewport/src/table_block.rs +++ b/viewport/src/table_block.rs @@ -1170,21 +1170,22 @@ pub fn cell_id(block_id: u64, row: usize, col: usize) -> WidgetId { WidgetId::from(format!("table_cell_{}_{}_{}", block_id, row, col)) } -fn cell_border() -> Border { +fn cell_border_for(is_eval: bool) -> Border { let ws = palette::widget_surface(); Border { - color: ws.border, + color: if is_eval { ws.eval_border } else { ws.border }, width: 1.0, radius: 0.0.into(), } } -fn cell_input_style(_theme: &Theme, _status: text_input::Status) -> text_input::Style { +fn cell_input_style_for(is_eval: bool) -> text_input::Style { let p = palette::current(); let ws = palette::widget_surface(); + let fill = if is_eval { ws.eval_fill } else { ws.fill }; text_input::Style { - background: Background::Color(ws.fill), - border: cell_border(), + background: Background::Color(fill), + border: cell_border_for(is_eval), icon: p.overlay2, placeholder: p.overlay0, value: ws.body_text, @@ -1192,12 +1193,13 @@ fn cell_input_style(_theme: &Theme, _status: text_input::Status) -> text_input:: } } -fn header_cell_style(_theme: &Theme, _status: text_input::Status) -> text_input::Style { +fn header_cell_style_for(is_eval: bool) -> text_input::Style { let p = palette::current(); let ws = palette::widget_surface(); + let fill = if is_eval { ws.eval_fill } else { ws.fill }; text_input::Style { - background: Background::Color(ws.fill), - border: cell_border(), + background: Background::Color(fill), + border: cell_border_for(is_eval), icon: p.overlay2, placeholder: p.overlay0, value: ws.header_accent, @@ -1423,10 +1425,9 @@ where { // Edit mode (or eval-result table that the user can still // copy from) — use the real text_input. - let style_fn: fn(&Theme, text_input::Status) -> text_input::Style = if is_header { - header_cell_style - } else { - cell_input_style + let is_eval = read_only; + let style_fn = move |_theme: &Theme, _status: text_input::Status| -> text_input::Style { + if is_header { header_cell_style_for(is_eval) } else { cell_input_style_for(is_eval) } }; let mut input = text_input::TextInput::new("", cell) .id(cell_id(block_id, ri, ci)) @@ -1474,20 +1475,19 @@ where .color(oklab::lighten_for_size(label_color, font_size)) .wrapping(if block.wrap { Wrapping::Word } else { Wrapping::None }); + let is_eval = read_only; let container_style = move |_theme: &Theme| { let ws = palette::widget_surface(); let p = palette::current(); + let surface_fill = if is_eval { ws.eval_fill } else { ws.fill }; let background = if is_focused_this { - // Tinted blue background — Excel/Numbers selection look. - // Heavier alpha than the default tint so selection is - // unmistakably visible against the cell fill. Some(Background::Color(Color { a: 0.45, ..p.blue })) } else { - Some(Background::Color(ws.fill)) + Some(Background::Color(surface_fill)) }; container::Style { background, - border: cell_border(), + border: cell_border_for(is_eval), text_color: Some(oklab::lighten_for_size(label_color, font_size)), shadow: Shadow::default(), snap: false, @@ -1597,14 +1597,21 @@ where let outer: Element<'a, Message, Theme, iced_wgpu::Renderer> = if read_only { iced_widget::container(with_plus) - .padding(Padding { top: 2.0, right: 0.0, bottom: 2.0, left: 8.0 }) + .padding(Padding { top: 6.0, right: 6.0, bottom: 6.0, left: 12.0 }) .width(Length::Shrink) - .style(|_theme: &Theme| container::Style { - background: None, - border: Border::default(), - text_color: None, - shadow: Shadow::default(), - snap: false, + .style(|_theme: &Theme| { + let ws = palette::widget_surface(); + container::Style { + background: Some(Background::Color(ws.eval_fill)), + border: Border { + color: ws.eval_accent, + width: 0.0, + radius: 4.0.into(), + }, + text_color: None, + shadow: Shadow::default(), + snap: false, + } }) .into() } else { diff --git a/windows/src/app.rs b/windows/src/app.rs index 3eff15f..7fe7fb7 100644 --- a/windows/src/app.rs +++ b/windows/src/app.rs @@ -19,6 +19,7 @@ use acord_viewport::{ viewport_send_command, viewport_free_string, ViewportHandle, }; +use acord_viewport::browser::{self, BrowserHandle}; use crate::config::Config; use crate::menu::{AppMenu, MenuAction}; @@ -34,6 +35,11 @@ pub struct App { current_file: Option, last_autosave_attempt: Instant, last_autosaved_hash: Option, + + browser_window: Option, + browser_handle: Option, + browser_cursor: PhysicalPosition, + browser_scale: f32, } impl App { @@ -49,6 +55,10 @@ impl App { current_file: None, last_autosave_attempt: Instant::now(), last_autosaved_hash: None, + browser_window: None, + browser_handle: None, + browser_cursor: PhysicalPosition::new(0.0, 0.0), + browser_scale: 1.0, } } @@ -106,9 +116,83 @@ impl App { menu.set_auto_pair_check(bit, (new_flags & bit) != 0); } } + MenuAction::ToggleBrowser => self.toggle_browser(event_loop), } } + fn toggle_browser(&mut self, event_loop: &ActiveEventLoop) { + if self.browser_window.is_some() { + self.close_browser(); + } else { + self.open_browser(event_loop); + } + } + + fn open_browser(&mut self, event_loop: &ActiveEventLoop) { + let mut attrs = WindowAttributes::default() + .with_title("Documents - Acord") + .with_inner_size(LogicalSize::new(900.0, 650.0)); + if let Some(icon) = load_window_icon() { + attrs = attrs.with_window_icon(Some(icon)); + } + let window = match event_loop.create_window(attrs) { + Ok(w) => w, + Err(_) => return, + }; + self.browser_scale = window.scale_factor() as f32; + let size = window.inner_size(); + let w = size.width as f32 / self.browser_scale; + let h = size.height as f32 / self.browser_scale; + + use raw_window_handle::{HasDisplayHandle, HasWindowHandle}; + let display = match window.display_handle() { + Ok(d) => d.as_raw(), + Err(_) => return, + }; + let win_handle = match window.window_handle() { + Ok(w) => w.as_raw(), + Err(_) => return, + }; + + let notes_dir = self.config.notes_dir(); + let _ = std::fs::create_dir_all(¬es_dir); + + match browser::handle::create(display, win_handle, w, h, self.browser_scale, notes_dir) { + Some(handle) => { + self.browser_handle = Some(handle); + self.browser_window = Some(window); + } + None => { + drop(window); + } + } + } + + fn close_browser(&mut self) { + self.browser_handle = None; + self.browser_window = None; + } + + fn drain_browser_open(&mut self) { + let Some(handle) = self.browser_handle.as_mut() else { return }; + let Some(path) = browser::handle::take_pending_open(handle) else { return }; + if let Ok(text) = std::fs::read_to_string(&path) { + let c = CString::new(text).unwrap_or_default(); + viewport_set_text(self.handle, c.as_ptr()); + let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("md"); + let c_ext = CString::new(ext).unwrap(); + viewport_set_lang(self.handle, c_ext.as_ptr()); + if let Some(w) = &self.window { + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("Acord"); + w.set_title(&format!("{name} - Acord")); + w.focus_window(); + } + self.current_file = Some(path); + self.last_autosaved_hash = None; + } + self.close_browser(); + } + fn open_file(&mut self) { let dialog = rfd::FileDialog::new() .add_filter("Markdown", &["md", "markdown"]) @@ -208,6 +292,91 @@ impl App { _ => 0, } } + + fn handle_browser_event(&mut self, event: WindowEvent) { + let Some(handle) = self.browser_handle.as_mut() else { return }; + + match event { + WindowEvent::CloseRequested => { + self.close_browser(); + } + WindowEvent::Resized(size) => { + let w = size.width as f32 / self.browser_scale; + let h = size.height as f32 / self.browser_scale; + browser::handle::resize(handle, w, h, self.browser_scale); + } + WindowEvent::ScaleFactorChanged { scale_factor, .. } => { + self.browser_scale = scale_factor as f32; + if let Some(win) = &self.browser_window { + let size = win.inner_size(); + let w = size.width as f32 / self.browser_scale; + let h = size.height as f32 / self.browser_scale; + browser::handle::resize(handle, w, h, self.browser_scale); + } + } + WindowEvent::RedrawRequested => { + browser::handle::render(handle); + } + WindowEvent::CursorMoved { position, .. } => { + self.browser_cursor = position; + let x = position.x as f32 / self.browser_scale; + let y = position.y as f32 / self.browser_scale; + browser::handle::push_mouse_move(handle, x, y); + } + WindowEvent::MouseInput { state, button, .. } => { + let pressed = state == ElementState::Pressed; + browser::handle::push_mouse_button(handle, Self::winit_button(button), pressed); + } + WindowEvent::MouseWheel { delta, .. } => { + let (dx, dy) = match delta { + MouseScrollDelta::LineDelta(dx, dy) => (dx * 20.0, dy * 20.0), + MouseScrollDelta::PixelDelta(d) => (d.x as f32, d.y as f32), + }; + browser::handle::push_scroll(handle, dx, -dy); + } + WindowEvent::KeyboardInput { event, .. } => { + use iced_wgpu::core::keyboard; + use iced_wgpu::core::Event as IcedEvent; + let pressed = event.state == ElementState::Pressed; + let modifiers = decode_winit_modifiers(self.modifiers); + let key = winit_key_to_iced(&event.logical_key); + let text = event.text.as_ref().map(|s| iced_wgpu::core::SmolStr::new(s.as_str())); + let physical_key = keyboard::key::Physical::Unidentified(keyboard::key::NativeCode::Unidentified); + let location = keyboard::Location::Standard; + let modified_key = key.clone(); + let ev = if pressed { + keyboard::Event::KeyPressed { + key, + modified_key, + physical_key, + location, + modifiers, + text, + repeat: event.repeat, + } + } else { + keyboard::Event::KeyReleased { + key, + modified_key, + physical_key, + location, + modifiers, + } + }; + browser::handle::push_event(handle, IcedEvent::Keyboard(ev)); + } + WindowEvent::ModifiersChanged(mods) => { + self.modifiers = mods.state(); + use iced_wgpu::core::keyboard; + use iced_wgpu::core::Event as IcedEvent; + browser::handle::push_event( + handle, + IcedEvent::Keyboard(keyboard::Event::ModifiersChanged(decode_winit_modifiers(mods.state()))), + ); + } + _ => {} + } + } } impl ApplicationHandler for App { @@ -262,7 +431,13 @@ impl ApplicationHandler for App { self.window = Some(window); } - fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) { + fn window_event(&mut self, event_loop: &ActiveEventLoop, id: WindowId, event: WindowEvent) { + let is_browser = self.browser_window.as_ref().map(|w| w.id() == id).unwrap_or(false); + if is_browser { + self.handle_browser_event(event); + return; + } + if self.handle.is_null() { return; } match event { @@ -358,28 +533,22 @@ impl ApplicationHandler for App { } fn about_to_wait(&mut self, _event_loop: &ActiveEventLoop) { - // Poll menu events. while let Some(action) = AppMenu::poll() { self.dispatch_menu(action, _event_loop); } - // Hash-gated autosave on a 500ms cadence. The hash skip means - // an idle doc doesn't tick the disk; a typing doc writes once - // per cadence regardless of keystroke rate. if self.last_autosave_attempt.elapsed() >= Duration::from_millis(500) { self.last_autosave_attempt = Instant::now(); self.try_autosave(); } - // Request a redraw if the viewport has pending work. + self.drain_browser_open(); if let Some(w) = &self.window { if !self.handle.is_null() { - // Always request redraw — viewport_render short-circuits - // internally when idle (needs_redraw == false && no pending - // eval). Requesting unconditionally is simpler than reading - // the handle's state from here, and wgpu PresentMode::Fifo - // throttles to vsync anyway. w.request_redraw(); } } + if let Some(w) = &self.browser_window { + w.request_redraw(); + } } } @@ -390,6 +559,32 @@ fn text_hash(s: &str) -> u64 { h.finish() } +/// Map winit logical keys to iced keyboard keys for direct iced event push. +fn winit_key_to_iced(key: &Key) -> iced_wgpu::core::keyboard::Key { + use iced_wgpu::core::keyboard::{key as ikey, Key as IKey}; + match key { + Key::Named(n) => match n { + NamedKey::Enter => IKey::Named(ikey::Named::Enter), + NamedKey::Tab => IKey::Named(ikey::Named::Tab), + NamedKey::Backspace => IKey::Named(ikey::Named::Backspace), + NamedKey::Escape => IKey::Named(ikey::Named::Escape), + NamedKey::Delete => IKey::Named(ikey::Named::Delete), + NamedKey::ArrowLeft => IKey::Named(ikey::Named::ArrowLeft), + NamedKey::ArrowRight => IKey::Named(ikey::Named::ArrowRight), + NamedKey::ArrowUp => IKey::Named(ikey::Named::ArrowUp), + NamedKey::ArrowDown => IKey::Named(ikey::Named::ArrowDown), + NamedKey::Home => IKey::Named(ikey::Named::Home), + NamedKey::End => IKey::Named(ikey::Named::End), + NamedKey::PageUp => IKey::Named(ikey::Named::PageUp), + NamedKey::PageDown => IKey::Named(ikey::Named::PageDown), + NamedKey::Space => IKey::Named(ikey::Named::Space), + _ => IKey::Unidentified, + }, + Key::Character(s) => IKey::Character(iced_wgpu::core::SmolStr::new(s.as_str())), + _ => IKey::Unidentified, + } +} + /// Map winit logical keys to the macOS-style keycodes the bridge expects. /// For Named keys, return the matching keycode. For character keys, the /// bridge ignores the keycode and uses the text parameter directly, so diff --git a/windows/src/menu.rs b/windows/src/menu.rs index 8c1961f..6bf84dc 100644 --- a/windows/src/menu.rs +++ b/windows/src/menu.rs @@ -36,6 +36,7 @@ pub enum MenuAction { Settings, ExportCrate, ToggleAutoPair(u32), + ToggleBrowser, } impl AppMenu { @@ -45,6 +46,7 @@ impl AppMenu { let file = Submenu::new("File", true); file.append(&MenuItem::with_id("new", "New Note", true, Some(Accelerator::new(Some(Modifiers::CONTROL), Code::KeyN)))).ok(); file.append(&MenuItem::with_id("open", "Open...", true, Some(Accelerator::new(Some(Modifiers::CONTROL), Code::KeyO)))).ok(); + file.append(&MenuItem::with_id("browse", "Documents...", true, Some(Accelerator::new(Some(Modifiers::ALT), Code::KeyB)))).ok(); file.append(&PredefinedMenuItem::separator()).ok(); file.append(&MenuItem::with_id("save", "Save", true, Some(Accelerator::new(Some(Modifiers::CONTROL), Code::KeyS)))).ok(); file.append(&MenuItem::with_id("save_as", "Save As...", true, Some(Accelerator::new(Some(Modifiers::CONTROL | Modifiers::SHIFT), Code::KeyS)))).ok(); @@ -119,6 +121,7 @@ impl AppMenu { match e.id().0.as_str() { "new" => Some(MenuAction::NewNote), "open" => Some(MenuAction::Open), + "browse" => Some(MenuAction::ToggleBrowser), "save" => Some(MenuAction::Save), "save_as" => Some(MenuAction::SaveAs), "quit" => Some(MenuAction::Quit),