diff --git a/.gitignore b/.gitignore index 76cd7c3..3790e1a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ .DS_Store target/ build/ +*.xcodeproj +*.xcassets +*.pbxproj dist/ diff --git a/Cargo.toml b/Cargo.toml index 0c4236e..42e6ff2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,8 +1,6 @@ [workspace] -members = ["core", "viewport", "windows", "linux", "xtask"] -# Excludes `linux` (deps don't build on macOS/Windows) and `xtask` (build-tool, -# not part of the app). The Linux script invokes `cargo build -p acord-linux` -# directly when running on a Linux host. +members = ["core", "viewport", "windows", "linux", "xtask", "compile"] +# linux and xtask excluded from default; linux built explicitly on linux hosts. default-members = ["core", "viewport", "windows"] resolver = "2" diff --git a/compile/Cargo.toml b/compile/Cargo.toml new file mode 100644 index 0000000..732f414 --- /dev/null +++ b/compile/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "acord-compile" +version = "0.1.0" +edition = "2024" + +[lib] +crate-type = ["rlib"] + +[dependencies] +acord-core = { path = "../core" } diff --git a/compile/src/lib.rs b/compile/src/lib.rs new file mode 100644 index 0000000..a4e7bd2 --- /dev/null +++ b/compile/src/lib.rs @@ -0,0 +1,563 @@ +//! cordial source decomposer -- produces self-contained Rust from Cordial. + +use acord_core::interp::{parse_program, Expr, Op, Stmt}; + +/// extension point for external projects adding custom decomposition rules. +pub trait DecomposeHook { + /// extra Rust source appended after the value prelude, before generated code. + fn preamble(&self) -> Option { None } + + /// intercepts a function call by name before the default decomposition. + fn call(&self, _name: &str, _args: &[Expr]) -> Option { None } + + /// intercepts an expression before the default decomposition. + fn expr(&self, _expr: &Expr) -> Option { None } + + /// intercepts a statement before the default decomposition. + fn stmt(&self, _stmt: &Stmt) -> Option { None } +} + +struct NoHook; +impl DecomposeHook for NoHook {} + +/// external module dependency discovered during decomposition. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct Dependency { + /// module name (note filename stem, hyphen-form). + pub module: String, + /// specific item imported, or None / Some("*") for the whole module. + pub item: Option, +} + +/// result of decomposing a single cordial source. +pub struct Decomposed { + /// self-contained rust source for this module. + pub code: String, + /// external modules referenced by use declarations. + pub deps: Vec, +} + +/// decomposes cordial source into standalone rust + a dependency list. +pub fn decompose(source: &str) -> Result { + decompose_with(source, &NoHook) +} + +/// decomposes with a custom hook for external extensions. +pub fn decompose_with(source: &str, hook: &dyn DecomposeHook) -> Result { + let stmts = parse_program(source)?; + let mut deps = Vec::new(); + let mut out = String::new(); + out.push_str(GENERATED_PRELUDE); + if let Some(extra) = hook.preamble() { + out.push_str(&extra); + out.push('\n'); + } + emit_program(&mut out, &stmts, &mut deps, hook)?; + Ok(Decomposed { code: out, deps }) +} + +fn emit_program(out: &mut String, stmts: &[Stmt], deps: &mut Vec, hook: &dyn DecomposeHook) -> Result<(), String> { + let mut uses = Vec::new(); + let mut fns = Vec::new(); + let mut rest = Vec::new(); + for s in stmts { + match s { + Stmt::Use(..) => uses.push(s), + Stmt::FnDef { .. } => fns.push(s), + _ => rest.push(s), + } + } + + for s in &uses { + emit_stmt(out, s, 0, deps, hook)?; + } + if !uses.is_empty() { out.push('\n'); } + + for s in &fns { + emit_stmt(out, s, 0, deps, hook)?; + out.push('\n'); + } + indent(out, 0, "pub fn run() -> V {"); + for s in &rest { + emit_stmt(out, s, 1, deps, hook)?; + } + indent(out, 1, "V::Void"); + indent(out, 0, "}"); + Ok(()) +} + +fn emit_stmt(out: &mut String, stmt: &Stmt, depth: usize, deps: &mut Vec, hook: &dyn DecomposeHook) -> Result<(), String> { + if let Some(custom) = hook.stmt(stmt) { + indent(out, depth, &custom); + return Ok(()); + } + match stmt { + Stmt::Let(name, _ann, expr) => { + let rhs = emit_expr(expr, hook)?; + indent(out, depth, &format!("let mut {} = {};", ident(name), rhs)); + } + Stmt::Assign(name, expr) => { + let rhs = emit_expr(expr, hook)?; + indent(out, depth, &format!("{} = {};", ident(name), rhs)); + } + Stmt::PathAssign(lhs, expr) => { + let lhs_s = emit_expr(lhs, hook)?; + let rhs = emit_expr(expr, hook)?; + indent(out, depth, &format!("v_path_assign(&mut {}, &{});", lhs_s, rhs)); + } + Stmt::Return(expr) => { + let rhs = emit_expr(expr, hook)?; + indent(out, depth, &format!("return {};", rhs)); + } + Stmt::ExprStmt(expr) => { + let rendered = emit_expr(expr, hook)?; + indent(out, depth, &format!("let _ = {};", rendered)); + } + Stmt::FnDef { name, params, body, .. } => { + let param_list: String = params.iter() + .map(|(p, _)| format!("mut {}: V", ident(p))) + .collect::>() + .join(", "); + indent(out, depth, &format!("pub fn {}({}) -> V {{", ident(name), param_list)); + for s in body { + emit_stmt(out, s, depth + 1, deps, hook)?; + } + indent(out, depth + 1, "V::Void"); + indent(out, depth, "}"); + } + Stmt::While(cond, body) => { + let c = emit_expr(cond, hook)?; + indent(out, depth, &format!("while v_truthy(&{}) {{", c)); + for s in body { + emit_stmt(out, s, depth + 1, deps, hook)?; + } + indent(out, depth, "}"); + } + Stmt::IfElse(cond, then_body, else_body) => { + let c = emit_expr(cond, hook)?; + indent(out, depth, &format!("if v_truthy(&{}) {{", c)); + for s in then_body { + emit_stmt(out, s, depth + 1, deps, hook)?; + } + if let Some(els) = else_body { + indent(out, depth, "} else {"); + for s in els { + emit_stmt(out, s, depth + 1, deps, hook)?; + } + } + indent(out, depth, "}"); + } + Stmt::ForLoop(var, iter_expr, body) => { + let iter_s = emit_expr(iter_expr, hook)?; + indent(out, depth, &format!("for {} in v_iter(&{}) {{", ident(var), iter_s)); + for s in body { + emit_stmt(out, s, depth + 1, deps, hook)?; + } + indent(out, depth, "}"); + } + Stmt::Use(module, item) => { + let mod_ident = module.replace('-', "_"); + deps.push(Dependency { module: module.clone(), item: item.clone() }); + indent(out, depth, &format!("mod {};", mod_ident)); + match item.as_deref() { + None | Some("*") => indent(out, depth, &format!("use {}::*;", mod_ident)), + Some(name) => indent(out, depth, &format!("use {}::{};", mod_ident, ident(name))), + } + } + Stmt::CellAssign { table, cell, value, .. } => { + let v = emit_expr(value, hook)?; + indent(out, depth, &format!( + "v_cell_set({:?}, {}, {}, &{});", + table, cell.0, cell.1, v + )); + } + Stmt::SolveDef { name, params, target_var, source_fn, .. } => { + let param_list: String = params.iter() + .map(|p| format!("mut {}: V", ident(p))) + .collect::>() + .join(", "); + indent(out, depth, &format!("pub fn {}({}) -> V {{", ident(name), param_list)); + indent(out, depth + 1, &format!( + "v_solve_newton({:?}, {:?})", + target_var, source_fn + )); + indent(out, depth, "}"); + } + } + Ok(()) +} + +fn emit_expr(expr: &Expr, hook: &dyn DecomposeHook) -> Result { + if let Some(custom) = hook.expr(expr) { + return Ok(custom); + } + Ok(match expr { + Expr::Num(n) => format!("V::Num({})", fmt_num(*n)), + Expr::Bool(b) => format!("V::Bool({})", b), + Expr::Str(s) => format!("V::Str({:?}.into())", s), + Expr::Ident(name) => format!("{}.clone()", ident(name)), + Expr::UnaryOp(op, inner) => { + let inner_s = emit_expr(inner, hook)?; + match op { + Op::Neg => format!("v_neg(&{})", inner_s), + Op::Not => format!("v_not(&{})", inner_s), + Op::Strip => format!("v_strip(&{})", inner_s), + _ => format!("v_neg(&{})", inner_s), + } + } + Expr::BinOp(op, l, r) => { + let ls = emit_expr(l, hook)?; + let rs = emit_expr(r, hook)?; + let func = match op { + Op::Add => "v_add", + Op::Sub => "v_sub", + Op::Mul => "v_mul", + Op::Div => "v_div", + Op::Mod => "v_rem", + Op::Pow => "v_pow", + Op::Eq => "v_eq", + Op::Neq => "v_neq", + Op::Lt => "v_lt", + Op::Gt => "v_gt", + Op::Lte => "v_lte", + Op::Gte => "v_gte", + Op::And => "v_and", + Op::Or => "v_or", + _ => "v_add", + }; + format!("{}(&{}, &{})", func, ls, rs) + } + Expr::Call(name, args) => { + if let Some(custom) = hook.call(name, args) { + custom + } else { + let arg_list: Vec = args.iter() + .map(|a| emit_expr(a, hook)) + .collect::>()?; + format!("{}({})", ident(name), arg_list.join(", ")) + } + } + Expr::Array(items) => { + let parts: Vec = items.iter() + .map(|e| emit_expr(e, hook)) + .collect::>()?; + format!("V::Array(vec![{}])", parts.join(", ")) + } + Expr::Index(base, idx) => { + let b = emit_expr(base, hook)?; + let i = emit_expr(idx, hook)?; + format!("v_index(&{}, &{})", b, i) + } + Expr::Range(start, end) => { + let s = emit_expr(start, hook)?; + let e = emit_expr(end, hook)?; + format!("v_range(&{}, &{})", s, e) + } + Expr::IsCheck(inner, type_name) => { + let i = emit_expr(inner, hook)?; + format!("v_is(&{}, {:?})", i, type_name) + } + Expr::CellRef { table, target, .. } => { + let t = table.as_deref().unwrap_or("_default"); + match target { + acord_core::interp::CellRefTarget::Cell(col, row) => { + format!("v_cell_get({:?}, {}, {})", t, col, row) + } + acord_core::interp::CellRefTarget::Range(c1, r1, c2, r2) => { + format!("v_cell_range({:?}, {}, {}, {}, {})", t, c1, r1, c2, r2) + } + acord_core::interp::CellRefTarget::Whole => { + format!("v_cell_table({:?})", t) + } + } + } + Expr::SolveMacro { var, source_fn } => { + format!("v_solve_call({:?}, {:?})", var, source_fn) + } + Expr::Struct(fields) => { + let entries: Vec = fields.iter() + .map(|(k, v)| { + let val = emit_expr(v, hook)?; + Ok(format!("({:?}.into(), {})", k, val)) + }) + .collect::, String>>()?; + format!("V::Struct(vec![{}].into_iter().collect())", entries.join(", ")) + } + Expr::Field(base, field) => { + let b = emit_expr(base, hook)?; + format!("v_field(&{}, {:?})", b, field) + } + }) +} + +fn indent(out: &mut String, depth: usize, line: &str) { + for _ in 0..depth { out.push_str(" "); } + out.push_str(line); + out.push('\n'); +} + +fn ident(name: &str) -> String { + if RESERVED.contains(&name) { format!("r#{}", name) } else { name.replace('-', "_") } +} + +fn fmt_num(n: f64) -> String { + if n.is_nan() { return "f64::NAN".into(); } + if n.is_infinite() { + return if n > 0.0 { "f64::INFINITY".into() } else { "f64::NEG_INFINITY".into() }; + } + let s = format!("{}", n); + if s.contains('.') || s.contains('e') || s.contains('E') { s } else { format!("{}.0", s) } +} + +const RESERVED: &[&str] = &[ + "as", "break", "const", "continue", "crate", "else", "enum", "extern", + "false", "fn", "for", "if", "impl", "in", "let", "loop", "match", "mod", + "move", "mut", "pub", "ref", "return", "self", "Self", "static", "struct", + "super", "trait", "true", "type", "unsafe", "use", "where", "while", + "async", "await", "dyn", +]; + +const GENERATED_PRELUDE: &str = r#"#![allow(unused_variables, unused_mut, dead_code, unused_imports, non_snake_case)] +use std::collections::BTreeMap; + +#[derive(Clone, Debug, PartialEq)] +pub enum V { + Num(f64), + Str(String), + Bool(bool), + Array(Vec), + Struct(BTreeMap), + Void, +} + +impl std::fmt::Display for V { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + V::Num(n) => write!(f, "{}", n), + V::Str(s) => write!(f, "{}", s), + V::Bool(b) => write!(f, "{}", b), + V::Array(a) => { + write!(f, "[")?; + for (i, v) in a.iter().enumerate() { + if i > 0 { write!(f, ", ")?; } + write!(f, "{}", v)?; + } + write!(f, "]") + } + V::Struct(m) => { + write!(f, "{{")?; + for (i, (k, v)) in m.iter().enumerate() { + if i > 0 { write!(f, ", ")?; } + write!(f, "{}: {}", k, v)?; + } + write!(f, "}}") + } + V::Void => write!(f, "()"), + } + } +} + +fn v_num(v: &V) -> f64 { match v { V::Num(n) => *n, V::Bool(b) => if *b { 1.0 } else { 0.0 }, _ => 0.0 } } +fn v_truthy(v: &V) -> bool { match v { V::Bool(b) => *b, V::Num(n) => *n != 0.0, V::Str(s) => !s.is_empty(), V::Array(a) => !a.is_empty(), _ => false } } + +fn v_neg(a: &V) -> V { V::Num(-v_num(a)) } +fn v_not(a: &V) -> V { V::Bool(!v_truthy(a)) } +fn v_strip(a: &V) -> V { match a { V::Str(s) => V::Str(s.trim().into()), _ => a.clone() } } + +fn v_add(a: &V, b: &V) -> V { match (a, b) { (V::Str(s1), V::Str(s2)) => V::Str(format!("{}{}", s1, s2)), (V::Array(a1), V::Array(a2)) => V::Array([a1.as_slice(), a2.as_slice()].concat()), _ => V::Num(v_num(a) + v_num(b)) } } +fn v_sub(a: &V, b: &V) -> V { V::Num(v_num(a) - v_num(b)) } +fn v_mul(a: &V, b: &V) -> V { V::Num(v_num(a) * v_num(b)) } +fn v_div(a: &V, b: &V) -> V { let d = v_num(b); V::Num(if d == 0.0 { f64::NAN } else { v_num(a) / d }) } +fn v_rem(a: &V, b: &V) -> V { V::Num(v_num(a) % v_num(b)) } +fn v_pow(a: &V, b: &V) -> V { V::Num(v_num(a).powf(v_num(b))) } + +fn v_eq(a: &V, b: &V) -> V { V::Bool(a == b) } +fn v_neq(a: &V, b: &V) -> V { V::Bool(a != b) } +fn v_lt(a: &V, b: &V) -> V { V::Bool(v_num(a) < v_num(b)) } +fn v_gt(a: &V, b: &V) -> V { V::Bool(v_num(a) > v_num(b)) } +fn v_lte(a: &V, b: &V) -> V { V::Bool(v_num(a) <= v_num(b)) } +fn v_gte(a: &V, b: &V) -> V { V::Bool(v_num(a) >= v_num(b)) } +fn v_and(a: &V, b: &V) -> V { V::Bool(v_truthy(a) && v_truthy(b)) } +fn v_or(a: &V, b: &V) -> V { V::Bool(v_truthy(a) || v_truthy(b)) } + +fn v_index(base: &V, idx: &V) -> V { + match (base, idx) { + (V::Array(arr), V::Num(n)) => arr.get(*n as usize).cloned().unwrap_or(V::Void), + (V::Struct(m), V::Str(k)) => m.get(k).cloned().unwrap_or(V::Void), + _ => V::Void, + } +} + +fn v_range(start: &V, end: &V) -> V { + let s = v_num(start) as i64; + let e = v_num(end) as i64; + V::Array((s..=e).map(|i| V::Num(i as f64)).collect()) +} + +fn v_is(val: &V, type_name: &str) -> V { + let matches = match (val, type_name) { + (V::Num(_), "number") => true, + (V::Str(_), "string") => true, + (V::Bool(_), "bool") => true, + (V::Array(_), "array") => true, + (V::Struct(_), "struct") => true, + (V::Void, "void") => true, + _ => false, + }; + V::Bool(matches) +} + +fn v_field(base: &V, name: &str) -> V { + match base { + V::Struct(m) => m.get(name).cloned().unwrap_or(V::Void), + V::Array(a) => match name { + "len" => V::Num(a.len() as f64), + _ => V::Void, + }, + _ => V::Void, + } +} + +fn v_field_set(base: &mut V, _val: &V) { + let _ = base; // path assignment placeholder +} + +fn v_iter(val: &V) -> Vec { + match val { + V::Array(a) => a.clone(), + V::Num(n) => (0..(*n as i64).max(0)).map(|i| V::Num(i as f64)).collect(), + _ => Vec::new(), + } +} + +fn v_path_assign(target: &mut V, val: &V) { *target = val.clone(); } +fn v_cell_get(_table: &str, _col: u32, _row: u32) -> V { V::Void } +fn v_cell_range(_table: &str, _c1: u32, _r1: u32, _c2: u32, _r2: u32) -> V { V::Array(Vec::new()) } +fn v_cell_table(_table: &str) -> V { V::Array(Vec::new()) } +fn v_cell_set(_table: &str, _col: u32, _row: u32, _val: &V) {} +fn v_solve_newton(_target_var: &str, _source_fn: &str) -> V { V::Void } +fn v_solve_call(_var: &str, _source_fn: &str) -> V { V::Void } + +// --- generated code below --- + +"#; + +#[cfg(test)] +mod tests { + use super::*; + + fn dec(src: &str) -> String { + decompose(src).expect("decompose failed").code + } + + fn dec_full(src: &str) -> Decomposed { + decompose(src).expect("decompose failed") + } + + #[test] + fn empty_produces_run() { + let out = dec(""); + assert!(out.contains("pub fn run() -> V")); + } + + #[test] + fn let_binding() { + let out = dec("let x = 5"); + assert!(out.contains("let mut x = V::Num(5.0)")); + } + + #[test] + fn binop() { + let out = dec("let x = 1 + 2"); + assert!(out.contains("v_add(")); + } + + #[test] + fn fn_def() { + let out = dec("fn add(a, b) {\n return a + b\n}"); + assert!(out.contains("pub fn add(mut a: V, mut b: V) -> V")); + } + + #[test] + fn if_else() { + let out = dec("if true {\n let x = 1\n} else {\n let x = 2\n}"); + assert!(out.contains("if v_truthy(")); + assert!(out.contains("} else {")); + } + + #[test] + fn while_loop() { + let out = dec("let i = 0\nwhile i < 10 {\n i = i + 1\n}"); + assert!(out.contains("while v_truthy(")); + } + + #[test] + fn for_loop() { + let out = dec("for x in [1, 2, 3] {\n let _ = x\n}"); + assert!(out.contains("for x in v_iter(")); + } + + #[test] + fn array_literal() { + let out = dec("let a = [1, 2, 3]"); + assert!(out.contains("V::Array(vec![")); + } + + #[test] + fn index_access() { + let out = dec("let a = [1, 2]\nlet b = a[0]"); + assert!(out.contains("v_index(")); + } + + #[test] + fn struct_literal() { + let out = dec("let p = {x: 1, y: 2}"); + assert!(out.contains("V::Struct(")); + } + + #[test] + fn field_access() { + let out = dec("let p = {x: 1}\nlet v = p.x"); + assert!(out.contains("v_field(")); + } + + #[test] + fn reserved_word_raw_ident() { + let out = dec("let type = 1"); + assert!(out.contains("r#type")); + } + + #[test] + fn self_contained_compiles() { + let out = dec("fn double(x) {\n return x * 2\n}\nlet r = double(5)"); + assert!(out.contains("pub fn double(")); + assert!(out.contains("pub fn run()")); + assert!(!out.contains("acord")); + } + + #[test] + fn use_emits_mod_and_reports_dep() { + let r = dec_full("use math"); + assert!(r.code.contains("mod math;")); + assert!(r.code.contains("use math::*;")); + assert_eq!(r.deps.len(), 1); + assert_eq!(r.deps[0].module, "math"); + assert_eq!(r.deps[0].item, None); + } + + #[test] + fn use_specific_item() { + let r = dec_full("use utils::double"); + assert!(r.code.contains("mod utils;")); + assert!(r.code.contains("use utils::double;")); + assert_eq!(r.deps[0].item.as_deref(), Some("double")); + } + + #[test] + fn use_with_underscored_name() { + let r = dec_full("use my_lib"); + assert!(r.code.contains("mod my_lib;")); + assert!(r.code.contains("use my_lib::*;")); + assert_eq!(r.deps[0].module, "my_lib"); + } +} diff --git a/core/cbindgen.toml b/core/cbindgen.toml index ee86b11..7e4c259 100644 --- a/core/cbindgen.toml +++ b/core/cbindgen.toml @@ -1,5 +1,5 @@ language = "C" -header = "/* Generated by cbindgen — do not edit */" +header = "/* Generated by cbindgen - do not edit */" include_guard = "SWIFTLY_H" include_version = false tab_width = 4 diff --git a/core/include/acord.h b/core/include/acord.h index f2645ef..6eb0bf2 100644 --- a/core/include/acord.h +++ b/core/include/acord.h @@ -1,4 +1,4 @@ -/* Generated by cbindgen — do not edit */ +/* Generated by cbindgen - do not edit */ #ifndef SWIFTLY_H #define SWIFTLY_H diff --git a/core/src/doc.rs b/core/src/doc.rs index 353644d..e6c6c67 100644 --- a/core/src/doc.rs +++ b/core/src/doc.rs @@ -51,12 +51,7 @@ fn is_cordial(line: &str) -> bool { let after_eq = rest.as_bytes().get(eq_pos + 1); if after_eq != Some(&b'=') { let name = rest[..eq_pos].trim(); - // Plain binding: `let x = …`. Covers every RHS — plain - // expressions, struct/macro-looking constructions like - // `let lfreq = solve!(l, f0)`, and the function-inversion - // math form `let f(a, b) = expr where …` (where the LHS - // is a function-def-shaped name+params, same as Cordial's - // existing top-level `f(x) = …` short form). + // accepts plain bindings, solve macros, and function-def short forms. if is_ident(name) || is_assignment_target(name) { return true; } @@ -96,21 +91,16 @@ fn is_cordial(line: &str) -> bool { } fn is_assignment_target(s: &str) -> bool { - // simple variable: `x` if is_ident(s) { return true; } - // function def: `f(x)` or `f(x, y)` if let Some(paren) = s.find('(') { let name = &s[..paren]; if is_ident(name) && s.ends_with(')') { return true; } } - // cell-ref target: `@Table:A1`, `@Block::Table:A1`, or even bare - // `@Table` / `@Table:A1:B2`. The interpreter's parser surfaces - // whole-table / range mis-assignments as errors, so the classifier - // only needs to recognize the `@name…` shape here. + // @-prefixed cell-ref targets; validity checked downstream by the parser. if let Some(rest) = s.strip_prefix('@') { if let Some(first) = rest.chars().next() { if first.is_alphabetic() || first == '_' { diff --git a/core/src/eval.rs b/core/src/eval.rs index 52c1227..db9a2a0 100644 --- a/core/src/eval.rs +++ b/core/src/eval.rs @@ -103,7 +103,7 @@ pub fn evaluate_line(text: &str) -> Result { match interp.eval_expr_str(text) { Ok(v) => Ok(v.display()), Err(_) => { - // fall back to cord-expr/cord-trig for trig and CORDIC expressions + // cord-expr/cord-trig fallback path let graph = cord_expr::parse_expr(text)?; let val = cord_trig::eval::evaluate(&graph, 0.0, 0.0, 0.0); Ok(format_value(val)) @@ -124,45 +124,35 @@ fn format_value(val: f64) -> String { // --- Module evaluation pipeline --- -/// Source material for a single module (block). +/// source material for a single module (block). pub struct ModuleSource { - /// Module name (from heading text, normalized). pub name: String, - /// Raw text content of all text blocks in this module, joined. pub text: String, - /// True for the root module (H1 section). Its exports are auto-imported - /// into every other module. pub is_root: bool, } -/// Per-module evaluation result. +/// per-module evaluation result. pub struct ModuleResult { pub name: String, pub doc_result: DocumentResult, pub exports: interp::ModuleExports, } -/// Evaluate modules in dependency order. Root module is evaluated first -/// and its exports are auto-imported into every other module. `use` -/// declarations are resolved via topological sort. Failed `use` (module -/// name doesn't match any source) is silently dropped. +/// evaluates modules in topological dependency order, auto-importing root exports. pub fn evaluate_modules(sources: &[ModuleSource]) -> Vec { use std::collections::HashMap; - // Index modules by name let name_to_idx: HashMap<&str, usize> = sources.iter().enumerate() .map(|(i, s)| (s.name.as_str(), i)) .collect(); - // Extract use declarations from each module let use_decls: Vec> = sources.iter() .map(|s| interp::extract_use_declarations(&s.text)) .collect(); - // Build adjacency list for topo sort (dependency edges: module -> modules it depends on) let n = sources.len(); let mut in_degree = vec![0usize; n]; - let mut dependents: Vec> = vec![Vec::new(); n]; // dep -> modules that depend on it + let mut dependents: Vec> = vec![Vec::new(); n]; // dep -> dependent module indices for (i, decls) in use_decls.iter().enumerate() { for decl in decls { @@ -172,12 +162,10 @@ pub fn evaluate_modules(sources: &[ModuleSource]) -> Vec { in_degree[i] += 1; } } - // Unknown module names are silently ignored (failed use = prose) } } - // Kahn's algorithm for topological sort. Root modules get priority - // (pushed to front of queue). + // Kahn's algorithm; root modules get queue priority. let mut queue: std::collections::VecDeque = std::collections::VecDeque::new(); for (i, s) in sources.iter().enumerate() { if in_degree[i] == 0 { @@ -200,17 +188,13 @@ pub fn evaluate_modules(sources: &[ModuleSource]) -> Vec { } } - // Any modules not in `order` are part of a cycle. Append them at - // the end — they'll evaluate without their cyclic dependencies - // (which means their `use`d bindings won't be available, producing - // natural "undefined variable" errors downstream). + // cyclic modules appended at the end; unresolved deps produce undefined-variable errors. for i in 0..n { if !order.contains(&i) { order.push(i); } } - // Evaluate in topological order let mut exports_by_name: HashMap = HashMap::new(); let mut root_exports: Option = None; let mut results: Vec> = (0..n).map(|_| None).collect(); @@ -218,17 +202,14 @@ pub fn evaluate_modules(sources: &[ModuleSource]) -> Vec { for &idx in &order { let source = &sources[idx]; - // Create interpreter with imported scope let mut interp = interp::Interpreter::new(); - // Auto-import root module exports (unless this IS the root) if !source.is_root { if let Some(ref root_exp) = root_exports { interp.import_all(root_exp); } } - // Import use'd modules' exports for decl in &use_decls[idx] { if let Some(module_exports) = exports_by_name.get(&decl.module) { match &decl.item { @@ -245,7 +226,6 @@ pub fn evaluate_modules(sources: &[ModuleSource]) -> Vec { } } - // Evaluate this module's text let doc_result = evaluate_document_with_interp(&mut interp, &source.text); let module_exports = interp.exports(); @@ -264,7 +244,7 @@ pub fn evaluate_modules(sources: &[ModuleSource]) -> Vec { results.into_iter().flatten().collect() } -/// Evaluate a document's text using an existing (pre-populated) interpreter. +/// evaluates a document's text using a pre-populated interpreter. pub fn evaluate_document_with_interp(interp: &mut interp::Interpreter, text: &str) -> DocumentResult { let classified = classify_document(text); let mut results = Vec::new(); @@ -411,7 +391,6 @@ mod tests { #[test] fn eval_type_annotation_int_lossy_rejected() { - // Round-trip rule: lossy coercion is rejected. let doc = "let x: int = 3.7\n/= x"; let result = evaluate_document(doc); assert!(result.errors.len() >= 1, "should error on lossy int"); @@ -548,7 +527,6 @@ mod tests { ]; let results = evaluate_modules(&sources); assert_eq!(results.len(), 3); - // "main" should see both root's `pi` (auto-import) and math's `double` (via use) let main_result = results.iter().find(|r| r.name == "main").unwrap(); assert_eq!(main_result.doc_result.results.len(), 1); assert_eq!(main_result.doc_result.results[0].result, "6.28"); @@ -611,7 +589,6 @@ mod tests { ModuleSource { name: "a".into(), text: "use b\nlet x = 1".into(), is_root: false }, ModuleSource { name: "b".into(), text: "use a\nlet y = 2".into(), is_root: false }, ]; - // Shouldn't panic. One of them evaluates without the other's exports. let results = evaluate_modules(&sources); assert_eq!(results.len(), 3); } diff --git a/core/src/interp.rs b/core/src/interp.rs index 40a2db0..fbcf9e4 100644 --- a/core/src/interp.rs +++ b/core/src/interp.rs @@ -1,4 +1,6 @@ -use std::collections::HashMap; +use std::cell::RefCell; +use std::collections::{BTreeMap, HashMap}; +use std::rc::Rc; // --- Values --- @@ -8,10 +10,85 @@ pub enum Value { Bool(bool), Str(String), Array(Vec), + Struct(Rc>>), + Ring(Rc>), + Extern(ExternHandle), Void, Error(String), } +/// opaque handle for an embedder-owned object, addressed by kind and id. +#[derive(Clone, Debug)] +pub struct ExternHandle { + pub kind: String, + pub id: u64, +} + +/// shared mutable ring buffer, enabled by use ring. +#[derive(Debug)] +pub struct RingBuf { + pub volume: usize, + pub shape: Option<(usize, usize)>, + /// fixed-capacity backing storage, logical order starting from head. + pub slots: Vec>, + /// next write position modulo volume. + pub head: usize, + /// filled slot count, capped at volume. + pub len: usize, +} + +impl RingBuf { + pub fn flat(volume: usize) -> Self { + RingBuf { volume, shape: None, slots: vec![None; volume], head: 0, len: 0 } + } + + pub fn rect(length: usize, width: usize) -> Self { + let v = length.saturating_mul(width); + RingBuf { volume: v, shape: Some((length, width)), slots: vec![None; v], head: 0, len: 0 } + } + + /// writes incoming values at head and advances, evicting oldest when full. + pub fn push_run(&mut self, incoming: &[Value]) -> Result<(), String> { + let n = incoming.len(); + if n > self.volume { + return Err(format!( + "ring buffer push of {} items exceeds volume {}, pick a wider buffer or split the run", + n, self.volume + )); + } + if self.volume == 0 || n == 0 { + return Ok(()); + } + for v in incoming { + self.slots[self.head] = Some(v.clone()); + self.head = (self.head + 1) % self.volume; + if self.len < self.volume { self.len += 1; } + } + Ok(()) + } + + /// returns filled slots oldest-to-newest, cloned. + pub fn snapshot(&self) -> Vec { + if self.len == 0 { return Vec::new(); } + let start = (self.head + self.volume - self.len) % self.volume; + (0..self.len).map(|i| self.slots[(start + i) % self.volume].clone().unwrap()).collect() + } + + /// renders the logical sequence as an array-style display string. + pub fn display(&self) -> String { + let parts: Vec = self.snapshot().into_iter().map(|v| match v { + Value::Str(s) => format!("\"{}\"", s), + Value::Void => "null".to_string(), + other => other.display(), + }).collect(); + let shape = match self.shape { + Some((l, w)) => format!(" [{}x{}]", l, w), + None => String::new(), + }; + format!("ring{}({})", shape, parts.join(", ")) + } +} + impl Value { pub fn display(&self) -> String { match self { @@ -26,10 +103,25 @@ impl Value { } let inner: Vec = items.iter().map(|v| match v { Value::Str(s) => format!("\"{}\"", s), + Value::Void => "null".to_string(), other => other.display(), }).collect(); format!("[{}]", inner.join(", ")) } + Value::Ring(r) => r.borrow().display(), + Value::Extern(h) => format!("<{} #{}>", h.kind, h.id), + Value::Struct(s) => { + let map = s.borrow(); + let parts: Vec = map.iter().map(|(k, v)| { + let rendered = match v { + Value::Str(s) => format!("\"{}\"", s), + Value::Void => "null".to_string(), + other => other.display(), + }; + format!("{}: {}", k, rendered) + }).collect(); + format!("{{{}}}", parts.join(", ")) + } Value::Void => String::new(), Value::Error(e) => format!("error: {}", e), } @@ -45,6 +137,9 @@ impl Value { Value::Number(n) => *n != 0.0, Value::Str(s) => !s.is_empty(), Value::Array(a) => !a.is_empty(), + Value::Ring(r) => r.borrow().len > 0, + Value::Struct(s) => !s.borrow().is_empty(), + Value::Extern(_) => true, Value::Void => false, Value::Error(_) => false, } @@ -62,7 +157,7 @@ fn format_number(n: f64) -> String { } } -/// renders a scalar+unit value in SPICE notation. +/// formats a scalar+unit pair in SPICE notation. fn format_spice(n: f64, unit: &str) -> String { if n == 0.0 { return format!("0{}", unit); @@ -96,7 +191,7 @@ fn format_spice(n: f64, unit: &str) -> String { format!("{}{}{}{}", trimmed, prefix, sep, unit) } -/// peels a spice-shaped value down to (scalar, unit). +/// extracts (scalar, unit) from a spice-shaped value. fn unwrap_spice(v: &Value) -> (Value, Option) { if let Value::Array(a) = v { if a.len() == 2 { @@ -108,7 +203,7 @@ fn unwrap_spice(v: &Value) -> (Value, Option) { (v.clone(), None) } -/// re-wraps a numeric result with a carried unit, or drops the tag for non-numbers. +/// re-wraps a numeric result with a carried unit, dropping the tag on non-numbers. fn retag_spice(v: Value, unit: Option) -> Value { match (&v, unit) { (Value::Number(_), Some(u)) => Value::Array(vec![v, Value::Str(u)]), @@ -159,7 +254,7 @@ fn combine_unit_additive(a: &str, b: &str) -> Option { } } -/// parses a cell address like `A1` into 0-based `(col, row)`. +/// 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() { @@ -202,7 +297,7 @@ fn col_letters_to_index(s: &str) -> Option { Some(result - 1) } -/// renders a 0-based (col, row) as a spreadsheet-style address. +/// formats 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; @@ -217,7 +312,7 @@ pub fn display_addr(col: u32, row: u32) -> String { format!("{}{}", letters, row + 1) } -/// coerces a raw cell string into a Value. +/// coerces a raw cell string into a typed Value. fn coerce_cell_value(s: &str) -> Value { let trimmed = s.trim(); if trimmed.is_empty() { @@ -272,6 +367,7 @@ enum Token { Tilde, Colon, DotDot, + Dot, Arrow, At, Let, @@ -289,7 +385,7 @@ enum Token { Eof, } -/// returns true if the source declares `use spice` or a `use spice::…` import. +/// checks the source for a use spice or use spice::item declaration. fn source_enables_spice(src: &str) -> bool { src.lines().any(|l| { let t = l.trim(); @@ -302,6 +398,19 @@ fn source_enables_spice(src: &str) -> bool { }) } +/// checks the source for a use ring or use ring::name declaration. +fn source_enables_ring(src: &str) -> bool { + src.lines().any(|l| { + let t = l.trim(); + if t == "use ring" { return true; } + if let Some(rest) = t.strip_prefix("use ring::") { + !rest.is_empty() && rest.chars().all(|c| c.is_alphanumeric() || c == '_') + } else { + false + } + }) +} + const SPICE_UNITS: &[&str] = &["F", "H", "HZ", "V", "A", "W", "R", "OHM", "S", "J"]; fn spice_prefix_scale(c: char) -> Option { @@ -314,7 +423,7 @@ fn spice_prefix_scale(c: char) -> Option { } } -/// parses a post-number alpha run as `(scale, unit_uppercase)`. +/// 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; @@ -343,7 +452,7 @@ fn parse_spice_suffix(alpha: &str) -> Option<(f64, String)> { None } -/// consumes an optional `e[+-]?DIGITS` and returns the multiplier. +/// consumes an optional e[+-]?DIGITS exponent suffix and returns the multiplier. fn try_consume_exponent(chars: &[char], i: &mut usize) -> f64 { let len = chars.len(); if *i >= len { return 1.0; } @@ -358,7 +467,7 @@ fn try_consume_exponent(chars: &[char], i: &mut usize) -> f64 { 10f64.powi(exp) } -/// pushes a Number or Spice token plus any implicit-multiplication `Star`. +/// pushes a Number or Spice token plus any implicit-multiplication Star. fn finalize_number( tokens: &mut Vec, mut value: f64, @@ -383,6 +492,15 @@ fn finalize_number( return; } } + // bare f/F suffix consumed silently; all numbers already f64. + if !spice && run_end - run_start == 1 { + let c = chars[run_start]; + if c == 'f' || c == 'F' { + tokens.push(Token::Number(value)); + *i = run_end; + return; + } + } tokens.push(Token::Number(value)); if *i < len { let c = chars[*i]; @@ -469,6 +587,9 @@ fn tokenize(input: &str, spice: bool) -> Result, String> { '.' if i + 1 < len && chars[i + 1] == '.' => { tokens.push(Token::DotDot); i += 2; } + '.' if !(i + 1 < len && chars[i + 1].is_ascii_digit()) => { + tokens.push(Token::Dot); i += 1; + } '!' => { if i + 1 < len && chars[i + 1] == '=' { tokens.push(Token::BangEq); i += 2; @@ -583,7 +704,7 @@ fn tokenize(input: &str, spice: bool) -> Result, String> { // --- AST --- #[derive(Debug, Clone)] -enum Op { +pub enum Op { Add, Sub, Mul, Div, Mod, Pow, Eq, Neq, Lt, Gt, Lte, Gte, And, Or, Not, Neg, @@ -591,9 +712,10 @@ enum Op { } #[derive(Debug, Clone)] -enum Stmt { +pub enum Stmt { Let(String, Option, Expr), Assign(String, Expr), + PathAssign(Expr, Expr), While(Expr, Vec), IfElse(Expr, Vec, Option>), ForLoop(String, Expr, Vec), @@ -630,7 +752,7 @@ pub enum CellRefTarget { } #[derive(Debug, Clone)] -enum Expr { +pub enum Expr { Num(f64), Str(String), Bool(bool), @@ -651,6 +773,8 @@ enum Expr { var: String, source_fn: String, }, + Struct(Vec<(String, Expr)>), + Field(Box, String), } // --- Parser --- @@ -799,6 +923,15 @@ impl Parser { } self.pos = saved; let expr = self.parse_expr()?; + if self.peek() == &Token::Eq && is_lvalue(&expr) { + self.advance(); + let rhs = self.parse_expr()?; + self.skip_newlines(); + if let Expr::Ident(name) = expr { + return Ok(Stmt::Assign(name, rhs)); + } + return Ok(Stmt::PathAssign(expr, rhs)); + } self.skip_newlines(); Ok(Stmt::ExprStmt(expr)) } @@ -1280,11 +1413,24 @@ impl Parser { expr = Expr::Call(name.clone(), args); } } - while self.peek() == &Token::LBracket { - self.advance(); - let index = self.parse_expr()?; - self.expect(&Token::RBracket)?; - expr = Expr::Index(Box::new(expr), Box::new(index)); + while matches!(self.peek(), Token::LBracket | Token::Dot) { + match self.peek() { + Token::LBracket => { + self.advance(); + let index = self.parse_expr()?; + self.expect(&Token::RBracket)?; + expr = Expr::Index(Box::new(expr), Box::new(index)); + } + Token::Dot => { + self.advance(); + let field = match self.advance() { + Token::Ident(name) => name, + other => return Err(format!("expected field name after '.', got {:?}", other)), + }; + expr = Expr::Field(Box::new(expr), field); + } + _ => unreachable!(), + } } Ok(expr) } @@ -1306,6 +1452,14 @@ impl Parser { { return self.parse_solve_macro(); } + // name!(...) desugars to name(...); bang consumed and discarded. + if self.tokens.get(self.pos + 1) == Some(&Token::Bang) + && self.tokens.get(self.pos + 2) == Some(&Token::LParen) + { + self.advance(); + self.advance(); + return Ok(Expr::Ident(name)); + } self.advance(); Ok(Expr::Ident(name)) } @@ -1328,6 +1482,24 @@ impl Parser { self.expect(&Token::RBracket)?; Ok(Expr::Array(items)) } + Token::LBrace => { + self.advance(); + let mut fields: Vec<(String, Expr)> = Vec::new(); + while self.peek() != &Token::RBrace { + let key = match self.advance() { + Token::Ident(name) => name, + Token::Str(s) => s, + other => return Err(format!("expected field name in struct literal, got {:?}", other)), + }; + self.expect(&Token::Colon)?; + let val = self.parse_expr()?; + fields.push((key, val)); + if self.peek() == &Token::Comma { self.advance(); } + else { break; } + } + self.expect(&Token::RBrace)?; + Ok(Expr::Struct(fields)) + } t => Err(format!("unexpected token: {:?}", t)), } } @@ -1355,6 +1527,9 @@ pub struct Interpreter { fns: HashMap, solved_fns: HashMap, spice_enabled: bool, + ring_enabled: bool, + return_slot: Option, + custom_builtins: HashMap Result>>, tables: HashMap>>, current_table: Option, current_block: Option, @@ -1379,6 +1554,9 @@ impl Interpreter { fns: HashMap::new(), solved_fns: HashMap::new(), spice_enabled: false, + ring_enabled: false, + return_slot: None, + custom_builtins: HashMap::new(), tables: HashMap::new(), current_table: None, current_block: None, @@ -1386,27 +1564,27 @@ impl Interpreter { } } - /// registers a table's contents under `name` (lowercased). + /// 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); } - /// sets the table anchor for bare cell refs. + /// sets the default 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()); } - /// sets the block anchor for unqualified block-scoped table names. + /// sets the default block anchor for unqualified table names. pub fn set_current_block(&mut self, name: Option<&str>) { self.current_block = name.map(|s| s.to_lowercase()); } - /// drains cell writes accumulated during the last eval. + /// drains cell writes accumulated during the last evaluation pass. pub fn drain_table_writes(&mut self) -> Vec { std::mem::take(&mut self.table_writes) } - /// overwrites a cell's raw string in the registered table without logging a write. + /// overwrites a cell's raw string in the registered table, bypassing the write log. 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) { @@ -1418,7 +1596,7 @@ impl Interpreter { } } - /// resolves a (block, table) pair to a registered HashMap key. + /// resolves a (block, table) pair to a registered table key. fn resolve_table_key(&self, block: Option<&str>, table: Option<&str>) -> Option { match (block, table) { (Some(b), Some(t)) => { @@ -1441,7 +1619,7 @@ impl Interpreter { } } - /// resolves a (block, table) pair, returning a synthesized key when unregistered. + /// resolves a (block, table) pair, synthesizing a 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())), @@ -1504,6 +1682,9 @@ impl Interpreter { if !self.spice_enabled && source_enables_spice(trimmed) { self.spice_enabled = true; } + if !self.ring_enabled && source_enables_ring(trimmed) { + self.ring_enabled = true; + } let tokens = tokenize(trimmed, self.spice_enabled)?; let mut parser = Parser::new(tokens); let stmts = parser.parse_program()?; @@ -1517,7 +1698,7 @@ impl Interpreter { } } - /// evaluates a pre-parsed cell formula. + /// evaluates a pre-parsed cell formula against the current scope. pub fn eval_formula(&mut self, f: &ParsedFormula) -> Result { self.eval_expr(&f.ast, 0) } @@ -1541,6 +1722,54 @@ impl Interpreter { self.spice_enabled } + pub fn set_ring_enabled(&mut self, on: bool) { + self.ring_enabled = on; + } + + pub fn ring_enabled(&self) -> bool { + self.ring_enabled + } + + /// installs an embedder-side builtin under the given name, overriding hardcoded builtins. + pub fn register_builtin(&mut self, name: &str, handler: F) + where + F: Fn(&mut Interpreter, &[Value]) -> Result + 'static, + { + self.custom_builtins.insert(name.to_string(), Rc::new(handler)); + } + + /// removes a registered builtin, silent on unknown names. + pub fn unregister_builtin(&mut self, name: &str) { + self.custom_builtins.remove(name); + } + + /// stores a value under the given name, replacing any existing binding. + pub fn set_var(&mut self, name: &str, value: Value) { + self.vars.insert(name.to_string(), value); + } + + /// returns a clone of the value bound to the given name, or None. + pub fn get_var(&self, name: &str) -> Option { + self.vars.get(name).cloned() + } + + /// invokes a function by name with pre-evaluated Values. + pub fn call(&mut self, fn_name: &str, args: &[Value]) -> Result { + let tmps: Vec = (0..args.len()).map(|i| format!("__call_arg_{}", i)).collect(); + let saved: Vec> = tmps.iter().zip(args.iter()) + .map(|(t, v)| self.vars.insert(t.clone(), v.clone())) + .collect(); + let arg_exprs: Vec = tmps.iter().map(|t| Expr::Ident(t.clone())).collect(); + let result = self.eval_call(fn_name, &arg_exprs, 0); + for (tmp, prev) in tmps.into_iter().zip(saved.into_iter()).rev() { + match prev { + Some(v) => { self.vars.insert(tmp, v); } + None => { self.vars.remove(&tmp); } + } + } + result + } + fn exec_stmt(&mut self, stmt: &Stmt, depth: u32) -> Result { match stmt { Stmt::Let(name, _type_ann, Expr::SolveMacro { var, source_fn }) => { @@ -1612,6 +1841,11 @@ impl Interpreter { } Ok(Value::Void) } + Stmt::PathAssign(lhs, expr) => { + let val = self.eval_expr(expr, depth)?; + self.assign_path(lhs, val, depth)?; + Ok(Value::Void) + } Stmt::While(cond, body) => { let mut iterations = 0; loop { @@ -1671,7 +1905,8 @@ impl Interpreter { } Stmt::Return(expr) => { let val = self.eval_expr(expr, depth)?; - Err(format!("\x00return:{}", encode_return(&val))) + self.return_slot = Some(val); + Err("\x00return".to_string()) } Stmt::Use(_, _) => { Ok(Value::Void) @@ -1794,6 +2029,23 @@ impl Interpreter { let v = self.eval_expr(inner, depth)?; Ok(Value::Bool(value_is_kind(&v, target))) } + Expr::Struct(fields) => { + let mut map: BTreeMap = BTreeMap::new(); + for (k, expr) in fields { + let v = self.eval_expr(expr, depth)?; + map.insert(k.clone(), v); + } + Ok(Value::Struct(Rc::new(RefCell::new(map)))) + } + Expr::Field(target, name) => { + let target_val = self.eval_expr(target, depth)?; + match target_val { + Value::Struct(s) => s.borrow().get(name).cloned().ok_or_else(|| + format!("struct has no field '{}'", name) + ), + other => Err(format!("cannot read field '{}' on {}", name, type_name(&other))), + } + } Expr::Range(start, end) => { let sv = self.eval_expr(start, depth)?; let ev = self.eval_expr(end, depth)?; @@ -1894,6 +2146,14 @@ impl Interpreter { return Err("maximum call depth exceeded".into()); } + if let Some(handler) = self.custom_builtins.get(name).cloned() { + let mut arg_vals = Vec::with_capacity(args.len()); + for a in args { + arg_vals.push(self.eval_expr(a, depth)?); + } + return handler(self, &arg_vals); + } + if self.fns.contains_key(name) { return self.call_user_fn(name, args, depth); } @@ -1901,7 +2161,7 @@ impl Interpreter { return self.call_solved_fn(name, args, depth); } - // builtins are case-insensitive (spreadsheet convention: SUM, Sum, sum all work). + // case-insensitive builtin lookup let canon = name.to_ascii_lowercase(); let name = canon.as_str(); match name { @@ -2009,12 +2269,643 @@ impl Interpreter { _ => Err("push() expects an array as first argument".into()), }; } + "map" => { + if args.len() != 2 { + return Err("map() expects 2 arguments (array, fn)".into()); + } + let arr_val = self.eval_expr(&args[0], depth)?; + let fn_name = ident_arg(&args[1], "map", "second")?; + let items = match arr_val { + Value::Array(a) => a, + _ => return Err("map() first argument must be an array".into()), + }; + let mut out = Vec::with_capacity(items.len()); + for v in items { + out.push(self.apply_unary_fn(&fn_name, v, depth)?); + } + return Ok(Value::Array(out)); + } + "each" => { + if args.len() != 2 { + return Err("each() expects 2 arguments (array, fn)".into()); + } + let arr_val = self.eval_expr(&args[0], depth)?; + let fn_name = ident_arg(&args[1], "each", "second")?; + let items = match arr_val { + Value::Array(a) => a, + _ => return Err("each() first argument must be an array".into()), + }; + for v in items { + self.apply_unary_fn(&fn_name, v, depth)?; + } + return Ok(Value::Void); + } + "fold" => { + if args.len() != 3 { + return Err("fold() expects 3 arguments (array, seed, fn)".into()); + } + let arr_val = self.eval_expr(&args[0], depth)?; + let seed = self.eval_expr(&args[1], depth)?; + let fn_name = ident_arg(&args[2], "fold", "third")?; + let items = match arr_val { + Value::Array(a) => a, + _ => return Err("fold() first argument must be an array".into()), + }; + let mut acc = seed; + for v in items { + acc = self.apply_binary_fn(&fn_name, acc, v, depth)?; + } + return Ok(acc); + } + "ring" => { + if !self.ring_enabled { + return Err("ring(): enable with `use ring`".into()); + } + return match args.len() { + 1 => { + let v = self.eval_expr(&args[0], depth)?; + match v { + Value::Number(n) if n >= 0.0 && n.is_finite() && n == n.trunc() => { + Ok(Value::Ring(Rc::new(RefCell::new(RingBuf::flat(n as usize))))) + } + Value::Array(items) if items.len() == 2 => { + let l = expect_usize(&items[0], "ring(): length")?; + let w = expect_usize(&items[1], "ring(): width")?; + Ok(Value::Ring(Rc::new(RefCell::new(RingBuf::rect(l, w))))) + } + _ => Err("ring(): single argument must be a volume integer or [length, width]".into()), + } + } + 2 => { + let head = self.eval_expr(&args[0], depth)?; + let vol_val = self.eval_expr(&args[1], depth)?; + match (&head, &vol_val) { + (Value::Bool(false), Value::Number(n)) if *n >= 0.0 && n.is_finite() && *n == n.trunc() => { + Ok(Value::Ring(Rc::new(RefCell::new(RingBuf::flat(*n as usize))))) + } + _ => Err("ring(): two-argument form is `ring(false, volume)`".into()), + } + } + _ => Err("ring() expects 1 or 2 arguments".into()), + }; + } + "peek" => { + if !self.ring_enabled { + return Err("peek(): enable with `use ring`".into()); + } + if args.is_empty() || args.len() > 2 { + return Err("peek() expects 1 or 2 arguments (ring [, count])".into()); + } + let ring_val = self.eval_expr(&args[0], depth)?; + let ring = match ring_val { + Value::Ring(r) => r, + _ => return Err("peek() first argument must be a ring buffer".into()), + }; + let snapshot: Vec = ring.borrow().snapshot(); + let limited = if args.len() == 2 { + let n_val = self.eval_expr(&args[1], depth)?; + let n = expect_usize(&n_val, "peek(): count")?; + let take = n.min(snapshot.len()); + snapshot[snapshot.len() - take..].to_vec() + } else { + snapshot + }; + return Ok(Value::Array(limited)); + } + "iter" => { + if !self.ring_enabled { + return Err("iter(): enable with `use ring`".into()); + } + if args.len() != 3 { + return Err("iter() expects 3 arguments (array, fn, ring)".into()); + } + let arr_val = self.eval_expr(&args[0], depth)?; + let fn_name = ident_arg(&args[1], "iter", "second")?; + let ring_val = self.eval_expr(&args[2], depth)?; + let ring = match ring_val { + Value::Ring(r) => r, + _ => return Err("iter(): third argument must be a ring buffer".into()), + }; + let items = match arr_val { + Value::Array(a) => a, + _ => return Err("iter(): first argument must be an array".into()), + }; + for v in items { + let result = self.apply_unary_fn(&fn_name, v, depth)?; + let pushed: Vec = match result { + Value::Array(a) => a, + Value::Void => Vec::new(), + other => vec![other], + }; + ring.borrow_mut().push_run(&pushed)?; + } + return Ok(Value::Ring(ring)); + } + "all" => { + if args.len() != 2 { + return Err("all() expects 2 arguments (array, fn)".into()); + } + let arr_val = self.eval_expr(&args[0], depth)?; + let fn_name = ident_arg(&args[1], "all", "second")?; + let items = match arr_val { + Value::Array(a) => a, + _ => return Err("all() first argument must be an array".into()), + }; + for v in items { + let r = self.apply_unary_fn(&fn_name, v, depth)?; + if !r.truthy() { + return Ok(Value::Bool(false)); + } + } + return Ok(Value::Bool(true)); + } + "any" => { + if args.len() != 2 { + return Err("any() expects 2 arguments (array, fn)".into()); + } + let arr_val = self.eval_expr(&args[0], depth)?; + let fn_name = ident_arg(&args[1], "any", "second")?; + let items = match arr_val { + Value::Array(a) => a, + _ => return Err("any() first argument must be an array".into()), + }; + for v in items { + let r = self.apply_unary_fn(&fn_name, v, depth)?; + if r.truthy() { + return Ok(Value::Bool(true)); + } + } + return Ok(Value::Bool(false)); + } + "reduce" => { + if args.len() != 2 { + return Err("reduce() expects 2 arguments (array, fn)".into()); + } + let arr_val = self.eval_expr(&args[0], depth)?; + let fn_name = ident_arg(&args[1], "reduce", "second")?; + let items = match arr_val { + Value::Array(a) => a, + _ => return Err("reduce() first argument must be an array".into()), + }; + let mut iter = items.into_iter(); + let mut acc = iter.next().ok_or_else(|| + "reduce() on empty array; use fold() with a seed".to_string() + )?; + for v in iter { + acc = self.apply_binary_fn(&fn_name, acc, v, depth)?; + } + return Ok(acc); + } + "filter" => { + if args.len() != 2 { + return Err("filter() expects 2 arguments (array, fn)".into()); + } + let arr_val = self.eval_expr(&args[0], depth)?; + let fn_name = ident_arg(&args[1], "filter", "second")?; + let items = match arr_val { + Value::Array(a) => a, + _ => return Err("filter() first argument must be an array".into()), + }; + let mut out = Vec::new(); + for v in items { + let keep = self.apply_unary_fn(&fn_name, v.clone(), depth)?.truthy(); + if keep { out.push(v); } + } + return Ok(Value::Array(out)); + } + "find" => { + if args.len() != 2 { + return Err("find() expects 2 arguments (array, fn)".into()); + } + let arr_val = self.eval_expr(&args[0], depth)?; + let fn_name = ident_arg(&args[1], "find", "second")?; + let items = match arr_val { + Value::Array(a) => a, + _ => return Err("find() first argument must be an array".into()), + }; + for v in items { + let hit = self.apply_unary_fn(&fn_name, v.clone(), depth)?.truthy(); + if hit { return Ok(v); } + } + return Ok(Value::Void); + } + "take_while" => { + if args.len() != 2 { + return Err("take_while() expects 2 arguments (array, fn)".into()); + } + let arr_val = self.eval_expr(&args[0], depth)?; + let fn_name = ident_arg(&args[1], "take_while", "second")?; + let items = match arr_val { + Value::Array(a) => a, + _ => return Err("take_while() first argument must be an array".into()), + }; + let mut out = Vec::new(); + for v in items { + let keep = self.apply_unary_fn(&fn_name, v.clone(), depth)?.truthy(); + if !keep { break; } + out.push(v); + } + return Ok(Value::Array(out)); + } + "skip_while" => { + if args.len() != 2 { + return Err("skip_while() expects 2 arguments (array, fn)".into()); + } + let arr_val = self.eval_expr(&args[0], depth)?; + let fn_name = ident_arg(&args[1], "skip_while", "second")?; + let items = match arr_val { + Value::Array(a) => a, + _ => return Err("skip_while() first argument must be an array".into()), + }; + let mut out = Vec::new(); + let mut dropping = true; + for v in items { + if dropping { + let drop = self.apply_unary_fn(&fn_name, v.clone(), depth)?.truthy(); + if drop { continue; } + dropping = false; + } + out.push(v); + } + return Ok(Value::Array(out)); + } + "inspect" | "tap" => { + if args.len() != 2 { + return Err(format!("{}() expects 2 arguments (array, fn)", name)); + } + let arr_val = self.eval_expr(&args[0], depth)?; + let fn_name = ident_arg(&args[1], name, "second")?; + let items = match arr_val { + Value::Array(a) => a, + _ => return Err(format!("{}() first argument must be an array", name)), + }; + for v in &items { + self.apply_unary_fn(&fn_name, v.clone(), depth)?; + } + return Ok(Value::Array(items)); + } + "take" => { + if args.len() != 2 { + return Err("take() expects 2 arguments (array, n)".into()); + } + let arr_val = self.eval_expr(&args[0], depth)?; + let n_val = self.eval_expr(&args[1], depth)?; + let n = expect_usize(&n_val, "take(): n")?; + let items = match arr_val { + Value::Array(a) => a, + _ => return Err("take() first argument must be an array".into()), + }; + return Ok(Value::Array(items.into_iter().take(n).collect())); + } + "skip" | "drop" => { + if args.len() != 2 { + return Err(format!("{}() expects 2 arguments (array, n)", name)); + } + let arr_val = self.eval_expr(&args[0], depth)?; + let n_val = self.eval_expr(&args[1], depth)?; + let n = expect_usize(&n_val, &format!("{}(): n", name))?; + let items = match arr_val { + Value::Array(a) => a, + _ => return Err(format!("{}() first argument must be an array", name)), + }; + return Ok(Value::Array(items.into_iter().skip(n).collect())); + } + "chunk" => { + if args.len() != 2 { + return Err("chunk() expects 2 arguments (array, n)".into()); + } + let arr_val = self.eval_expr(&args[0], depth)?; + let n_val = self.eval_expr(&args[1], depth)?; + let n = expect_usize(&n_val, "chunk(): n")?; + if n == 0 { return Err("chunk(): n must be > 0".into()); } + let items = match arr_val { + Value::Array(a) => a, + _ => return Err("chunk() first argument must be an array".into()), + }; + let chunks: Vec = items.chunks(n) + .map(|c| Value::Array(c.to_vec())) + .collect(); + return Ok(Value::Array(chunks)); + } + "window" => { + if args.len() != 2 { + return Err("window() expects 2 arguments (array, n)".into()); + } + let arr_val = self.eval_expr(&args[0], depth)?; + let n_val = self.eval_expr(&args[1], depth)?; + let n = expect_usize(&n_val, "window(): n")?; + if n == 0 { return Err("window(): n must be > 0".into()); } + let items = match arr_val { + Value::Array(a) => a, + _ => return Err("window() first argument must be an array".into()), + }; + if items.len() < n { + return Ok(Value::Array(Vec::new())); + } + let windows: Vec = (0..=items.len() - n) + .map(|i| Value::Array(items[i..i + n].to_vec())) + .collect(); + return Ok(Value::Array(windows)); + } + "zip" => { + if args.len() != 2 { + return Err("zip() expects 2 arguments (array, array)".into()); + } + let a = self.eval_expr(&args[0], depth)?; + let b = self.eval_expr(&args[1], depth)?; + let (left, right) = match (a, b) { + (Value::Array(l), Value::Array(r)) => (l, r), + _ => return Err("zip() expects two arrays".into()), + }; + let pairs: Vec = left.into_iter().enumerate() + .map(|(i, l)| { + let r = right.get(i).cloned().unwrap_or(Value::Void); + Value::Array(vec![l, r]) + }) + .collect(); + return Ok(Value::Array(pairs)); + } + "flatten" => { + if args.len() != 1 { + return Err("flatten() expects 1 argument".into()); + } + let arr_val = self.eval_expr(&args[0], depth)?; + let items = match arr_val { + Value::Array(a) => a, + _ => return Err("flatten() expects an array".into()), + }; + let mut out = Vec::new(); + for v in items { + match v { + Value::Array(inner) => out.extend(inner), + other => out.push(other), + } + } + return Ok(Value::Array(out)); + } + "flat_map" => { + if args.len() != 2 { + return Err("flat_map() expects 2 arguments (array, fn)".into()); + } + let arr_val = self.eval_expr(&args[0], depth)?; + let fn_name = ident_arg(&args[1], "flat_map", "second")?; + let items = match arr_val { + Value::Array(a) => a, + _ => return Err("flat_map() first argument must be an array".into()), + }; + let mut out = Vec::new(); + for v in items { + let r = self.apply_unary_fn(&fn_name, v, depth)?; + match r { + Value::Array(inner) => out.extend(inner), + Value::Void => {}, + other => out.push(other), + } + } + return Ok(Value::Array(out)); + } + "sort" => { + if args.len() != 1 { + return Err("sort() expects 1 argument".into()); + } + let arr_val = self.eval_expr(&args[0], depth)?; + let mut items = match arr_val { + Value::Array(a) => a, + _ => return Err("sort() expects an array".into()), + }; + sort_values(&mut items)?; + return Ok(Value::Array(items)); + } + "sort_by" => { + if args.len() != 2 { + return Err("sort_by() expects 2 arguments (array, fn)".into()); + } + let arr_val = self.eval_expr(&args[0], depth)?; + let fn_name = ident_arg(&args[1], "sort_by", "second")?; + let items = match arr_val { + Value::Array(a) => a, + _ => return Err("sort_by() first argument must be an array".into()), + }; + let mut keyed: Vec<(Value, Value)> = Vec::with_capacity(items.len()); + for v in items { + let key = self.apply_unary_fn(&fn_name, v.clone(), depth)?; + keyed.push((key, v)); + } + let mut keys: Vec = keyed.iter().map(|(k, _)| k.clone()).collect(); + sort_values(&mut keys)?; + let mut out: Vec = Vec::with_capacity(keyed.len()); + for k in keys { + let pos = keyed.iter().position(|(kk, _)| values_equal(kk, &k)); + if let Some(p) = pos { + let (_, v) = keyed.remove(p); + out.push(v); + } + } + return Ok(Value::Array(out)); + } + "distinct" | "unique" => { + if args.len() != 1 { + return Err(format!("{}() expects 1 argument", name)); + } + let arr_val = self.eval_expr(&args[0], depth)?; + let items = match arr_val { + Value::Array(a) => a, + _ => return Err(format!("{}() expects an array", name)), + }; + let mut out: Vec = Vec::new(); + for v in items { + if !out.iter().any(|seen| values_equal(seen, &v)) { + out.push(v); + } + } + return Ok(Value::Array(out)); + } + "delta" => { + if args.len() != 1 { + return Err("delta() expects 1 argument".into()); + } + let arr_val = self.eval_expr(&args[0], depth)?; + let items = match arr_val { + Value::Array(a) => a, + _ => return Err("delta() expects an array".into()), + }; + let nums: Vec = items.iter().map(|v| match v { + Value::Number(n) => Ok(*n), + Value::Array(a) if a.len() == 2 => match &a[0] { + Value::Number(n) => Ok(*n), + _ => Err("delta(): non-numeric element"), + }, + _ => Err("delta(): non-numeric element"), + }).collect::>()?; + let diffs: Vec = nums.windows(2) + .map(|w| Value::Number(w[1] - w[0])) + .collect(); + return Ok(Value::Array(diffs)); + } + "scan" => { + if args.len() != 3 { + return Err("scan() expects 3 arguments (array, seed, fn)".into()); + } + let arr_val = self.eval_expr(&args[0], depth)?; + let seed = self.eval_expr(&args[1], depth)?; + let fn_name = ident_arg(&args[2], "scan", "third")?; + let items = match arr_val { + Value::Array(a) => a, + _ => return Err("scan() first argument must be an array".into()), + }; + let mut acc = seed; + let mut out = Vec::with_capacity(items.len()); + for v in items { + acc = self.apply_binary_fn(&fn_name, acc, v, depth)?; + out.push(acc.clone()); + } + return Ok(Value::Array(out)); + } + "history" => { + if !self.ring_enabled { + return Err("history(): enable with `use ring`".into()); + } + if args.is_empty() || args.len() > 2 { + return Err("history() expects 1 or 2 arguments (ring [, count])".into()); + } + let ring_val = self.eval_expr(&args[0], depth)?; + let ring = match ring_val { + Value::Ring(r) => r, + _ => return Err("history() first argument must be a ring buffer".into()), + }; + let snapshot = ring.borrow().snapshot(); + let limited = if args.len() == 2 { + let n_val = self.eval_expr(&args[1], depth)?; + let n = expect_usize(&n_val, "history(): count")?; + let take = n.min(snapshot.len()); + snapshot[snapshot.len() - take..].to_vec() + } else { + snapshot + }; + return Ok(Value::Array(limited)); + } _ => {} } Err(format!("undefined function '{}'", name)) } + /// writes a value to the place named by an lvalue chain. + fn assign_path(&mut self, lhs: &Expr, value: Value, depth: u32) -> Result<(), String> { + match lhs { + Expr::Ident(name) => { + self.vars.insert(name.clone(), value); + Ok(()) + } + Expr::Field(inner, field) => { + let target = self.eval_expr(inner, depth)?; + match target { + Value::Struct(s) => { + s.borrow_mut().insert(field.clone(), value); + Ok(()) + } + other => Err(format!( + "cannot assign field '{}' on {}", + field, type_name(&other) + )), + } + } + Expr::Index(inner, idx_expr) => { + let idx_val = self.eval_expr(idx_expr, depth)?; + let idx = match idx_val { + Value::Number(n) => n as usize, + other => return Err(format!( + "index must be a number, got {}", + type_name(&other) + )), + }; + self.assign_index_slot(inner, idx, value, depth) + } + _ => Err("invalid assignment target".into()), + } + } + + /// resolves an indexed lvalue chain and writes to the target array slot. + fn assign_index_slot(&mut self, inner: &Expr, idx: usize, value: Value, depth: u32) -> Result<(), String> { + let mut indices: Vec = vec![idx]; + let mut cur = inner; + loop { + match cur { + Expr::Index(parent, parent_idx) => { + let v = self.eval_expr(parent_idx, depth)?; + let i = match v { + Value::Number(n) => n as usize, + other => return Err(format!( + "index must be a number, got {}", + type_name(&other) + )), + }; + indices.push(i); + cur = parent; + } + _ => break, + } + } + let root_name = match cur { + Expr::Ident(name) => name.clone(), + _ => return Err("indexed assignment must root at a variable".into()), + }; + indices.reverse(); + let root = self.vars.get_mut(&root_name) + .ok_or_else(|| format!("variable '{}' not defined", root_name))?; + let mut target: &mut Value = root; + for (step, i) in indices.iter().enumerate() { + let last = step == indices.len() - 1; + match target { + Value::Array(vec) => { + if *i >= vec.len() { + return Err(format!("index {} out of bounds (len {})", i, vec.len())); + } + if last { + vec[*i] = value; + return Ok(()); + } + target = &mut vec[*i]; + } + other => return Err(format!("cannot index into {}", type_name(other))), + } + } + Err("empty index chain".into()) + } + + fn apply_unary_fn(&mut self, fn_name: &str, value: Value, depth: u32) -> Result { + let tmp = format!("__hof_arg_{}", depth); + let saved = self.vars.insert(tmp.clone(), value); + let result = self.eval_call(fn_name, &[Expr::Ident(tmp.clone())], depth + 1); + match saved { + Some(prev) => { self.vars.insert(tmp, prev); } + None => { self.vars.remove(&tmp); } + } + result + } + + /// invokes a named function with two argument values. + fn apply_binary_fn(&mut self, fn_name: &str, a: Value, b: Value, depth: u32) -> Result { + let tmp_a = format!("__hof_a_{}", depth); + let tmp_b = format!("__hof_b_{}", depth); + let saved_a = self.vars.insert(tmp_a.clone(), a); + let saved_b = self.vars.insert(tmp_b.clone(), b); + let result = self.eval_call( + fn_name, + &[Expr::Ident(tmp_a.clone()), Expr::Ident(tmp_b.clone())], + depth + 1, + ); + match saved_b { + Some(prev) => { self.vars.insert(tmp_b.clone(), prev); } + None => { self.vars.remove(&tmp_b); } + } + match saved_a { + Some(prev) => { self.vars.insert(tmp_a, prev); } + None => { self.vars.remove(&tmp_a); } + } + result + } + fn call_user_fn(&mut self, name: &str, args: &[Expr], depth: u32) -> Result { let fdef = self.fns.get(name).cloned() .ok_or_else(|| format!("undefined function '{}'", name))?; @@ -2051,7 +2942,7 @@ impl Interpreter { Err(e) if e.starts_with('\x00') => { self.vars = saved_vars; self.var_types = saved_types; - let raw = decode_return(&e); + let raw = self.return_slot.take().unwrap_or(Value::Void); return Ok(apply_fn_return_type(&fdef.return_type, raw, name)?); } Err(e) => { @@ -2170,7 +3061,7 @@ impl Interpreter { Ok(Value::Number(result)) } - /// runs damped Newton's method with a secant-approximated derivative. + /// damped Newton's method with secant-approximated derivative. fn numerical_solve( &mut self, def: &SolvedFnDef, @@ -2288,49 +3179,15 @@ impl Interpreter { } } -const RETURN_PREFIX: &str = "\x00return:"; - -fn encode_return(val: &Value) -> String { - match val { - Value::Number(n) => format!("n:{}", n), - Value::Bool(b) => format!("b:{}", b), - Value::Str(s) => format!("s:{}", s), - Value::Void => "v:".into(), - Value::Array(a) if a.len() == 2 => { - if let (Value::Number(n), Value::Str(u)) = (&a[0], &a[1]) { - return format!("q:{}|{}", n, u); - } - format!("s:{}", val.display()) - } - _ => format!("s:{}", val.display()), - } -} - -fn decode_return(encoded: &str) -> Value { - let payload = &encoded[RETURN_PREFIX.len()..]; - if let Some(rest) = payload.strip_prefix("n:") { - rest.parse::().map(Value::Number).unwrap_or(Value::Void) - } else if let Some(rest) = payload.strip_prefix("b:") { - Value::Bool(rest == "true") - } else if let Some(rest) = payload.strip_prefix("s:") { - Value::Str(rest.to_string()) - } else if let Some(rest) = payload.strip_prefix("q:") { - let (n_str, u) = rest.split_once('|').unwrap_or((rest, "")); - match n_str.parse::() { - Ok(n) => Value::Array(vec![Value::Number(n), Value::Str(u.to_string())]), - Err(_) => Value::Void, - } - } else { - Value::Void - } -} - fn type_name(v: &Value) -> &'static str { match v { Value::Number(_) => "number", Value::Bool(_) => "bool", Value::Str(_) => "str", Value::Array(_) => "array", + Value::Ring(_) => "ring", + Value::Struct(_) => "struct", + Value::Extern(_) => "extern", Value::Void => "void", Value::Error(_) => "error", } @@ -2491,7 +3348,7 @@ fn apply_fn_return_type(ret_ty: &Option, val: Value, fn_name: &str) -> R } } -// --- Public API for eval.rs integration --- +// --- Public eval API --- pub struct InterpResult { pub line: usize, @@ -2532,6 +3389,18 @@ pub struct ParsedFormula { ast: Expr, } +/// parses a cordial source string into a statement list. +pub fn parse_program(source: &str) -> Result, String> { + let trimmed = source.trim(); + if trimmed.is_empty() { + return Ok(Vec::new()); + } + let spice = source_enables_spice(trimmed); + let tokens = tokenize(trimmed, spice)?; + let mut parser = Parser::new(tokens); + parser.parse_program() +} + pub fn parse_formula(text: &str) -> Result { parse_formula_with_spice(text, false) } @@ -2619,6 +3488,12 @@ fn collect_formula_refs(expr: &Expr, current_table: &str, out: &mut Vec collect_formula_refs(inner, current_table, out), + Expr::Field(inner, _) => collect_formula_refs(inner, current_table, out), + Expr::Struct(fields) => { + for (_, expr) in fields { + collect_formula_refs(expr, current_table, out); + } + } Expr::Num(_) | Expr::Str(_) | Expr::Bool(_) | Expr::SolveMacro { .. } => {} } } @@ -2632,7 +3507,7 @@ impl Interpreter { } } - /// imports every binding from `exports` into the current scope. + /// 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()); @@ -2751,6 +3626,66 @@ pub fn interpret_document_with(interp: &mut Interpreter, lines: &[(usize, &str, results } +/// sorts a Value array in-place; numeric ordering when all elements are numbers, otherwise lexicographic. +fn sort_values(items: &mut Vec) -> Result<(), String> { + let all_numeric = items.iter().all(|v| match v { + Value::Number(_) => true, + Value::Array(a) if a.len() == 2 => matches!(&a[0], Value::Number(_)), + _ => false, + }); + if all_numeric { + items.sort_by(|a, b| { + let na = scalar_of(a); + let nb = scalar_of(b); + na.partial_cmp(&nb).unwrap_or(std::cmp::Ordering::Equal) + }); + } else { + items.sort_by(|a, b| a.display().cmp(&b.display())); + } + Ok(()) +} + +/// extracts the numeric scalar from a plain Number or spice-tagged 2-tuple. +fn scalar_of(v: &Value) -> f64 { + match v { + Value::Number(n) => *n, + Value::Array(a) if a.len() == 2 => match &a[0] { + Value::Number(n) => *n, + _ => 0.0, + }, + _ => 0.0, + } +} + +/// checks whether an expression names a writable lvalue. +fn is_lvalue(expr: &Expr) -> bool { + match expr { + Expr::Ident(_) => true, + Expr::Field(inner, _) => is_lvalue(inner), + Expr::Index(inner, _) => is_lvalue(inner), + _ => false, + } +} + +/// extracts a non-negative integer from a Value. +fn expect_usize(v: &Value, label: &str) -> Result { + match v { + Value::Number(n) if *n >= 0.0 && n.is_finite() && *n == n.trunc() => Ok(*n as usize), + _ => Err(format!("{} must be a non-negative integer", label)), + } +} + +/// extracts a function-name identifier from a higher-order builtin argument. +fn ident_arg(expr: &Expr, fn_name: &str, slot_label: &str) -> Result { + match expr { + Expr::Ident(name) => Ok(name.clone()), + _ => Err(format!( + "{}() {} argument must be a function name", + fn_name, slot_label + )), + } +} + fn flatten_numbers(v: &Value) -> Vec { let mut out = Vec::new(); walk(v, &mut out); @@ -4160,6 +5095,551 @@ fn find(arr, target) { assert!((n - want).abs() / want < 1e-6, "got {}, want {}", n, want); } + #[test] + fn map_user_fn_doubles() { + let mut i = Interpreter::new(); + i.exec_line("fn double(x) {\n return x * 2\n}").unwrap(); + let v = i.eval_expr_str("map([1, 2, 3], double)").unwrap(); + assert_eq!(v.display(), "[2, 4, 6]"); + } + + #[test] + fn map_builtin_passthrough() { + let mut i = Interpreter::new(); + let v = i.eval_expr_str("map([-1, -4, 9], abs)").unwrap(); + assert_eq!(v.display(), "[1, 4, 9]"); + } + + #[test] + fn each_returns_void() { + let mut i = Interpreter::new(); + i.exec_line("fn noop(x) {\n return x\n}").unwrap(); + let r = i.exec_line("each([1, 2, 3], noop)").unwrap(); + assert!(r.is_none()); + } + + #[test] + fn fold_with_seed() { + let mut i = Interpreter::new(); + i.exec_line("fn add(a, b) {\n return a + b\n}").unwrap(); + let v = i.eval_expr_str("fold([1, 2, 3, 4], 10, add)").unwrap(); + assert_eq!(v.display(), "20"); + } + + #[test] + fn fold_empty_returns_seed() { + let mut i = Interpreter::new(); + i.exec_line("fn add(a, b) {\n return a + b\n}").unwrap(); + let v = i.eval_expr_str("fold([], 42, add)").unwrap(); + assert_eq!(v.display(), "42"); + } + + #[test] + fn reduce_left() { + let mut i = Interpreter::new(); + i.exec_line("fn add(a, b) {\n return a + b\n}").unwrap(); + let v = i.eval_expr_str("reduce([1, 2, 3, 4], add)").unwrap(); + assert_eq!(v.display(), "10"); + } + + #[test] + fn reduce_empty_errors() { + let mut i = Interpreter::new(); + i.exec_line("fn add(a, b) {\n return a + b\n}").unwrap(); + assert!(i.eval_expr_str("reduce([], add)").is_err()); + } + + #[test] + fn all_true_when_every_passes() { + let mut i = Interpreter::new(); + i.exec_line("fn pos(x) {\n return x > 0\n}").unwrap(); + let v = i.eval_expr_str("all([1, 2, 3], pos)").unwrap(); + assert_eq!(v.display(), "true"); + } + + #[test] + fn all_false_short_circuits() { + let mut i = Interpreter::new(); + i.exec_line("fn pos(x) {\n return x > 0\n}").unwrap(); + let v = i.eval_expr_str("all([1, -2, 3], pos)").unwrap(); + assert_eq!(v.display(), "false"); + } + + #[test] + fn all_empty_is_true() { + let mut i = Interpreter::new(); + i.exec_line("fn pos(x) {\n return x > 0\n}").unwrap(); + let v = i.eval_expr_str("all([], pos)").unwrap(); + assert_eq!(v.display(), "true"); + } + + #[test] + fn any_true_short_circuits() { + let mut i = Interpreter::new(); + i.exec_line("fn pos(x) {\n return x > 0\n}").unwrap(); + let v = i.eval_expr_str("any([-1, -2, 3], pos)").unwrap(); + assert_eq!(v.display(), "true"); + } + + #[test] + fn any_empty_is_false() { + let mut i = Interpreter::new(); + i.exec_line("fn pos(x) {\n return x > 0\n}").unwrap(); + let v = i.eval_expr_str("any([], pos)").unwrap(); + assert_eq!(v.display(), "false"); + } + + #[test] + fn ring_requires_use_ring() { + let mut i = Interpreter::new(); + assert!(i.eval_expr_str("ring(4)").is_err()); + } + + #[test] + fn ring_flat_single_arg() { + let mut i = Interpreter::new(); + i.exec_line("use ring").unwrap(); + let r = i.eval_expr_str("ring(4)").unwrap(); + match r { + Value::Ring(buf) => { + let b = buf.borrow(); + assert_eq!(b.volume, 4); + assert!(b.shape.is_none()); + assert_eq!(b.slots.len(), 4); + } + _ => panic!("expected ring"), + } + } + + #[test] + fn ring_rect_from_pair() { + let mut i = Interpreter::new(); + i.exec_line("use ring").unwrap(); + let r = i.eval_expr_str("ring([3, 4])").unwrap(); + match r { + Value::Ring(buf) => { + let b = buf.borrow(); + assert_eq!(b.volume, 12); + assert_eq!(b.shape, Some((3, 4))); + } + _ => panic!("expected ring"), + } + } + + #[test] + fn ring_bang_sugar_parses() { + let mut i = Interpreter::new(); + i.exec_line("use ring").unwrap(); + let r = i.eval_expr_str("ring!(4)").unwrap(); + match r { + Value::Ring(buf) => assert_eq!(buf.borrow().volume, 4), + _ => panic!("expected ring"), + } + } + + #[test] + fn ring_false_volume_form() { + let mut i = Interpreter::new(); + i.exec_line("use ring").unwrap(); + let r = i.eval_expr_str("ring(false, 5)").unwrap(); + match r { + Value::Ring(buf) => { + let b = buf.borrow(); + assert_eq!(b.volume, 5); + assert!(b.shape.is_none()); + } + _ => panic!("expected ring"), + } + } + + #[test] + fn iter_fills_ring_in_order() { + let mut i = Interpreter::new(); + i.exec_line("use ring").unwrap(); + i.exec_line("fn double(x) {\n return x * 2\n}").unwrap(); + i.exec_line("let buf = ring!(3)").unwrap(); + i.exec_line("iter([1, 2, 3], double, buf)").unwrap(); + let v = i.eval_expr_str("buf").unwrap(); + assert!(v.display().contains("2") && v.display().contains("4") && v.display().contains("6")); + } + + #[test] + fn iter_overwrites_oldest_when_full() { + let mut i = Interpreter::new(); + i.exec_line("use ring").unwrap(); + i.exec_line("fn id(x) {\n return x\n}").unwrap(); + i.exec_line("let buf = ring!(3)").unwrap(); + i.exec_line("iter([1, 2, 3, 4, 5], id, buf)").unwrap(); + let v = i.eval_expr_str("buf").unwrap(); + let s = v.display(); + // expect [3, 4, 5] + assert!(s.contains("3") && s.contains("4") && s.contains("5")); + assert!(!s.contains("1, 2")); + } + + #[test] + fn iter_rejects_push_into_zero_volume() { + let mut i = Interpreter::new(); + i.exec_line("use ring").unwrap(); + i.exec_line("fn id(x) {\n return x\n}").unwrap(); + i.exec_line("let buf = ring!(0)").unwrap(); + assert!(i.exec_line("iter([1], id, buf)").is_err()); + } + + #[test] + fn peek_requires_use_ring() { + let mut i = Interpreter::new(); + i.exec_line("let buf = 1").unwrap(); + assert!(i.eval_expr_str("peek(buf)").is_err()); + } + + #[test] + fn peek_clones_full_ring() { + let mut i = Interpreter::new(); + i.exec_line("use ring").unwrap(); + i.exec_line("fn id(x) {\n return x\n}").unwrap(); + i.exec_line("let buf = ring!(5)").unwrap(); + i.exec_line("iter([1, 2, 3], id, buf)").unwrap(); + assert_eq!(i.eval_expr_str("peek(buf)").unwrap().display(), "[1, 2, 3]"); + assert_eq!(i.eval_expr_str("peek(buf)").unwrap().display(), "[1, 2, 3]"); + } + + #[test] + fn peek_with_count_returns_tail() { + let mut i = Interpreter::new(); + i.exec_line("use ring").unwrap(); + i.exec_line("fn id(x) {\n return x\n}").unwrap(); + i.exec_line("let buf = ring!(5)").unwrap(); + i.exec_line("iter([1, 2, 3, 4, 5], id, buf)").unwrap(); + assert_eq!(i.eval_expr_str("peek(buf, 3)").unwrap().display(), "[3, 4, 5]"); + assert_eq!(i.eval_expr_str("peek(buf, 99)").unwrap().display(), "[1, 2, 3, 4, 5]"); + assert_eq!(i.eval_expr_str("peek(buf, 0)").unwrap().display(), "[]"); + } + + #[test] + fn peek_does_not_consume_during_iter() { + let mut i = Interpreter::new(); + i.exec_line("use ring").unwrap(); + i.exec_line("fn step(x) {\n return x * 10\n}").unwrap(); + i.exec_line("let buf = ring!(3)").unwrap(); + i.exec_line("iter([1, 2, 3], step, buf)").unwrap(); + i.eval_expr_str("peek(buf)").unwrap(); + i.exec_line("iter([4], step, buf)").unwrap(); + assert_eq!(i.eval_expr_str("peek(buf)").unwrap().display(), "[20, 30, 40]"); + } + + #[test] + fn filter_keeps_predicate_passes() { + let mut i = Interpreter::new(); + i.exec_line("fn pos(x) {\n return x > 0\n}").unwrap(); + assert_eq!(i.eval_expr_str("filter([-1, 2, -3, 4], pos)").unwrap().display(), "[2, 4]"); + } + + #[test] + fn find_returns_first_match_or_void() { + let mut i = Interpreter::new(); + i.exec_line("fn big(x) {\n return x > 5\n}").unwrap(); + assert_eq!(i.eval_expr_str("find([1, 6, 9], big)").unwrap().display(), "6"); + assert!(matches!(i.eval_expr_str("find([1, 2, 3], big)").unwrap(), Value::Void)); + } + + #[test] + fn take_while_stops_at_first_false() { + let mut i = Interpreter::new(); + i.exec_line("fn pos(x) {\n return x > 0\n}").unwrap(); + assert_eq!(i.eval_expr_str("take_while([1, 2, -3, 4], pos)").unwrap().display(), "[1, 2]"); + } + + #[test] + fn skip_while_drops_leading_passes() { + let mut i = Interpreter::new(); + i.exec_line("fn pos(x) {\n return x > 0\n}").unwrap(); + assert_eq!(i.eval_expr_str("skip_while([1, 2, -3, 4], pos)").unwrap().display(), "[-3, 4]"); + } + + #[test] + fn inspect_returns_input_unchanged() { + let mut i = Interpreter::new(); + i.exec_line("fn noop(x) {\n return x\n}").unwrap(); + assert_eq!(i.eval_expr_str("inspect([1, 2, 3], noop)").unwrap().display(), "[1, 2, 3]"); + } + + #[test] + fn take_caps_at_length() { + let mut i = Interpreter::new(); + assert_eq!(i.eval_expr_str("take([1, 2, 3], 2)").unwrap().display(), "[1, 2]"); + assert_eq!(i.eval_expr_str("take([1, 2, 3], 10)").unwrap().display(), "[1, 2, 3]"); + } + + #[test] + fn skip_drops_n() { + let mut i = Interpreter::new(); + assert_eq!(i.eval_expr_str("skip([1, 2, 3, 4], 2)").unwrap().display(), "[3, 4]"); + assert_eq!(i.eval_expr_str("drop([1, 2, 3, 4], 1)").unwrap().display(), "[2, 3, 4]"); + } + + #[test] + fn chunk_partitions_remainder_in_tail() { + let mut i = Interpreter::new(); + assert_eq!(i.eval_expr_str("chunk([1, 2, 3, 4, 5], 2)").unwrap().display(), + "[[1, 2], [3, 4], [5]]"); + } + + #[test] + fn window_slides_one_at_a_time() { + let mut i = Interpreter::new(); + assert_eq!(i.eval_expr_str("window([1, 2, 3, 4], 2)").unwrap().display(), + "[[1, 2], [2, 3], [3, 4]]"); + assert_eq!(i.eval_expr_str("window([1, 2], 5)").unwrap().display(), "[]"); + } + + #[test] + fn zip_anchors_on_left_and_pads_right() { + let mut i = Interpreter::new(); + assert_eq!(i.eval_expr_str("zip([1, 2, 3], [10, 20, 30])").unwrap().display(), + "[[1, 10], [2, 20], [3, 30]]"); + assert_eq!(i.eval_expr_str("zip([1, 2, 3], [10])").unwrap().display(), + "[[1, 10], [2, null], [3, null]]"); + + assert_eq!(i.eval_expr_str("zip([1], [10, 20, 30])").unwrap().display(), + "[[1, 10]]"); + } + + #[test] + fn flatten_one_level() { + let mut i = Interpreter::new(); + assert_eq!(i.eval_expr_str("flatten([[1, 2], [3], [4, 5]])").unwrap().display(), + "[1, 2, 3, 4, 5]"); + } + + #[test] + fn flat_map_splats_array_results() { + let mut i = Interpreter::new(); + i.exec_line("fn pair(x) {\n return [x, x * 10]\n}").unwrap(); + assert_eq!(i.eval_expr_str("flat_map([1, 2, 3], pair)").unwrap().display(), + "[1, 10, 2, 20, 3, 30]"); + } + + #[test] + fn sort_numeric_ascending() { + let mut i = Interpreter::new(); + assert_eq!(i.eval_expr_str("sort([3, 1, 4, 1, 5, 9, 2, 6])").unwrap().display(), + "[1, 1, 2, 3, 4, 5, 6, 9]"); + } + + #[test] + fn sort_by_user_key() { + let mut i = Interpreter::new(); + i.exec_line("fn neg(x) {\n return 0 - x\n}").unwrap(); + assert_eq!(i.eval_expr_str("sort_by([1, 2, 3], neg)").unwrap().display(), + "[3, 2, 1]"); + } + + #[test] + fn distinct_preserves_first_occurrence() { + let mut i = Interpreter::new(); + assert_eq!(i.eval_expr_str("distinct([3, 1, 4, 1, 5, 9, 2, 6, 5])").unwrap().display(), + "[3, 1, 4, 5, 9, 2, 6]"); + } + + #[test] + fn delta_pairwise_differences() { + let mut i = Interpreter::new(); + assert_eq!(i.eval_expr_str("delta([1, 3, 6, 10])").unwrap().display(), "[2, 3, 4]"); + assert_eq!(i.eval_expr_str("delta([5])").unwrap().display(), "[]"); + } + + #[test] + fn scan_emits_running_accumulator() { + let mut i = Interpreter::new(); + i.exec_line("fn add(a, b) {\n return a + b\n}").unwrap(); + assert_eq!(i.eval_expr_str("scan([1, 2, 3, 4], 0, add)").unwrap().display(), + "[1, 3, 6, 10]"); + } + + #[test] + fn history_matches_peek_semantics() { + let mut i = Interpreter::new(); + i.exec_line("use ring").unwrap(); + i.exec_line("fn id(x) {\n return x\n}").unwrap(); + i.exec_line("let buf = ring!(5)").unwrap(); + i.exec_line("iter([1, 2, 3], id, buf)").unwrap(); + assert_eq!(i.eval_expr_str("history(buf)").unwrap().display(), "[1, 2, 3]"); + assert_eq!(i.eval_expr_str("history(buf, 2)").unwrap().display(), "[2, 3]"); + } + + #[test] + fn fn_returning_array_round_trips() { + let mut i = Interpreter::new(); + i.exec_line("fn dup(x) {\n return [x, x]\n}").unwrap(); + assert_eq!(i.eval_expr_str("dup(7)").unwrap().display(), "[7, 7]"); + } + + #[test] + fn float_suffix_literal() { + let mut i = Interpreter::new(); + assert_eq!(i.eval_expr_str("0.1f").unwrap().display(), "0.1"); + assert_eq!(i.eval_expr_str("1f + 2f").unwrap().display(), "3"); + assert_eq!(i.eval_expr_str("0.5F * 4F").unwrap().display(), "2"); + } + + #[test] + fn float_annotation_accepts_decimal() { + let mut i = Interpreter::new(); + i.exec_line("let x: float = 0.25f").unwrap(); + assert_eq!(i.eval_expr_str("x").unwrap().display(), "0.25"); + } + + #[test] + fn struct_literal_and_field_read() { + let mut i = Interpreter::new(); + i.exec_line("let p = {x: 1, y: 2}").unwrap(); + assert_eq!(i.eval_expr_str("p.x").unwrap().display(), "1"); + assert_eq!(i.eval_expr_str("p.y").unwrap().display(), "2"); + } + + #[test] + fn struct_field_write() { + let mut i = Interpreter::new(); + i.exec_line("let p = {x: 1, y: 2}").unwrap(); + i.exec_line("p.x = 99").unwrap(); + assert_eq!(i.eval_expr_str("p.x").unwrap().display(), "99"); + } + + #[test] + fn struct_nested_field_write() { + let mut i = Interpreter::new(); + i.exec_line("let n = {pos: {x: 1, y: 2}, r: 5}").unwrap(); + i.exec_line("n.pos.x = 42").unwrap(); + assert_eq!(i.eval_expr_str("n.pos.x").unwrap().display(), "42"); + assert_eq!(i.eval_expr_str("n.r").unwrap().display(), "5"); + } + + #[test] + fn struct_in_array_field_mutates_through_index() { + let mut i = Interpreter::new(); + i.exec_line("let nodes = [{x: 1, r: 10}, {x: 2, r: 20}]").unwrap(); + i.exec_line("nodes[1].r = 99").unwrap(); + assert_eq!(i.eval_expr_str("nodes[1].r").unwrap().display(), "99"); + assert_eq!(i.eval_expr_str("nodes[0].r").unwrap().display(), "10"); + } + + #[test] + fn array_slot_assignment() { + let mut i = Interpreter::new(); + i.exec_line("let arr = [1, 2, 3]").unwrap(); + i.exec_line("arr[1] = 42").unwrap(); + assert_eq!(i.eval_expr_str("arr").unwrap().display(), "[1, 42, 3]"); + } + + #[test] + fn struct_field_unknown_errors() { + let mut i = Interpreter::new(); + i.exec_line("let p = {x: 1}").unwrap(); + assert!(i.eval_expr_str("p.missing").is_err()); + } + + #[test] + fn struct_display_formats_in_order() { + let mut i = Interpreter::new(); + let v = i.eval_expr_str("{name: \"a\", value: 5}").unwrap(); + assert_eq!(v.display(), "{name: \"a\", value: 5}"); + } + + #[test] + fn extern_handle_round_trips() { + let mut i = Interpreter::new(); + let h = Value::Extern(ExternHandle { kind: "toroid".into(), id: 42 }); + i.set_var("t", h); + let back = i.eval_expr_str("t").unwrap(); + match back { + Value::Extern(h) => { + assert_eq!(h.kind, "toroid"); + assert_eq!(h.id, 42); + } + _ => panic!("expected Extern"), + } + } + + #[test] + fn custom_builtin_invoked_from_cordial() { + use std::cell::Cell; + use std::rc::Rc; + let calls = Rc::new(Cell::new(0)); + let calls_h = Rc::clone(&calls); + let mut i = Interpreter::new(); + i.register_builtin("scene_update", move |_interp, args| { + calls_h.set(calls_h.get() + 1); + match args { + [Value::Extern(h)] => Ok(Value::Str(format!("updated {}#{}", h.kind, h.id))), + _ => Err("scene_update(extern)".into()), + } + }); + i.set_var("t1", Value::Extern(ExternHandle { kind: "node".into(), id: 7 })); + let r = i.eval_expr_str("scene_update(t1)").unwrap(); + assert_eq!(r.display(), "updated node#7"); + assert_eq!(calls.get(), 1); + } + + #[test] + fn custom_builtin_overrides_hardcoded() { + let mut i = Interpreter::new(); + i.register_builtin("sin", |_interp, _args| Ok(Value::Number(42.0))); + assert_eq!(i.eval_expr_str("sin(0)").unwrap().display(), "42"); + } + + #[test] + fn unregister_restores_hardcoded() { + let mut i = Interpreter::new(); + i.register_builtin("sin", |_interp, _args| Ok(Value::Number(42.0))); + i.unregister_builtin("sin"); + let r = i.eval_expr_str("sin(0)").unwrap(); + assert!(matches!(r, Value::Number(n) if n.abs() < 1e-9)); + } + + #[test] + fn call_invokes_user_fn_by_name_with_values() { + let mut i = Interpreter::new(); + i.exec_line("fn add(a, b) {\n return a + b\n}").unwrap(); + let r = i.call("add", &[Value::Number(3.0), Value::Number(4.0)]).unwrap(); + assert_eq!(r.display(), "7"); + } + + #[test] + fn call_passes_extern_through_user_fn() { + let mut i = Interpreter::new(); + i.exec_line("fn pass(x) {\n return x\n}").unwrap(); + let h = Value::Extern(ExternHandle { kind: "anchor".into(), id: 9 }); + let r = i.call("pass", &[h]).unwrap(); + match r { + Value::Extern(h) => { assert_eq!(h.kind, "anchor"); assert_eq!(h.id, 9); } + _ => panic!("expected Extern back"), + } + } + + #[test] + fn host_driven_loop_round_trip() { + use std::cell::RefCell; + use std::rc::Rc; + let scene_log: Rc>> = Rc::new(RefCell::new(Vec::new())); + let log_h = Rc::clone(&scene_log); + let mut i = Interpreter::new(); + i.register_builtin("emit", move |_interp, args| { + if let [Value::Extern(h)] = args { + log_h.borrow_mut().push(h.id); + Ok(Value::Void) + } else { + Err("emit(extern)".into()) + } + }); + i.exec_line("fn step(handle) {\n emit(handle)\n return handle\n}").unwrap(); + for id in 0..3u64 { + let h = Value::Extern(ExternHandle { kind: "node".into(), id }); + i.call("step", &[h]).unwrap(); + } + assert_eq!(*scene_log.borrow(), vec![0, 1, 2]); + } + #[test] fn spice_lc_tank_use_case() { let mut i = Interpreter::new(); diff --git a/ios/project.yml b/ios/project.yml index 47425ab..957c51d 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -16,8 +16,7 @@ settings: TARGETED_DEVICE_FAMILY: "2,1" IPHONEOS_DEPLOYMENT_TARGET: "17.0" SWIFT_OBJC_BRIDGING_HEADER: ../viewport/include/acord.h - # Cargo writes to /tmp/acord/target by default (see scripts/_build-dirs.sh). - # xcodeproj.sh runs cargo with both targets and copies their output here. + # xcodeproj.sh builds both targets into /tmp/acord/target and copies output here. "LIBRARY_SEARCH_PATHS[sdk=iphoneos*]": /tmp/acord/target/aarch64-apple-ios/release "LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]": /tmp/acord/target/aarch64-apple-ios-sim/release OTHER_LDFLAGS: diff --git a/ios/src/AcordApp.swift b/ios/src/AcordApp.swift index 1f701d2..b97d46d 100644 --- a/ios/src/AcordApp.swift +++ b/ios/src/AcordApp.swift @@ -9,9 +9,7 @@ struct AcordApp: App { dlog("AcordApp.init") } - /// Pipes Rust staticlib stderr into both NSLog (for Console.app) and the - /// real stdout (for `xcrun devicectl --console`, which only forwards - /// stdout/stderr). Without this, `eprintln!()` from Rust is silently dropped. + /// redirects Rust staticlib stderr into NSLog and real stdout. private static func captureStderr() { let realStdout = dup(fileno(stdout)) guard realStdout != -1 else { return } diff --git a/ios/src/Debug.swift b/ios/src/Debug.swift index c417b09..904c429 100644 --- a/ios/src/Debug.swift +++ b/ios/src/Debug.swift @@ -1,9 +1,6 @@ import Foundation -/// Gated logging — every diagnostic print in the iOS shell goes through here. -/// Release builds compile this to a no-op so no log lines leak into shipping. -/// Define DEBUG via `-D DEBUG` when invoking swiftc (debug.sh does this; the -/// release path used by install.sh does not). +/// conditional diagnostic logging for the iOS shell. @inline(__always) func dlog(_ message: @autoclosure () -> String, file: StaticString = #file, line: UInt = #line) { #if DEBUG diff --git a/ios/src/DocumentPicker.swift b/ios/src/DocumentPicker.swift index ac94de0..7eaeb10 100644 --- a/ios/src/DocumentPicker.swift +++ b/ios/src/DocumentPicker.swift @@ -1,10 +1,7 @@ import UIKit import UniformTypeIdentifiers -/// Bridges UIDocumentPickerViewController into the Rust viewport. -/// Open and Save flows rely on iOS's per-file permission grant — a -/// security-scoped URL is what the picker hands back, and we copy bytes in -/// or out under `startAccessingSecurityScopedResource` while it's in scope. +/// bridges UIDocumentPickerViewController into the Rust viewport for Open and Save flows. enum DocumentPicker { private static var openDelegate: OpenDelegate? private static var saveDelegate: SaveDelegate? @@ -15,10 +12,7 @@ enum DocumentPicker { dlog("presentOpen: topViewController returned nil — picker NOT shown") return } - // .item is the broadest "any file" UTI — without this, files whose UTI - // doesn't exactly match get rendered grey/unselectable in the picker. - // asCopy:true sidesteps the security-scoped-resource entitlement dance: - // iOS hands us a copy in our sandbox tmp dir we can just read. + // .item catches files with non-exact UTI matches; asCopy gives a sandbox-local copy. var types: [UTType] = [.plainText, .utf8PlainText, .text, .sourceCode, .data, .item] if let md = UTType(filenameExtension: "md") { types.insert(md, at: 0) } if let md = UTType("net.daringfireball.markdown") { types.insert(md, at: 0) } @@ -92,7 +86,7 @@ private final class OpenDelegate: NSObject, UIDocumentPickerDelegate { return } dlog("open: url=\(url.path)") - // asCopy:true means url is already in our sandbox tmp dir — no scoped access needed. + // asCopy url lives in sandbox tmp, no scoped access needed. do { let data = try Data(contentsOf: url) guard let text = String(data: data, encoding: .utf8) else { diff --git a/ios/src/IcedViewportRepresentable.swift b/ios/src/IcedViewportRepresentable.swift index 1ce9949..d182df5 100644 --- a/ios/src/IcedViewportRepresentable.swift +++ b/ios/src/IcedViewportRepresentable.swift @@ -1,9 +1,7 @@ import SwiftUI import UIKit -/// SwiftUI wrapper around the UIView that hosts the Rust viewport. -/// Stashes the underlying view into the shared ViewportController so the -/// menu bar can dispatch commands against the same handle. +/// wraps the Rust viewport UIView and shares it with ViewportController. struct IcedViewportRepresentable: UIViewRepresentable { let controller: ViewportController diff --git a/ios/src/IcedViewportView.swift b/ios/src/IcedViewportView.swift index 576b72b..05f2dab 100644 --- a/ios/src/IcedViewportView.swift +++ b/ios/src/IcedViewportView.swift @@ -1,9 +1,7 @@ import UIKit import QuartzCore -/// CAMetalLayer-backed UIView that owns the Rust viewport handle and pumps -/// CADisplayLink ticks into `viewport_render`. UIKeyInput conformance is what -/// makes the soft keyboard appear when the view becomes first responder. +/// CAMetalLayer-backed UIView owning the Rust viewport handle and driving the render loop. class IcedViewportView: UIView, UIKeyInput { override class var layerClass: AnyClass { CAMetalLayer.self } @@ -42,10 +40,7 @@ class IcedViewportView: UIView, UIKeyInput { if window != nil && viewportHandle == nil && !isTornDown { createViewport() startDisplayLink() - // intentionally NOT becoming first responder here — claiming it on - // appear conflicts with SwiftUI Menu popovers (see the - // _UIReparentingView warning when clicking the menu strip). - // touchesBegan claims it instead, which is the natural moment. + // touchesBegan claims first responder to avoid SwiftUI Menu popover conflicts. } else if window == nil { teardown() } @@ -140,7 +135,7 @@ class IcedViewportView: UIView, UIKeyInput { guard let handle = viewportHandle else { return } viewport_render(handle) renderCount += 1 - // first frame, then 60th (~1s), then every 600 (~10s) to confirm the loop is alive. + // logs at frame 1, 60, and every 600th frame. if renderCount == 1 || renderCount == 60 || renderCount % 600 == 0 { let ml = layer as? CAMetalLayer dlog("renderFrame #\(renderCount) bounds=\(bounds.width)x\(bounds.height) drawable=\(ml?.drawableSize ?? .zero) scale=\(ml?.contentsScale ?? 0)") diff --git a/ios/src/MenuBar.swift b/ios/src/MenuBar.swift index 935cde4..de6dee0 100644 --- a/ios/src/MenuBar.swift +++ b/ios/src/MenuBar.swift @@ -1,8 +1,6 @@ import SwiftUI -/// Top toolbar with File / Edit / Render / View menus, mirroring the -/// editor's MenuCategory layout. Uses SwiftUI Menu so each label opens a -/// dropdown of buttons; each button dispatches through ViewportController. +/// top toolbar with File / Edit / Render / View menus dispatching through ViewportController. struct MenuBar: View { @ObservedObject var controller: ViewportController @@ -72,7 +70,7 @@ struct MenuBar: View { } private extension View { - /// Consistent Menu chrome — slightly padded, light hit target. + /// applies consistent menu label padding and hit target. var menuLabel: some View { self .padding(.horizontal, 10) diff --git a/ios/src/PermissionsManager.swift b/ios/src/PermissionsManager.swift index d6f2cf8..174d511 100644 --- a/ios/src/PermissionsManager.swift +++ b/ios/src/PermissionsManager.swift @@ -2,16 +2,9 @@ import UIKit import Photos import AVFoundation -/// Drives the iOS permission dialogs that fire on first launch. -/// Photos / Camera / Microphone trigger native system prompts when their -/// `requestAuthorization` is called, the Info.plist usage strings exist, -/// and the corresponding framework is linked. -/// File-system access on iOS isn't a global permission — DocumentPicker's -/// per-file picker IS the consent moment for that, and asCopy:true means -/// no entitlements beyond the picker itself are required. +/// sequences Photos / Camera / Microphone permission prompts on first launch. enum PermissionsManager { - /// Sequentially asks for Photos → Camera → Microphone access. - /// Each call shows the system prompt only when status is .notDetermined. + /// requests Photos, Camera, Microphone access in order, skipping already-determined ones. static func requestSystemPermissions() { dlog("requestSystemPermissions called") let photosStatus = PHPhotoLibrary.authorizationStatus(for: .readWrite) diff --git a/ios/src/ViewportController.swift b/ios/src/ViewportController.swift index d823b8f..b30a8f7 100644 --- a/ios/src/ViewportController.swift +++ b/ios/src/ViewportController.swift @@ -1,8 +1,6 @@ import SwiftUI -/// Bridges SwiftUI menu buttons to the Rust viewport handle. -/// Holds a weak reference to the IcedViewportView so the menu can dispatch -/// commands without owning the rendering surface. +/// bridges SwiftUI menu buttons to the Rust viewport handle. final class ViewportController: ObservableObject { weak var view: IcedViewportView? @@ -15,7 +13,7 @@ final class ViewportController: ObservableObject { viewport_send_command(h, code) } - /// Editor commands (mirror viewport/src/lib.rs::viewport_send_command codes). + /// editor commands matching viewport_send_command codes. func toggleBold() { send(1) } func toggleItalic() { send(2) } func insertTable() { send(3) } @@ -28,8 +26,7 @@ final class ViewportController: ObservableObject { func setViewMode() { send(13) } func toggleSettings() { send(16) } - /// Hand-rolled key events for shortcuts that flow through iced's text bindings - /// rather than the cmd dispatcher (Find, Undo, Redo, etc.). + /// sends raw key events for shortcuts handled by iced text bindings (Find, Undo, Redo). private func sendKey(keyCode: UInt32, modifiers: UInt32, character: String) { guard let h = view?.viewportHandle else { dlog("sendKey: no handle (key=\(character.debugDescription) mods=\(modifiers))") @@ -42,20 +39,16 @@ final class ViewportController: ObservableObject { } } - /// `f` / cmd. The viewport reads .super_key as cmd via iced's modifier mapping. - /// keycode 3 is the macOS keycode for `f`; iced doesn't actually use the - /// platform keycode on macOS — it pulls the Key from the characters string. - /// So we pass 0 and let the character drive it. + /// cmd+character shortcuts; keycode 0 defers key resolution to the characters string. func toggleFind() { sendKey(keyCode: 0, modifiers: cmdMask, character: "f") } func undo() { sendKey(keyCode: 0, modifiers: cmdMask, character: "z") } func redo() { sendKey(keyCode: 0, modifiers: cmdMask | shiftMask, character: "Z") } - // UIKeyModifierFlags bits; copied here so the controller doesn't import UIKit. + // UIKeyModifierFlags bit positions. private var cmdMask: UInt32 { 1 << 20 } private var shiftMask: UInt32 { 1 << 17 } - /// File operations — Open and Save go through UIDocumentPicker so iOS - /// prompts the user to grant per-file access. + /// file operations routed through UIDocumentPicker for per-file access grants. func newNote() { guard let h = view?.viewportHandle else { dlog("newNote: no handle") diff --git a/linux/Cargo.toml b/linux/Cargo.toml index a0f2671..88b5978 100644 --- a/linux/Cargo.toml +++ b/linux/Cargo.toml @@ -7,10 +7,7 @@ edition = "2024" name = "acord" path = "src/main.rs" -# x11 and wayland are independent winit backends. Default builds enable both -# so a single binary runs on either display server. The build script can pass -# `--no-default-features --features x11` (or `wayland`) to force one — useful -# for flatpak builds or stripped-down distros. +# default enables both x11 and wayland; pass --no-default-features --features x11 (or wayland) to force one. [features] default = ["x11", "wayland"] x11 = ["winit/x11"] diff --git a/linux/src/app.rs b/linux/src/app.rs index 314bb17..c50af42 100644 --- a/linux/src/app.rs +++ b/linux/src/app.rs @@ -108,7 +108,7 @@ impl App { MenuAction::NewNote => self.new_note(), MenuAction::Settings => { let cfg = config_path(); - // Prefer xdg-open; fall back to $EDITOR or nano. + // opens config with xdg-open, falling back to $EDITOR or nano. let opened = std::process::Command::new("xdg-open").arg(&cfg).spawn().is_ok(); if !opened { let editor = std::env::var("EDITOR").unwrap_or_else(|_| "nano".into()); @@ -243,7 +243,7 @@ impl App { self.last_autosaved_hash = None; } - /// true when path is an .md inside the configured notes library (recursive). + /// checks whether path resolves to an .md inside the configured notes library. fn is_acord_note(&self, path: &std::path::Path) -> bool { let ext = path.extension().and_then(|e| e.to_str()).unwrap_or(""); if !ext.eq_ignore_ascii_case("md") { @@ -255,7 +255,7 @@ impl App { canon_path.starts_with(&canon_dir) } - /// path under /.external/ for storing an external file's archive companion. + /// computes the /.external/ path hosting an external file's archive companion. fn external_sidecar_path(&self, original: &std::path::Path) -> PathBuf { let canon = std::fs::canonicalize(original).unwrap_or_else(|_| original.to_path_buf()); let s = canon.to_string_lossy(); @@ -524,7 +524,7 @@ impl App { let hash = text_hash(&text); if Some(hash) == self.last_autosaved_hash { return; } - // skip the launch stub so it can't overwrite last session's Untitled.md. + // skip the empty launch stub to preserve last session's Untitled.md. if self.current_file.is_none() && is_effectively_blank(&text) { return; } @@ -665,9 +665,7 @@ impl ApplicationHandler for App { WindowEvent::KeyboardInput { event, .. } => { let pressed = event.state == ElementState::Pressed; - // App-level shortcut? Fire on press only and short-circuit so the - // viewport's text_editor doesn't also see the keystroke (otherwise - // Ctrl+S would type 's' as well as save). + // app-level shortcuts consume the press event before the viewport's text editor. if pressed { if let Some(action) = match_shortcut(self.modifiers, &event.logical_key) { self.dispatch_menu(action, event_loop); @@ -722,9 +720,7 @@ impl ApplicationHandler for App { if let Some(w) = &self.browser_window { w.request_redraw(); } - // request_redraw alone doesn't reliably wake the loop on Wayland - // when idle; pin a hard wake-up so the autosave check actually - // runs even with no input or compositor frame callback arriving. + // hard wake-up at 500ms keeps the autosave check running on idle Wayland sessions. event_loop.set_control_flow(ControlFlow::WaitUntil( Instant::now() + Duration::from_millis(500), )); @@ -738,7 +734,7 @@ fn text_hash(s: &str) -> u64 { h.finish() } -/// true when the buffer is empty or just leading heading markers. +/// detects buffers containing only whitespace or bare heading markers. fn is_effectively_blank(text: &str) -> bool { let trimmed = text.trim(); if trimmed.is_empty() { @@ -747,9 +743,7 @@ fn is_effectively_blank(text: &str) -> bool { trimmed.trim_start_matches('#').trim().is_empty() } -/// 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). +/// translates winit logical keys into iced keyboard keys for direct 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 { @@ -775,8 +769,7 @@ fn winit_key_to_iced(key: &Key) -> iced_wgpu::core::keyboard::Key { } } -/// 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. +/// maps winit logical keys to macOS-style virtual keycodes for the bridge. fn winit_key_to_code(key: &Key) -> u32 { match key { Key::Named(n) => match n { @@ -817,9 +810,7 @@ fn encode_modifiers(state: ModifiersState) -> u32 { if state.control_key() { flags |= 1 << 18; } if state.alt_key() { flags |= 1 << 19; } if state.super_key() { flags |= 1 << 20; } - // Mirror Ctrl→LOGO so iced text_editor's Cmd+C/V/X/Z/A bindings fire on - // Ctrl. Same trick the Windows shell uses; both action-modifier-on-Ctrl - // platforms need it. + // alias Ctrl as LOGO so iced text_editor Cmd+C/V/X/Z/A bindings fire on Ctrl. if state.control_key() { flags |= 1 << 20; } flags } @@ -848,8 +839,7 @@ fn config_path() -> std::path::PathBuf { .join("config.json") } -/// Loads `icon.png` next to the exe. Returns None on any failure — winit -/// silently uses the WM default in that case. +/// loads icon.png from the executable directory, returning None on failure. fn load_window_icon() -> Option { let exe_dir = std::env::current_exe().ok()?.parent()?.to_path_buf(); let png_path = exe_dir.join("icon.png"); diff --git a/linux/src/shortcuts.rs b/linux/src/shortcuts.rs index 667d542..f921da0 100644 --- a/linux/src/shortcuts.rs +++ b/linux/src/shortcuts.rs @@ -2,9 +2,7 @@ use winit::keyboard::{Key, ModifiersState, SmolStr}; #[derive(Clone, Copy)] #[allow(dead_code)] -// LiveMode/EditorMode/ViewMode are dispatched but not yet bound to a shortcut -// — Linux has no menu bar to expose them via, so they wait for either a key -// binding decision or an iced-rendered menu inside the viewport. +// LiveMode/EditorMode/ViewMode dispatched but not yet bound to a shortcut key. pub enum MenuAction { NewNote, Open, @@ -28,13 +26,9 @@ pub enum MenuAction { ToggleBrowser, } -/// Matches an app-level shortcut. Returns Some(action) for combos that should -/// fire a MenuAction; None for combos that should fall through to the -/// viewport (cut/copy/paste/undo/redo/select-all are handled inside iced via -/// the Ctrl→LOGO modifier alias, plain typing, navigation, etc.). +/// matches Ctrl-chord shortcuts to MenuAction, returning None for combos handled by iced. 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. + // Alt+B opens the document browser (mirrors macOS Ctrl+B). if modifiers.alt_key() && !modifiers.control_key() && !modifiers.super_key() { if let Key::Character(s) = key { if ascii_lower(s) == 'b' { diff --git a/macos/src/AppDelegate.swift b/macos/src/AppDelegate.swift index cb36056..8764966 100644 --- a/macos/src/AppDelegate.swift +++ b/macos/src/AppDelegate.swift @@ -22,102 +22,40 @@ func applyThemeAppearance() { } } -class WindowController { - let window: NSWindow - let appState: AppState - init(window: NSWindow, appState: AppState) { - self.window = window - self.appState = appState - } -} - class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { - var window: NSWindow! - var appState: AppState! - private var titleCancellable: AnyCancellable? - private var textCancellable: AnyCancellable? - private var titleBarView: TitleBarView? - private var focusTitleObserver: NSObjectProtocol? - private var windowControllers: [WindowController] = [] - /// Writes the viewport's current text to the notes directory on a - /// tight interval. Deliberately bypasses `appState.documentText` — the - /// Combine sink on that property pushes text back into the viewport - /// via `vp.setText`, which rebuilds viewport state and clears the - /// eval overlay. By writing straight to disk, autosave can't disturb - /// what the user sees. - private var autosaveTimer: Timer? - /// Hash of the viewport text the last time autosave actually wrote to - /// disk. The 100ms timer compares against this and skips the write if - /// nothing has changed — without this gate, autosave rewrites the - /// entire file every tick (~500 KB/s sustained on a 50 KB doc, which - /// macOS flags as a disk-writes throttle event). - private var lastAutosavedHash: Int? - - private var viewport: IcedViewportView? { - window?.contentView as? IcedViewportView - } + private var editorWindows: [EditorWindow] = [] + private var pendingOpenURLs: [URL] = [] + private var didFinishLaunching = false + private var windowCloseObservers: [NSObjectProtocol] = [] func applicationDidFinishLaunching(_ notification: Notification) { _ = ConfigManager.shared - appState = AppState() - - let viewport = IcedViewportView(frame: NSRect(x: 0, y: 0, width: 1200, height: 800)) - viewport.autoresizingMask = [.width, .height] - - window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 1200, height: 800), - styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], - backing: .buffered, - defer: false - ) - window.isReleasedWhenClosed = false - window.titlebarAppearsTransparent = true - window.titleVisibility = .hidden - window.backgroundColor = Theme.current.base - window.title = "Acord" - window.contentView = viewport - window.center() - window.setFrameAutosaveName("AcordMainWindow") - window.makeKeyAndOrderFront(nil) - applyThemeAppearance() - setupTitleBar() setupMenuBar() - observeDocumentTitle() - observeDocumentText() - wireLoadedTextSync() - syncThemeToViewport() - syncGutterPrefsToViewport() - syncSettingsToViewport() - startAutosaveTimer() - - DocumentBrowserController.shared = DocumentBrowserController(appState: appState) - - NotificationCenter.default.addObserver( - self, selector: #selector(settingsDidChange), - name: .settingsChanged, object: nil - ) - - NotificationCenter.default.addObserver( - self, selector: #selector(handleNewNoteSeeded), - name: .newNoteSeeded, object: nil - ) - - if let url = pendingOpenURLs.first { - pendingOpenURLs = [] - appState.loadNoteFromFile(url) + DocumentBrowserController.shared = DocumentBrowserController { [weak self] path in + self?.openInActiveEditor(URL(fileURLWithPath: path)) } + + // first URL loads into the main window, extras spawn new ones. + let urls = pendingOpenURLs + pendingOpenURLs = [] + let initialURL = urls.first + spawnEditorWindow(loadingURL: initialURL, isMain: true) + for url in urls.dropFirst() { + spawnEditorWindow(loadingURL: url, isMain: false) + } + + didFinishLaunching = true } - private var pendingOpenURLs: [URL] = [] - func application(_ application: NSApplication, open urls: [URL]) { - guard let url = urls.first else { return } - if appState != nil { - appState.loadNoteFromFile(url) + if didFinishLaunching { + for url in urls { + openInNewWindow(url) + } } else { - pendingOpenURLs = [url] + pendingOpenURLs.append(contentsOf: urls) } } @@ -125,41 +63,73 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { return true } - // Runs before AppKit tears the window down. We must front-run the window - // teardown so the Rust-backed viewport releases its wgpu/Metal resources - // while the NSView + CAMetalLayer it holds raw pointers to are still - // alive. `applicationWillTerminate` is too late: by the time that fires, - // AppKit has already started deallocating the window/contentView graph - // and the delegate can no longer safely read `self.window`. + /// releases every viewport's GPU resources before AppKit deallocates the window graph. func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { - // Pull out any unsaved text before tearing down. `getText` refreshes - // the viewport's own `cachedText`, so later reads during teardown - // can fall back to it if the handle is already gone. - syncTextFromViewport() - appState.saveNote() - - // Explicit, ordered teardown of every viewport we own, while the - // views + window graph are still fully alive. - if let vp = viewport { - vp.teardown() + for editor in editorWindows { + editor.syncTextFromViewport() + editor.appState.saveNote() + editor.teardown() } - for controller in windowControllers { - if let vp = controller.window.contentView as? IcedViewportView { - vp.teardown() + editorWindows.removeAll() + for observer in windowCloseObservers { + NotificationCenter.default.removeObserver(observer) + } + windowCloseObservers.removeAll() + return .terminateNow + } + + // MARK: - Window management + + private func spawnEditorWindow(loadingURL url: URL?, isMain: Bool) { + let autosaveName = isMain ? "AcordMainWindow" : nil + let editor = EditorWindow(frameAutosaveName: autosaveName) + editorWindows.append(editor) + + let observer = NotificationCenter.default.addObserver( + forName: NSWindow.willCloseNotification, + object: editor.window, + queue: .main + ) { [weak self, weak editor] _ in + guard let self = self, let editor = editor else { return } + self.handleEditorWindowClose(editor) + } + windowCloseObservers.append(observer) + + if let url = url { + editor.loadFromURL(url) + } + } + + /// opens a URL in a new editor window. + func openInNewWindow(_ url: URL) { + spawnEditorWindow(loadingURL: url, isMain: false) + } + + /// opens a URL in the frontmost editor window, spawning one when none exist. + func openInActiveEditor(_ url: URL) { + guard let target = frontmostEditor() else { + spawnEditorWindow(loadingURL: url, isMain: false) + return + } + target.window.makeKeyAndOrderFront(nil) + target.loadFromURL(url) + } + + private func handleEditorWindowClose(_ editor: EditorWindow) { + editor.syncTextFromViewport() + editor.appState.saveNote() + editor.teardown() + editorWindows.removeAll { $0 === editor } + } + + /// returns the topmost editor window in z-order, falling back to the first in the list. + private func frontmostEditor() -> EditorWindow? { + for w in NSApp.orderedWindows where w.isVisible { + if let editor = editorWindows.first(where: { $0.window === w }) { + return editor } } - - // Drop strong refs so AppKit doesn't try to replay anything through - // the delegate during its own terminate phases. - titleCancellable = nil - textCancellable = nil - if let observer = focusTitleObserver { - NotificationCenter.default.removeObserver(observer) - focusTitleObserver = nil - } - NotificationCenter.default.removeObserver(self) - - return .terminateNow + return editorWindows.first } // MARK: - Menu bar @@ -319,7 +289,9 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { flags ^= bit ConfigManager.shared.autoPairFlags = flags sender.state = (flags & bit) != 0 ? .on : .off - viewport?.setAutoPairFlags(flags) + for editor in editorWindows { + editor.viewport.setAutoPairFlags(flags) + } } private func buildRenderMenu() -> NSMenuItem { @@ -411,31 +383,11 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { // MARK: - Actions @objc private func newNote() { - appState.newNote() + frontmostEditor()?.newNote() } @objc private func newWindow() { - let state = AppState() - let viewport = IcedViewportView(frame: NSRect(x: 0, y: 0, width: 1200, height: 800)) - viewport.autoresizingMask = [.width, .height] - - let win = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 1200, height: 800), - styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], - backing: .buffered, - defer: false - ) - win.isReleasedWhenClosed = false - win.titlebarAppearsTransparent = true - win.titleVisibility = .hidden - win.backgroundColor = Theme.current.base - win.title = "Acord" - win.contentView = viewport - win.center() - win.makeKeyAndOrderFront(nil) - - let controller = WindowController(window: win, appState: state) - windowControllers.append(controller) + spawnEditorWindow(loadingURL: nil, isMain: false) } @objc private func openStorageDirectory() { @@ -445,172 +397,165 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { } @objc private func boldSelection() { - viewport?.sendCommand(1) + frontmostEditor()?.viewport.sendCommand(1) } @objc private func italicizeSelection() { - viewport?.sendCommand(2) + frontmostEditor()?.viewport.sendCommand(2) } @objc private func insertTable() { - viewport?.sendCommand(3) + frontmostEditor()?.viewport.sendCommand(3) } @objc private func smartEval() { - viewport?.sendCommand(4) + frontmostEditor()?.viewport.sendCommand(4) } @objc private func openNote() { - let panel = NSOpenPanel() - panel.allowedContentTypes = Self.supportedContentTypes - panel.canChooseFiles = true - panel.canChooseDirectories = false - panel.allowsMultipleSelection = false - panel.beginSheetModal(for: window) { [weak self] response in - guard response == .OK, let url = panel.url else { return } - self?.appState.loadNoteFromFile(url) - } + frontmostEditor()?.openNotePanel() } @objc private func saveNote() { - syncTextFromViewport() - appState.bindAutoSaveURL() - appState.saveNote() + frontmostEditor()?.saveNote() } @objc private func saveNoteAs() { - syncTextFromViewport() - let panel = NSSavePanel() - panel.allowedContentTypes = Self.supportedContentTypes - panel.nameFieldStringValue = defaultFilename() - if let url = appState.currentFileURL { - panel.directoryURL = url.deletingLastPathComponent() - panel.nameFieldStringValue = url.lastPathComponent - } - panel.beginSheetModal(for: window) { [weak self] response in - guard response == .OK, let url = panel.url else { return } - self?.appState.saveNoteToFile(url) - } + frontmostEditor()?.saveNoteAs() } - /// renders the document to a PDF chosen via save dialog and opens it for printing. @objc private func printNote() { - guard let w = window, let vp = w.contentView as? IcedViewportView, - let handle = vp.viewportHandle else { return } - syncTextFromViewport() - - let title = appState.currentFileURL?.deletingPathExtension().lastPathComponent ?? "Acord Document" - var len: UInt = 0 - guard let ptr = title.withCString({ t in viewport_render_pdf(handle, t, &len) }), len > 0 else { - let alert = NSAlert() - alert.messageText = "Print failed" - alert.informativeText = "Could not render this document to PDF." - alert.runModal() - return - } - let data = Data(bytes: ptr, count: Int(len)) - viewport_free_bytes(ptr, len) - - let panel = NSSavePanel() - panel.title = "Print to PDF" - panel.prompt = "Save" - panel.allowedContentTypes = [.pdf] - panel.nameFieldStringValue = "\(title).pdf" - panel.beginSheetModal(for: w) { response in - guard response == .OK, let url = panel.url else { return } - do { - try data.write(to: url) - NSWorkspace.shared.open(url) - } catch { - let alert = NSAlert() - alert.messageText = "Print failed" - alert.informativeText = error.localizedDescription - alert.runModal() - } - } + frontmostEditor()?.printNote() } @objc private func exportCrate() { - syncTextFromViewport() - guard let w = window, let vp = w.contentView as? IcedViewportView, - let handle = vp.viewportHandle else { return } + frontmostEditor()?.exportCrate() + } - let panel = NSSavePanel() - panel.title = "Export as Rust Library" - panel.message = "Choose a location and name for your exported crate" - panel.prompt = "Export" - panel.nameFieldLabel = "Crate name:" - panel.nameFieldStringValue = defaultCrateName() - panel.canCreateDirectories = true + @objc private func formatDocument() { + frontmostEditor()?.viewport.sendCommand(10) + } - panel.beginSheetModal(for: w) { response in - guard response == .OK, let url = panel.url else { return } - let parentDir = url.deletingLastPathComponent().path - let name = url.lastPathComponent - parentDir.withCString { pd in - name.withCString { n in - if let cstr = viewport_export_crate(handle, pd, n) { - let resultPath = String(cString: cstr) - viewport_free_string(cstr) - self.notifyExportComplete(at: resultPath) - } else { - self.notifyExportFailed() - } - } + @objc private func openSettings() { + guard let editor = frontmostEditor() else { return } + editor.syncSettingsToViewport() + editor.viewport.sendCommand(16) + } + + @objc private func setLiveMode() { + frontmostEditor()?.viewport.sendCommand(11) + } + + @objc private func setEditorMode() { + frontmostEditor()?.viewport.sendCommand(12) + } + + @objc private func setViewMode() { + frontmostEditor()?.viewport.sendCommand(13) + } + + @objc private func setLayoutModeFree() { + frontmostEditor()?.viewport.sendCommand(17) + } + + @objc private func setLayoutModeRelative() { + frontmostEditor()?.viewport.sendCommand(18) + } + + @objc private func setLayoutModeAnchored() { + frontmostEditor()?.viewport.sendCommand(19) + } + + @objc private func toggleSnapping() { + frontmostEditor()?.viewport.sendCommand(20) + } + + @objc private func toggleBrowser() { + DocumentBrowserController.shared?.toggle() + } + + @objc private func zoomIn() { + if let browser = DocumentBrowserController.shared, browser.isKeyWindow { + browser.sendCommand(7) + return + } + ConfigManager.shared.zoomLevel += 1 + NotificationCenter.default.post(name: .settingsChanged, object: nil) + } + + @objc private func zoomOut() { + if let browser = DocumentBrowserController.shared, browser.isKeyWindow { + browser.sendCommand(8) + return + } + let current = ConfigManager.shared.zoomLevel + if 11 + current > 8 { + ConfigManager.shared.zoomLevel -= 1 + NotificationCenter.default.post(name: .settingsChanged, object: nil) + } + } + + @objc private func zoomReset() { + if let browser = DocumentBrowserController.shared, browser.isKeyWindow { + browser.sendCommand(9) + return + } + ConfigManager.shared.zoomLevel = 0 + NotificationCenter.default.post(name: .settingsChanged, object: nil) + } + + // MARK: - Menu validation + + func validateMenuItem(_ menuItem: NSMenuItem) -> Bool { + let editor = frontmostEditor() + let mode = editor?.viewport.renderMode() ?? 0 + let layout = editor?.viewport.layoutMode() ?? 0 + switch menuItem.action { + case #selector(setLiveMode): + menuItem.state = mode == 0 ? .on : .off + case #selector(setEditorMode): + menuItem.state = mode == 1 ? .on : .off + case #selector(setViewMode): + menuItem.state = mode == 2 ? .on : .off + case #selector(setLayoutModeFree): + menuItem.state = layout == 0 ? .on : .off + case #selector(setLayoutModeRelative): + menuItem.state = layout == 1 ? .on : .off + case #selector(setLayoutModeAnchored): + menuItem.state = layout == 2 ? .on : .off + case #selector(toggleSnapping): + menuItem.state = (editor?.viewport.snapping() ?? false) ? .on : .off + default: + break + } + return true + } + + // MARK: - File-format helpers + + static let supportedContentTypes: [UTType] = { + let extensions = [ + "md", "markdown", "mdown", + "csv", "json", "toml", "yaml", "yml", "xml", "svg", + "rs", "c", "cpp", "cc", "cxx", "h", "hpp", "hxx", + "js", "jsx", "ts", "tsx", + "html", "htm", "css", "scss", "less", + "py", "go", "rb", "php", "lua", + "sh", "bash", "zsh", "fish", + "java", "kt", "kts", "swift", "zig", "sql", + "mk", "ini", "cfg", "conf", "env", + "lock", "txt", "text", "log" + ] + var types: [UTType] = [.plainText] + for ext in extensions { + if let t = UTType(filenameExtension: ext) { + types.append(t) } } - } + return Array(Set(types)) + }() - private func defaultCrateName() -> String { - let firstLine = appState.documentText - .components(separatedBy: "\n").first? - .trimmingCharacters(in: .whitespaces) ?? "" - let stripped = firstLine.replacingOccurrences( - of: "^#+\\s*", with: "", options: .regularExpression - ) - let words = stripped.split(separator: " ").prefix(2).joined(separator: "-") - let sanitized = words.lowercased() - .map { $0.isLetter || $0.isNumber || $0 == "-" ? String($0) : "" }.joined() - return sanitized.isEmpty ? "my-note" : sanitized - } - - private func notifyExportComplete(at path: String) { - let alert = NSAlert() - alert.messageText = "Export complete" - alert.informativeText = "Crate written to:\n\(path)\n\nCheck the README for build and install instructions." - alert.addButton(withTitle: "Reveal in Finder") - alert.addButton(withTitle: "OK") - if alert.runModal() == .alertFirstButtonReturn { - NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: path)]) - } - } - - private func notifyExportFailed() { - let alert = NSAlert() - alert.messageText = "Export failed" - alert.informativeText = "Could not export the note. Check the folder permissions and that the crate name doesn't collide with an existing folder." - alert.addButton(withTitle: "OK") - alert.runModal() - } - - private func defaultFilename() -> String { - if let url = appState.currentFileURL { - return url.lastPathComponent - } - let firstLine = appState.documentText - .components(separatedBy: "\n").first? - .trimmingCharacters(in: .whitespaces) ?? "" - let stripped = firstLine.replacingOccurrences( - of: "^#+\\s*", with: "", options: .regularExpression - ) - let trimmed = stripped.trimmingCharacters(in: .whitespaces) - let ext = extensionForFormat(appState.currentFileFormat) - guard !trimmed.isEmpty, trimmed != "Untitled" else { return "note.\(ext)" } - let sanitized = trimmed.map { "/:\\\\".contains($0) ? "-" : String($0) }.joined() - return sanitized.prefix(80) + ".\(ext)" - } - - private func extensionForFormat(_ format: FileFormat) -> String { + static func extensionForFormat(_ format: FileFormat) -> String { switch format { case .markdown: return "md" case .csv: return "csv" @@ -649,331 +594,4 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { case .plainText, .unknown: return "txt" } } - - private static let supportedContentTypes: [UTType] = { - let extensions = [ - "md", "markdown", "mdown", - "csv", "json", "toml", "yaml", "yml", "xml", "svg", - "rs", "c", "cpp", "cc", "cxx", "h", "hpp", "hxx", - "js", "jsx", "ts", "tsx", - "html", "htm", "css", "scss", "less", - "py", "go", "rb", "php", "lua", - "sh", "bash", "zsh", "fish", - "java", "kt", "kts", "swift", "zig", "sql", - "mk", "ini", "cfg", "conf", "env", - "lock", "txt", "text", "log" - ] - var types: [UTType] = [.plainText] - for ext in extensions { - if let t = UTType(filenameExtension: ext) { - types.append(t) - } - } - return Array(Set(types)) - }() - - func validateMenuItem(_ menuItem: NSMenuItem) -> Bool { - let mode = viewport?.renderMode() ?? 0 - let layout = viewport?.layoutMode() ?? 0 - switch menuItem.action { - case #selector(setLiveMode): - menuItem.state = mode == 0 ? .on : .off - case #selector(setEditorMode): - menuItem.state = mode == 1 ? .on : .off - case #selector(setViewMode): - menuItem.state = mode == 2 ? .on : .off - case #selector(setLayoutModeFree): - menuItem.state = layout == 0 ? .on : .off - case #selector(setLayoutModeRelative): - menuItem.state = layout == 1 ? .on : .off - case #selector(setLayoutModeAnchored): - menuItem.state = layout == 2 ? .on : .off - case #selector(toggleSnapping): - menuItem.state = (viewport?.snapping() ?? false) ? .on : .off - default: - break - } - return true - } - - @objc private func setLiveMode() { - viewport?.sendCommand(11) - } - - @objc private func setEditorMode() { - viewport?.sendCommand(12) - } - - @objc private func handleNewNoteSeeded() { - viewport?.sendCommand(12) - } - - @objc private func setViewMode() { - viewport?.sendCommand(13) - } - - @objc private func setLayoutModeFree() { - viewport?.sendCommand(17) - } - - @objc private func setLayoutModeRelative() { - viewport?.sendCommand(18) - } - - @objc private func setLayoutModeAnchored() { - viewport?.sendCommand(19) - } - - @objc private func toggleSnapping() { - viewport?.sendCommand(20) - } - - @objc private func formatDocument() { - viewport?.sendCommand(10) - } - - @objc private func openSettings() { - syncSettingsToViewport() - viewport?.sendCommand(16) - } - - @objc private func settingsDidChange() { - window.backgroundColor = Theme.current.base - syncThemeToViewport() - syncGutterPrefsToViewport() - syncSettingsToViewport() - window.contentView?.needsDisplay = true - } - - private func syncSettingsToViewport() { - viewport?.setSettingsView( - themeMode: ConfigManager.shared.themeMode, - lineIndicator: ConfigManager.shared.lineIndicatorMode, - gutterRainbow: ConfigManager.shared.gutterRainbow, - autoSaveDir: ConfigManager.shared.autoSaveDirectory - ) - } - - private func drainShellActions() { - guard let vp = viewport else { return } - while let raw = vp.takeShellAction() { - let parts = raw.split(separator: ":", maxSplits: 1).map(String.init) - let kind = parts[0] - let value = parts.count > 1 ? parts[1] : "" - switch kind { - case "new_note": newNote() - case "open": openNote() - case "save": saveNote() - case "save_as": saveNoteAs() - case "quit": NSApp.terminate(nil) - case "settings": break - case "export_crate": exportCrate() - case "toggle_browser": toggleBrowser() - case "set_theme_mode": - ConfigManager.shared.themeMode = value - NotificationCenter.default.post(name: .settingsChanged, object: nil) - case "set_line_indicator": - ConfigManager.shared.lineIndicatorMode = value - NotificationCenter.default.post(name: .settingsChanged, object: nil) - case "set_gutter_rainbow": - ConfigManager.shared.gutterRainbow = (value == "true") - NotificationCenter.default.post(name: .settingsChanged, object: nil) - case "pick_auto_save_dir": - pickAutoSaveDirectory() - default: - break - } - } - } - - private func pickAutoSaveDirectory() { - let panel = NSOpenPanel() - panel.canChooseFiles = false - panel.canChooseDirectories = true - panel.canCreateDirectories = true - panel.allowsMultipleSelection = false - panel.directoryURL = URL(fileURLWithPath: ConfigManager.shared.autoSaveDirectory) - panel.beginSheetModal(for: window) { response in - guard response == .OK, let url = panel.url else { return } - ConfigManager.shared.autoSaveDirectory = url.path - NotificationCenter.default.post(name: .settingsChanged, object: nil) - } - } - - private func syncThemeToViewport() { - let mode = ConfigManager.shared.themeMode - let name: String - switch mode { - case "dark": name = "kicad" - case "light": name = "latte" - default: - let appearance = NSApp.effectiveAppearance - let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua - name = isDark ? "kicad" : "latte" - } - viewport?.setTheme(name) - } - - private func syncGutterPrefsToViewport() { - viewport?.setLineIndicator(ConfigManager.shared.lineIndicatorMode) - viewport?.setGutterRainbow(ConfigManager.shared.gutterRainbow) - viewport?.setAutoPairFlags(ConfigManager.shared.autoPairFlags) - } - - @objc private func toggleBrowser() { - DocumentBrowserController.shared?.toggle() - } - - @objc private func zoomIn() { - if let browser = DocumentBrowserController.shared, browser.isKeyWindow { - browser.sendCommand(7) - return - } - ConfigManager.shared.zoomLevel += 1 - NotificationCenter.default.post(name: .settingsChanged, object: nil) - } - - @objc private func zoomOut() { - if let browser = DocumentBrowserController.shared, browser.isKeyWindow { - browser.sendCommand(8) - return - } - let current = ConfigManager.shared.zoomLevel - if 11 + current > 8 { - ConfigManager.shared.zoomLevel -= 1 - NotificationCenter.default.post(name: .settingsChanged, object: nil) - } - } - - @objc private func zoomReset() { - if let browser = DocumentBrowserController.shared, browser.isKeyWindow { - browser.sendCommand(9) - return - } - ConfigManager.shared.zoomLevel = 0 - NotificationCenter.default.post(name: .settingsChanged, object: nil) - } - - private func setupTitleBar() { - let accessory = TitleBarAccessoryController() - window.addTitlebarAccessoryViewController(accessory) - - let tbv = accessory.titleView - tbv.onCommit = { [weak self] rawTitle in - guard let self = self else { return } - // Only drop the document's first line if it actually IS a title - // (starts with `#`). Normalize whatever the user typed in the - // title bar to a `# ` prefix so the saved markdown is valid. - let trimmed = rawTitle.trimmingCharacters(in: .whitespaces) - let normalizedTitle: String - if trimmed.isEmpty { - normalizedTitle = "" - } else if trimmed.hasPrefix("#") { - normalizedTitle = trimmed - } else { - normalizedTitle = "# " + trimmed - } - - let lines = self.appState.documentText.components(separatedBy: "\n") - let firstIsTitle = lines.first - .map { $0.trimmingCharacters(in: .whitespaces).hasPrefix("#") } - ?? false - let body: [String] = firstIsTitle ? Array(lines.dropFirst()) : lines - - let newLines: [String] - if normalizedTitle.isEmpty { - newLines = body - } else { - newLines = [normalizedTitle] + body - } - self.appState.documentText = newLines.joined(separator: "\n") - } - - titleBarView = tbv - - focusTitleObserver = NotificationCenter.default.addObserver( - forName: .focusTitle, object: nil, queue: .main - ) { [weak self] _ in - self?.titleBarView?.beginEditing() - } - } - - private func observeDocumentText() { - textCancellable = appState.$documentText - .receive(on: RunLoop.main) - .sink { [weak self] text in - guard let self = self, let vp = self.viewport else { return } - // Idempotent: when the sync timer pulls text FROM the - // viewport and assigns it to `documentText`, this sink - // fires again and would push the identical text back in — - // and `vp.setText` rebuilds viewport state, clearing eval - // results. Skip the round-trip when vp already has it. - if vp.getText() == text { return } - vp.setText(text) - } - } - - /// pushes loaded note text into the viewport synchronously so the autosave timer and quit handler can never observe stale viewport state across a note swap. - private func wireLoadedTextSync() { - appState.onLoadedTextChanged = { [weak self] text in - guard let self = self, let vp = self.viewport else { return } - if vp.getText() != text { - vp.setText(text) - } - self.lastAutosavedHash = text.hashValue - } - appState.takeArchiveBytesFromViewport = { [weak self] in - self?.viewport?.takeSidecarBytes() - } - appState.applyArchiveBytesToViewport = { [weak self] data in - self?.viewport?.applySidecarBytes(data) - } - } - - private func syncTextFromViewport() { - guard let w = window, let vp = w.contentView as? IcedViewportView else { return } - let text = vp.getText() - if !text.isEmpty || appState.documentText.isEmpty { - appState.documentText = text - } - } - - /// 100ms autosave loop. Reads straight from the viewport and writes a - /// file in the notes directory — no Combine publishers, no `setText`, - /// no viewport-state rebuilds. The existing explicit flows (Cmd+S, - /// note switch, quit) still route through `syncTextFromViewport` so - /// `appState.documentText` stays current when Swift actually needs it. - private func startAutosaveTimer() { - autosaveTimer?.invalidate() - autosaveTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in - self?.drainShellActions() - self?.persistViewportToNotesDir() - } - } - - private func persistViewportToNotesDir() { - guard let w = window, let vp = w.contentView as? IcedViewportView else { return } - let text = vp.getText() - guard !AppState.isEffectivelyBlank(text) else { return } - let hash = text.hashValue - if hash == lastAutosavedHash { return } - appState.writeAutosavedCopy(text: text) - lastAutosavedHash = hash - } - - private func observeDocumentTitle() { - titleCancellable = appState.$documentText - .receive(on: RunLoop.main) - .sink { [weak self] text in - guard let self = self else { return } - let firstLine = text.components(separatedBy: "\n").first? - .trimmingCharacters(in: .whitespaces) ?? "" - let clean = firstLine.replacingOccurrences( - of: "^#+\\s*", with: "", options: .regularExpression - ) - let displayTitle = clean.isEmpty ? "Acord" : String(clean.prefix(60)) - self.window.title = displayTitle - self.titleBarView?.title = firstLine - } - } } diff --git a/macos/src/AppState.swift b/macos/src/AppState.swift index c80ff53..195e13b 100644 --- a/macos/src/AppState.swift +++ b/macos/src/AppState.swift @@ -132,16 +132,13 @@ class AppState: ObservableObject { private var autoSaveDirty = false private var autoSaveCoolingDown = false private let autoSaveQueue = DispatchQueue(label: "com.acord.autosave") - /// fires synchronously after a load/new note swap so the host shell can push the new text into the viewport before the autosave timer reads stale viewport state. + /// fires synchronously after a load/new-note swap, delivering text before the autosave timer polls. var onLoadedTextChanged: ((String) -> Void)? - /// drains the document's archive zip from the viewport for embed-on-save. + /// drains the document's archive zip from the viewport. var takeArchiveBytesFromViewport: (() -> Data?)? /// applies an archive zip's metadata back into the viewport. var applyArchiveBytesToViewport: ((Data) -> Void)? - /// Per-note autosave file path, established on the first write and never - /// changed for the rest of the session. Stops the title-derived filename - /// from re-deriving on every keystroke and littering the notes directory - /// with `u.md`, `us.md`, `use.md`, ... + /// per-note autosave file path, locked on the first write to prevent keystroke-driven renames. private var autoSavePaths: [UUID: URL] = [:] init() { @@ -202,23 +199,11 @@ class AppState: ObservableObject { } private func shouldAutoSave() -> Bool { - // Autosave only when the note has real user content. A freshly- - // created doc that picked up the default `Header 1 | Header 2 | - // Header 3` table from Cmd+T without the user typing anything - // still reads as "blank" by this check — that's what stops the - // ~/.acord/notes directory from accumulating `{uuid}.md` phantoms. - // - // Explicit saves (Cmd+S → `saveNote`) skip this gate, so a user - // who genuinely wants to keep a note with only an empty table - // can still force it. + // gates on non-blank content to prevent phantom .md files from empty tables. !AppState.isEffectivelyBlank(documentText) } - /// Shared blank-detection used by both the autosave gate and (via its - /// `static` form) the browser's `(empty note)` preview label. A note - /// is "blank" when, after the `` sidecar is - /// stripped, nothing remains except whitespace or default empty-table - /// scaffolding (all-empty cells or the `Header N` placeholder row). + /// returns true for notes containing only whitespace or default empty-table scaffolding. static func isEffectivelyBlank(_ text: String) -> Bool { let body = stripSidecarArchive(text) let trimmed = body.trimmingCharacters(in: .whitespacesAndNewlines) @@ -267,11 +252,7 @@ class AppState: ObservableObject { return cleaned.isEmpty ? UUID().uuidString : cleaned } - /// Resolve the autosave file URL for `noteID`. First call for a noteID - /// derives a filename from the title (or the UUID when there's no title); - /// the resulting path is then locked in for the rest of the session, so - /// later keystrokes can't spawn a fresh file each time the title grows. - /// Must be called on the main thread (mutates `autoSavePaths`). + /// resolves the autosave file URL, locking the derived filename on first call per noteID. private func resolveAutoSaveURL(noteID: UUID, text: String) -> URL { if let url = autoSavePaths[noteID] { return url @@ -297,14 +278,12 @@ class AppState: ObservableObject { currentFileFormat = .markdown } - /// Background-safe atomic write. No path resolution here — the URL was - /// resolved on the main thread before dispatch. + /// atomically writes text to a pre-resolved URL from a background queue. private static func writeAutoSaveFile(at url: URL, text: String) { try? text.write(to: url, atomically: true, encoding: .utf8) } - /// magic separating the markdown body from the appended raw zip; surrounding - /// NULs trip text editors into binary mode so the archive shows as garbage. + /// byte sequence separating the markdown body from the appended raw zip. static let binarySentinel: Data = { var d = Data() d.append(0x0A) // \n @@ -338,7 +317,7 @@ class AppState: ObservableObject { return out } - /// true when url is an .md inside the configured notes library (recursive). + /// checks whether url resolves to an .md inside the configured notes library. func isAcordNote(_ url: URL) -> Bool { let format = FileFormat.from(filename: url.lastPathComponent) guard format.isMarkdown else { return false } @@ -349,7 +328,21 @@ class AppState: ObservableObject { || standardized.path == dir.path } - /// path under /.external/ for an external file's archive companion. + /// computes the /.external/ autosave shadow path of an external file. + func externalAutosaveShadowURL(for original: URL) -> URL { + let trimmed = original.standardizedFileURL.path + .trimmingCharacters(in: CharacterSet(charactersIn: "/")) + let encoded = trimmed.map { ch -> Character in + (ch == "/" || ch == "\\" || ch == ":") ? "." : ch + } + let name = String(encoded) + ".md" + let dir = URL(fileURLWithPath: ConfigManager.shared.autoSaveDirectory) + .appendingPathComponent(".external", isDirectory: true) + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir.appendingPathComponent(name) + } + + /// computes the /.external/ archive companion path of an external file. func externalSidecarPath(for original: URL) -> URL { let trimmed = original.standardizedFileURL.path .trimmingCharacters(in: CharacterSet(charactersIn: "/")) @@ -362,12 +355,12 @@ class AppState: ObservableObject { .appendingPathComponent(name) } - /// reads the companion archive zip for an external file, if it exists. + /// reads the companion archive zip of an external file, returning nil when absent. func readExternalSidecar(for original: URL) -> Data? { try? Data(contentsOf: externalSidecarPath(for: original)) } - /// writes (or clears) the companion archive for an external file. + /// writes or clears the companion archive of an external file. func writeExternalSidecar(for original: URL, archive: Data?) { let path = externalSidecarPath(for: original) if let archive = archive, !archive.isEmpty { @@ -379,14 +372,10 @@ class AppState: ObservableObject { } } - /// Strip the `` sidecar comment from `text`. - /// The markdown body before the comment is the user's actual content; - /// non-markdown destinations (.rs, .json, .csv-source, etc.) must not - /// inherit the comment because it isn't valid syntax in those formats. + /// strips the sidecar archive comment from text, leaving only the markdown body. private static func stripArchiveForExternalSave(_ text: String) -> String { var body = stripSidecarArchive(text) - // `stripSidecarArchive` keeps trailing whitespace — trim so we don't - // leave a flapping blank line where the comment used to be. + // trim trailing blank lines left behind by the stripped comment. while body.hasSuffix("\n\n") { body.removeLast() } @@ -466,17 +455,16 @@ class AppState: ObservableObject { writeNoteFile(text: textToSave, to: url) currentFileURL = url currentFileFormat = format - // An explicit save-to-disk locks the autosave path to the same file - // for the rest of the session — keystrokes after Save As shouldn't - // start a fresh autosave file under the old name. + // pins the autosave target, routing external destinations to a local shadow. if format.isMarkdown { - autoSavePaths[currentNoteID] = url + autoSavePaths[currentNoteID] = isAcordNote(url) + ? url + : externalAutosaveShadowURL(for: url) } modified = false } - /// embeds the binary archive into in-library .md files; for everything else - /// writes plain text and stashes the archive at /.external/. + /// writes note content to url, embedding the archive inline or stashing it externally. private func writeNoteFile(text: String, to url: URL) { let archive = takeArchiveBytesFromViewport?() let inLibrary = isAcordNote(url) @@ -493,10 +481,7 @@ class AppState: ObservableObject { } } - /// Project the in-memory `documentText` onto the right shape for an - /// external file format. CSV gets converted from the markdown table, - /// non-markdown formats get the sidecar archive comment stripped (the - /// HTML comment isn't valid in .rs/.json/etc.), markdown passes through. + /// projects documentText into the target format, converting CSV or stripping the archive comment. private func textForExternalSave(format: FileFormat) -> String { if format.isCSV { return markdownTableToCSV(documentText) } if format.isMarkdown { return documentText } @@ -516,8 +501,9 @@ class AppState: ObservableObject { guard let id = bridge.installDocument(text: text) else { return } + // routes external files to a local shadow under .external/. if format.isMarkdown { - autoSavePaths[id] = url + autoSavePaths[id] = inLibrary ? url : externalAutosaveShadowURL(for: url) } currentNoteID = id currentFileURL = url @@ -680,12 +666,7 @@ class AppState: ObservableObject { evalResults = bridge.evaluate(currentNoteID) } - /// Write a caller-provided text snapshot to the notes directory, - /// bypassing the `documentText` pipeline entirely. Used by the - /// AppDelegate's 100ms autosave timer, which reads text directly - /// from the viewport — routing through `documentText.didSet` would - /// trip the Combine → `vp.setText` round-trip and wipe viewport - /// state (including visible eval results). + /// writes a caller-provided text snapshot to disk, bypassing the documentText pipeline. func writeAutosavedCopy(text: String) { let noteID = currentNoteID let url = resolveAutoSaveURL(noteID: noteID, text: text) diff --git a/macos/src/DocumentBrowserWindow.swift b/macos/src/DocumentBrowserWindow.swift index 498fb98..093d1d6 100644 --- a/macos/src/DocumentBrowserWindow.swift +++ b/macos/src/DocumentBrowserWindow.swift @@ -5,10 +5,12 @@ class DocumentBrowserController { let window: NSWindow private let view: IcedBrowserView - private let appState: AppState - init(appState: AppState) { - self.appState = appState + /// forwards the picked note path to the caller. + private let onOpenPath: (String) -> Void + + init(onOpenPath: @escaping (String) -> Void) { + self.onOpenPath = onOpenPath let dir = ConfigManager.shared.autoSaveDirectory let frame = NSRect(x: 0, y: 0, width: 900, height: 650) view = IcedBrowserView(frame: frame, notesDir: dir) @@ -29,9 +31,8 @@ class DocumentBrowserController { view.onOpenPath = { [weak self] path in guard let self = self else { return } - let url = URL(fileURLWithPath: path) DispatchQueue.main.async { - self.appState.loadNoteFromFile(url) + self.onOpenPath(path) self.window.orderOut(nil) } } @@ -46,7 +47,6 @@ class DocumentBrowserController { } } - /// true while the browser window is the focused window. var isKeyWindow: Bool { window.isKeyWindow } /// forwards a numeric command to the embedded browser view. diff --git a/macos/src/EditorWindow.swift b/macos/src/EditorWindow.swift new file mode 100644 index 0000000..a533ff2 --- /dev/null +++ b/macos/src/EditorWindow.swift @@ -0,0 +1,449 @@ +import Cocoa +import Combine + +class EditorWindow: NSObject { + let window: NSWindow + let appState: AppState + let viewport: IcedViewportView + private(set) var titleBarView: TitleBarView? + + private var titleCancellable: AnyCancellable? + private var textCancellable: AnyCancellable? + private var autosaveTimer: Timer? + private var lastAutosavedHash: Int? + private var observers: [NSObjectProtocol] = [] + + init(frameAutosaveName: String?) { + self.appState = AppState() + self.viewport = IcedViewportView(frame: NSRect(x: 0, y: 0, width: 1200, height: 800)) + viewport.autoresizingMask = [.width, .height] + + self.window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 1200, height: 800), + styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + window.titlebarAppearsTransparent = true + window.titleVisibility = .hidden + window.backgroundColor = Theme.current.base + window.title = "Acord" + window.contentView = viewport + if let autosave = frameAutosaveName { + window.setFrameAutosaveName(autosave) + } + window.center() + + super.init() + + setupTitleBar() + observeDocumentText() + observeDocumentTitle() + wireLoadedTextSync() + syncThemeToViewport() + syncGutterPrefsToViewport() + syncSettingsToViewport() + startAutosaveTimer() + registerObservers() + + window.makeKeyAndOrderFront(nil) + } + + func loadFromURL(_ url: URL) { + appState.loadNoteFromFile(url) + viewport.setLang(url.pathExtension) + } + + // MARK: - Title bar + + private func setupTitleBar() { + let accessory = TitleBarAccessoryController() + window.addTitlebarAccessoryViewController(accessory) + + let tbv = accessory.titleView + tbv.onCommit = { [weak self] rawTitle in + guard let self = self else { return } + let trimmed = rawTitle.trimmingCharacters(in: .whitespaces) + let normalizedTitle: String + if trimmed.isEmpty { + normalizedTitle = "" + } else if trimmed.hasPrefix("#") { + normalizedTitle = trimmed + } else { + normalizedTitle = "# " + trimmed + } + + let lines = self.appState.documentText.components(separatedBy: "\n") + let firstIsTitle = lines.first + .map { $0.trimmingCharacters(in: .whitespaces).hasPrefix("#") } + ?? false + let body: [String] = firstIsTitle ? Array(lines.dropFirst()) : lines + + let newLines: [String] + if normalizedTitle.isEmpty { + newLines = body + } else { + newLines = [normalizedTitle] + body + } + self.appState.documentText = newLines.joined(separator: "\n") + } + + titleBarView = tbv + } + + // MARK: - Combine bindings + + private func observeDocumentText() { + textCancellable = appState.$documentText + .receive(on: RunLoop.main) + .sink { [weak self] text in + guard let self = self else { return } + if self.viewport.getText() == text { return } + self.viewport.setText(text) + } + } + + private func observeDocumentTitle() { + titleCancellable = appState.$documentText + .receive(on: RunLoop.main) + .sink { [weak self] text in + guard let self = self else { return } + let firstLine = text.components(separatedBy: "\n").first? + .trimmingCharacters(in: .whitespaces) ?? "" + let clean = firstLine.replacingOccurrences( + of: "^#+\\s*", with: "", options: .regularExpression + ) + let displayTitle = clean.isEmpty ? "Acord" : String(clean.prefix(60)) + self.window.title = displayTitle + self.titleBarView?.title = firstLine + } + } + + private func wireLoadedTextSync() { + appState.onLoadedTextChanged = { [weak self] text in + guard let self = self else { return } + if self.viewport.getText() != text { + self.viewport.setText(text) + } + self.lastAutosavedHash = text.hashValue + } + appState.takeArchiveBytesFromViewport = { [weak self] in + self?.viewport.takeSidecarBytes() + } + appState.applyArchiveBytesToViewport = { [weak self] data in + self?.viewport.applySidecarBytes(data) + } + } + + // MARK: - Notifications + + private func registerObservers() { + let settings = NotificationCenter.default.addObserver( + forName: .settingsChanged, object: nil, queue: .main + ) { [weak self] _ in + self?.applySettingsChange() + } + observers.append(settings) + + let newNote = NotificationCenter.default.addObserver( + forName: .newNoteSeeded, object: appState, queue: .main + ) { [weak self] _ in + self?.viewport.sendCommand(12) + } + observers.append(newNote) + + let focusTitle = NotificationCenter.default.addObserver( + forName: .focusTitle, object: nil, queue: .main + ) { [weak self] _ in + guard let self = self, self.window.isKeyWindow else { return } + self.titleBarView?.beginEditing() + } + observers.append(focusTitle) + } + + private func applySettingsChange() { + window.backgroundColor = Theme.current.base + syncThemeToViewport() + syncGutterPrefsToViewport() + syncSettingsToViewport() + window.contentView?.needsDisplay = true + } + + // MARK: - Viewport sync + + func syncSettingsToViewport() { + viewport.setSettingsView( + themeMode: ConfigManager.shared.themeMode, + lineIndicator: ConfigManager.shared.lineIndicatorMode, + gutterRainbow: ConfigManager.shared.gutterRainbow, + autoSaveDir: ConfigManager.shared.autoSaveDirectory + ) + } + + func syncThemeToViewport() { + let mode = ConfigManager.shared.themeMode + let name: String + switch mode { + case "dark": name = "kicad" + case "light": name = "latte" + default: + let appearance = NSApp.effectiveAppearance + let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua + name = isDark ? "kicad" : "latte" + } + viewport.setTheme(name) + } + + func syncGutterPrefsToViewport() { + viewport.setLineIndicator(ConfigManager.shared.lineIndicatorMode) + viewport.setGutterRainbow(ConfigManager.shared.gutterRainbow) + viewport.setAutoPairFlags(ConfigManager.shared.autoPairFlags) + } + + func syncTextFromViewport() { + let text = viewport.getText() + if !text.isEmpty || appState.documentText.isEmpty { + appState.documentText = text + } + } + + // MARK: - Autosave loop + + private func startAutosaveTimer() { + autosaveTimer?.invalidate() + autosaveTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in + self?.drainShellActions() + self?.persistViewportToNotesDir() + } + } + + private func drainShellActions() { + while let raw = viewport.takeShellAction() { + let parts = raw.split(separator: ":", maxSplits: 1).map(String.init) + let kind = parts[0] + let value = parts.count > 1 ? parts[1] : "" + handleShellAction(kind: kind, value: value) + } + } + + private func handleShellAction(kind: String, value: String) { + switch kind { + case "new_note": newNote() + case "open": openNotePanel() + case "save": saveNote() + case "save_as": saveNoteAs() + case "quit": NSApp.terminate(nil) + case "settings": break + case "export_crate": exportCrate() + case "toggle_browser": DocumentBrowserController.shared?.toggle() + case "set_theme_mode": + ConfigManager.shared.themeMode = value + NotificationCenter.default.post(name: .settingsChanged, object: nil) + case "set_line_indicator": + ConfigManager.shared.lineIndicatorMode = value + NotificationCenter.default.post(name: .settingsChanged, object: nil) + case "set_gutter_rainbow": + ConfigManager.shared.gutterRainbow = (value == "true") + NotificationCenter.default.post(name: .settingsChanged, object: nil) + case "pick_auto_save_dir": + pickAutoSaveDirectory() + default: + break + } + } + + private func persistViewportToNotesDir() { + let text = viewport.getText() + guard !AppState.isEffectivelyBlank(text) else { return } + let hash = text.hashValue + if hash == lastAutosavedHash { return } + appState.writeAutosavedCopy(text: text) + lastAutosavedHash = hash + } + + // MARK: - Actions + + func newNote() { + appState.newNote() + viewport.setLang("") + } + + func saveNote() { + syncTextFromViewport() + appState.bindAutoSaveURL() + appState.saveNote() + } + + func saveNoteAs() { + syncTextFromViewport() + let panel = NSSavePanel() + panel.allowedContentTypes = AppDelegate.supportedContentTypes + panel.nameFieldStringValue = defaultFilename() + if let url = appState.currentFileURL { + panel.directoryURL = url.deletingLastPathComponent() + panel.nameFieldStringValue = url.lastPathComponent + } + panel.beginSheetModal(for: window) { [weak self] response in + guard response == .OK, let url = panel.url else { return } + self?.appState.saveNoteToFile(url) + } + } + + /// presents the file picker and forwards the chosen URL to the AppDelegate router. + func openNotePanel() { + let panel = NSOpenPanel() + panel.allowedContentTypes = AppDelegate.supportedContentTypes + panel.canChooseFiles = true + panel.canChooseDirectories = false + panel.allowsMultipleSelection = false + panel.beginSheetModal(for: window) { response in + guard response == .OK, let url = panel.url else { return } + (NSApp.delegate as? AppDelegate)?.openInNewWindow(url) + } + } + + func printNote() { + guard let handle = viewport.viewportHandle else { return } + syncTextFromViewport() + + let title = appState.currentFileURL?.deletingPathExtension().lastPathComponent ?? "Acord Document" + var len: UInt = 0 + guard let ptr = title.withCString({ t in viewport_render_pdf(handle, t, &len) }), len > 0 else { + let alert = NSAlert() + alert.messageText = "Print failed" + alert.informativeText = "Could not render this document to PDF." + alert.runModal() + return + } + let data = Data(bytes: ptr, count: Int(len)) + viewport_free_bytes(ptr, len) + + let panel = NSSavePanel() + panel.title = "Print to PDF" + panel.prompt = "Save" + panel.allowedContentTypes = [.pdf] + panel.nameFieldStringValue = "\(title).pdf" + panel.beginSheetModal(for: window) { response in + guard response == .OK, let url = panel.url else { return } + do { + try data.write(to: url) + NSWorkspace.shared.open(url) + } catch { + let alert = NSAlert() + alert.messageText = "Print failed" + alert.informativeText = error.localizedDescription + alert.runModal() + } + } + } + + func exportCrate() { + syncTextFromViewport() + guard let handle = viewport.viewportHandle else { return } + + let panel = NSSavePanel() + panel.title = "Export as Rust Library" + panel.message = "Choose a location and name for your exported crate" + panel.prompt = "Export" + panel.nameFieldLabel = "Crate name:" + panel.nameFieldStringValue = defaultCrateName() + panel.canCreateDirectories = true + + panel.beginSheetModal(for: window) { [weak self] response in + guard response == .OK, let url = panel.url else { return } + let parentDir = url.deletingLastPathComponent().path + let name = url.lastPathComponent + parentDir.withCString { pd in + name.withCString { n in + if let cstr = viewport_export_crate(handle, pd, n) { + let resultPath = String(cString: cstr) + viewport_free_string(cstr) + self?.notifyExportComplete(at: resultPath) + } else { + self?.notifyExportFailed() + } + } + } + } + } + + func pickAutoSaveDirectory() { + let panel = NSOpenPanel() + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.canCreateDirectories = true + panel.allowsMultipleSelection = false + panel.directoryURL = URL(fileURLWithPath: ConfigManager.shared.autoSaveDirectory) + panel.beginSheetModal(for: window) { response in + guard response == .OK, let url = panel.url else { return } + ConfigManager.shared.autoSaveDirectory = url.path + NotificationCenter.default.post(name: .settingsChanged, object: nil) + } + } + + // MARK: - Helpers + + private func notifyExportComplete(at path: String) { + let alert = NSAlert() + alert.messageText = "Export complete" + alert.informativeText = "Crate written to:\n\(path)\n\nCheck the README for build and install instructions." + alert.addButton(withTitle: "Reveal in Finder") + alert.addButton(withTitle: "OK") + if alert.runModal() == .alertFirstButtonReturn { + NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: path)]) + } + } + + private func notifyExportFailed() { + let alert = NSAlert() + alert.messageText = "Export failed" + alert.informativeText = "Could not export the note. Check the folder permissions and that the crate name doesn't collide with an existing folder." + alert.addButton(withTitle: "OK") + alert.runModal() + } + + private func defaultCrateName() -> String { + let firstLine = appState.documentText + .components(separatedBy: "\n").first? + .trimmingCharacters(in: .whitespaces) ?? "" + let stripped = firstLine.replacingOccurrences( + of: "^#+\\s*", with: "", options: .regularExpression + ) + let words = stripped.split(separator: " ").prefix(2).joined(separator: "-") + let sanitized = words.lowercased() + .map { $0.isLetter || $0.isNumber || $0 == "-" ? String($0) : "" }.joined() + return sanitized.isEmpty ? "my-note" : sanitized + } + + private func defaultFilename() -> String { + if let url = appState.currentFileURL { + return url.lastPathComponent + } + let firstLine = appState.documentText + .components(separatedBy: "\n").first? + .trimmingCharacters(in: .whitespaces) ?? "" + let stripped = firstLine.replacingOccurrences( + of: "^#+\\s*", with: "", options: .regularExpression + ) + let trimmed = stripped.trimmingCharacters(in: .whitespaces) + let ext = AppDelegate.extensionForFormat(appState.currentFileFormat) + guard !trimmed.isEmpty, trimmed != "Untitled" else { return "note.\(ext)" } + let sanitized = trimmed.map { "/:\\\\".contains($0) ? "-" : String($0) }.joined() + return sanitized.prefix(80) + ".\(ext)" + } + + // MARK: - Teardown + + func teardown() { + autosaveTimer?.invalidate() + autosaveTimer = nil + for observer in observers { + NotificationCenter.default.removeObserver(observer) + } + observers = [] + titleCancellable = nil + textCancellable = nil + viewport.teardown() + } +} diff --git a/macos/src/IcedViewportView.swift b/macos/src/IcedViewportView.swift index 8635f39..5af7c86 100644 --- a/macos/src/IcedViewportView.swift +++ b/macos/src/IcedViewportView.swift @@ -5,9 +5,7 @@ class IcedViewportView: NSView { private(set) var viewportHandle: OpaquePointer? private var displayLink: CVDisplayLink? private var isTornDown = false - // Last text pulled out of Rust. Refreshed on every edit tick via the - // render loop, so terminate/save paths can read a current-enough value - // without touching the viewport once teardown has begun. + // latest text snapshot from Rust, read each render tick private var cachedText: String = "" override init(frame frameRect: NSRect) { @@ -51,10 +49,7 @@ class IcedViewportView: NSView { viewport_destroy(handle) } - /// Ordered shutdown: stop the display link first (joins in-flight CV - /// callbacks), then snapshot the text into `cachedText`, then drop the - /// Rust handle. Idempotent — safe to call from both the terminate hook - /// and `deinit`. + /// stops display link, snapshots text, drops the Rust handle. idempotent. func teardown() { if isTornDown { return } isTornDown = true @@ -146,9 +141,7 @@ class IcedViewportView: NSView { override func mouseDragged(with event: NSEvent) { guard let h = viewportHandle else { return } let pt = convert(event.locationInWindow, from: nil) - // Use the 255 sentinel — pointer move only, no button event. mouseDown - // already fired ButtonPressed; sending another one per drag tick would - // restart iced's selection on every frame and make click+drag twitch. + // 255 sentinel = pointer move only, no button state change viewport_mouse_event(h, Float(pt.x), Float(pt.y), 255, false) } @@ -241,7 +234,7 @@ class IcedViewportView: NSView { return result } - /// drains the document's archive zip bytes for embed-on-save. + /// drains the document's archive zip bytes. func takeSidecarBytes() -> Data? { if isTornDown { return nil } guard let h = viewportHandle else { return nil } @@ -275,6 +268,14 @@ class IcedViewportView: NSView { } } + /// configures the syntax highlighter to a file extension or clears when empty. + func setLang(_ ext: String) { + guard let h = viewportHandle else { return } + ext.withCString { cstr in + viewport_set_lang(h, cstr) + } + } + func setLineIndicator(_ mode: String) { guard let h = viewportHandle else { return } mode.withCString { cstr in diff --git a/macos/src/RustBridge.swift b/macos/src/RustBridge.swift index 79e1580..5c6badc 100644 --- a/macos/src/RustBridge.swift +++ b/macos/src/RustBridge.swift @@ -90,8 +90,7 @@ class RustBridge { return (id, text) } - /// installs a doc from already-decoded text — used when the shell read raw bytes - /// itself (so it could split off the binary archive trailer) and just needs a UUID. + /// installs a doc from already-decoded text and returns a UUID. func installDocument(text: String) -> UUID? { guard let ptr = acord_doc_new() else { return nil } text.withCString { cstr in diff --git a/macos/src/main.swift b/macos/src/main.swift index 1810899..9399815 100644 --- a/macos/src/main.swift +++ b/macos/src/main.swift @@ -1,9 +1,6 @@ import Cocoa -// The delegate is stored in a static so it outlives `app.run()`. NSApplication -// holds its delegate weakly; if the compiler decides the local `delegate` -// binding isn't needed past `app.delegate = ...` it can be released early, -// tearing down state mid-run. A static keeps a concrete strong reference. +// static strong ref prevents the weakly-held delegate from early release enum AcordAppMain { static let delegate = AppDelegate() } diff --git a/scripts/_build-dirs.sh b/scripts/_build-dirs.sh index 6c7ccb7..3e48894 100755 --- a/scripts/_build-dirs.sh +++ b/scripts/_build-dirs.sh @@ -1,10 +1,5 @@ #!/usr/bin/env bash -# Sourced by build / install / debug scripts to redirect cargo's target dir -# to the boot SSD instead of the repo's external spinning disk. -# Scripts read compiled artifacts from $CARGO_TARGET_DIR and copy the final -# .app / .exe / binary into $ROOT/build/ as real files at the end. -# -# Override the SSD location with: export CARGO_TARGET_DIR=/some/other/path +# redirects CARGO_TARGET_DIR to the boot SSD instead of the repo's external disk if [ -n "${ACORD_BUILD_DIRS_DONE:-}" ]; then return 0 2>/dev/null || exit 0 diff --git a/scripts/android/select.sh b/scripts/android/select.sh index 94297ec..0d4c0b9 100755 --- a/scripts/android/select.sh +++ b/scripts/android/select.sh @@ -1,15 +1,12 @@ #!/usr/bin/env bash set -euo pipefail -# Stub — the Android shell hasn't been started yet. -# When it lands, this should mirror scripts/ios/select.sh: list `adb devices` -# entries plus available emulators (`emulator -list-avds`), let the user -# pick one, and write the choice to $HOME/.acord/android-target. +# stub -- Android shell not yet implemented cat <<'EOF' >&2 -android select is a stub — the Android shell isn't built yet. +android select is a stub -- the Android shell has not been built yet. -when it ships, this will: +planned behavior: - list connected devices (adb devices) - list available emulators (emulator -list-avds) - write the picked target to $HOME/.acord/android-target diff --git a/scripts/ios/build.sh b/scripts/ios/build.sh index 7807e69..55e2f7d 100755 --- a/scripts/ios/build.sh +++ b/scripts/ios/build.sh @@ -10,7 +10,7 @@ case "$(uname -s)" in *) echo "wrong platform: $(uname -s) — iOS build requires macOS" >&2; exit 1;; esac -# Default to simulator; override with `bash scripts/ios/build.sh device`. +# defaults to simulator, pass "device" for physical hardware TARGET="${1:-sim}" case "$TARGET" in @@ -30,8 +30,7 @@ case "$TARGET" in ;; esac -# debug.sh sets ACORD_IOS_CONFIG=debug to flip cargo to the dev profile and -# pass -D DEBUG / -Onone / -g to swiftc. install.sh leaves it unset → release. +# ACORD_IOS_CONFIG=debug flips to dev profile + debug swiftc flags CONFIG="${ACORD_IOS_CONFIG:-release}" case "$CONFIG" in release) @@ -56,7 +55,7 @@ RUST_LIB="$CARGO_TARGET_DIR/$RUST_TARGET/$CARGO_PROFILE_DIR" SDK="$(xcrun --sdk "$SDK_NAME" --show-sdk-path)" -# the user has esp-clang on PATH; fall through to apple's so cc-rs picks the right one. +# force apple clang over esp-clang on PATH export CC=/usr/bin/clang export CXX=/usr/bin/clang++ export IPHONEOS_DEPLOYMENT_TARGET=17.0 @@ -69,14 +68,12 @@ if [ ! -f "$RUST_LIB/libacord_viewport.a" ]; then exit 1 fi -# build app bundle (iOS bundles are flat — Info.plist, executable and resources live at the root) +# build flat app bundle rm -rf "$APP" mkdir -p "$APP" cp "$ROOT/ios/Info.plist" "$APP/Info.plist" -# asset catalog → compiled Assets.car + partial Info.plist via actool. -# regenerate the source PNGs from the SVG so a fresh checkout doesn't need -# a manual icon-rebuild step. +# regenerate icon PNGs from SVG, compile asset catalog via actool bash "$ROOT/scripts/ios/generate-icons.sh" ACTOOL_PARTIAL="$BUILD/ios/actool-partial-info.plist" @@ -135,9 +132,7 @@ else | plutil -extract Entitlements xml1 -o "$ENT" - \ || { echo "ERROR: could not extract entitlements from profile" >&2; exit 1; } - # find a codesigning identity that's in the profile's DeveloperCertificates list. - # we pull each cert's SHA1 out of the profile, then pick whichever one find-identity - # also lists as valid in the keychain. fail loudly if none match. + # match a keychain identity against the profile's DeveloperCertificates TMPDIR_PROF="$(mktemp -d)" PROFILE_PLIST="$TMPDIR_PROF/profile.plist" security cms -D -i "$PROFILE" > "$PROFILE_PLIST" 2>/dev/null diff --git a/scripts/ios/debug.sh b/scripts/ios/debug.sh index b887de5..8980a7b 100644 --- a/scripts/ios/debug.sh +++ b/scripts/ios/debug.sh @@ -1,9 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -# Builds debug, installs to the chosen target, and launches with stdio attached -# so Swift print() AND Rust eprintln!() (via captureStderr in AcordApp) stream -# straight into this terminal. +# debug build + install + launch with stdio attached ROOT="$(cd "$(dirname "$0")/../.." && pwd)" source "$ROOT/scripts/_build-dirs.sh" diff --git a/scripts/ios/generate-icons.sh b/scripts/ios/generate-icons.sh index 0471afb..f30f7d3 100755 --- a/scripts/ios/generate-icons.sh +++ b/scripts/ios/generate-icons.sh @@ -1,7 +1,5 @@ #!/usr/bin/env bash -# Generates ios/Assets.xcassets/AppIcon.appiconset/ from assets/Acord.svg. -# Used by both the CLI build (build.sh) and the Xcode project path -# (xcodeproj.sh). Idempotent — re-running just overwrites. +# generates ios app icon assets from assets/Acord.svg at all required sizes. set -euo pipefail ROOT="$(cd "$(dirname "$0")/../.." && pwd)" @@ -21,8 +19,7 @@ fi mkdir -p "$APPICON" -# (filename, pixel size) pairs covering iPhone + iPad icon slots through iOS 17. -# 1024 is the marketing icon; the rest are point-size@scale variants. +# filename and pixel size pairs for iPhone + iPad icon slots through iOS 17. SIZES=( "Icon-20.png 20" "Icon-20@2x.png 40" @@ -47,7 +44,7 @@ for entry in "${SIZES[@]}"; do rsvg-convert --width="$size" --height="$size" "$SVG" -o "$APPICON/$name" done -# Top-level Assets.xcassets/Contents.json (xcode requires it even if empty-ish). +# top-level Assets.xcassets/Contents.json required by xcode. cat > "$ASSETS/Contents.json" <<'EOF' { "info" : { @@ -57,7 +54,7 @@ cat > "$ASSETS/Contents.json" <<'EOF' } EOF -# AppIcon.appiconset/Contents.json — maps every Icon-*.png to its slot. +# AppIcon.appiconset/Contents.json mapping each Icon-*.png to a slot. cat > "$APPICON/Contents.json" <<'EOF' { "images" : [ diff --git a/scripts/ios/install.sh b/scripts/ios/install.sh index e745911..2305b4a 100755 --- a/scripts/ios/install.sh +++ b/scripts/ios/install.sh @@ -7,10 +7,7 @@ cd "$ROOT" CONFIG_FILE="$HOME/.acord/ios-target" -# resolve target. priority: -# 1. explicit cli arg ("sim" / "device") -# 2. saved selection from `cargo xtask select-ios` -# 3. auto-detect (paired physical device wins) +# resolve target from cli arg, saved config, or auto-detect. TARGET="" SELECTED_ID="" SELECTED_LABEL="" @@ -60,7 +57,7 @@ case "$TARGET" in fi fi - # boot it if it isn't already. + # boot the simulator if not already running. STATE="$(xcrun simctl list devices | awk -v id="$DEV" '$0 ~ id { for (i=1; i<=NF; i++) if ($i ~ /^\(/) state=$i } END { gsub(/[()]/, "", state); print state }')" if [ "$STATE" != "Booted" ]; then xcrun simctl boot "$DEV" 2>/dev/null || true diff --git a/scripts/ios/select.sh b/scripts/ios/select.sh index bdae084..858c75e 100755 --- a/scripts/ios/select.sh +++ b/scripts/ios/select.sh @@ -1,10 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -# Interactive picker for the iOS deploy target. Lists every paired physical -# device and every available iPad simulator, lets the user pick one, then -# stores the choice at $HOME/.acord/ios-target so install.sh / debug.sh can -# read it back without prompting again. +# pick an iOS deploy target and persist the selection to $HOME/.acord/ios-target. CONFIG_DIR="$HOME/.acord" CONFIG_FILE="$CONFIG_DIR/ios-target" @@ -14,7 +11,7 @@ ALL_FILE="$(mktemp)" trap 'rm -f "$ALL_FILE"' EXIT echo "Scanning paired devices..." -# devicectl columns are aligned with 2+ spaces. fields: name | host | uuid | state | model +# devicectl columns aligned with 2+ spaces: name | host | uuid | state | model xcrun devicectl list devices 2>/dev/null \ | awk -F' +' '/available \(paired\)/ { gsub(/^[[:space:]]+|[[:space:]]+$/, "", $1) @@ -24,8 +21,7 @@ xcrun devicectl list devices 2>/dev/null \ }' >> "$ALL_FILE" || true echo "Scanning iPad simulators..." -# simctl line: " iPad Pro 11-inch (M4) (UUID) (Shutdown)" -# strip whitespace, peel off "(state)" then "(uuid)" from the right. +# simctl line format: " iPad Pro 11-inch (M4) (UUID) (Shutdown)" xcrun simctl list devices available 2>/dev/null \ | awk '/iPad/ { line=$0 diff --git a/scripts/ios/xcodeproj.sh b/scripts/ios/xcodeproj.sh index 2c8fccc..91e5e85 100644 --- a/scripts/ios/xcodeproj.sh +++ b/scripts/ios/xcodeproj.sh @@ -11,17 +11,17 @@ if ! command -v xcodegen >/dev/null 2>&1; then exit 1 fi -# the user has esp-clang on PATH; make sure cc-rs picks apple's. +# force apple clang over esp-clang on PATH. export CC=/usr/bin/clang export CXX=/usr/bin/clang++ export IPHONEOS_DEPLOYMENT_TARGET=17.0 -# build the staticlibs xcode will link against — both arches so xcode can target either. +# build staticlibs for both iOS targets (device + sim). echo "Building Rust staticlibs for both iOS targets (release)..." cargo build --release --target aarch64-apple-ios -p acord-viewport cargo build --release --target aarch64-apple-ios-sim -p acord-viewport -# regenerate the asset catalog so xcode picks up the latest svg. +# regenerate the asset catalog from the latest svg. bash "$ROOT/scripts/ios/generate-icons.sh" cd "$ROOT/ios" diff --git a/scripts/linux/build.sh b/scripts/linux/build.sh index 1325881..f850ccb 100644 --- a/scripts/linux/build.sh +++ b/scripts/linux/build.sh @@ -10,17 +10,12 @@ case "$(uname -s)" in *) echo "wrong platform: $(uname -s) — use cargo xtask build" >&2; exit 1;; esac -# Pick the winit backend(s). Default builds enable both x11 and wayland so a -# single binary works on either; ACORD_FEATURES overrides for cases where -# only one backend is available (flatpak, stripped distros, debugging one -# backend in isolation). +# pick winit backend(s), defaulting to both x11 and wayland unless ACORD_FEATURES overrides. detect_features() { if [ -n "${ACORD_FEATURES:-}" ]; then echo "$ACORD_FEATURES" return fi - # No detection — both backends are linked by default. Override only when - # you need to force one. echo "" } @@ -41,7 +36,7 @@ mkdir -p "$STAGE" cp "$CARGO_TARGET_DIR/release/acord" "$STAGE/Acord" chmod +x "$STAGE/Acord" -# Rasterize the SVG icon next to the binary so load_window_icon picks it up. +# rasterize the SVG icon next to the binary. if command -v rsvg-convert >/dev/null 2>&1 && [ -f "$ROOT/assets/Acord.svg" ]; then rsvg-convert --width 256 --height 256 "$ROOT/assets/Acord.svg" -o "$STAGE/icon.png" else diff --git a/scripts/linux/debug.sh b/scripts/linux/debug.sh index 67c9af0..f4fe1f3 100644 --- a/scripts/linux/debug.sh +++ b/scripts/linux/debug.sh @@ -1,8 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -# Debug build — same wiring as build.sh but unoptimised, with -g, and -# launched in the foreground so Rust panics print straight to this terminal. +# unoptimised build with -g, launched in the foreground. ROOT="$(cd "$(dirname "$0")/../.." && pwd)" source "$ROOT/scripts/_build-dirs.sh" @@ -23,7 +22,7 @@ fi EXE="$CARGO_TARGET_DIR/debug/acord" -# Rasterize the icon next to the exe so the dev binary has a window icon too. +# rasterize the icon next to the exe. if command -v rsvg-convert >/dev/null 2>&1 && [ -f "$ROOT/assets/Acord.svg" ]; then rsvg-convert --width 256 --height 256 "$ROOT/assets/Acord.svg" -o "$CARGO_TARGET_DIR/debug/icon.png" fi diff --git a/scripts/linux/install.sh b/scripts/linux/install.sh index 01783ea..56e8604 100644 --- a/scripts/linux/install.sh +++ b/scripts/linux/install.sh @@ -12,15 +12,14 @@ esac bash "$ROOT/scripts/linux/build.sh" -# XDG-correct install: binary into ~/.local/bin (PATH on most distros), icon -# + .desktop into ~/.local/share for the launcher menu. +# XDG-correct install: binary, icon, and .desktop entry into ~/.local/. BIN_DIR="${XDG_BIN_HOME:-$HOME/.local/bin}" APP_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/applications" ICON_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/icons/hicolor/256x256/apps" mkdir -p "$BIN_DIR" "$APP_DIR" "$ICON_DIR" -# Kill running instance before replacing the binary. +# kill running instance before replacing the binary. pkill -x Acord 2>/dev/null || true sleep 0.3 @@ -42,8 +41,7 @@ Categories=Utility;TextEditor;Office; MimeType=text/markdown;text/plain; EOF -# Update the desktop database so the launcher picks up the new entry. Quiet -# fallback if the tool isn't installed. +# update the desktop database, skipping quietly if the tool isn't installed. if command -v update-desktop-database >/dev/null 2>&1; then update-desktop-database "$APP_DIR" >/dev/null 2>&1 || true fi diff --git a/scripts/macos/build-universal.sh b/scripts/macos/build-universal.sh index 350681b..5168576 100644 --- a/scripts/macos/build-universal.sh +++ b/scripts/macos/build-universal.sh @@ -30,7 +30,7 @@ lipo -create \ "$CARGO_TARGET_DIR/x86_64-apple-darwin/release/libacord_viewport.a" \ -output "$CARGO_TARGET_DIR/universal/libacord_viewport.a" -# TODO: regenerate AppIcon.icns from assets/Acord.svg here (see build.sh). +# TODO(icon): regenerate AppIcon.icns from assets/Acord.svg here. mkdir -p "$MACOS" "$RESOURCES" cp "$ROOT/macos/Info.plist" "$CONTENTS/Info.plist" diff --git a/scripts/macos/debug.sh b/scripts/macos/debug.sh index 36b0173..cb732d8 100644 --- a/scripts/macos/debug.sh +++ b/scripts/macos/debug.sh @@ -1,9 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -# Debug build — same wiring as build.sh but unoptimised, with -g, and -# launched in the foreground so Rust panics print straight to this terminal -# (the panic hook in viewport/src/lib.rs flushes stderr before SIGABRT). +# unoptimised build with -g, launched in the foreground. ROOT="$(cd "$(dirname "$0")/../.." && pwd)" source "$ROOT/scripts/_build-dirs.sh" diff --git a/scripts/macos/install.sh b/scripts/macos/install.sh index 4e7265d..ca968d0 100644 --- a/scripts/macos/install.sh +++ b/scripts/macos/install.sh @@ -14,7 +14,7 @@ DEST="/Applications/Acord.app" bash "$ROOT/scripts/macos/build.sh" -# Kill running instance before replacing. +# kill running instance pkill -f "Acord.app/Contents/MacOS/Acord" 2>/dev/null || true sleep 0.5 diff --git a/scripts/macos/package.sh b/scripts/macos/package.sh index 7b7cd60..431c8cb 100644 --- a/scripts/macos/package.sh +++ b/scripts/macos/package.sh @@ -1,21 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -# Cross-compile + zip distributables from a single macOS host. -# -# Six targets: -# macos-aarch64 macos-x86_64 -# windows-aarch64 windows-x86_64 -# linux-aarch64 linux-x86_64 -# -# Output: dist/acord-.zip per target. -# -# Tooling: -# - rustup, swiftc, zip, codesign — assumed present on a dev mac -# - rsvg-convert — brew install librsvg (for the macOS app icon) -# - zig + cargo-zigbuild — used for windows/linux cross-compile -# brew install zig -# cargo install cargo-zigbuild +# cross-compile + zip distributables for all six platform targets from a macOS host ROOT="$(cd "$(dirname "$0")/../.." && pwd)" source "$ROOT/scripts/_build-dirs.sh" @@ -26,7 +12,7 @@ case "$(uname -s)" in *) echo "package.sh: macOS host only (need swiftc + codesign)" >&2; exit 1;; esac -# raises the per-process file descriptor limit for the linker +# raise per-process file descriptor limit ulimit -n 65536 2>/dev/null || ulimit -n 8192 2>/dev/null || true ALL_TARGETS=( @@ -78,8 +64,7 @@ PKG="$ROOT/build/package" DIST="$ROOT/dist" mkdir -p "$PKG" "$DIST" -# Generate a 256px PNG once, reused by Windows + Linux. macOS uses a separate -# .icns set generated below. +# generate a 256px PNG for Windows + Linux icons ICON_PNG="$ROOT/build/icon.png" if [ ! -f "$ICON_PNG" ] || [ "$ROOT/assets/Acord.svg" -nt "$ICON_PNG" ]; then if command -v rsvg-convert >/dev/null 2>&1 && [ -f "$ROOT/assets/Acord.svg" ]; then @@ -207,8 +192,7 @@ build_linux() { echo "==> Linux $arch (${rust_target}.2.17 via cargo-zigbuild — both x11+wayland linked)" - # The .2.17 suffix targets glibc 2.17 (CentOS 7 baseline) for max distro - # compatibility. zigbuild handles the symbol versioning via zig cc. + # .2.17 suffix pins glibc 2.17 (CentOS 7 baseline) cargo zigbuild --release -p acord-linux --target "${rust_target}.2.17" local stage="$PKG/linux-${arch}/acord" @@ -221,9 +205,7 @@ build_linux() { [ -f "$ROOT/LICENCE" ] && cp "$ROOT/LICENCE" "$stage/LICENCE" [ -f "$ROOT/README.md" ] && cp "$ROOT/README.md" "$stage/README.md" - # Self-contained installer the user runs after unzipping. Outer EOF is - # single-quoted so $BIN_DIR / $HERE / etc. survive into the install - # script verbatim and expand only when the user runs it. + # single-quoted heredoc preserves $ expansions for the generated install script cat > "$stage/install.sh" <<'INSTALLER_EOF' #!/usr/bin/env bash set -euo pipefail diff --git a/scripts/windows/build.ps1 b/scripts/windows/build.ps1 index 0b8b9ee..ca2c2a9 100644 --- a/scripts/windows/build.ps1 +++ b/scripts/windows/build.ps1 @@ -10,8 +10,7 @@ if ($LASTEXITCODE -ne 0) { throw "cargo build failed" } $exe = Join-Path $root "target\release\acord.exe" if (-not (Test-Path $exe)) { throw "binary not found at $exe" } -# Rasterize the SVG icon next to the exe so load_window_icon picks it up. -# Falls back silently if rsvg-convert isn't installed. +# rasterize the SVG icon next to the exe $svg = Join-Path $root "assets\Acord.svg" $png = Join-Path (Split-Path -Parent $exe) "icon.png" if (Test-Path $svg) { diff --git a/scripts/windows/debug.ps1 b/scripts/windows/debug.ps1 index 63aaa36..07df68a 100644 --- a/scripts/windows/debug.ps1 +++ b/scripts/windows/debug.ps1 @@ -11,7 +11,7 @@ if ($LASTEXITCODE -ne 0) { throw "cargo build failed" } $exe = Join-Path $root "target\debug\acord.exe" if (-not (Test-Path $exe)) { throw "binary not found at $exe" } -# Same icon rasterization as build.ps1 — debug builds want the icon too. +# rasterize the SVG icon next to the exe $svg = Join-Path $root "assets\Acord.svg" $png = Join-Path (Split-Path -Parent $exe) "icon.png" if (Test-Path $svg) { @@ -20,8 +20,6 @@ if (Test-Path $svg) { } } -# Foreground exec so panic output (RUST_BACKTRACE=1) lands in this terminal -# rather than vanishing on a detached console. Debug builds use the -# `console` subsystem by default so stderr is wired up automatically. +# foreground exec, panic output stays in the terminal Write-Host "Launching $exe ..." & $exe diff --git a/viewport/Cargo.toml b/viewport/Cargo.toml index 9c225cf..d23effc 100644 --- a/viewport/Cargo.toml +++ b/viewport/Cargo.toml @@ -26,6 +26,7 @@ zip = { version = "2", default-features = false, features = ["deflate"] } base64 = "0.22" ureq = "3" filetime = "0.2" +regex = "1" printpdf = { version = "0.9", default-features = false } [target.'cfg(not(target_os = "ios"))'.dependencies] diff --git a/viewport/include/acord.h b/viewport/include/acord.h index 7263ea1..80fdeba 100644 --- a/viewport/include/acord.h +++ b/viewport/include/acord.h @@ -40,7 +40,7 @@ #define USER_IDENT_HOP 3 /** - * Owns the browser window's wgpu surface, iced renderer, and BrowserState. + * wgpu surface, iced renderer, and state for one browser window. */ typedef struct BrowserHandle BrowserHandle; @@ -85,7 +85,7 @@ char *viewport_get_text(struct ViewportHandle *handle); void viewport_free_string(char *s); /** - * returns the archive zip bytes (or null when empty); writes the length to len_out. + * returns the archive zip bytes or null; writes the length to len_out. */ uint8_t *viewport_take_sidecar_bytes(struct ViewportHandle *handle, uintptr_t *len_out); @@ -102,11 +102,9 @@ void viewport_apply_sidecar_bytes(struct ViewportHandle *handle, void viewport_free_bytes(uint8_t *ptr, uintptr_t len); /** - * renders the current document as a printable PDF; returns owned bytes the shell must free with `viewport_free_bytes`. + * renders the document to PDF bytes; free with viewport_free_bytes. */ -uint8_t *viewport_render_pdf(struct ViewportHandle *handle, - const char *title, - uintptr_t *out_len); +uint8_t *viewport_render_pdf(struct ViewportHandle *handle, const char *title, uintptr_t *out_len); void viewport_set_theme(struct ViewportHandle *handle, const char *name); @@ -129,10 +127,7 @@ void viewport_set_settings_view(struct ViewportHandle *handle, char *viewport_take_shell_action(struct ViewportHandle *handle); /** - * Export the note as a standalone Rust crate at `out_dir/name/`. Returns - * a heap-allocated C string on success (the absolute path of the created - * folder), or null on failure. Free the returned string with - * `viewport_free_string`. + * exports the note as a Rust crate at out_dir/name/; returns the path or null. */ char *viewport_export_crate(struct ViewportHandle *handle, const char *out_dir, const char *name); @@ -167,7 +162,7 @@ char *browser_take_pending_open(struct BrowserHandle *handle); void browser_refresh(struct BrowserHandle *handle); /** - * dispatches a numeric zoom command into the browser's scale state. + * dispatches a zoom command into the browser scale state. */ void browser_send_command(struct BrowserHandle *handle, uint32_t command); diff --git a/viewport/src/block.rs b/viewport/src/block.rs index e585d89..1e6f726 100644 --- a/viewport/src/block.rs +++ b/viewport/src/block.rs @@ -1,18 +1,4 @@ -//! The `Block` trait that all block kinds implement. -//! -//! Each concrete struct (TextBlock, TableBlock, HeadingBlock, HrBlock, -//! TreeBlock) keeps its own internal data but exposes a common interface for -//! iteration, dispatch, selection participation, hit-testing, and serialization. -//! -//! Two rules apply throughout the trait: -//! -//! 1. **Iteration over recursion.** `selectable_paths` returns an iterator and -//! must be implemented without self-recursion. When nesting (cells containing -//! blocks) lands, this protects deep documents from stack overflow. -//! 2. **Layered draw order.** `view` returns a `LayeredView`, not a single -//! element. The document compositor merges overlays from every block into -//! shared layers, so cursorline (Below) and selection borders (Above) end up -//! in the right z-order regardless of where they were declared. +//! block trait and layered view compositor interface. use iced_wgpu::core::{Element, Point, Theme}; use crate::text_widget; @@ -20,48 +6,36 @@ use crate::text_widget; use crate::selection::{BlockId, InnerPath, NodePath, Selection}; use crate::table_block::TableMessage; -/// Z-ordering for the document compositor. Explicit draw order decoupled from -/// the structural list, so overlays render in the right place regardless of -/// where they were declared. +/// z-ordering layers for the document compositor. #[repr(u8)] #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum Layer { - /// Drawn behind block content. Cursorline highlights, selection background tints. + /// cursorline highlights, selection background tints. Below = 0, - /// The block's structural body. Most blocks emit their main element here. + /// structural body of the block. Content = 1, - /// Drawn in front of content. Selection borders, focus rings, eval result tags. + /// selection borders, focus rings, eval result tags. Above = 2, - /// The very top. Drag previews, ghosted reorder anchors. + /// drag previews, ghosted reorder anchors. Floating = 3, } -/// What a block returns from `view`. The compositor merges layered output across -/// all blocks, drawing in layer order rather than source order. +/// layered output from a block's view, merged by the compositor in layer order. pub struct LayeredView<'a, Message> { - /// The block's main structural element. Always rendered in document order - /// in a column; conceptually at `Layer::Content`. + /// main structural element, rendered in document order. pub base: Element<'a, Message, Theme, iced_wgpu::Renderer>, - /// Decorative overlays tagged with their target layer. + /// overlays tagged with their target layer. pub overlays: Vec<(Layer, Element<'a, Message, Theme, iced_wgpu::Renderer>)>, } impl<'a, Message> LayeredView<'a, Message> { - /// Convenience for blocks with no overlays (HR, headings without cursorline). + /// constructs a LayeredView with no overlays. pub fn just(base: Element<'a, Message, Theme, iced_wgpu::Renderer>) -> Self { Self { base, overlays: Vec::new() } } } -/// Shared rendering context passed by reference into every `Block::view` call. -/// Instead of embedding selection or focus state on every block, blocks query a -/// shared context. -/// -/// `on_text_action` and `on_table_msg` are plain function pointers, not boxed -/// closures: the editor only needs message constructors that wrap an index, no -/// captured state. This makes `ViewCtx` cheap to copy and avoids the -/// invariant-lifetime trap that capturing closures would impose on the trait -/// `view` signature. +/// shared rendering context passed into every Block::view call. pub struct ViewCtx<'a, Message: 'a> { pub block_index: usize, pub selection: &'a Selection, @@ -71,19 +45,14 @@ pub struct ViewCtx<'a, Message: 'a> { pub is_dark: bool, pub on_text_action: fn(usize, text_widget::Action) -> Message, pub on_table_msg: fn(usize, TableMessage) -> Message, - /// Computed values for cells whose raw text is a `/=...` formula. - /// Keyed by (table block id, col, row). A `Some(Value)` here means: - /// show the computed display form when not editing; a missing entry - /// means render the cell's raw text. + /// evaluated formula results keyed by (block id, col, row). pub computed_cells: &'a std::collections::HashMap< (BlockId, u32, u32), acord_core::interp::Value, >, } -/// Structural commands that mutate a block. The editor routes a `BlockCommand` -/// to a specific block based on the active focus / selection rather than -/// dispatching kind-specific messages directly. +/// structural mutation commands routed to a specific block by the editor. #[derive(Debug, Clone)] pub enum BlockCommand { // Table commands @@ -106,68 +75,39 @@ pub enum BlockCommand { Clear, } -/// The protocol every block kind implements. -/// -/// Generic over `Message` so concrete impls can plug in the editor's message -/// type without `block.rs` taking a hard dependency on `editor.rs`. The trait -/// stays dyn-compatible because the generic is at the trait level (not on -/// individual methods) — `Box>` works. +/// protocol every block kind implements, generic over the editor's message type. pub trait Block { fn id(&self) -> BlockId; fn kind_tag(&self) -> &'static str; - /// Document-relative line where this block begins. Maintained by - /// `recount_lines` after any structural mutation. + /// document-relative line where the block begins. fn start_line(&self) -> usize; fn set_start_line(&mut self, line: usize); - /// Line count this block contributes to the document. For text blocks - /// this is the editor `Content::line_count()`; for tables it's - /// `rows.len() + 1` (header + separator + data); fixed at 1 for - /// headings, HRs, and trees. + /// number of lines the block contributes to the document. fn line_count(&self) -> usize; - /// True if this block was produced by an eval `/=` table result rather - /// than parsed from markdown. Drives read-only behaviour and skips - /// markdown serialization. Defaults to false; only `TableBlock` overrides. + /// marks blocks produced by eval rather than parsed from markdown. fn is_eval_result(&self) -> bool { false } - /// Downcast hooks for the editor to access kind-specific fields - /// (`TextBlock::content`, `TableBlock::rows`, ...) from a `Box`. - /// Every concrete impl just returns `self` — these are required because - /// `Block` is generic over `Message` so we can't use Rust's trait - /// upcasting to `Any` directly. + /// downcast to concrete block types. fn as_any(&self) -> &dyn std::any::Any; fn as_any_mut(&mut self) -> &mut dyn std::any::Any; - /// Render the block. `ctx` carries selection / focus / theme / font as - /// shared state. Returns layered output rather than a single element so - /// the document compositor can merge overlays from multiple blocks into - /// shared layers. - /// - /// `ctx`'s lifetime is independent from the returned `LayeredView`'s `'a` - /// (which is tied to `&self`). Implementations must read what they need - /// from `ctx` eagerly into Copy locals — they may NOT capture `ctx` into - /// the returned element. This is what lets the editor's `view_blocks` build - /// a fresh per-iteration `ViewCtx` on the stack and return elements that - /// outlive the loop. + /// renders the block into layered output for the compositor. fn view<'a>(&'a self, ctx: &ViewCtx<'_, Message>) -> LayeredView<'a, Message>; - /// Markdown serialization. Rich side-channel state (col widths, row - /// heights, cell formulas) is serialized separately into the embedded - /// sidecar archive — this method returns plain markdown only. + /// serializes the block as plain markdown. fn to_md(&self) -> String; - /// Cursor coordinate (in this block's local space) -> innermost selectable - /// path. Returns `None` if the point doesn't hit anything selectable. + /// resolves a local-space point to the innermost selectable path. fn hit_test(&self, point: Point) -> Option; - /// Apply a structural mutation. Unsupported commands are silently ignored. + /// applies a structural mutation, ignoring unsupported commands. fn apply(&mut self, cmd: BlockCommand); - /// Iterate (NOT recurse) over all selectable inner paths in this block. - /// MUST be iterative — when nesting lands this gets exercised on deep trees. + /// iterates all selectable inner paths in the block (non-recursive). fn selectable_paths(&self) -> Box + '_>; } diff --git a/viewport/src/blocks.rs b/viewport/src/blocks.rs index 413debe..5c3826f 100644 --- a/viewport/src/blocks.rs +++ b/viewport/src/blocks.rs @@ -1,9 +1,4 @@ -//! Document parsing and block-list utilities. -//! -//! Owns the markdown -> `Vec` parser plus the round-trip helpers -//! (serialize, recount lines, locate by line). Every block kind lives in its -//! own module behind the `Block` trait; this file deals with document-wide -//! concerns only. +//! markdown to block-list parser and document-wide utilities. use std::sync::atomic::{AtomicU64, Ordering}; @@ -22,7 +17,7 @@ pub fn next_id() -> u64 { NEXT_BLOCK_ID.fetch_add(1, Ordering::Relaxed) } -/// Split text into lines, preserving trailing empty lines that `str::lines()` drops. +/// splits text into lines, preserving trailing empty lines. fn split_lines(text: &str) -> Vec<&str> { let mut lines: Vec<&str> = text.lines().collect(); if text.ends_with('\n') { @@ -231,11 +226,7 @@ pub fn parse_blocks(text: &str, lang: &str) -> Vec { blocks } -/// Incremental reparse: compare existing block kinds + spans to the new -/// classification, reuse boxed instances where the slot matches, rebuild the -/// rest. Preserves cursor state for unchanged text blocks because the -/// `text_editor::Content` instance is moved (not recreated) when both the -/// kind tag and the line span match. +/// reparses blocks incrementally, reusing existing instances at matching slots. pub fn reparse_incremental(old_blocks: &mut Vec, text: &str, lang: &str) { let lines: Vec<&str> = if text.is_empty() { Vec::new() @@ -255,12 +246,7 @@ pub fn reparse_incremental(old_blocks: &mut Vec, text: &str, lang: & .collect(); if old_descriptors == new_descriptors { - // Same structure: update text content in place for text blocks; for - // non-text kinds, rebuild from the span only when the serialized form - // actually changed — preserves heading/table/tree internal state on - // typical edits while still picking up content swaps (e.g. opening - // a note whose first heading lives at the same line as the previous - // note's heading). + // same structure: update text in place, rebuild non-text only on content change for (block, span) in old_blocks.iter_mut().zip(spans.iter()) { block.set_start_line(span.start); let block_text = lines[span.start..span.end].join("\n"); @@ -278,9 +264,7 @@ pub fn reparse_incremental(old_blocks: &mut Vec, text: &str, lang: & return; } - // Structure changed: rebuild, reusing blocks at matching positions. Match - // is by (kind_tag, start_line); the boxed instance is `mem::replace`'d so - // any preserved state (text editor cursor, table focus, drag) survives. + // structure changed: rebuild, reusing blocks at matching (kind_tag, start_line) positions let placeholder = || -> BoxedBlock { Box::new(TextBlock::new(0, "", 0, String::new())) }; @@ -328,8 +312,7 @@ fn span_kind_tag(kind: SpanKind) -> &'static str { } } -/// Serialize blocks back to document text. Eval-result tables and trees are -/// skipped — they're regenerated from source on every load. +/// serializes blocks back to document text, skipping eval-result tables and trees. pub fn serialize_blocks(blocks: &[BoxedBlock]) -> String { let mut parts = Vec::new(); for block in blocks { @@ -341,8 +324,7 @@ pub fn serialize_blocks(blocks: &[BoxedBlock]) -> String { continue; } let md = block.to_md(); - // For text blocks, push even empty strings — they preserve the empty - // line gap between adjacent non-text blocks. + // empty text blocks preserve line gaps between adjacent non-text blocks if tag == "text" || !md.is_empty() { parts.push(md); } @@ -350,7 +332,7 @@ pub fn serialize_blocks(blocks: &[BoxedBlock]) -> String { parts.join("\n") } -/// Update document-relative start_line on every block based on its line_count. +/// recalculates document-relative start_line on every block. pub fn recount_lines(blocks: &mut [BoxedBlock]) { let mut line = 0; for block in blocks.iter_mut() { @@ -359,7 +341,7 @@ pub fn recount_lines(blocks: &mut [BoxedBlock]) { } } -/// Find the block index containing a given global line number. +/// finds the block index containing a given global line number. pub fn block_at_line(blocks: &[BoxedBlock], global_line: usize) -> Option { for (i, block) in blocks.iter().enumerate() { let start = block.start_line(); diff --git a/viewport/src/bridge.rs b/viewport/src/bridge.rs index 5b3d06d..15809f2 100644 --- a/viewport/src/bridge.rs +++ b/viewport/src/bridge.rs @@ -11,12 +11,7 @@ pub fn push_mouse_event(handle: &mut ViewportHandle, x: f32, y: f32, button: u8, handle.events.push(Event::Mouse(mouse::Event::CursorMoved { position })); - // Sentinel: button == 255 means "pointer move only — do not fire any - // ButtonPressed/Released event." Used by mouseMoved and mouseDragged in - // the Swift shell. Without this, every drag tick would re-fire - // ButtonPressed(Left) and iced's text_editor would interpret each tick as - // a new click, restarting the active selection on every frame and making - // click+drag selection twitch / over-highlight. + // button==255 sentinel: pointer move only, no press/release event if button == 255 { return; } diff --git a/viewport/src/browser/handle.rs b/viewport/src/browser/handle.rs index a570db9..0fbf9ea 100644 --- a/viewport/src/browser/handle.rs +++ b/viewport/src/browser/handle.rs @@ -21,7 +21,7 @@ use crate::palette; use super::state::{BrowserMessage, BrowserState}; use super::ui; -/// Owns the browser window's wgpu surface, iced renderer, and BrowserState. +/// wgpu surface, iced renderer, and state for one browser window. pub struct BrowserHandle { pub surface: wgpu::Surface<'static>, pub device: wgpu::Device, @@ -40,7 +40,7 @@ pub struct BrowserHandle { pub needs_redraw: bool, } -/// The browser doesn't read or write the system clipboard. +/// clipboard stub, discards all reads and writes. struct NoopClipboard; impl clipboard::Clipboard for NoopClipboard { @@ -48,7 +48,7 @@ impl clipboard::Clipboard for NoopClipboard { fn write(&mut self, _kind: clipboard::Kind, _contents: String) {} } -/// Caller must keep the underlying winit Window alive for the surface's lifetime. +/// creates the wgpu surface, iced renderer, and browser state from raw window handles. pub fn create( raw_display: RawDisplayHandle, raw_window: RawWindowHandle, @@ -61,7 +61,7 @@ pub fn create( let backends = wgpu::Backends::METAL; #[cfg(target_os = "windows")] let backends = wgpu::Backends::DX12; - // accept GL alongside Vulkan so distros without a Vulkan ICD still get a browser surface. + // Vulkan + GL fallback #[cfg(not(any(target_os = "macos", target_os = "windows")))] let backends = wgpu::Backends::VULKAN | wgpu::Backends::GL; @@ -156,7 +156,7 @@ pub fn create( }) } -/// One frame: drains pending events into messages, applies them, then redraws. +/// drains pending events into messages, applies state updates, and redraws. pub fn render(handle: &mut BrowserHandle) { let pending = !handle.events.is_empty(); if !handle.needs_redraw && !pending { @@ -174,7 +174,7 @@ pub fn render(handle: &mut BrowserHandle) { .events .push(Event::Window(window::Event::RedrawRequested(iced_wgpu::core::time::Instant::now()))); - // pre-scans events so the modifier and cursor state are visible to message handlers fired this frame. + // pre-scan: update modifier and cursor state before message dispatch for ev in &handle.events { match ev { Event::Keyboard(iced_wgpu::core::keyboard::Event::ModifiersChanged(m)) => { diff --git a/viewport/src/browser/model.rs b/viewport/src/browser/model.rs index 45124a0..3bb50b4 100644 --- a/viewport/src/browser/model.rs +++ b/viewport/src/browser/model.rs @@ -22,7 +22,7 @@ pub struct BrowserItem { pub preview_lines: Vec, } -/// Folders first, then files; both in date-modified descending order. +/// sorts folders above 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() }; @@ -81,7 +81,6 @@ pub fn scan_directory(dir: &Path) -> Vec { } pub fn file_preview(path: &Path) -> String { - // bytes-first so the binary archive trailer doesn't trip the utf-8 decode. let Ok(bytes) = std::fs::read(path) else { return String::new() }; let (text_bytes, _) = crate::sidecar::extract_from_md(&bytes); let text = String::from_utf8_lossy(&text_bytes); @@ -130,7 +129,7 @@ fn strip_sidecar_archive(text: &str) -> &str { } } -/// True when the body has nothing but whitespace, separator rows, or default-header tables. +/// detects bodies containing only whitespace, separator rows, or default-header tables. fn body_looks_blank(body: &str) -> bool { let trimmed = body.trim(); if trimmed.is_empty() { return true; } @@ -176,7 +175,7 @@ pub fn rename(item_path: &Path, new_name: &str, is_file: bool) -> std::io::Resul Ok(dest) } -/// Copies the file to a sibling with a `name N.ext` suffix, picking the lowest free N. +/// copies the file to a sibling with a name N.ext suffix, picking the lowest free N. pub fn duplicate(item_path: &Path) -> std::io::Result { let parent = item_path.parent().unwrap_or_else(|| Path::new("")); let stem = item_path.file_stem().and_then(|s| s.to_str()).unwrap_or("copy"); @@ -207,14 +206,10 @@ pub fn move_into(item_path: &Path, folder: &Path) -> std::io::Result { Ok(dest) } -/// Bumps each path's mtime so it sorts immediately above `anchor` in date-descending order. -/// Items keep their relative order: the first path in `items` lands closest above the anchor. +/// bumps each path's mtime to sort immediately above the anchor in date-descending order. pub fn reorder_before(items: &[PathBuf], anchor: &Path) -> std::io::Result<()> { let anchor_meta = std::fs::metadata(anchor)?; let anchor_mtime = anchor_meta.modified().unwrap_or(SystemTime::UNIX_EPOCH); - // Spread the dragged items across one second of mtime above the anchor. - // The earliest in `items` gets the highest mtime so it sorts first under - // descending order — matches the user's drag-order expectation. let n = items.len(); if n == 0 { return Ok(()); } let step_ms: u64 = (1000 / n.max(1) as u64).max(1); @@ -227,8 +222,7 @@ pub fn reorder_before(items: &[PathBuf], anchor: &Path) -> std::io::Result<()> { Ok(()) } -/// Bumps each path's mtime above every existing item in `parent`, preserving drag order. -/// Used when dropping items at the very top of the grid. +/// bumps each path's mtime above every existing item in the parent, preserving drag order. pub fn reorder_to_top(items: &[PathBuf], parent: &Path) -> std::io::Result<()> { let mut max_mtime = SystemTime::UNIX_EPOCH; if let Ok(entries) = std::fs::read_dir(parent) { @@ -263,8 +257,7 @@ pub fn create_folder(parent: &Path) -> std::io::Result { Ok(dest) } -/// Creates a fresh folder next to `items` and moves each one inside. -/// Items already living in the destination are skipped to avoid same-name self-moves. +/// creates a fresh folder in parent and moves each item inside, skipping same-directory paths. pub fn create_folder_with_items(parent: &Path, items: &[PathBuf]) -> std::io::Result { let folder = create_folder(parent)?; for item in items { @@ -274,7 +267,7 @@ pub fn create_folder_with_items(parent: &Path, items: &[PathBuf]) -> std::io::Re Ok(folder) } -/// Sends the path to the OS trash; falls back to permanent delete on platforms without trash support. +/// sends the path to the OS trash, falling back to permanent delete on unsupported platforms. pub fn trash(item_path: &Path) -> std::io::Result<()> { match trash_crate_remove(item_path) { Ok(()) => Ok(()), diff --git a/viewport/src/browser/preview.rs b/viewport/src/browser/preview.rs index 7b2c9af..d9a8428 100644 --- a/viewport/src/browser/preview.rs +++ b/viewport/src/browser/preview.rs @@ -17,6 +17,7 @@ pub fn highlight_preview(source: &str) -> Vec { let settings = SyntaxSettings { lang: "rust".to_string(), source: source.to_string(), + user_idents: crate::syntax::scan_user_idents_in(source), }; let mut highlighter = SyntaxHighlighter::new(&settings); diff --git a/viewport/src/browser/state.rs b/viewport/src/browser/state.rs index 2fe1472..5924ec4 100644 --- a/viewport/src/browser/state.rs +++ b/viewport/src/browser/state.rs @@ -15,7 +15,7 @@ pub struct BrowserState { pub scale: f32, pub renaming: Option, pub rename_text: String, - /// holds the next path the host shell should open; drained each frame. + /// next path the host shell should open, drained each frame. pub pending_open: Option, pub context_menu: Option, pub drag: Option, @@ -31,7 +31,7 @@ pub struct DragState { pub items: Vec, pub start: Point, pub current: Point, - /// false until the cursor has moved past DRAG_THRESHOLD_PX from start. + /// set once the cursor moves past DRAG_THRESHOLD_PX from start. pub active: bool, pub hover: Option, } @@ -51,7 +51,7 @@ impl DragState { #[derive(Debug, Clone)] pub struct ContextMenu { pub anchor: Point, - /// None when the right-click landed between cards. + /// absent for right-clicks between cards. pub target: Option, } @@ -138,8 +138,7 @@ impl BrowserState { let plain_press = !mods.command() && !mods.shift(); let already_selected = self.selected.contains(&path) && self.selected.len() >= 1; if plain_press && already_selected && self.renaming.is_none() { - // Path is already part of the selection; arm a drag instead - // of collapsing the selection to just this one card. + // arm a drag rather than collapsing a multi-selection. let items: Vec = self.selected.iter().cloned().collect(); self.drag = Some(DragState { items, @@ -348,7 +347,7 @@ impl BrowserState { self.selected.contains(&item.path) } - /// True when `item` is part of an active (cursor moved past threshold) drag. + /// active-drag membership check for the given item. pub fn is_dragging(&self, item: &BrowserItem) -> bool { match self.drag.as_ref() { Some(d) if d.active => d.items.iter().any(|p| p == &item.path), @@ -356,8 +355,7 @@ impl BrowserState { } } - /// True when `item` is the currently hovered drop target during an active drag, - /// and dropping there would actually do something (target isn't part of the drag). + /// valid drop-target check for the given item during an active drag. pub fn is_drop_target(&self, item: &BrowserItem) -> bool { match self.drag.as_ref() { Some(d) if d.active => match &d.hover { @@ -372,7 +370,7 @@ impl BrowserState { item.kind == BrowserItemKind::File } - /// True when a context menu was opened on an item that's part of the live selection. + /// context menu target overlaps the live selection. pub fn context_acts_on_selection(&self) -> bool { match self.context_menu.as_ref().and_then(|m| m.target.as_ref()) { Some(t) => self.selected.contains(&t.item_path), diff --git a/viewport/src/browser/ui.rs b/viewport/src/browser/ui.rs index 199777b..bd645a3 100644 --- a/viewport/src/browser/ui.rs +++ b/viewport/src/browser/ui.rs @@ -28,9 +28,7 @@ pub fn view(state: &BrowserState) -> Element<'_, BrowserMessage, Theme, iced_wgp responsive(|size| scrollable(grid(state, size)).height(Length::Fill).into()).into() }; - // Captures right-clicks between cards plus drag motion/release that - // happens off-card. Cards have their own on_right_press / on_press, so - // this body-level mouse_area only sees the gaps for those. + // body-level mouse_area catches inter-card right-clicks and drag motion/release. let body: Element<_, _, _> = mouse_area(body_inner) .on_right_press(BrowserMessage::ShowEmptyContextMenu) .on_move(BrowserMessage::DragMove) @@ -134,7 +132,7 @@ fn empty_state() -> Element<'static, BrowserMessage, Theme, iced_wgpu::Renderer> .into() } -/// picks the column count whose card-width sits closest to the scale-adjusted target. +/// picks the column count with card-width closest to the scale-adjusted target. fn columns_for_width(avail_w: f32, scale: f32) -> usize { let target = TARGET_CARD_W * scale; let min_w = MIN_CARD_W * scale; @@ -451,8 +449,7 @@ fn positioned_menu<'a>( .into() } -/// renders the unified menu used by both the context menu and the menu bar. -/// `full` decides whether to show selection-dependent items beyond New Folder. +/// renders the context/menu-bar menu, optionally including selection-dependent items. fn menu_column<'a>( state: &'a BrowserState, full: bool, diff --git a/viewport/src/editor/content.rs b/viewport/src/editor/content.rs index f6244b8..46c7922 100644 --- a/viewport/src/editor/content.rs +++ b/viewport/src/editor/content.rs @@ -50,7 +50,7 @@ impl super::EditorState { } } - /// escapes the table at `table_idx` upward into the previous text block + /// escapes the table upward into the previous text block pub(super) fn escape_table_up(&mut self, table_idx: usize) { if table_idx > 0 { if let Some(tb) = self.text_block_at(table_idx - 1) { @@ -82,7 +82,7 @@ impl super::EditorState { self.reparse(); } - /// escapes the table at `table_idx` downward into the next text block + /// escapes the table downward into the next text block pub(super) fn escape_table_down(&mut self, table_idx: usize) { let next_idx = table_idx + 1; if next_idx < self.block_count() { @@ -109,7 +109,7 @@ impl super::EditorState { self.reparse(); } - /// fills the first empty `[]` slot in the editing cell's formula with the clicked cell's address; returns true when the click was consumed. + /// fills the first empty [] slot in the editing cell's formula with the clicked cell's address. pub(super) fn try_fill_formula_slot( &mut self, click_block_idx: usize, @@ -202,7 +202,7 @@ impl super::EditorState { parts.join("\n") } - /// moves the focused content's cursor to `target`, clamping line and column + /// moves the focused content's cursor to the target position, clamping line and column pub(super) fn safe_move_to(&mut self, mut cursor: Cursor) { { let content = self.content(); @@ -332,12 +332,12 @@ impl super::EditorState { } } - /// returns true when a non-eval table has a selected cell + /// index of the focused non-eval table with a selected cell, or None. pub(crate) fn active_table_index(&self) -> Option { self.focused_table_index() } - /// returns true when the focused block is a non-eval table + /// focused block non-eval table check. 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::() { @@ -347,7 +347,7 @@ impl super::EditorState { false } - /// returns true when the focused block is a table in whole-table-select mode + /// focused table whole-selection-mode check. 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::() { @@ -365,7 +365,7 @@ impl super::EditorState { Some((idx, r, tb.rows.len())) } - /// returns the focused block index when it's a table + /// returns the focused block index for a non-eval table with a focused cell. pub(crate) fn focused_table_index(&self) -> Option { let block = self.block_at(self.focused_block)?; let tb = block.as_any().downcast_ref::()?; @@ -376,7 +376,7 @@ impl super::EditorState { } } - /// returns true when the focused block is a table with a focused cell + /// focused-table selected-cell check, excluding edit mode. pub(crate) fn has_selected_cell_not_editing(&self) -> bool { if self.editing.is_some() { return false; @@ -390,7 +390,7 @@ impl super::EditorState { !tb.is_eval_result && tb.focused_cell.is_some() } - /// returns true when Cmd+C should copy the table selection instead of cell text + /// table-selection copy intercept check for Cmd+C. 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; }; diff --git a/viewport/src/editor/eval.rs b/viewport/src/editor/eval.rs index 652f5e5..8abec12 100644 --- a/viewport/src/editor/eval.rs +++ b/viewport/src/editor/eval.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::path::PathBuf; use std::sync::Arc; use std::time::Instant; @@ -83,6 +84,7 @@ impl super::EditorState { let text = self.get_clean_text(); self.parsed = markdown::parse(&text).collect(); self.rebuild_modules(); + self.refresh_text_caches(); } pub(super) fn build_block_infos(&self) -> Vec { @@ -352,7 +354,7 @@ impl super::EditorState { } } - /// returns true when an edit changed the block structure + /// reparses the full text and reconciles block structure changes. pub(super) fn check_block_structure(&mut self) { let cursor = self.content().cursor(); let full = self.full_text(); @@ -372,6 +374,7 @@ impl super::EditorState { } } self.rebuild_modules(); + self.refresh_text_caches(); } /// returns the concatenated text of all text blocks in a module @@ -387,7 +390,7 @@ impl super::EditorState { parts.join("\n") } - /// builds an interpreter pre-populated with root and `use`'d module exports + /// builds an interpreter pre-populated with root and use-declared module exports. pub(super) fn build_eval_interpreter(&self, block_idx: usize) -> acord_core::interp::Interpreter { use acord_core::interp; @@ -418,18 +421,11 @@ impl super::EditorState { let text = tb.content.text(); let use_decls = interp::extract_use_declarations(&text); for decl in &use_decls { - if let Some(dep_module) = self.modules.iter().find(|m| m.name == decl.module) { - let mut visited: std::collections::HashSet = std::collections::HashSet::new(); - if !my_module_name.is_empty() { - visited.insert(my_module_name.clone()); - } - let dep_exports = self.resolve_module_exports(dep_module, &mut visited); - match &decl.item { - None => eval_interp.import_all(&dep_exports), - Some(s) if s == "*" => eval_interp.import_all(&dep_exports), - Some(item) => { eval_interp.import_item(&dep_exports, item); } - } + let mut visited: std::collections::HashSet = std::collections::HashSet::new(); + if !my_module_name.is_empty() { + visited.insert(my_module_name.clone()); } + self.apply_use_decl(decl, &mut visited, &mut eval_interp); } } } @@ -438,7 +434,7 @@ impl super::EditorState { eval_interp } - /// recursively evaluates a module with its `use` declarations resolved + /// recursively evaluates a module with use declarations resolved. pub(super) fn resolve_module_exports( &self, module: &crate::module::Module, @@ -464,20 +460,114 @@ impl super::EditorState { let module_text = self.module_source_text(module); let use_decls = interp::extract_use_declarations(&module_text); for decl in &use_decls { - if let Some(dep) = self.modules.iter().find(|m| m.name == decl.module) { - let dep_exports = self.resolve_module_exports(dep, visited); - match &decl.item { - None => interp.import_all(&dep_exports), - Some(s) if s == "*" => interp.import_all(&dep_exports), - Some(item) => { interp.import_item(&dep_exports, item); } - } - } + self.apply_use_decl(decl, visited, &mut interp); } crate::eval::evaluate_document_with_interp(&mut interp, &module_text); interp.exports() } + /// flags use targets the language handles internally. + pub(super) fn is_builtin_use(module: &str) -> bool { + module == "spice" || module.starts_with("spice::") + || module == "ring" || module.starts_with("ring::") + } + + /// merges a single use decl's exports into the given interpreter. + pub(super) fn apply_use_decl( + &self, + decl: &acord_core::interp::UseDecl, + visited: &mut std::collections::HashSet, + target: &mut acord_core::interp::Interpreter, + ) { + if Self::is_builtin_use(&decl.module) { return; } + + if let Some(local) = self.modules.iter().find(|m| m.name == decl.module) { + let exports = self.resolve_module_exports(local, visited); + match &decl.item { + None => target.import_all(&exports), + Some(s) if s == "*" => target.import_all(&exports), + Some(item) => { target.import_item(&exports, item); } + } + return; + } + + self.apply_external_use_decl(decl, visited, target); + } + + /// resolves a sibling-note use decl against either the whole file or a named H2 submodule. + fn apply_external_use_decl( + &self, + decl: &acord_core::interp::UseDecl, + visited: &mut std::collections::HashSet, + target: &mut acord_core::interp::Interpreter, + ) { + use acord_core::interp; + + let cycle_key = format!("__ext__{}", decl.module); + if !visited.insert(cycle_key) { return; } + + let dir = self.notes_dir(); + let path = dir.join(format!("{}.md", decl.module)); + let Ok(bytes) = std::fs::read(&path) else { return }; + let (text_bytes, _archive) = crate::sidecar::extract_from_md(&bytes); + let Ok(raw) = String::from_utf8(text_bytes) else { return }; + + let loaded = crate::sidecar::extract_archive(&raw); + let clean = super::strip_result_lines(&loaded.markdown); + + let lang = self.lang_str(); + let ext_blocks = blocks::parse_blocks(&clean, &lang); + let ext_infos = build_block_infos_from(&ext_blocks); + let ext_modules = crate::module::compute_modules(&ext_infos); + + let submodule_match = decl.item.as_deref().and_then(|item| { + if item == "*" { return None; } + ext_modules.iter().find(|m| m.name == item) + }); + + if let Some(submod) = submodule_match { + let mut nested = interp::Interpreter::new(); + for d in interp::extract_use_declarations(&clean) { + self.apply_use_decl(&d, visited, &mut nested); + } + if !submod.is_root { + if let Some(root) = ext_modules.iter().find(|m| m.is_root) { + let root_text = module_text_from_blocks(root, &ext_blocks); + crate::eval::evaluate_document_with_interp(&mut nested, &root_text); + } + } + let submod_text = module_text_from_blocks(submod, &ext_blocks); + crate::eval::evaluate_document_with_interp(&mut nested, &submod_text); + target.import_all(&nested.exports()); + return; + } + + let mut nested = interp::Interpreter::new(); + for d in interp::extract_use_declarations(&clean) { + self.apply_use_decl(&d, visited, &mut nested); + } + crate::eval::evaluate_document_with_interp(&mut nested, &clean); + let exports = nested.exports(); + match &decl.item { + None => target.import_all(&exports), + Some(s) if s == "*" => target.import_all(&exports), + Some(item) => { target.import_item(&exports, item); } + } + } + + /// resolves the directory sibling notes live in. + pub(super) fn notes_dir(&self) -> PathBuf { + let from_settings = self.settings_view.auto_save_dir.trim(); + if !from_settings.is_empty() { + return PathBuf::from(from_settings); + } + if let Ok(home) = std::env::var("HOME") { + return PathBuf::from(home).join(".acord").join("notes"); + } + PathBuf::from(".") + } + pub(super) fn run_eval(&mut self) { self.rebuild_modules(); @@ -619,14 +709,14 @@ impl super::EditorState { Some(r.text.trim_start_matches(RESULT_PREFIX).trim().to_string()) } - /// reads line `line_idx` from the text block with the given id + /// reads one line from the text block with the given id. pub(super) 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()) } - /// copies `{source} → {value}` to the clipboard + /// copies the source line and result value pair to the clipboard. pub(super) 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, @@ -637,7 +727,7 @@ impl super::EditorState { self.pending_clipboard = Some(format!("{trimmed} {RESULT_PREFIX}{value}")); } - /// copies the result and drops a `let _ = value` line below the source + /// copies the result and inserts a let binding below the source line. pub(super) 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, @@ -676,6 +766,35 @@ impl super::EditorState { } } +/// builds BlockInfos from a free-standing block list. +fn build_block_infos_from(blocks: &[crate::blocks::BoxedBlock]) -> Vec { + use crate::heading_block::HeadingBlock; + use crate::module::BlockInfo; + blocks.iter().map(|block| { + let tag = block.kind_tag(); + let (heading_level, heading_text) = if let Some(hb) = block.as_any().downcast_ref::() { + (hb.level.as_u8(), hb.text.clone()) + } else { + (0, String::new()) + }; + let text_content = if tag == "text" { block.to_md() } else { String::new() }; + BlockInfo { id: block.id(), kind_tag: tag, heading_level, heading_text, text_content } + }).collect() +} + +/// concatenates the text-block sources within a module against an arbitrary block list. +fn module_text_from_blocks(module: &crate::module::Module, blocks: &[crate::blocks::BoxedBlock]) -> String { + let mut parts = Vec::new(); + for &bid in &module.block_ids { + if let Some(block) = blocks.iter().find(|b| b.id() == bid) { + if block.kind_tag() == "text" { + parts.push(block.to_md()); + } + } + } + parts.join("\n") +} + pub(super) fn parse_let_binding(line: &str) -> Option { let rest = line.strip_prefix("let ")?; let eq_pos = rest.find('=')?; diff --git a/viewport/src/editor/find.rs b/viewport/src/editor/find.rs index 4c05859..3d84ff3 100644 --- a/viewport/src/editor/find.rs +++ b/viewport/src/editor/find.rs @@ -4,41 +4,92 @@ impl super::EditorState { pub(super) fn update_find_matches(&mut self) { self.find.matches.clear(); self.find.current = 0; + self.find.regex_error = None; if self.find.query.is_empty() { return; } let text = self.get_clean_text(); + if self.find.regex_mode { + self.update_find_matches_regex(&text); + } else { + self.update_find_matches_plain(&text); + } + } + + fn update_find_matches_plain(&mut self, text: &str) { + use super::types::FindMatch; let query_lower = self.find.query.to_lowercase(); let text_lower = text.to_lowercase(); let mut line = 0usize; let mut col = 0usize; - let mut byte = 0usize; for (i, ch) in text_lower.char_indices() { - while byte < i { - byte += 1; - } if ch == '\n' { line += 1; col = 0; continue; } if text_lower[i..].starts_with(&query_lower) { - self.find.matches.push((line, col)); + let byte_end = text.char_indices() + .skip_while(|(bi, _)| *bi < i) + .skip(query_lower.chars().count()) + .map(|(bi, _)| bi) + .next() + .unwrap_or(text.len()); + self.find.matches.push(FindMatch { + line, col, byte_start: i, byte_end, + }); } col += 1; } } + fn update_find_matches_regex(&mut self, text: &str) { + use super::types::FindMatch; + let re = match regex::Regex::new(&self.find.query) { + Ok(r) => r, + Err(e) => { + self.find.regex_error = Some(format!("{}", e)); + return; + } + }; + let line_starts: Vec<(usize, usize)> = { + let mut starts = vec![(0usize, 0usize)]; + let mut ln = 0usize; + for (i, ch) in text.char_indices() { + if ch == '\n' { + ln += 1; + starts.push((ln, i + 1)); + } + } + starts + }; + + for m in re.find_iter(text) { + let byte_start = m.start(); + let byte_end = m.end(); + let (line, line_start) = line_starts + .iter() + .rev() + .find(|(_, bs)| *bs <= byte_start) + .copied() + .unwrap_or((0, 0)); + let col = text[line_start..byte_start].chars().count(); + self.find.matches.push(FindMatch { + line, col, byte_start, byte_end, + }); + } + } + pub(super) fn navigate_to_match(&mut self) { if self.find.matches.is_empty() { return; } let idx = self.find.current.min(self.find.matches.len() - 1); - let (line, col) = self.find.matches[idx]; + let m = &self.find.matches[idx]; self.safe_move_to(Cursor { - position: Position { line, column: col }, + position: Position { line: m.line, column: m.col }, selection: None, }); } diff --git a/viewport/src/editor/mod.rs b/viewport/src/editor/mod.rs index 740dfa7..44fa6bd 100644 --- a/viewport/src/editor/mod.rs +++ b/viewport/src/editor/mod.rs @@ -113,12 +113,10 @@ impl EditorState { .width(Length::Fill) .padding(Padding { top: 3.0, right: 10.0, bottom: 3.0, left: 10.0 }) .style(move |_theme: &Theme| { - let p = palette::current(); - let darken = |c: Color| Color { r: c.r * 0.45, g: c.g * 0.45, b: c.b * 0.45, a: c.a }; let bg = match render_mode { - RenderMode::Live => darken(p.mauve), - RenderMode::Editor => darken(p.blue), - RenderMode::View => darken(p.pink), + RenderMode::Live => Color::from_rgb(0.643, 0.184, 0.996), + RenderMode::Editor => Color::from_rgb(0.278, 0.745, 0.988), + RenderMode::View => Color::from_rgb(0.996, 0.306, 0.910), }; container::Style { background: Some(Background::Color(bg)), @@ -247,6 +245,7 @@ impl EditorState { let settings = SyntaxSettings { lang: lang_for_block.clone(), source: tb.content.text(), + user_idents: self.cached_user_idents.clone(), }; let editor_el: Element<'_, Message, Theme, iced_wgpu::Renderer> = editor .highlight_with::( @@ -386,12 +385,9 @@ impl EditorState { fn minimap_overlay(&self) -> Option> { if !self.minimap_enabled { return None; } if self.render_mode != RenderMode::Editor { return None; } + if self.cached_minimap_lines.is_empty() { return None; } - let text = self.full_text(); - if text.is_empty() { return None; } - - let lines = crate::minimap::classify_text(&text); - if lines.is_empty() { return None; } + let lines = self.cached_minimap_lines.clone(); let scroll = self.content().scroll_line(); let line_h = self.line_height().max(1.0); @@ -422,7 +418,7 @@ impl EditorState { Some(aligned.into()) } - /// renders the spillover popup of the first table that has one open + /// renders the spillover popup for the first table with an open spillover cell. fn spillover_view(&self) -> Option> { let p = palette::current(); let cell_text = self.layout.iter() @@ -724,6 +720,7 @@ impl EditorState { let settings = SyntaxSettings { lang: lang_for_block, source: tb.content.text(), + user_idents: syntax::scan_user_idents_in(&self.full_text()), }; editor .highlight_with::( @@ -935,7 +932,7 @@ impl EditorState { .into() } - /// returns the dropdown panel for the open category, anchored under its strip button + /// returns the dropdown panel for the open category, anchored under the strip button. #[cfg(any(target_os = "linux", target_os = "windows"))] fn menu_dropdown(&self, cat: MenuCategory) -> Element<'_, Message, Theme, iced_wgpu::Renderer> { let p = palette::current(); @@ -1272,7 +1269,10 @@ impl EditorState { .width(Length::FillPortion(3)) .style(find_input_style); - let match_label = if self.find.matches.is_empty() { + let match_label = if let Some(ref err) = self.find.regex_error { + let short = if err.len() > 20 { format!("{}...", &err[..20]) } else { err.clone() }; + short + } else if self.find.matches.is_empty() { if self.find.query.is_empty() { String::new() } else { @@ -1299,7 +1299,10 @@ impl EditorState { .into() }; + let regex_label = if self.find.regex_mode { ".*" } else { "Aa" }; + let row = iced_widget::row![ + btn(regex_label.into(), Message::ToggleRegex), search_input, label, btn("Prev".into(), Message::FindPrev), @@ -1402,7 +1405,7 @@ fn block_editor_id(block_id: u64) -> WidgetId { } -/// finds the first empty `[]` (whitespace allowed inside) and replaces it with `[]`. +/// finds the first empty [] slot and replaces the contents with the given address. fn splice_first_empty_slot(text: &str, addr: &str) -> Option { let bytes = text.as_bytes(); let mut i = 0; @@ -1526,27 +1529,10 @@ fn lang_from_extension(ext: &str) -> Option { "zig" => "zig", "sql" => "sql", "mk" => "make", - "cord" | "cordial" => "rust", + // cordial uses a dedicated line-classifier, no tree-sitter binding. + "cord" | "cordial" => return None, _ => return None, }; Some(lang.to_string()) } -fn detect_lang_from_content(text: &str) -> Option { - let keywords = ["fn ", "let ", "if ", "else ", "while ", "for ", "/="]; - let mut hits = 0; - for line in text.lines().take(50) { - let trimmed = line.trim(); - for kw in &keywords { - if trimmed.starts_with(kw) || trimmed.contains(&format!(" {kw}")) { - hits += 1; - } - } - if hits >= 2 { - return Some("rust".into()); - } - } - None -} - - diff --git a/viewport/src/editor/sidecar_io.rs b/viewport/src/editor/sidecar_io.rs index b798a53..caed015 100644 --- a/viewport/src/editor/sidecar_io.rs +++ b/viewport/src/editor/sidecar_io.rs @@ -28,12 +28,13 @@ impl super::EditorState { } } - /// returns the clean markdown body; the archive lives in a separate channel. + /// returns the clean markdown body without the archive comment. pub fn save_doc(&mut self) -> String { + self.refresh_notes_lib_rs(); self.get_clean_text() } - /// returns the archive zip bytes the shell should embed for in-library .md files. + /// returns the archive zip bytes the shell embeds into .md files. pub fn save_sidecar_bytes(&mut self) -> Option> { self.rebuild_modules(); let sidecar = self.build_sidecar(); @@ -41,6 +42,33 @@ impl super::EditorState { sidecar::build_archive_bytes(&sidecar, &block_files) } + /// rewrites the notes-folder lib.rs to declare every sibling note as a pub mod. + pub fn refresh_notes_lib_rs(&self) { + let dir = self.notes_dir(); + if !dir.is_dir() { return; } + + let mut names: Vec = Vec::new(); + let Ok(entries) = std::fs::read_dir(&dir) else { return }; + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|s| s.to_str()) != Some("md") { continue; } + let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else { continue }; + names.push(stem.to_string()); + } + names.sort(); + names.dedup(); + + let mut body = String::from("// auto-generated by acord. every note in this directory becomes a public module.\n\n"); + for name in &names { + let ident = note_name_to_ident(name); + if ident.is_empty() { continue; } + body.push_str(&format!("pub mod {};\n", ident)); + } + + let target = dir.join("lib.rs"); + let _ = std::fs::write(target, body); + } + /// applies an archive zip's metadata back into the document. pub fn apply_sidecar_bytes(&mut self, bytes: &[u8]) { if let Some(sc) = sidecar::extract_archive_bytes(bytes) { @@ -48,7 +76,7 @@ impl super::EditorState { } } - /// builds the per-block `.cord` source files for the sidecar archive + /// builds the per-block .cord source files included in the sidecar archive pub fn build_block_files(&self) -> Vec { use std::collections::HashSet; let mut files = Vec::with_capacity(self.modules.len()); @@ -208,7 +236,7 @@ impl super::EditorState { } } - let editor_w = 800.0f32; // approximate; TODO: pass actual width + let editor_w = 800.0f32; for (anchor, src, alt) in new_srcs { if !self.image_cache.contains_key(&src) { @@ -234,7 +262,7 @@ impl super::EditorState { } } -/// parses a markdown image reference `![alt](src)` from a line +/// parses a markdown image reference ![alt](src) from a line pub(super) fn parse_image_ref(line: &str) -> Option<(String, String)> { let trimmed = line.trim_start(); if !trimmed.starts_with("![") { return None; } @@ -298,3 +326,19 @@ pub fn write_clipboard_image_to_cache(img: &arboard::ImageData) -> Option String { + let mut out = String::with_capacity(stem.len()); + for ch in stem.chars() { + if ch.is_ascii_alphanumeric() || ch == '_' { + out.push(ch); + } else { + out.push('_'); + } + } + if out.chars().next().is_some_and(|c| c.is_ascii_digit()) { + out.insert_str(0, "n_"); + } + out +} diff --git a/viewport/src/editor/state.rs b/viewport/src/editor/state.rs index a118575..22675ee 100644 --- a/viewport/src/editor/state.rs +++ b/viewport/src/editor/state.rs @@ -45,7 +45,7 @@ pub struct EditorState { pub mods: Modifiers, pub(crate) selection: crate::selection::Selection, - /// the single path that keys are routed to + /// current key-routing path pub(crate) focus: Option, /// path of the cell currently in text-input edit mode #[allow(dead_code)] @@ -74,13 +74,11 @@ pub struct EditorState { pub line_indicator: LineIndicator, /// whether the gutter line numbers cycle through the rainbow palette pub gutter_rainbow: bool, - /// minimap on/off master switch pub minimap_enabled: bool, - /// minimap fades in only on hover (and only when no mouse button is held) + /// hover-only fade gate. pub minimap_hover_only: bool, - /// last frame any pointer button was held — suppresses the hover reveal + /// suppresses the hover reveal during a pointer drag. pub minimap_drag_suppress: bool, - /// pointer is currently inside the minimap region pub minimap_hovered: bool, /// pending clipboard text, drained by the shell each frame @@ -106,6 +104,11 @@ pub struct EditorState { pub active_free: Option, pub layout_mode: LayoutMode, pub snapping: bool, + + /// cached document-wide identifier rainbow map, recomputed on text change only. + pub(super) cached_user_idents: HashMap, + /// cached minimap line data, recomputed on text change only. + pub(super) cached_minimap_lines: Vec, } impl EditorState { @@ -122,7 +125,7 @@ impl EditorState { preview: false, render_mode: RenderMode::Live, parsed: Vec::new(), - lang: Some("rust".into()), + lang: None, scroll_offset: 0.0, eval_dirty: false, last_edit: Instant::now(), @@ -170,9 +173,18 @@ impl EditorState { active_free: None, layout_mode: LayoutMode::Free, snapping: false, + cached_user_idents: HashMap::new(), + cached_minimap_lines: Vec::new(), } } + /// recomputes cached syntax idents and minimap lines from current document text. + pub(super) fn refresh_text_caches(&mut self) { + let text = self.full_text(); + self.cached_user_idents = crate::syntax::scan_user_idents_in(&text); + self.cached_minimap_lines = crate::minimap::classify_text(&text); + } + /// returns the queued shell action and clears it pub fn take_pending_shell_action(&mut self) -> Option { self.pending_shell_action.take() diff --git a/viewport/src/editor/text_ops.rs b/viewport/src/editor/text_ops.rs index 0199bab..4da4793 100644 --- a/viewport/src/editor/text_ops.rs +++ b/viewport/src/editor/text_ops.rs @@ -25,6 +25,7 @@ impl super::EditorState { self.push_block(Box::new(TextBlock::new(blocks::next_id(), text, 0, lang))); self.recount_block_lines(); self.set_focused_block(0); + self.refresh_text_caches(); return; } let lang = self.lang_str(); @@ -110,7 +111,7 @@ impl super::EditorState { self.reparse(); } - /// replaces a byte range in the current content with `replacement` + /// replaces a byte range in the current content with the given string pub(super) fn replace_range(&mut self, start: usize, end: usize, replacement: &str) { let text = self.content().text(); if start > end || end > text.len() { return; } @@ -162,7 +163,7 @@ impl super::EditorState { } } - /// toggles the `> ` blockquote prefix on the current line + /// toggles the blockquote prefix on the current line pub(super) fn toggle_blockquote(&mut self) { let text = self.content().text(); let cursor = self.content().cursor(); @@ -190,14 +191,12 @@ impl super::EditorState { let text = self.content().text(); let cursor = self.content().cursor(); let pos = byte_offset_for_cursor(&text, &cursor.position); - // 1. Innermost unclosed delimiter? Close it. if let Some(close) = innermost_unclosed_delim(&text[..pos]) { self.content_mut().perform(text_widget::Action::Edit( text_widget::Edit::Paste(Arc::new(close.to_string())), )); return; } - // 2. Forward to the next outer scope's closing delimiter and step past it. if let Some(jump_to) = next_closing_delim_after(&text, pos) { let target = line_col_for_byte(&text, jump_to + 1); self.content_mut().perform(text_widget::Action::Move(Motion::DocumentStart)); @@ -210,11 +209,10 @@ impl super::EditorState { } return; } - // 3. At block scope: ensure newline sandwich. self.ensure_newline_sandwich(); } - /// places the cursor on its own line with one blank line of padding above and below + /// places the cursor on a blank line with one line of padding above and below pub(super) fn ensure_newline_sandwich(&mut self) { let text = self.content().text(); let cursor = self.content().cursor(); @@ -258,17 +256,17 @@ pub(super) fn leading_whitespace(line: &str) -> &str { &line[..end] } -/// counts consecutive trailing occurrences of `c` in `s` +/// counts consecutive trailing occurrences of a char in the string pub(super) fn count_trailing_char(s: &str, c: char) -> usize { s.chars().rev().take_while(|&x| x == c).count() } -/// counts consecutive leading occurrences of `c` in `s` +/// counts consecutive leading occurrences of a char in the string pub(super) fn count_leading_char(s: &str, c: char) -> usize { s.chars().take_while(|&x| x == c).count() } -/// converts a line/column position to a byte offset in `text` +/// converts a line/column position to a byte offset pub(super) 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() { @@ -283,7 +281,7 @@ pub(super) fn byte_offset_for_cursor(text: &str, pos: &text_widget::Position) -> text.len() } -/// inverse of `byte_offset_for_cursor` +/// converts a byte offset to a line/column position pub(super) fn line_col_for_byte(text: &str, byte: usize) -> (usize, usize) { let mut acc = 0usize; let mut line_idx = 0usize; @@ -299,7 +297,7 @@ pub(super) fn line_col_for_byte(text: &str, byte: usize) -> (usize, usize) { (last_line, text.lines().last().map(|l| l.chars().count()).unwrap_or(0)) } -/// walks `text` left-to-right tracking a delimiter stack +/// returns the innermost unclosed delimiter from a left-to-right stack walk pub(super) fn innermost_unclosed_delim(text: &str) -> Option { let mut stack: Vec = Vec::new(); for c in text.chars() { diff --git a/viewport/src/editor/types.rs b/viewport/src/editor/types.rs index cc7df31..4504b66 100644 --- a/viewport/src/editor/types.rs +++ b/viewport/src/editor/types.rs @@ -66,9 +66,9 @@ pub enum Message { ToggleBlockquote, /// wraps the selection in matching delimiters, or unwraps an existing pair WrapWith(&'static str, &'static str), - /// inserts paired `[]` or `{}` and places the cursor between them + /// inserts paired brackets and places the cursor between them AutoPair(&'static str, &'static str), - /// incremental scope exit, then newline-sandwich placement + /// incremental scope exit with newline-sandwich placement FixUp, Evaluate, /// evaluates every module in document order @@ -87,6 +87,7 @@ pub enum Message { ReplaceQueryChanged(String), ReplaceOne, ReplaceAll, + ToggleRegex, TableMsg(usize, TableMessage), DeleteCurrentTable, FocusedTableOp(TableMessage), @@ -156,9 +157,9 @@ pub enum Message { ToggleMinimap(bool), /// toggles between hover-only fade and always-on visibility. ToggleMinimapHoverOnly(bool), - /// pointer entered or left the minimap region. + /// reports pointer hover state over the minimap. MinimapHover(bool), - /// click on the minimap, value is the y-fraction in 0.0..1.0. + /// minimap click at y-fraction 0.0..1.0. MinimapJump(f32), } @@ -289,7 +290,7 @@ pub(super) const IMAGE_MAX_H: f32 = 600.0; pub(super) const IMAGE_PADDING: f32 = 48.0; pub(super) const IMAGE_VPAD: f32 = 4.0; -/// reference to a computed layer item for interleaved rendering +/// reference to a computed layer item in the interleaved stream pub(super) enum LayerItem<'a> { Inline(&'a InlineResult), Table(&'a ComputedTable), @@ -315,7 +316,7 @@ pub struct FreePlacement { pub h: f32, } -/// pending drag state for promoting a block onto a free layer. +/// pending drag state during block-to-free-layer promotion. #[derive(Debug, Clone)] pub struct PromoteDragState { pub node_id: FreeNodeId, @@ -327,7 +328,7 @@ pub struct PromoteDragState { pub fallback_table_idx: Option, } -/// pending drag state for resizing a free-layer object from an edge band. +/// pending drag state during edge-band resize of a free-layer object. #[derive(Debug, Clone)] pub struct ResizeDragState { pub node_id: FreeNodeId, @@ -377,8 +378,19 @@ pub struct FindState { pub visible: bool, pub query: String, pub replacement: String, - pub matches: Vec<(usize, usize)>, + pub matches: Vec, pub current: usize, + pub regex_mode: bool, + pub regex_error: Option, +} + +/// byte-range match with the line/col of the start position. +#[derive(Clone, Debug)] +pub struct FindMatch { + pub line: usize, + pub col: usize, + pub byte_start: usize, + pub byte_end: usize, } impl FindState { @@ -389,6 +401,8 @@ impl FindState { replacement: String::new(), matches: Vec::new(), current: 0, + regex_mode: false, + regex_error: None, } } } diff --git a/viewport/src/editor/update.rs b/viewport/src/editor/update.rs index 9bfe659..083f7f3 100644 --- a/viewport/src/editor/update.rs +++ b/viewport/src/editor/update.rs @@ -12,12 +12,12 @@ use super::types::{ ContextMenuState, InlinePressState, LayoutMode, Message, RenderMode, ResizeDragState, ShellAction, FIND_INPUT_ID, }; -use super::{block_editor_id, detect_lang_from_content}; +use super::block_editor_id; use super::eval::parse_let_binding; use super::text_ops::leading_whitespace; impl super::EditorState { - /// returns true when `message` is safe to dispatch in view mode + /// returns true for messages safe to dispatch in view mode pub(super) fn message_is_view_safe(message: &Message) -> bool { match message { Message::SetRenderMode(_) => true, @@ -28,7 +28,8 @@ impl super::EditorState { Message::ToggleFind | Message::HideFind => true, Message::FindQueryChanged(_) | Message::FindNext - | Message::FindPrev => true, + | Message::FindPrev + | Message::ToggleRegex => true, Message::ReplaceQueryChanged(_) => true, Message::TableMoveUp | Message::TableMoveDown @@ -165,9 +166,6 @@ impl super::EditorState { if is_edit { self.last_edit = Instant::now(); - if self.lang.is_none() { - self.lang = detect_lang_from_content(&self.content().text()); - } self.reparse(); if self.render_mode == RenderMode::Live { @@ -187,36 +185,19 @@ impl super::EditorState { self.push_undo_snapshot(); self.redo_stack.clear(); - let (match_line, match_col) = self.find.matches[self.find.current]; let clean = self.get_clean_text(); - let query_lower = self.find.query.to_lowercase(); - let query_char_count = query_lower.chars().count(); - let mut lines: Vec = clean.lines().map(|l| l.to_string()).collect(); - if match_line < lines.len() { - let line = &lines[match_line]; - let chars: Vec<(usize, char)> = line.char_indices().collect(); - if match_col < chars.len() { - let window: String = chars[match_col..] - .iter() - .take(query_char_count) - .map(|(_, c)| *c) - .collect::() - .to_lowercase(); - if window == query_lower { - let byte_start = chars[match_col].0; - let byte_end = if match_col + query_char_count < chars.len() { - chars[match_col + query_char_count].0 - } else { - line.len() - }; - let before = &line[..byte_start]; - let after = &line[byte_end..]; - lines[match_line] = - format!("{before}{}{after}", self.find.replacement); - } - } + let m = &self.find.matches[self.find.current]; + if m.byte_start > clean.len() || m.byte_end > clean.len() { + return; } - let new_text = lines.join("\n"); + + let replacement = if self.find.regex_mode { + expand_regex_replacement(&self.find.query, &clean[m.byte_start..m.byte_end], &self.find.replacement) + } else { + self.find.replacement.clone() + }; + + let new_text = format!("{}{}{}", &clean[..m.byte_start], replacement, &clean[m.byte_end..]); self.set_text(&new_text); self.run_eval(); self.update_find_matches(); @@ -235,28 +216,27 @@ impl super::EditorState { self.redo_stack.clear(); let clean = self.get_clean_text(); - let query_lower = self.find.query.to_lowercase(); - let query_char_count = query_lower.chars().count(); - let chars: Vec<(usize, char)> = clean.char_indices().collect(); - let mut result = String::with_capacity(clean.len()); - let mut ci = 0; - while ci < chars.len() { - let remaining = chars.len() - ci; - if remaining >= query_char_count { - let window: String = chars[ci..ci + query_char_count] - .iter() - .map(|(_, c)| *c) - .collect::() - .to_lowercase(); - if window == query_lower { - result.push_str(&self.find.replacement); - ci += query_char_count; - continue; - } + + if self.find.regex_mode { + if let Ok(re) = regex::Regex::new(&self.find.query) { + let result = re.replace_all(&clean, self.find.replacement.as_str()).into_owned(); + self.set_text(&result); + self.run_eval(); + self.update_find_matches(); + return; } - result.push(chars[ci].1); - ci += 1; } + + let mut result = String::with_capacity(clean.len()); + let mut pos = 0; + for m in &self.find.matches { + if m.byte_start >= pos && m.byte_end <= clean.len() { + result.push_str(&clean[pos..m.byte_start]); + result.push_str(&self.find.replacement); + pos = m.byte_end; + } + } + result.push_str(&clean[pos..]); self.set_text(&result); self.run_eval(); self.update_find_matches(); @@ -338,9 +318,6 @@ impl super::EditorState { None }; - // formula-fill interception: clicking a different cell while - // editing a `/=` formula fills its first empty `[]` slot with - // the clicked cell's address instead of switching focus. if let Some((r, c)) = select_target { if self.try_fill_formula_slot(idx, r, c) { return; @@ -552,6 +529,14 @@ impl super::EditorState { } Message::ReplaceOne => self.handle_replace_one(), Message::ReplaceAll => self.handle_replace_all(), + Message::ToggleRegex => { + self.find.regex_mode = !self.find.regex_mode; + self.update_find_matches(); + if !self.find.matches.is_empty() { + self.find.current = 0; + self.navigate_to_match(); + } + } Message::TableMsg(idx, tmsg) => self.handle_table_msg(idx, tmsg), Message::DeleteCurrentTable => { if let Some(target) = self.focused_table_index() { @@ -950,3 +935,30 @@ impl super::EditorState { } } } + +fn expand_regex_replacement(pattern: &str, matched_text: &str, replacement: &str) -> String { + let Ok(re) = regex::Regex::new(pattern) else { return replacement.to_string() }; + let Some(caps) = re.captures(matched_text) else { return replacement.to_string() }; + let mut result = String::new(); + let bytes = replacement.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'$' && i + 1 < bytes.len() { + if bytes[i + 1] == b'0' { + result.push_str(caps.get(0).map(|m| m.as_str()).unwrap_or("")); + i += 2; + } else if bytes[i + 1].is_ascii_digit() { + let idx = (bytes[i + 1] - b'0') as usize; + result.push_str(caps.get(idx).map(|m| m.as_str()).unwrap_or("")); + i += 2; + } else { + result.push('$'); + i += 1; + } + } else { + result.push(bytes[i] as char); + i += 1; + } + } + result +} diff --git a/viewport/src/export.rs b/viewport/src/export.rs index 66a5612..0aab38d 100644 --- a/viewport/src/export.rs +++ b/viewport/src/export.rs @@ -1,12 +1,4 @@ -//! Export a note as a standalone Rust crate. The crate mirrors the sidecar -//! ZIP's structure (src/blocks/*.cord + config.toml) but is written to a -//! user-chosen folder on disk with the full Cargo scaffolding (Cargo.toml, -//! build.sh, install.sh, README.md, src/main.rs, src/lib.rs). -//! -//! The main module (src/main.rs) runs a REPL using acord-core's interpreter. -//! Each `.cord` block is a submodule loaded into the REPL's scope at startup. -//! AOT codegen (Cordial → Rust source) is planned separately — build.sh is a -//! stub that currently just does `cargo build --release` of the REPL binary. +//! Exports a note as a standalone Rust crate with Cargo scaffolding and REPL binary. use std::fs; use std::io::Write; @@ -16,8 +8,7 @@ use crate::editor::EditorState; use crate::heading_block::HeadingBlock; use crate::text_block::TextBlock; -/// Convert a free-form string to hyphen-form for use as a crate/folder name. -/// Lowercase, spaces and underscores become `-`, non-alphanumeric stripped. +/// converts a free-form string to lowercase hyphen-form crate name. pub fn to_hyphen_name(s: &str) -> String { s.trim() .to_lowercase() @@ -29,9 +20,7 @@ pub fn to_hyphen_name(s: &str) -> String { .to_string() } -/// Export the current note as a standalone Rust crate at `out_dir`. The -/// folder name is the crate name; spaces/underscores in the user-supplied -/// name get converted to hyphens. Returns Ok(path) on success. +/// writes a standalone Rust crate to out_dir/hyphenated-name/. pub fn export_crate(state: &EditorState, out_dir: &Path, name: &str) -> Result { let crate_name = to_hyphen_name(name); if crate_name.is_empty() { @@ -44,19 +33,16 @@ pub fn export_crate(state: &EditorState, out_dir: &Path, name: &str) -> Result

Option { - // arboard uses NSPasteboard on macOS, Win32 on Windows — no subprocess. - // Image-first: if the pasteboard holds a bitmap, encode it to PNG in - // the on-disk image cache and yield a markdown reference. Wrapping - // newlines guarantee the `![](…)` lands as the only thing on its line - // so `parse_image_ref` will pick it up. let mut board = self.board.borrow_mut(); + // encode pasteboard bitmap to PNG and yield a markdown image reference if let Ok(img) = board.get_image() { if let Some(path) = crate::editor::write_clipboard_image_to_cache(&img) { return Some(format!("\n![]({})\n", path)); } } - // Line-ending normalisation: web pages and cross-platform apps keep - // `\r\n` in the pasteboard; collapse to `\n` so iced's buffer and - // our gutter line counter agree. + // normalize \r\n to \n board.get_text() .ok() .map(|s| s.replace("\r\n", "\n").replace('\r', "\n")) @@ -52,8 +46,7 @@ impl clipboard::Clipboard for AcordClipboard { } } -/// iOS stub — UIPasteboard wiring lives on the Swift side, fed through the -/// FFI shell-action bus when the user explicitly copies/pastes. +/// iOS stub; UIPasteboard access handled on the Swift side via shell-action bus. #[cfg(target_os = "ios")] struct AcordClipboard; @@ -63,10 +56,7 @@ impl clipboard::Clipboard for AcordClipboard { fn write(&mut self, _kind: clipboard::Kind, _contents: String) {} } -/// Mac/Windows entry point used by the C FFI. Synthesizes the platform's -/// display handle from the window pointer the Swift bridge provides. -/// Returns None on platforms that need both display and window — those -/// shells should call `create_native` directly. +/// synthesizes platform display+window handles from a single native pointer. pub fn create( native_handle: *mut c_void, width: f32, @@ -103,9 +93,7 @@ pub fn create( create_native(raw_display, raw_window, width, height, scale) } -/// Rust-native entry point. Takes typed handles directly so shells that own -/// a winit Window (Linux, future Windows refactor) can build a surface -/// without going through the C FFI's single-pointer compromise. +/// creates the wgpu surface, device, queue, renderer, and viewport from typed handles. pub fn create_native( raw_display: RawDisplayHandle, raw_window: RawWindowHandle, @@ -177,10 +165,7 @@ pub fn create_native( Shell::headless(), ); - // NOTE: `Font::DEFAULT` is cosmic-text's hardcoded "Noto Sans Mono" which - // iOS doesn't ship — we override per-span with EDITOR_FONT, but anything - // unstyled falls back to this missing font. If iOS shows invisible text - // while spans render, this is the suspect. + // Font::DEFAULT = cosmic-text "Noto Sans Mono", absent on iOS; EDITOR_FONT overrides per-span crate::ios_dlog!("renderer init font=Font::DEFAULT (= cosmic 'Noto Sans Mono', iOS likely missing) editor_font={:?}", crate::syntax::EDITOR_FONT); let renderer = iced_wgpu::Renderer::new(engine, Font::DEFAULT, Pixels(16.0)); @@ -208,17 +193,12 @@ pub fn create_native( state: EditorState::new(), events: initial_events, cursor: mouse::Cursor::Available(focus_point), - // First frame must paint. needs_redraw: true, }) } pub fn render(handle: &mut ViewportHandle) { - // Idle-frame short circuit. The Swift CVDisplayLink ticks viewport_render() at - // vsync regardless of activity. Without this gate we'd rebuild the entire widget - // tree, run update + draw, and present a frame ~60 times per second forever. - // We still wake up while `eval_dirty` is set so the eval debounce in - // EditorState::tick() can fire after typing stops. + // skip frame entirely when idle and no eval debounce pending let pending_events = !handle.events.is_empty(); if !handle.needs_redraw && !handle.state.has_pending_eval() && !pending_events { return; @@ -252,20 +232,10 @@ pub fn render(handle: &mut ViewportHandle) { let mut clipboard = AcordClipboard; let mut messages: Vec = Vec::new(); let mut consumed: Vec = Vec::new(); - // Captured during the event scan, applied to `handle.state.mods` AFTER - // `ui` is released — the UI build above borrows `&handle.state` so we - // can't mutate any field of state while it's alive. let mut latest_mods: Option = None; - // Cmd+A escalation: armed by the first press, escalates on the second. - // Some(true) = arm for next press, Some(false) = disarm. None = unchanged. - // Events are scanned in order so the LAST write wins — a Cmd+A followed - // by a mouse click in the same frame correctly disarms. let mut new_cmd_a_armed: Option = None; for (ev_idx, event) in handle.events.iter().enumerate() { - // Default-disarm Cmd+A for any user input. The Cmd+A arm below - // overwrites with Some(true) when it actually wants to arm. Events - // are scanned in order so the LAST write wins for the frame. let is_user_input = matches!( event, Event::Keyboard(keyboard::Event::KeyPressed { .. }) @@ -275,11 +245,6 @@ pub fn render(handle: &mut ViewportHandle) { new_cmd_a_armed = Some(false); } - // View mode: drop the keys that would write to the document so - // the iced widget never sees them. `i` and `/` (mode-switch) - // fall through to their own match arms below; mouse events, - // scroll, navigation/selection keys, and modifier-prefixed - // shortcuts also pass through and get message-layer gated. if handle.state.render_mode == RenderMode::View { let is_typing_char = match event { Event::Keyboard(keyboard::Event::KeyPressed { @@ -322,32 +287,21 @@ pub fn render(handle: &mut ViewportHandle) { messages.push(Message::InsertTable); consumed.push(ev_idx); } - // Cmd+A — first press lets the focused block do its - // local select-all (text_editor selects its text; - // table cells in select mode upgrade to whole-table - // selection). Second press while still armed escalates - // to whole-document selection. "a" => { if handle.state.cmd_a_armed { messages.push(Message::SelectAllBlocks); new_cmd_a_armed = Some(false); consumed.push(ev_idx); } else { - // First press path. Decide what "local select all" - // means for the focused block. if handle.state.table_is_focused_block() && !handle.state.focused_table_is_select_all() && handle.state.editing.is_none() { - // Cell-selected table → escalate to whole-table. messages.push(Message::FocusedTableOp( TableMessage::SelectAll, )); consumed.push(ev_idx); } - // For text blocks (and table cells in edit mode), - // do NOT consume — let iced's text_editor / - // text_input handle Cmd+A natively. new_cmd_a_armed = Some(true); } } @@ -364,29 +318,23 @@ pub fn render(handle: &mut ViewportHandle) { consumed.push(ev_idx); } "x" if modifiers.shift() => { - // Cmd+Shift+X: strikethrough (Cmd+S is reserved for save) messages.push(Message::ToggleStrike); consumed.push(ev_idx); } "." if modifiers.shift() => { - // Cmd+> : blockquote prefix messages.push(Message::ToggleBlockquote); consumed.push(ev_idx); } "\"" | "'" => { - // Cmd+" / Cmd+' wrap selection in matching quotes. let q: &'static str = if c.as_str() == "\"" { "\"" } else { "'" }; messages.push(Message::WrapWith(q, q)); consumed.push(ev_idx); } "9" | "(" => { - // Cmd+9 (or Cmd+Shift+9 = Cmd+( ) wraps in parens. messages.push(Message::WrapWith("(", ")")); consumed.push(ev_idx); } "0" if modifiers.shift() => { - // Cmd+Shift+0: reset zoom (moved off Cmd+0 to make - // room for the FixUp catch-all). messages.push(Message::ZoomReset); consumed.push(ev_idx); } @@ -395,11 +343,6 @@ pub fn render(handle: &mut ViewportHandle) { consumed.push(ev_idx); } "c" => { - // Table cell copy: when the focused block is a table - // with a selection (or an open spillover popup), Cmd+C - // copies the cell payload before iced's text widget - // sees the event. Otherwise let it fall through so - // text-block / cell-edit copy keep working. if handle.state.should_intercept_table_copy() { messages.push(Message::CopyFocusedTableSelection); consumed.push(ev_idx); @@ -436,7 +379,6 @@ pub fn render(handle: &mut ViewportHandle) { _ => {} } } - // Ctrl+I → Editor mode, Ctrl+/ → Live mode Event::Keyboard(keyboard::Event::KeyPressed { key: keyboard::Key::Character(c), modifiers, @@ -454,7 +396,6 @@ pub fn render(handle: &mut ViewportHandle) { _ => {} } } - // Ctrl+Escape → View mode Event::Keyboard(keyboard::Event::KeyPressed { key: keyboard::Key::Named(keyboard::key::Named::Escape), modifiers, @@ -463,7 +404,6 @@ pub fn render(handle: &mut ViewportHandle) { messages.push(Message::SetRenderMode(RenderMode::View)); consumed.push(ev_idx); } - // View mode: `i` → Editor, `/` → Live Event::Keyboard(keyboard::Event::KeyPressed { key: keyboard::Key::Character(c), modifiers, @@ -523,9 +463,6 @@ pub fn render(handle: &mut ViewportHandle) { messages.push(Message::TableEnter); consumed.push(ev_idx); } - // Up arrow inside a table cell. If we're on a non-top row, - // move the cell focus up. If we're on row 0, escape upward - // into the previous text block (synthesize one if none). Named::ArrowUp => { if let Some((block_idx, row, _)) = handle.state.active_table_focused_row() @@ -538,7 +475,6 @@ pub fn render(handle: &mut ViewportHandle) { consumed.push(ev_idx); } } - // Mirror of Up — row navigation with edge-escape. Named::ArrowDown => { if let Some((block_idx, row, total)) = handle.state.active_table_focused_row() @@ -551,8 +487,6 @@ pub fn render(handle: &mut ViewportHandle) { consumed.push(ev_idx); } } - // Left/Right walk the row. No edge-escape — at column 0 or - // the last column the move just no-ops; cell stays put. Named::ArrowLeft => { messages.push(Message::TableMoveLeft); consumed.push(ev_idx); @@ -561,10 +495,6 @@ pub fn render(handle: &mut ViewportHandle) { messages.push(Message::TableMoveRight); consumed.push(ev_idx); } - // Backspace/Delete behavior depends on selection scope: - // - whole table selected → clear every cell's content - // - single cell selected (not editing) → clear that cell - // Edit mode is handled by text_input's own backspace. Named::Backspace | Named::Delete if handle.state.focused_table_is_select_all() => { @@ -580,8 +510,6 @@ pub fn render(handle: &mut ViewportHandle) { _ => {} } } - // Cmd+Backspace with the whole document selected → wipe all - // blocks down to one empty text block. Event::Keyboard(keyboard::Event::KeyPressed { key: keyboard::Key::Named(keyboard::key::Named::Backspace), modifiers, @@ -592,10 +520,6 @@ pub fn render(handle: &mut ViewportHandle) { messages.push(Message::DeleteAllBlocks); consumed.push(ev_idx); } - // Cmd+Backspace with the whole table selected → delete the table. - // Mirrors the user's "Cmd+Delete deletes whatever's selected" rule - // applied at table scope. Single-cell selection has its own - // Cmd+Alt+Backspace = delete row binding below. Event::Keyboard(keyboard::Event::KeyPressed { key: keyboard::Key::Named(keyboard::key::Named::Backspace), modifiers, @@ -606,8 +530,6 @@ pub fn render(handle: &mut ViewportHandle) { messages.push(Message::DeleteCurrentTable); consumed.push(ev_idx); } - // Plain Backspace/Delete with whole document selected → clear all - // block content but keep structure. Event::Keyboard(keyboard::Event::KeyPressed { key: keyboard::Key::Named(named), modifiers, @@ -634,8 +556,6 @@ pub fn render(handle: &mut ViewportHandle) { messages.push(Message::ExitCellEdit); consumed.push(ev_idx); } else { - // Nothing to dismiss — cycle modes: - // Live → Editor → View → Live. match handle.state.render_mode { RenderMode::Live => { messages.push(Message::SetRenderMode(RenderMode::Editor)); @@ -652,10 +572,6 @@ pub fn render(handle: &mut ViewportHandle) { } } } - // Printable-key entry into a selected cell. When a table cell is - // selected (highlighted) but not yet in edit mode, hitting any - // printable character should overwrite the cell with that - // character and enter edit mode in one step. Event::Keyboard(keyboard::Event::KeyPressed { key: keyboard::Key::Character(c), modifiers, @@ -677,8 +593,7 @@ pub fn render(handle: &mut ViewportHandle) { } } - // Strip keyboard events we've already routed into Messages, so iced's - // text_input/text_editor doesn't also process them and corrupt cell content. + // remove consumed events before passing the remainder to iced if !consumed.is_empty() { let consumed_set: std::collections::HashSet = consumed.into_iter().collect(); let mut filtered: Vec = Vec::with_capacity(handle.events.len()); @@ -699,9 +614,6 @@ pub fn render(handle: &mut ViewportHandle) { ); handle.events.clear(); - // Snapshot which cell (if any) is currently focused in any table, so that - // subsequent structural edit shortcuts (insert row, delete col, ...) can - // target the right block without a separate focus-tracking field. let focused_id = { use iced_wgpu::core::widget::operation::{Focusable, Operation}; use iced_wgpu::core::widget::Id as CoreId; @@ -746,8 +658,6 @@ pub fn render(handle: &mut ViewportHandle) { if let Some(armed) = new_cmd_a_armed { handle.state.cmd_a_armed = armed; } - // Update cursor pos BEFORE draining messages so right-click handlers can - // anchor the context menu at the current position in the same frame. if let Some(pt) = handle.cursor.position() { handle.state.cursor_pos = pt; if handle.state.tick_promote_drag() { @@ -763,7 +673,6 @@ pub fn render(handle: &mut ViewportHandle) { handle.state.update(msg); } - // Drain any clipboard write the editor queued during update/tick. #[cfg(not(target_os = "ios"))] if let Some(text) = handle.state.pending_clipboard.take() { if let Ok(mut board) = arboard::Clipboard::new() { @@ -775,8 +684,6 @@ pub fn render(handle: &mut ViewportHandle) { handle.state.tick(); let pending_focus = handle.state.take_pending_focus(); - // Drain BEFORE the second `ui` is built — `view()` re-borrows state and - // would block any subsequent mutable take. let pending_scroll = handle.state.take_pending_scroll(); let theme = Theme::Dark; @@ -784,9 +691,6 @@ pub fn render(handle: &mut ViewportHandle) { text_color: Color::WHITE, }; - // First-frame palette/theme dump so we can spot a "white text on white - // background" misconfiguration. Then a periodic re-log so palette swaps - // mid-session show up too. { use std::sync::atomic::{AtomicU64, Ordering}; static FRAME: AtomicU64 = AtomicU64::new(0); @@ -817,11 +721,7 @@ pub fn render(handle: &mut ViewportHandle) { ui.operate(&handle.renderer, &mut op); } - // Forward any wheel-scroll delta that an inner text_editor swallowed - // (Action::Scroll) to the outer document scrollable. text_editor captures - // WheelScrolled when the cursor is over its bounds, which would otherwise - // leave the page stuck. The editor.rs Action::Scroll handler accumulates - // pixel deltas into pending_scroll; here we drain and apply them. + // forward accumulated wheel-scroll delta to the document scrollable if let Some(delta_y) = pending_scroll { use iced_wgpu::core::widget::operation::scrollable::{scroll_by, AbsoluteOffset}; use iced_wgpu::core::widget::Id as CoreId; @@ -841,8 +741,6 @@ pub fn render(handle: &mut ViewportHandle) { frame.present(); - // Frame is on screen. Clear dirty so the next vsync tick is a free no-op - // unless something genuinely changed (input event, eval debounce, etc.). handle.needs_redraw = false; } diff --git a/viewport/src/hr_block.rs b/viewport/src/hr_block.rs index f970136..8e04b2a 100644 --- a/viewport/src/hr_block.rs +++ b/viewport/src/hr_block.rs @@ -98,7 +98,6 @@ impl Block for HrBlock { } fn apply(&mut self, _cmd: BlockCommand) { - // HRs have no structural state to mutate. } fn selectable_paths(&self) -> Box + '_> { diff --git a/viewport/src/lib.rs b/viewport/src/lib.rs index 9775de2..0923e55 100644 --- a/viewport/src/lib.rs +++ b/viewport/src/lib.rs @@ -1,28 +1,8 @@ -//! Acord viewport — the iced+wgpu editor surface and its supporting widgets. -//! -//! ## Reusing pieces in other apps -//! -//! - [`text_widget`] — the per-line `fill_paragraph` compositor. Can host any -//! iced `Element` inline with text. -//! - [`widgets::menu`] — generic-Message menu strip + dropdown panel. -//! - [`widgets::dialog`] — modal overlay + segmented-row patterns. -//! - [`widgets::style`] — iced style functions matching Acord's chrome. -//! - [`browser`] — the file-grid document browser. -//! - [`palette`] — the global theme palette ([`set_palette_theme`] swaps it). -//! - [`syntax`] — tree-sitter highlighter producing iced `Span` colors. -//! - [`oklab`] — OKLab colour-space utilities used for tone math. -//! - [`bridge`] — FFI helpers translating native key/mouse codes to iced events. -//! -//! The full editor lives in [`editor::EditorState`]; lower-level block kinds -//! (text, table, heading, tree, hr) live in their own modules and implement -//! the [`block::Block`] trait. +//! iced+wgpu editor surface, supporting widgets, and C-FFI entry points. use std::ffi::{c_char, c_void, CStr, CString}; -/// Gated diagnostic logging — `eprintln!` to stderr, prefixed with the -/// callsite. Compiles to nothing outside debug builds on iOS. -/// AcordApp.swift's captureStderr() pumps stderr → stdout + NSLog so output -/// shows up under `cargo xtask debug-ios --console`. +/// prefixed eprintln on iOS debug builds; compiles away otherwise. #[macro_export] macro_rules! ios_dlog { ($($arg:tt)*) => {{ @@ -86,17 +66,11 @@ pub struct ViewportHandle { pub state: EditorState, pub events: Vec, cursor: iced_wgpu::core::mouse::Cursor, - /// Set true on any FFI input or state-change call. handle::render() early-returns - /// when this is false AND no pending eval debounce, so the vsync display link - /// becomes a microsecond no-op while the editor is idle. + /// dirty flag gating vsync frame presentation pub needs_redraw: bool, } -/// Install a panic hook that flushes a full backtrace to stderr AND to -/// `~/.acord/crash.log` before the process aborts. Called once on first -/// viewport_create. Without the file fallback, the Windows release build -/// (`#![windows_subsystem = "windows"]`) detaches the console and stderr -/// goes nowhere — users get a silent crash with no diagnostic surface. +/// writes panics to stderr and ~/.acord/crash.log with full backtraces. fn install_panic_hook() { use std::sync::Once; static ONCE: Once = Once::new(); @@ -133,8 +107,7 @@ fn install_panic_hook() { }); } -/// Best-effort timestamp for the crash log header. Avoids pulling chrono -/// for one line — uses SystemTime::now() epoch seconds as a stable suffix. +/// epoch-seconds timestamp for the crash log header. fn chrono_now() -> String { use std::time::{SystemTime, UNIX_EPOCH}; SystemTime::now() @@ -267,8 +240,6 @@ pub extern "C" fn viewport_set_text(handle: *mut ViewportHandle, text: *const c_ } else { unsafe { CStr::from_ptr(text) }.to_str().unwrap_or("") }; - // Goes through `load_doc` so any embedded sidecar archive comment is - // pulled out before the markdown body reaches the parser. h.state.load_doc(s); h.needs_redraw = true; } @@ -304,7 +275,7 @@ pub extern "C" fn viewport_free_string(s: *mut c_char) { unsafe { drop(CString::from_raw(s)); } } -/// returns the archive zip bytes (or null when empty); writes the length to len_out. +/// returns the archive zip bytes or null; writes the length to len_out. #[unsafe(no_mangle)] pub extern "C" fn viewport_take_sidecar_bytes( handle: *mut ViewportHandle, @@ -355,7 +326,7 @@ pub extern "C" fn viewport_free_bytes(ptr: *mut u8, len: usize) { unsafe { drop(Box::from_raw(std::slice::from_raw_parts_mut(ptr, len))); } } -/// renders the current document as a printable PDF; returns owned bytes the shell must free with `viewport_free_bytes`. +/// renders the document to PDF bytes; free with viewport_free_bytes. #[unsafe(no_mangle)] pub extern "C" fn viewport_render_pdf( handle: *mut ViewportHandle, @@ -512,10 +483,7 @@ pub extern "C" fn viewport_take_shell_action(handle: *mut ViewportHandle) -> *mu CString::new(s).map(|c| c.into_raw()).unwrap_or(std::ptr::null_mut()) } -/// Export the note as a standalone Rust crate at `out_dir/name/`. Returns -/// a heap-allocated C string on success (the absolute path of the created -/// folder), or null on failure. Free the returned string with -/// `viewport_free_string`. +/// exports the note as a Rust crate at out_dir/name/; returns the path or null. #[unsafe(no_mangle)] pub extern "C" fn viewport_export_crate( handle: *mut ViewportHandle, @@ -639,7 +607,7 @@ pub extern "C" fn browser_refresh(handle: *mut BrowserHandle) { browser::handle::refresh(h); } -/// dispatches a numeric zoom command into the browser's scale state. +/// dispatches a zoom command into the browser scale state. #[unsafe(no_mangle)] pub extern "C" fn browser_send_command(handle: *mut BrowserHandle, command: u32) { let h = match unsafe { handle.as_mut() } { Some(h) => h, None => return }; diff --git a/viewport/src/minimap.rs b/viewport/src/minimap.rs index 2788b10..897f8e4 100644 --- a/viewport/src/minimap.rs +++ b/viewport/src/minimap.rs @@ -21,7 +21,7 @@ pub struct MinimapLine { pub kind: LineKind, } -/// classifies a single source line for minimap colouring. +/// classifies a single source line by minimap colour category. pub fn classify(line: &str) -> MinimapLine { let trimmed = line.trim_start(); let kind = if trimmed.is_empty() { diff --git a/viewport/src/module.rs b/viewport/src/module.rs index 9125294..e49a224 100644 --- a/viewport/src/module.rs +++ b/viewport/src/module.rs @@ -1,8 +1,6 @@ use crate::selection::BlockId; -/// A module groups consecutive blocks that share a scope. -/// H2 headings start named modules; HRs close the current module -/// and start an unnamed one; H1 marks the root module. +/// consecutive blocks sharing a scope, bounded by H2/HR headings. #[derive(Debug, Clone)] pub struct Module { pub name: String, @@ -11,33 +9,19 @@ pub struct Module { pub is_root: bool, } -/// Lightweight descriptor used by compute_modules so it doesn't need -/// access to the full Block trait (which is generic over Message). +/// lightweight descriptor decoupled from the generic Block trait. pub struct BlockInfo { pub id: BlockId, pub kind_tag: &'static str, - /// For heading blocks: the level (1, 2, 3). Zero for non-headings. + /// heading level (1, 2, 3) or zero for non-headings. pub heading_level: u8, - /// For heading blocks: the heading text. Empty for non-headings. + /// heading text, empty for non-headings. pub heading_text: String, - /// For text blocks: the raw markdown content (used to auto-name - /// unnamed modules from first `fn`/`let`). Empty for non-text blocks. + /// raw markdown content, empty for non-text blocks. pub text_content: String, } -/// Walk blocks in layout order and group them into modules based on -/// heading/HR boundaries. -/// -/// Rules: -/// - H1 -> root module (is_root = true) -/// - H2 -> close current, start named module -/// - HR -> close current, start unnamed module -/// - HR immediately followed by H1/H2 -> absorbed into the heading module -/// so the divider counts as decoration, not its own dangling block. -/// - Everything else -> append to current module -/// -/// Unnamed modules are auto-named from their first `fn` or `let` -/// declaration, falling back to `_unnamed_N`. +/// groups blocks into modules by walking heading/HR boundaries in layout order. pub fn compute_modules(infos: &[BlockInfo]) -> Vec { let mut modules: Vec = Vec::new(); let mut current = Module { @@ -96,9 +80,7 @@ pub fn compute_modules(infos: &[BlockInfo]) -> Vec { modules } -/// Returns the HR block id if `current` is a freshly-opened HR-only module -/// (one block, no heading) — meaning the HR immediately precedes the caller's -/// heading and should be folded into it. None otherwise. +/// extracts the lone HR id from a fresh HR-only module, folding it into the next heading. fn take_dangling_hr(current: &Module, infos: &[BlockInfo]) -> Option { if current.block_ids.len() != 1 || current.heading_block.is_some() { return None; @@ -109,7 +91,7 @@ fn take_dangling_hr(current: &Module, infos: &[BlockInfo]) -> Option { .map(|i| i.id) } -/// If a module has no name, derive one from its first `fn`/`let` declaration. +/// derives a name from the first fn/let declaration, falling back to _unnamed_N. fn finalize_unnamed(module: &mut Module, counter: &mut usize, infos: &[BlockInfo]) { if !module.name.is_empty() { return; @@ -147,16 +129,16 @@ fn extract_ident(s: &str) -> Option { if ident.is_empty() { None } else { Some(ident) } } -/// Scope of a table name assigned by a heading above it. +/// scope of a heading-assigned table name. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TableNameScope { - /// H3 heading — name is globally visible across all modules. + /// H3: globally visible across all modules. Global, - /// H4 heading — name is only visible within the owning module. + /// H4: visible only within the owning module. BlockScoped, } -/// Result of scanning for heading-named tables. +/// heading-to-table name assignment. #[derive(Debug, Clone)] pub struct TableNameAssignment { pub table_id: BlockId, @@ -164,8 +146,7 @@ pub struct TableNameAssignment { pub scope: TableNameScope, } -/// Scan the layout for H3/H4 headings directly above a table (only -/// whitespace/empty blocks between) and return name assignments. +/// scans for H3/H4 headings directly above a table, skipping whitespace blocks between. pub fn detect_table_names(infos: &[BlockInfo]) -> Vec { let mut assignments = Vec::new(); let len = infos.len(); @@ -175,8 +156,6 @@ pub fn detect_table_names(infos: &[BlockInfo]) -> Vec { if infos[i].kind_tag != "heading" || (level != 3 && level != 4) { continue; } - // Look ahead for the next non-heading block. Skip whitespace-only - // text blocks between the heading and the table. for j in (i + 1)..len { match infos[j].kind_tag { "table" => { @@ -203,7 +182,7 @@ pub fn detect_table_names(infos: &[BlockInfo]) -> Vec { assignments } -/// Lowercase, spaces to underscores, strip non-ident characters. +/// lowercases, spaces to underscores, strips non-ident characters. pub fn normalize_name(heading_text: &str) -> String { heading_text .trim() @@ -214,12 +193,7 @@ pub fn normalize_name(heading_text: &str) -> String { .collect() } -/// Positional fallback names for every block and table in the document, -/// assigned globally in layout order (1-indexed: `block_1`, `table_1`, …). -/// Headings and HRs count as blocks; tables also get their own sequence. -/// Cross-block refs use `block_N::table_N`. Heading-derived names from -/// `detect_table_names` take precedence — positional names are always -/// available as an additional lookup key. +/// assigns 1-indexed positional names (block_N, table_N) in layout order. pub fn compute_positional_ids(infos: &[BlockInfo]) -> PositionalIds { let mut blocks = Vec::new(); let mut tables = Vec::new(); @@ -236,9 +210,7 @@ pub fn compute_positional_ids(infos: &[BlockInfo]) -> PositionalIds { PositionalIds { blocks, tables } } -/// Output of `compute_positional_ids`. `tables` entries also carry the -/// 1-indexed block position the table appears in, so the caller can build -/// the cross-block alias `block_N::table_M`. +/// positional block and table IDs with their 1-indexed positions. pub struct PositionalIds { pub blocks: Vec<(BlockId, String)>, pub tables: Vec<(BlockId, String, usize)>, diff --git a/viewport/src/oklab.rs b/viewport/src/oklab.rs index 4e0f116..165f55c 100644 --- a/viewport/src/oklab.rs +++ b/viewport/src/oklab.rs @@ -1,10 +1,4 @@ -//! Perceptually uniform color operations. -//! -//! Wraps Björn Ottosson's OKLab. Used to compensate for the apparent dimming -//! of small glyphs and thin strokes that arises from antialiased coverage -//! blending against a near-black background. The compensation is calibrated -//! by rendered pixel size: smaller -> bigger boost, with a knee at -//! `THRESHOLD_PX`. Hue and chroma are preserved; only L is adjusted. +//! Perceptually uniform color operations via OKLab. use crate::palette; use iced_wgpu::core::Color; @@ -62,14 +56,12 @@ fn from_oklab(lab: [f32; 3], alpha: f32) -> Color { } } -/// Linear-with-knee curve: full boost at `size_px = 0`, zero at and above -/// `THRESHOLD_PX`. Returns the unsigned magnitude — the caller decides the -/// sign (positive = lighten on dark bg, negative = darken on light bg). +/// unsigned L boost magnitude, full at 0 px tapering to zero at THRESHOLD_PX. pub fn size_boost(size_px: f32) -> f32 { (BASE_BOOST * (1.0 - size_px / THRESHOLD_PX)).max(0.0) } -/// Add `l_delta` to OKLab L while preserving chroma. +/// adds l_delta to OKLab L, preserving chroma. pub fn lighten(color: Color, l_delta: f32) -> Color { if l_delta == 0.0 { return color; @@ -79,9 +71,7 @@ pub fn lighten(color: Color, l_delta: f32) -> Color { from_oklab(lab, color.a) } -/// Compensate for AA coverage blending: brighten on dark backgrounds -/// (where AA dims), darken on light backgrounds (where AA washes out). -/// Identity when `size_px >= THRESHOLD_PX`. +/// compensates for AA coverage blending per glyph size and theme polarity. pub fn lighten_for_size(color: Color, size_px: f32) -> Color { let mag = size_boost(size_px); if mag == 0.0 { @@ -91,10 +81,7 @@ pub fn lighten_for_size(color: Color, size_px: f32) -> Color { lighten(color, delta) } -/// Hue-rotate a color by 180° in OKLab while preserving lightness and -/// chroma magnitude — produces the perceptual complement (red→cyan, -/// blue→amber, yellow→indigo, green→magenta). Used by the gutter to render -/// "lines above the cursor" as the inverse of the rainbow used below. +/// rotates hue 180 degrees in OKLab, producing the perceptual complement. pub fn invert_hue(color: Color) -> Color { let mut lab = to_oklab(color); lab[1] = -lab[1]; @@ -102,10 +89,7 @@ pub fn invert_hue(color: Color) -> Color { from_oklab(lab, color.a) } -/// Drain chroma toward zero by `t` (0.0 = identity, 1.0 = grey at the same -/// L). Lightness is preserved, so a "faded red" stays as bright as the red -/// it came from — it just stops being red. Used by the gutter rainbow to -/// dissolve into neutral without dimming. +/// drains chroma toward zero by t (0.0 = identity, 1.0 = neutral grey at same L). pub fn desaturate(color: Color, t: f32) -> Color { let k = 1.0 - t.clamp(0.0, 1.0); if k == 1.0 { return color; } @@ -115,8 +99,16 @@ pub fn desaturate(color: Color, t: f32) -> Color { from_oklab(lab, color.a) } -/// Perceptual interpolation between two colors. `t = 0` returns `a`, -/// `t = 1` returns `b`. +/// scales chroma outward by the given factor, preserving lightness. +pub fn saturate(color: Color, factor: f32) -> Color { + if factor == 1.0 { return color; } + let mut lab = to_oklab(color); + lab[1] *= factor; + lab[2] *= factor; + from_oklab(lab, color.a) +} + +/// perceptual interpolation between two colors in OKLab. pub fn mix(a: Color, b: Color, t: f32) -> Color { let la = to_oklab(a); let lb = to_oklab(b); diff --git a/viewport/src/palette.rs b/viewport/src/palette.rs index 022c9c0..df1dee3 100644 --- a/viewport/src/palette.rs +++ b/viewport/src/palette.rs @@ -60,25 +60,22 @@ pub static MOCHA: Palette = Palette { crust: Color::from_rgb(0.067, 0.067, 0.106), }; -/// KiCad-inspired dark — near-black background, saturated accents, high -/// contrast. The signature KiCad schematic-editor feel: vivid greens, -/// bright cyans, punchy reds and yellows on a deep navy base. +/// KiCad-inspired dark palette with saturated accents on a deep navy base. pub static KICAD: Palette = Palette { - // From acord-palette-used.svg, 13 user-kept swatches (rounded to f32). - rosewater: Color::from_rgb(0.976, 0.639, 0.984), // (249,163,251) light pink - flamingo: Color::from_rgb(1.000, 0.718, 0.937), // (255,183,239) pink-light - pink: Color::from_rgb(0.988, 0.545, 0.808), // (252,139,206) - mauve: Color::from_rgb(0.741, 0.494, 0.984), // (189,126,251) - red: Color::from_rgb(0.973, 0.545, 0.545), // (248,139,139) - maroon: Color::from_rgb(0.933, 0.506, 0.639), // (238,129,163) - peach: Color::from_rgb(1.000, 0.667, 0.396), // (255,170,101) - yellow: Color::from_rgb(1.000, 0.886, 0.486), // (255,226,124) - green: Color::from_rgb(0.592, 0.925, 0.671), // (151,236,171) - teal: Color::from_rgb(0.310, 1.000, 0.882), // (79,255,225) - sky: Color::from_rgb(0.404, 0.812, 0.973), // (103,207,248) - sapphire: Color::from_rgb(0.384, 0.635, 0.949), // unchanged — unused slot - blue: Color::from_rgb(0.310, 0.643, 0.992), // (79,164,253) - lavender: Color::from_rgb(0.957, 0.737, 0.373), // (244,188,95) amber accent + rosewater: Color::from_rgb(0.976, 0.639, 0.984), + flamingo: Color::from_rgb(1.000, 0.718, 0.937), + pink: Color::from_rgb(0.988, 0.545, 0.808), + mauve: Color::from_rgb(0.741, 0.494, 0.984), + red: Color::from_rgb(0.973, 0.545, 0.545), + maroon: Color::from_rgb(0.933, 0.506, 0.639), + peach: Color::from_rgb(1.000, 0.667, 0.396), + yellow: Color::from_rgb(1.000, 0.886, 0.486), + green: Color::from_rgb(0.592, 0.925, 0.671), + teal: Color::from_rgb(0.310, 1.000, 0.882), + sky: Color::from_rgb(0.404, 0.812, 0.973), + sapphire: Color::from_rgb(0.384, 0.635, 0.949), + blue: Color::from_rgb(0.310, 0.643, 0.992), + lavender: Color::from_rgb(0.957, 0.737, 0.373), text: Color::from_rgb(0.965, 0.954, 0.969), subtext1: Color::from_rgb(0.824, 0.813, 0.852), subtext0: Color::from_rgb(0.679, 0.668, 0.725), @@ -145,8 +142,7 @@ pub fn set_theme(name: &str) { IS_DARK.with(|d| *d.borrow_mut() = dark); } -/// Colors for bordered inline widgets (tables, trees). Shared so both -/// widget types render identical frosted-card surfaces in both themes. +/// shared surface colors for bordered inline widgets (tables, trees). pub struct WidgetSurface { pub fill: Color, pub border: Color, diff --git a/viewport/src/print.rs b/viewport/src/print.rs index dc48b1e..be636a1 100644 --- a/viewport/src/print.rs +++ b/viewport/src/print.rs @@ -1,7 +1,4 @@ //! Black-and-white PDF print of the open document. -//! -//! No styling beyond what print needs: Helvetica body, Helvetica-Bold headings, -//! Courier code/tables, simple grid borders, page numbers at the bottom. use printpdf::{ BuiltinFont, Color, Greyscale, Line, LinePoint, Mm, Op, PdfDocument, PdfFontHandle, @@ -16,10 +13,9 @@ use crate::table_block::TableBlock; use crate::text_block::TextBlock; use crate::tree_block::TreeBlock; -/// US Letter with 1-inch margins. const PAGE_W_MM: f32 = 215.9; const PAGE_H_MM: f32 = 279.4; -const MARGIN_MM: f32 = 19.05; // 0.75 inch — slightly tighter than 1" so notes fit +const MARGIN_MM: f32 = 19.05; const BODY_PT: f32 = 10.5; const CODE_PT: f32 = 9.5; @@ -30,9 +26,7 @@ const BLOCK_GAP_PT: f32 = 8.0; const TABLE_PAD_PT: f32 = 4.0; const TABLE_LINE_PT: f32 = 0.5; -/// Approximate glyph widths in em-units. Built-in font metrics aren't exposed -/// when `text_layout` is off; these are conservative averages so wrapping -/// under-fills slightly rather than overflowing. +/// approximate glyph widths in em-units per builtin font family. fn avg_em(font: BuiltinFont) -> f32 { match font { BuiltinFont::Courier @@ -154,7 +148,6 @@ fn wrap_lines(text: &str, font: BuiltinFont, size_pt: f32, max_w_pt: f32) -> Vec if approx_text_width_pt(&candidate, font, size_pt) <= max_w_pt { current = candidate; } else if current.is_empty() { - // single word too long for the line — let it overflow rather than truncate out.push(word.to_string()); current = String::new(); } else { @@ -174,7 +167,6 @@ fn strip_inline_md(line: &str) -> String { while let Some(c) = chars.next() { match c { '*' | '_' => { - // collapse runs of the same marker while chars.peek() == Some(&c) { chars.next(); } } '`' => { @@ -192,7 +184,6 @@ struct Layout { margin_pt: f32, pages: Vec>, cur: Vec, - /// y-cursor in PDF coordinates (origin at bottom-left, growing upward). y_pt: f32, page_count: usize, } @@ -304,7 +295,6 @@ impl Layout { let font_head = BuiltinFont::HelveticaBold; let cell_inner_w = col_w - 2.0 * TABLE_PAD_PT; - // pre-wrap each cell into lines so we can compute row heights up front. let wrapped: Vec>> = rows .iter() .enumerate() diff --git a/viewport/src/selection.rs b/viewport/src/selection.rs index 9838dad..8c56bdf 100644 --- a/viewport/src/selection.rs +++ b/viewport/src/selection.rs @@ -1,12 +1,4 @@ -//! Central selection model. -//! -//! Every selectable element in the document — a cell, a row, a column, a line, -//! a character range, or a whole block — is addressed by a `NodePath`. The -//! `Selection` enum holds whatever the user has currently selected and is the -//! single source of truth (no per-block selection state). -//! -//! Cursorline highlight, table cell selection, multi-line picks, and cross-block -//! ranges are all the same primitive at different scopes. +//! Central selection model addressing every selectable element via NodePath. pub type BlockId = u64; @@ -20,24 +12,15 @@ impl TextPos { pub const ZERO: Self = TextPos { line: 0, col: 0 }; } -/// Address of any selectable element inside a block. Which variants are valid -/// depends on the block kind: a heading only meaningfully has `Whole`; a table -/// has `Cell`/`CellRect`/`Row`/`Col`; a text block has `Line`/`LineRange`. +/// address of a selectable element inside a block, variant set depends on block kind. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum InnerPath { - /// The whole block (heading, HR, tree, or "this entire table/text block"). Whole, - /// A specific line in a text-bearing block (cursorline target). Line(usize), - /// A character range within a text-bearing block. LineRange { start: TextPos, end: TextPos }, - /// A cell at (row, col) in a table. Cell { row: usize, col: usize }, - /// A rectangular range of cells. CellRect { r0: usize, c0: usize, r1: usize, c1: usize }, - /// An entire row of a table. Row(usize), - /// An entire column of a table. Col(usize), } @@ -61,17 +44,13 @@ impl NodePath { } } -/// The single selection state for the entire document. +/// single selection state for the entire document. #[derive(Debug, Clone, PartialEq, Eq, Default)] pub enum Selection { - /// Nothing selected. #[default] None, - /// Cursor anchor at a single path. No range. Cursorline target. Caret(NodePath), - /// A range from anchor to head. The two paths can live in different blocks. Range { anchor: NodePath, head: NodePath }, - /// An independent set (Cmd-click multi-cell, multi-line picks). Set(Vec), } @@ -80,10 +59,7 @@ impl Selection { matches!(self, Selection::None) } - /// True if the given path is a member of this selection. Iterative — no - /// recursion. Range membership beyond exact endpoints (e.g. "is cell - /// (2,3) inside the rect from (1,1) to (3,5)?") is the consumer block's - /// responsibility; this only does point-equality. + /// point-equality membership check against the selection endpoints. pub fn contains_path(&self, path: &NodePath) -> bool { match self { Selection::None => false, @@ -93,7 +69,7 @@ impl Selection { } } - /// True if any path in the selection lives in the given block. + /// true when any path in the selection belongs to the given block. pub fn touches_block(&self, block_id: BlockId) -> bool { match self { Selection::None => false, diff --git a/viewport/src/sidecar.rs b/viewport/src/sidecar.rs index 946e3ca..9d89c77 100644 --- a/viewport/src/sidecar.rs +++ b/viewport/src/sidecar.rs @@ -1,43 +1,4 @@ -//! Embedded sidecar archive. -//! -//! Markdown is the floor — `.md` files stay readable in vim and on GitHub — -//! but Numbers-class table features (positional metadata, per-cell formatting, -//! formulas, references) don't fit in markdown. -//! -//! Acord stores rich metadata in the SAME `.md` file as a base64-encoded zip -//! wrapped in an HTML comment appended to the end of the document: -//! -//! ```text -//! ...the user's markdown content... -//! -//! -//! ``` -//! -//! Why this shape: -//! - HTML comments are valid markdown — every renderer (GitHub, Bear, Obsidian) -//! treats them as invisible. Vim shows them as a single comment block, not -//! as binary garbage. -//! - Base64 stays text-clean — no `\0` bytes, vim won't flag the file as -//! binary, `git diff` is still legible (modulo a wide line at the bottom). -//! - The zip's central directory makes it trivial to add more entries later -//! (per-block scratch state, formula caches, embedded images) without -//! changing the framing. -//! -//! Per-table linking is positional: the Nth non-eval table in document layout -//! order is sidecar key "N". No proprietary tags appear in the markdown body. -//! Identity is runtime state derived from the document, never written to disk. -//! -//! The archive is structured like a Rust crate — each block is a submodule -//! file under `src/`, and `config.toml` holds display-only metadata (col -//! widths, row heights, cell styles). Save direction only: the markdown is -//! always the source of truth; the archive is regenerated fresh on every save. -//! On load, only `config.toml` is read for display metadata. If missing or -//! malformed, start fresh — next save overwrites. -//! -//! Eval result tables are explicitly NOT persisted. Only the source `/= expr` -//! line goes into markdown; the result table re-renders fresh on load. +//! Embedded sidecar archive: rich table metadata stored as a zip inside the .md file. use std::collections::HashMap; use std::io::{Cursor, Read, Write}; @@ -48,26 +9,17 @@ use serde::{Deserialize, Serialize}; use zip::write::SimpleFileOptions; use zip::{CompressionMethod, ZipArchive, ZipWriter}; -/// Sentinel that opens the embedded archive comment. Anything from this string -/// to the matching `-->` is the archive payload (base64-encoded zip). const ARCHIVE_OPEN: &str = ""; -/// Root-level display metadata file inside the zip. Holds col widths, row -/// heights, cell styles, formulas — things that don't affect evaluation. const CONFIG_ENTRY: &str = "config.toml"; -/// Directory inside the zip holding one `.cord` file per block. Each file -/// contains TOML front-matter + source, structured like a crate submodule. const SRC_DIR: &str = "src/"; -/// Top-level schema of a `.acord.toml` companion. Versioned so we can -/// migrate later as the Numbers-class table feature set grows. +/// top-level versioned schema of the sidecar config. #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct Sidecar { - /// Schema version. Bump on incompatible changes. #[serde(default = "default_version")] pub version: u32, - /// Table metadata indexed by `[#id]` markers in the markdown. #[serde(default)] pub tables: HashMap, } @@ -78,20 +30,12 @@ fn default_version() -> u32 { #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct TableSidecar { - /// Per-column widths in pixels. Same length as the table's column count - /// (or shorter; missing entries fall back to the editor's default width). #[serde(default)] pub col_widths: Vec, - /// Sparse per-row explicit heights. Keys are row indices serialized as - /// strings (TOML's native key type); convert with `parse::()` at - /// the boundary. A table with a few resized rows doesn't carry the - /// default for every other row. #[serde(default)] pub row_heights: HashMap, - /// Per-cell metadata indexed by spreadsheet-style address ("A1", "D2", ...). #[serde(default)] pub cells: HashMap, - /// Cell formulas indexed by spreadsheet address. #[serde(default)] pub formulas: HashMap, } @@ -108,9 +52,7 @@ pub struct CellSidecar { pub align: Option, } -/// Reads sidecar TOML. Returns `Default` on parse error so a corrupt sidecar -/// never blocks opening a markdown file — the user just loses the rich metadata -/// until they re-save. +/// reads sidecar TOML, defaulting on parse error. pub struct SidecarReader { inner: Sidecar, } @@ -130,10 +72,7 @@ impl SidecarReader { } } -/// Accumulates sidecar entries during a save pass. Each block's `to_md` writes -/// its side-channel state into the writer; after the pass, `flush` produces the -/// TOML text to write to disk (or `None` if there's nothing to write — empty -/// sidecars should be deleted from disk to avoid littering). +/// accumulates sidecar entries during a save pass, flushing to TOML or None. pub struct SidecarWriter { inner: Sidecar, } @@ -152,7 +91,7 @@ impl SidecarWriter { self.inner.tables.insert(id, data); } - /// Returns the serialized TOML, or `None` if the sidecar has no entries. + /// serializes to TOML, or None when empty. pub fn flush(self) -> Option { if self.inner.tables.is_empty() { return None; @@ -171,19 +110,13 @@ impl Default for SidecarWriter { // Embedded archive: split markdown text into (body, optional sidecar) // ---------------------------------------------------------------------------- -/// Result of pulling an archive out of an `.md` file. `markdown` is the user -/// content with the archive comment stripped; `sidecar` is the parsed config -/// (or `None` if the file had no archive). +/// markdown body with the archive comment stripped, plus parsed sidecar config. pub struct LoadedDoc { pub markdown: String, pub sidecar: Option, } -/// Pull an embedded archive out of a markdown file. If the file has no -/// `` comment, returns the text unchanged with -/// `sidecar = None`. Failure modes (truncated comment, bad base64, malformed -/// zip, malformed TOML) all degrade gracefully to "no sidecar" — the user -/// never loses access to their markdown content because of corrupted metadata. +/// extracts an embedded archive comment, degrading gracefully on any failure. pub fn extract_archive(text: &str) -> LoadedDoc { let Some(open_idx) = text.rfind(ARCHIVE_OPEN) else { return LoadedDoc { @@ -211,8 +144,7 @@ pub fn extract_archive(text: &str) -> LoadedDoc { } } -/// A single block's source file for the archive. Written to `src/` -/// inside the zip. Content is TOML front-matter + `---` separator + raw source. +/// single block source file written to src/ inside the archive zip. pub struct BlockFile { pub filename: String, pub content: String, @@ -233,9 +165,7 @@ pub fn extract_archive_bytes(bytes: &[u8]) -> Option { toml::from_str::(&toml_text).ok() } -/// magic separating the markdown body from the appended raw zip; the surrounding NULs -/// trip text editors into "binary mode" so the archive shows up as garbage, not as -/// readable base64. +/// binary sentinel separating markdown from appended raw zip bytes. pub const BINARY_SENTINEL: &[u8] = b"\n\x00ACORD-ARCHIVE\x00\n"; /// appends raw zip bytes after the markdown body, separated by BINARY_SENTINEL. @@ -268,8 +198,7 @@ fn rfind_subslice(haystack: &[u8], needle: &[u8]) -> Option { haystack.windows(needle.len()).rposition(|w| w == needle) } -/// legacy embed format: base64-encoded zip inside an HTML comment. Kept for the -/// round-trip tests and the load-side back-compat path; new saves go through embed_in_md. +/// legacy embed format: base64-encoded zip inside an HTML comment. pub fn embed_archive(markdown: &str, sidecar: &Sidecar, block_files: &[BlockFile]) -> String { let Some(zip_bytes) = build_archive_bytes(sidecar, block_files) else { return markdown.to_string(); @@ -289,8 +218,6 @@ pub fn embed_archive(markdown: &str, sidecar: &Sidecar, block_files: &[BlockFile } fn strip_trailing_blank_lines(s: &str) -> String { - // Walk back over consecutive trailing newlines / whitespace lines so that - // round-tripping a doc with an archive doesn't accumulate blank lines. let mut end = s.len(); let bytes = s.as_bytes(); while end > 0 { @@ -310,8 +237,6 @@ fn strip_trailing_blank_lines(s: &str) -> String { } fn decode_archive_payload(payload: &str) -> Option { - // Strip whitespace inside the comment so the wrapping is invisible to the - // decoder. let cleaned: String = payload.chars().filter(|c| !c.is_whitespace()).collect(); let zip_bytes = B64.decode(cleaned.as_bytes()).ok()?; let toml_text = read_zip(&zip_bytes)?; @@ -430,8 +355,6 @@ mod tests { #[test] fn extract_with_corrupt_payload_recovers_markdown() { - // Garbage in the comment body must NOT eat the user's markdown — they - // get the body back, sidecar None. let doc = "# Body\n\nstuff\n\n\n"; let loaded = extract_archive(doc); assert!(loaded.markdown.contains("# Body")); diff --git a/viewport/src/syntax.rs b/viewport/src/syntax.rs index 4222735..0e60498 100644 --- a/viewport/src/syntax.rs +++ b/viewport/src/syntax.rs @@ -12,12 +12,10 @@ use crate::palette; pub const EVAL_RESULT_KIND: u8 = 24; pub const EVAL_ERROR_KIND: u8 = 25; -// --- Cordial (eval-line) tokens. Start at 50 to leave room above the -// markdown range. A single hand-rolled scanner (`highlight_cordial`) dispatches -// on these so every Cordial visual element — the `/=` sigil, the `@` ref -// prefix, `::`, table/block names, cell addresses, keywords, builtins, -// numbers, strings, comments — gets its own color. +// cordial token IDs, starting at 50 above the markdown range. const COR_EVAL_SIGIL: u8 = 50; +const COR_EVAL_TABLE_SUFFIX: u8 = 68; +const COR_EVAL_TREE_SUFFIX: u8 = 69; const COR_AT_SIGIL: u8 = 51; const COR_COLON_COLON: u8 = 52; const COR_REF_COLON: u8 = 53; @@ -33,18 +31,12 @@ const COR_OPERATOR: u8 = 62; const COR_BRACKET: u8 = 63; const COR_TYPE_ANN: u8 = 64; -// Per-identifier rainbow. Each user-introduced name (let, fn, params, for var, -// math-form fn def) gets one of eight palette slots, picked with a stride -// that avoids adjacent colors landing on consecutive identifiers. Subsequent -// references resolve to the same slot so the name reads the same color -// throughout the document. +// per-identifier rainbow: 8 palette slots, hop-of-3 stride to separate adjacent names. const USER_IDENT_BASE: u8 = 70; pub const USER_IDENT_PALETTE_SIZE: u8 = 8; pub const USER_IDENT_HOP: u32 = 3; -/// The 8-slot rainbow shared by user-identifier highlighting and the gutter -/// line-number rainbow. Same hop-of-3 walk through the same palette so the -/// two systems read as one design. +/// maps an index into the 8-slot rainbow palette via hop-of-3 stride. pub fn rainbow_color(idx: u32) -> Color { let slot = ((idx * USER_IDENT_HOP) % USER_IDENT_PALETTE_SIZE as u32) as u8; highlight_color(USER_IDENT_BASE + slot) @@ -70,11 +62,7 @@ const MD_TASK_OPEN: u8 = 42; const MD_TASK_DONE: u8 = 43; const MD_BOLD_ITALIC: u8 = 44; -/// The monospace family used for the editor body and every inline highlight -/// span. Naming the family explicitly (rather than `Family::Monospace`) forces -/// cosmic-text / fontdb to resolve real Bold, Italic and BoldItalic faces, -/// which the generic monospace fallback does not reliably do on macOS because -/// cosmic-text hardcodes its default monospace family to "Noto Sans Mono". +/// named monospace family, forcing fontdb to resolve real Bold/Italic faces per platform. #[cfg(any(target_os = "macos", target_os = "ios"))] pub const EDITOR_FONT: Font = Font::with_name("Menlo"); #[cfg(target_os = "windows")] @@ -86,6 +74,8 @@ pub const EDITOR_FONT: Font = Font::with_name("DejaVu Sans Mono"); pub struct SyntaxSettings { pub lang: String, pub source: String, + /// doc-wide user-ident to rainbow-slot map, computed across all text blocks. + pub user_idents: HashMap, } #[derive(Clone, Copy, Debug)] @@ -111,54 +101,62 @@ pub struct SyntaxHighlighter { current_line: usize, line_decors: Vec, user_idents: HashMap, - /// per-line tree-sitter spans for fenced code body lines, by absolute line index. + /// per-line tree-sitter spans for fenced code body lines, keyed by absolute line index. code_block_spans: HashMap, SyntaxHighlight)>>, + /// line count at last full rebuild, drives incremental skip. + prev_line_count: usize, } impl SyntaxHighlighter { fn rebuild(&mut self, source: &str) { - self.spans = highlight_source(source, &self.lang); + let new_line_count = source.split('\n').count(); + let structure_changed = new_line_count != self.prev_line_count; + self.line_offsets.clear(); let mut offset = 0; for line in source.split('\n') { self.line_offsets.push(offset); offset += line.len() + 1; } - let classified = classify_document(source); - self.line_kinds = classified.into_iter().map(|cl| cl.kind).collect(); - self.line_decors.clear(); - let mut in_fence = false; - for (i, raw_line) in source.split('\n').enumerate() { - let is_md = i < self.line_kinds.len() && self.line_kinds[i] == LineKind::Markdown; - if is_md { - let trimmed = raw_line.trim_start(); - if trimmed.starts_with("```") { - in_fence = !in_fence; - self.line_decors.push(LineDecor::FenceMarker); - } else if in_fence { - self.line_decors.push(LineDecor::CodeBlock); - } else if is_horizontal_rule(trimmed) { - self.line_decors.push(LineDecor::HorizontalRule); - } else if trimmed.starts_with("> ") || trimmed == ">" { - self.line_decors.push(LineDecor::Blockquote); + if structure_changed { + self.spans = highlight_source(source, &self.lang); + let classified = classify_document(source); + self.line_kinds = classified.into_iter().map(|cl| cl.kind).collect(); + + self.line_decors.clear(); + let mut in_fence = false; + for (i, raw_line) in source.split('\n').enumerate() { + let is_md = i < self.line_kinds.len() && self.line_kinds[i] == LineKind::Markdown; + if is_md { + let trimmed = raw_line.trim_start(); + if trimmed.starts_with("```") { + in_fence = !in_fence; + self.line_decors.push(LineDecor::FenceMarker); + } else if in_fence { + self.line_decors.push(LineDecor::CodeBlock); + } else if is_horizontal_rule(trimmed) { + self.line_decors.push(LineDecor::HorizontalRule); + } else if trimmed.starts_with("> ") || trimmed == ">" { + self.line_decors.push(LineDecor::Blockquote); + } else { + self.line_decors.push(LineDecor::None); + } } else { + if in_fence { in_fence = false; } self.line_decors.push(LineDecor::None); } - } else { - if in_fence { in_fence = false; } - self.line_decors.push(LineDecor::None); } + + self.scan_fenced_code_blocks(source); + self.prev_line_count = new_line_count; } self.in_fenced_code = false; self.current_line = 0; - - self.scan_user_idents(source); - self.scan_fenced_code_blocks(source); } - /// runs each language-tagged fenced block through tree-sitter and stashes per-line spans. + /// highlights language-tagged fenced blocks via tree-sitter, stashing per-line spans. fn scan_fenced_code_blocks(&mut self, source: &str) { self.code_block_spans.clear(); let lines: Vec<&str> = source.split('\n').collect(); @@ -233,86 +231,8 @@ impl SyntaxHighlighter { } } - /// Walk the source, find every identifier introduction site (let, fn, - /// for, math-form fn def, params), and assign each unique name a slot - /// in the user-ident rainbow. Subsequent references in `highlight_cordial` - /// look the name up here. - fn scan_user_idents(&mut self, source: &str) { - self.user_idents.clear(); - let mut next_slot: u32 = 0; - for line in source.split('\n') { - let trimmed = line.trim_start(); - let bytes = trimmed.as_bytes(); - - // `let IDENT...` - if let Some(rest) = trimmed.strip_prefix("let ") { - let mut i = 0; - let rb = rest.as_bytes(); - while i < rb.len() && rb[i] == b' ' { i += 1; } - let name_start = i; - while i < rb.len() && (rb[i].is_ascii_alphanumeric() || rb[i] == b'_') { i += 1; } - if i > name_start { - assign_user_ident(&mut self.user_idents, &mut next_slot, &rest[name_start..i]); - } - while i < rb.len() && rb[i] == b' ' { i += 1; } - if i < rb.len() && rb[i] == b'(' { - extract_paren_idents(&rest[i + 1..], &mut self.user_idents, &mut next_slot); - } - continue; - } - - // `fn IDENT(...)` - if let Some(rest) = trimmed.strip_prefix("fn ") { - let mut i = 0; - let rb = rest.as_bytes(); - while i < rb.len() && rb[i] == b' ' { i += 1; } - let name_start = i; - while i < rb.len() && (rb[i].is_ascii_alphanumeric() || rb[i] == b'_') { i += 1; } - if i > name_start { - assign_user_ident(&mut self.user_idents, &mut next_slot, &rest[name_start..i]); - } - while i < rb.len() && rb[i] == b' ' { i += 1; } - if i < rb.len() && rb[i] == b'(' { - extract_paren_idents(&rest[i + 1..], &mut self.user_idents, &mut next_slot); - } - continue; - } - - // `for IDENT in ...` - if let Some(rest) = trimmed.strip_prefix("for ") { - let rb = rest.as_bytes(); - let mut i = 0; - while i < rb.len() && rb[i] == b' ' { i += 1; } - let name_start = i; - while i < rb.len() && (rb[i].is_ascii_alphanumeric() || rb[i] == b'_') { i += 1; } - if i > name_start { - assign_user_ident(&mut self.user_idents, &mut next_slot, &rest[name_start..i]); - } - continue; - } - - // `IDENT(...) = ...` math-form fn def, OR `IDENT = ...` assignment - let mut i = 0; - let name_start = i; - while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') { i += 1; } - if i > name_start { - let name = &trimmed[name_start..i]; - let mut j = i; - while j < bytes.len() && bytes[j] == b' ' { j += 1; } - if j < bytes.len() { - if bytes[j] == b'(' { - assign_user_ident(&mut self.user_idents, &mut next_slot, name); - extract_paren_idents(&trimmed[j + 1..], &mut self.user_idents, &mut next_slot); - } else if bytes[j] == b'=' && (j + 1 >= bytes.len() || bytes[j + 1] != b'=') { - assign_user_ident(&mut self.user_idents, &mut next_slot, name); - } - } - } - } - } - - /// byte ranges of inline format markers on a non-fenced markdown line, empty otherwise. + /// byte ranges of inline format markers on a non-fenced markdown line. pub fn line_marker_ranges(&self, line_idx: usize, line_text: &str) -> Vec> { if line_idx >= self.line_kinds.len() { return Vec::new(); @@ -401,10 +321,80 @@ impl SyntaxHighlighter { } } -/// Scan a Cordial line (or an eval line) and emit per-token highlight -/// spans. Idempotent, single-pass; each branch either consumes a whole -/// token or advances one byte. Unknown bytes get no highlight (they fall -/// through to the editor's default text color). +/// assigns each introduced identifier (let, fn, for, params) a rainbow slot at doc scope. +pub fn scan_user_idents_in(source: &str) -> HashMap { + let mut map: HashMap = HashMap::new(); + let mut next_slot: u32 = 0; + + for line in source.split('\n') { + let trimmed = line.trim_start(); + let bytes = trimmed.as_bytes(); + + if let Some(rest) = trimmed.strip_prefix("let ") { + let mut i = 0; + let rb = rest.as_bytes(); + while i < rb.len() && rb[i] == b' ' { i += 1; } + let name_start = i; + while i < rb.len() && (rb[i].is_ascii_alphanumeric() || rb[i] == b'_') { i += 1; } + if i > name_start { + assign_user_ident(&mut map, &mut next_slot, &rest[name_start..i]); + } + while i < rb.len() && rb[i] == b' ' { i += 1; } + if i < rb.len() && rb[i] == b'(' { + extract_paren_idents(&rest[i + 1..], &mut map, &mut next_slot); + } + continue; + } + + if let Some(rest) = trimmed.strip_prefix("fn ") { + let mut i = 0; + let rb = rest.as_bytes(); + while i < rb.len() && rb[i] == b' ' { i += 1; } + let name_start = i; + while i < rb.len() && (rb[i].is_ascii_alphanumeric() || rb[i] == b'_') { i += 1; } + if i > name_start { + assign_user_ident(&mut map, &mut next_slot, &rest[name_start..i]); + } + while i < rb.len() && rb[i] == b' ' { i += 1; } + if i < rb.len() && rb[i] == b'(' { + extract_paren_idents(&rest[i + 1..], &mut map, &mut next_slot); + } + continue; + } + + if let Some(rest) = trimmed.strip_prefix("for ") { + let rb = rest.as_bytes(); + let mut i = 0; + while i < rb.len() && rb[i] == b' ' { i += 1; } + let name_start = i; + while i < rb.len() && (rb[i].is_ascii_alphanumeric() || rb[i] == b'_') { i += 1; } + if i > name_start { + assign_user_ident(&mut map, &mut next_slot, &rest[name_start..i]); + } + continue; + } + + let mut i = 0; + let name_start = i; + while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') { i += 1; } + if i > name_start { + let name = &trimmed[name_start..i]; + let mut j = i; + while j < bytes.len() && bytes[j] == b' ' { j += 1; } + if j < bytes.len() { + if bytes[j] == b'(' { + assign_user_ident(&mut map, &mut next_slot, name); + extract_paren_idents(&trimmed[j + 1..], &mut map, &mut next_slot); + } else if bytes[j] == b'=' && (j + 1 >= bytes.len() || bytes[j + 1] != b'=') { + assign_user_ident(&mut map, &mut next_slot, name); + } + } + } + } + + map +} + fn assign_user_ident(map: &mut HashMap, slot: &mut u32, name: &str) { if name.is_empty() || is_cordial_keyword(name) @@ -432,8 +422,7 @@ fn extract_paren_idents(s: &str, map: &mut HashMap, slot: &mut u32) b'(' => { depth += 1; i += 1; } b')' => { depth -= 1; i += 1; } b':' => { - // Skip the type identifier that follows; type names belong - // to the type-annotation color, not the user-ident rainbow. + // skip type identifier following the colon. i += 1; while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') { i += 1; } while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') { i += 1; } @@ -454,36 +443,40 @@ fn highlight_cordial(line: &str, user_idents: &HashMap) -> Vec<(Rang let mut spans: Vec<(Range, SyntaxHighlight)> = Vec::new(); let mut i = 0; - // Opening `/=`, `/=|`, `/=\` sigil (with optional leading whitespace). + // /= sigil green, | table suffix yellow, \ tree suffix red. let leading = line.len() - line.trim_start().len(); if leading + 2 <= len && &bytes[leading..leading + 2] == b"/=" { - let sigil_end = if leading + 3 <= len - && (bytes[leading + 2] == b'|' || bytes[leading + 2] == b'\\') - { - leading + 3 - } else { - leading + 2 - }; - spans.push((leading..sigil_end, SyntaxHighlight { kind: COR_EVAL_SIGIL })); - i = sigil_end; + spans.push((leading..leading + 2, SyntaxHighlight { kind: COR_EVAL_SIGIL })); + i = leading + 2; + if i < len { + let suffix_kind = match bytes[i] { + b'|' => Some(COR_EVAL_TABLE_SUFFIX), + b'\\' => Some(COR_EVAL_TREE_SUFFIX), + _ => None, + }; + if let Some(kind) = suffix_kind { + spans.push((i..i + 1, SyntaxHighlight { kind })); + i += 1; + } + } } while i < len { let c = bytes[i]; - // Whitespace: skip. + // whitespace if c == b' ' || c == b'\t' || c == b'\r' { i += 1; continue; } - // Line comment: `// …` — rest of line. + // line comment if c == b'/' && i + 1 < len && bytes[i + 1] == b'/' { spans.push((i..len, SyntaxHighlight { kind: COR_COMMENT })); break; } - // String literal. + // string literal if c == b'"' { let start = i; i += 1; @@ -495,15 +488,13 @@ fn highlight_cordial(line: &str, user_idents: &HashMap) -> Vec<(Rang continue; } - // `@` cell reference: @[Block::]Table[:A1[:B4]] or @T[A1:B4]. + // @ cell reference if c == b'@' { spans.push((i..i + 1, SyntaxHighlight { kind: COR_AT_SIGIL })); i += 1; - // First ident. let n1_start = i; while i < len && is_ident_byte(bytes[i]) { i += 1; } let n1_end = i; - // Is it a block qualifier? Look for `::` after. if i + 1 < len && bytes[i] == b':' && bytes[i + 1] == b':' { if n1_end > n1_start { spans.push((n1_start..n1_end, SyntaxHighlight { kind: COR_BLOCK_NAME })); @@ -518,7 +509,6 @@ fn highlight_cordial(line: &str, user_idents: &HashMap) -> Vec<(Rang } else if n1_end > n1_start { spans.push((n1_start..n1_end, SyntaxHighlight { kind: COR_TABLE_NAME })); } - // Optional `:A1` or `:A1:B2` cell/range target. if i < len && bytes[i] == b':' { spans.push((i..i + 1, SyntaxHighlight { kind: COR_REF_COLON })); i += 1; @@ -529,7 +519,6 @@ fn highlight_cordial(line: &str, user_idents: &HashMap) -> Vec<(Rang i = consume_cell_addr(bytes, i, &mut spans); } } else if i < len && bytes[i] == b'[' { - // Bracket range: `[A1:B2]`. spans.push((i..i + 1, SyntaxHighlight { kind: COR_BRACKET })); i += 1; i = consume_cell_addr(bytes, i, &mut spans); @@ -546,10 +535,7 @@ fn highlight_cordial(line: &str, user_idents: &HashMap) -> Vec<(Rang continue; } - // Numeric literal (integer or decimal, with optional leading `-` - // in operator-valid position — keep it simple: only recognise as - // a number when we're right after an operator or at the start of - // whitespace, otherwise leave `-` to the operator scanner). + // numeric literal if c.is_ascii_digit() || (c == b'.' && i + 1 < len && bytes[i + 1].is_ascii_digit()) { @@ -561,7 +547,7 @@ fn highlight_cordial(line: &str, user_idents: &HashMap) -> Vec<(Rang continue; } - // Identifier → keyword / builtin / type-annotation / user-rainbow. + // identifier: keyword / builtin / type-annotation / user-rainbow if is_ident_byte(c) && !c.is_ascii_digit() { let start = i; while i < len && is_ident_byte(bytes[i]) { i += 1; } @@ -578,28 +564,28 @@ fn highlight_cordial(line: &str, user_idents: &HashMap) -> Vec<(Rang continue; } - // `::` as a namespace separator outside of a ref (e.g. `use mod::item`). + // :: namespace separator if c == b':' && i + 1 < len && bytes[i + 1] == b':' { spans.push((i..i + 2, SyntaxHighlight { kind: COR_COLON_COLON })); i += 2; continue; } - // Plain `:` — likely a type annotation colon in `let x: T = …`. + // plain colon (type annotation) if c == b':' { spans.push((i..i + 1, SyntaxHighlight { kind: COR_REF_COLON })); i += 1; continue; } - // Bracket / brace / paren — separate color from operators. + // brackets / parens / commas if matches!(c, b'(' | b')' | b'{' | b'}' | b'[' | b']' | b',') { spans.push((i..i + 1, SyntaxHighlight { kind: COR_BRACKET })); i += 1; continue; } - // Operator run: consume a contiguous block of operator bytes. + // operator run if is_operator_byte(c) { let start = i; while i < len && is_operator_byte(bytes[i]) { i += 1; } @@ -622,9 +608,6 @@ fn consume_cell_addr( while i < bytes.len() && bytes[i].is_ascii_alphabetic() { i += 1; } let letters_end = i; while i < bytes.len() && bytes[i].is_ascii_digit() { i += 1; } - // Only tag as a cell address when we matched BOTH letters AND digits — - // otherwise we're looking at a bare identifier or a digit run that - // some other branch should have handled. if i > start && letters_end > start && i > letters_end { spans.push((start..i, SyntaxHighlight { kind: COR_CELL_ADDR })); } @@ -643,9 +626,6 @@ fn is_operator_byte(b: u8) -> bool { fn is_cordial_keyword(w: &str) -> bool { matches!(w, "let" | "fn" | "if" | "else" | "while" | "for" | "in" | "return" | "use" | "is" | "true" | "false" | "and" | "or" | "not" - // Function-inversion DSL — two forms: - // programmer: let lfreq = solve!(l, f0) // or `solve!(l from f0)` - // math: let lfreq(freq, c) = l where f0(l, c) = freq | "solve" | "where" | "from") } @@ -656,6 +636,15 @@ fn is_cordial_builtin(w: &str) -> bool { | "sqrt" | "abs" | "floor" | "ceil" | "round" | "ln" | "log" // collections | "len" | "range" | "push" + // higher-order + | "map" | "each" | "fold" | "reduce" | "all" | "any" + | "filter" | "find" | "take_while" | "skip_while" | "inspect" | "tap" + | "take" | "skip" | "drop" | "chunk" | "window" + | "zip" | "flatten" | "flat_map" + | "sort" | "sort_by" | "distinct" | "unique" + | "delta" | "scan" + // ring module (gated by `use ring`) + | "ring" | "iter" | "peek" | "history" // aggregates | "sum" | "avg" | "min" | "max" | "count" | "std_devp" | "std_devs" // constants @@ -667,9 +656,6 @@ fn is_cordial_type_annotation(w: &str) -> bool { matches!(w, "int" | "float" | "bool" | "str" | "number" | "array" | "vec") } -/// Did the scanner just emit a `:` span? Used so a type name following a -/// `:` picks up the type-annotation color only in the `let x: T = …` shape, -/// never when it happens to sit elsewhere on the line. fn last_token_is_colon(spans: &[(Range, SyntaxHighlight)]) -> bool { matches!(spans.last(), Some((_, h)) if h.kind == COR_REF_COLON) } @@ -737,16 +723,8 @@ fn parse_inline(text: &str, base: usize) -> Vec<(Range, SyntaxHighlight)> continue; } - // cosmic-text's partial reshape (called by iced's text_editor after - // add_span) drops the new run's attrs on the FIRST glyph of the new - // attribute run, so `*hello*` would render with "h" plain and "ello" - // italic. Workaround: emit the bold/italic span covering the opening - // marker bytes too — the marker becomes the "lost first glyph" and - // the first letter of the inner text gets the style. The marker span - // pushed first is overridden by the bold/italic span that follows - // because cosmic-text uses the LAST add_span to win on overlap. - // Markers (`*`, `**`, `***`) end up italic/bold themselves, which is - // imperceptible at typical font sizes. + // workaround: extend bold/italic spans to cover opening markers, + // counteracting cosmic-text's partial reshape glyph-attr drop. if i + 2 < len && bytes[i] == b'*' && bytes[i + 1] == b'*' && bytes[i + 2] == b'*' { if let Some(end) = find_triple_star(bytes, i + 3) { @@ -774,7 +752,6 @@ fn parse_inline(text: &str, base: usize) -> Vec<(Range, SyntaxHighlight)> let kind = if h.kind == MD_ITALIC { MD_BOLD_ITALIC } else { h.kind }; spans.push((r, SyntaxHighlight { kind })); } - // Extend bold over closing marker for visual consistency. spans.push((base + end..base + end + 2, SyntaxHighlight { kind: MD_BOLD })); } } @@ -924,8 +901,9 @@ impl highlighter::Highlighter for SyntaxHighlighter { in_fenced_code: false, current_line: 0, line_decors: Vec::new(), - user_idents: HashMap::new(), + user_idents: settings.user_idents.clone(), code_block_spans: HashMap::new(), + prev_line_count: 0, }; h.rebuild(&settings.source); h @@ -933,6 +911,7 @@ impl highlighter::Highlighter for SyntaxHighlighter { fn update(&mut self, new_settings: &Self::Settings) { self.lang = new_settings.lang.clone(); + self.user_idents = new_settings.user_idents.clone(); self.rebuild(&new_settings.source); } @@ -955,7 +934,19 @@ impl highlighter::Highlighter for SyntaxHighlighter { return vec![(0..line.len(), SyntaxHighlight { kind: EVAL_ERROR_KIND })].into_iter(); } - let is_markdown = ln < self.line_kinds.len() + // pure-code mode bypasses cordial and markdown classifiers. + let is_pure_code = !self.lang.is_empty(); + + if !is_pure_code + && ln < self.line_kinds.len() + && matches!(self.line_kinds[ln], LineKind::Cordial | LineKind::Eval | LineKind::Comment) + { + if self.in_fenced_code { self.in_fenced_code = false; } + return highlight_cordial(line, &self.user_idents).into_iter(); + } + + let is_markdown = !is_pure_code + && ln < self.line_kinds.len() && self.line_kinds[ln] == LineKind::Markdown; if is_markdown { @@ -971,25 +962,11 @@ impl highlighter::Highlighter for SyntaxHighlighter { return vec![(0..line.len(), SyntaxHighlight { kind: MD_CODE_BLOCK })].into_iter(); } - // Markdown lines always return md_spans, even when empty — - // falling through to the code path would let plain prose pick up - // Rust keyword highlighting on words like "let", "type", "return". return self.highlight_markdown(line).into_iter(); } else if self.in_fenced_code { self.in_fenced_code = false; } - // Non-markdown lines are Cordial / Eval / Comment — hand-rolled - // Cordial scanner, not the generic tree-sitter path (which uses - // the configured `lang`, wrong for Cordial). Each token gets its - // own color: `/=`, `@`, `::`, table / block names, cell addresses, - // keywords, builtins, numbers, strings, comments. - if ln < self.line_kinds.len() - && matches!(self.line_kinds[ln], LineKind::Cordial | LineKind::Eval | LineKind::Comment) - { - return highlight_cordial(line, &self.user_idents).into_iter(); - } - if ln >= self.line_offsets.len() { return Vec::new().into_iter(); } @@ -1062,7 +1039,9 @@ pub fn highlight_color(kind: u8) -> Color { 23 => p.text, 24 => p.green, 25 => p.maroon, - COR_EVAL_SIGIL => p.teal, + COR_EVAL_SIGIL => p.green, + COR_EVAL_TABLE_SUFFIX => p.yellow, + COR_EVAL_TREE_SUFFIX => p.red, COR_AT_SIGIL => p.mauve, COR_COLON_COLON => p.flamingo, COR_REF_COLON => p.flamingo, @@ -1101,8 +1080,6 @@ pub fn highlight_color(kind: u8) -> Color { } pub fn highlight_font(kind: u8) -> Option { - // Spans inherit the named family from EDITOR_FONT so fontdb can pick up - // the real Bold, Italic and BoldItalic faces of the system monospace. match kind { MD_HEADING_MARKER => Some(Font { weight: Weight::Bold, ..EDITOR_FONT }), MD_H1 => Some(Font { weight: Weight::Black, ..EDITOR_FONT }), @@ -1121,7 +1098,7 @@ pub fn highlight_font(kind: u8) -> Option { } } -/// maps a fenced-code label to a tree-sitter language id, recursing on the trailing extension for dotted labels. +/// maps a fenced-code label to a tree-sitter language id. fn canonical_code_lang(label: &str) -> Option { let label = label.trim().to_ascii_lowercase(); if label.is_empty() { diff --git a/viewport/src/table_block.rs b/viewport/src/table_block.rs index d173db3..53e1ee3 100644 --- a/viewport/src/table_block.rs +++ b/viewport/src/table_block.rs @@ -18,11 +18,7 @@ use crate::syntax::EDITOR_FONT; const MIN_COL_WIDTH: f32 = 60.0; const DEFAULT_COL_WIDTH: f32 = 120.0; -/// Sanity cap for double-click auto-fit. Drag past it for explicit override. const AUTO_FIT_MAX: f32 = 600.0; -/// Approximate monospace glyph advance at the editor's default font size. -/// Used when the renderer's actual font_size isn't available (e.g. during -/// table construction). Tracks `font_size * 0.6` for size 13. const APPROX_CHAR_W: f32 = 7.8; const CELL_PADDING: Padding = Padding { top: 2.0, @@ -32,14 +28,9 @@ const CELL_PADDING: Padding = Padding { }; const ROW_NUMBER_WIDTH: f32 = 26.0; const PLUS_BUTTON_THICKNESS: f32 = 14.0; -/// Default per-row height. Calibrated to match the natural height of an iced -/// text_input at size 13 with CELL_PADDING — 13pt font + ~1.3 line height + -/// 4px vertical padding + 2px border ≈ 23. const ROW_HEIGHT_ESTIMATE: f32 = 23.0; const MIN_ROW_HEIGHT: f32 = 18.0; const ROW_RESIZE_HANDLE_HEIGHT: f32 = 3.0; -/// Vertical gap between rows. Slightly tighter than RESIZE_HANDLE_WIDTH — -/// the horizontal gap stays at 4 so the resize handle has enough hit area. const CELL_GAP_Y: f32 = 2.0; #[derive(Debug, Clone, Copy)] @@ -48,23 +39,12 @@ pub enum ReorderDrag { Row { from: usize, target: usize, start_y: f32 }, } -/// Modifier state at the moment of a selection click or drag. Computed by -/// editor.rs from its tracked modifier state, then passed in via the -/// table-state mutation methods. The modifier determines the OPERATION on -/// the existing selection — single-cell click vs rectangular drag is -/// orthogonal to the modifier and determined by gesture (click = 1 cell, -/// drag = rectangle). +/// modifier-derived operation applied to the selection on click or drag. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SelectionMode { - /// No modifier — selection becomes only the clicked cell (or drag rect). Replace, - /// Cmd — toggle (XOR): each touched cell is added if absent, removed if - /// present. Toggle, - /// Shift — add (union): each touched cell is added; nothing is removed. Extend, - /// Cmd+Shift — remove (subtract): each touched cell is removed; nothing - /// is added. Subtract, } @@ -72,32 +52,17 @@ pub enum SelectionMode { pub enum TableMessage { CellChanged(usize, usize, String), FocusCell(usize, usize), - /// Single click on a cell — select it but stay out of edit mode. Cell - /// renders as static text with a tinted background. The Numbers/Excel - /// "I might do something to this cell" gesture. + /// single-cell selection without entering edit mode. SelectCell(usize, usize), - /// Double click on a cell — enter edit mode. Cell renders as a text_input - /// and gets iced focus. Same target as `SelectCell`, but the editor's - /// `editing` field is also set to this cell's `NodePath`. + /// double-click enters edit mode on the cell. EditCell(usize, usize), - /// Click on the corner-cell delete affordance. The editor's TableMsg arm - /// promotes this to a top-level `DeleteCurrentTable` so undo + block - /// removal go through the same path as the keyboard shortcut. DeleteTable, - /// Click on the corner-cell drag handle. Selects the whole table — - /// every cell renders highlighted, plain Backspace clears all cells, - /// Cmd+Backspace deletes the table outright. Single-cell click clears - /// this back to single-cell selection. + /// selects the whole table (corner-cell click). SelectAll, - /// Plain Backspace/Delete with `table_selected == true` — empty every - /// cell's content but leave the row/column structure intact. + /// empties every cell's content, preserving row/column structure. ClearAll, - /// Right-click on a cell. Promoted in editor.rs to a top-level - /// `ShowContextMenu` carrying the cursor anchor position. ContextMenu(usize, usize), - /// Mouse entered a cell. Used by the drag-select system: when a drag - /// is armed (post-click, pre-release), each `CellEnter` extends the - /// selection rectangle to that cell. No-op when no drag is active. + /// drag-select extension: expands the rectangle to the entered cell. CellEnter(usize, usize), AddRow, AddColumn, @@ -113,100 +78,47 @@ pub enum TableMessage { BeginColReorder(usize), BeginRowReorder(usize), EndDrag, - /// Double-click on the column resize handle: fit width to the widest - /// cell content in the column. f32 carries the current font_size so the - /// pixel width tracks zoom level. + /// double-click auto-fits column width to content at given font_size. AutoFitCol(usize, f32), - /// Toggle the per-table word-wrap mode. Wrap on (default): rows grow to - /// fit; nothing clips. Wrap off: cells clip; spillover popup reveals - /// content on click or 3s hover. ToggleWrap, - /// Open the spillover popup for a cell. Replaces any existing spillover - /// (only one open per table at a time). Click on a clipped cell when - /// `wrap == false`. OpenSpillover(usize, usize), - /// Close the active spillover popup. Click outside, ESC, or any cell - /// selection change. CloseSpillover, - /// Click on a column-header sort arrow: cycles that column through - /// Neutral → Asc → Desc → Neutral and re-applies the composite sort. + /// cycles column sort: Neutral -> Asc -> Desc -> Neutral. CycleSort(usize), - /// mouse pressed on the corner select-all marker, arms a free-layer drag. PromoteCornerPress, - /// mouse released on the corner select-all marker, ends or cancels the drag. PromoteCornerRelease, } -/// Trait-implementing block for tables. Owns all the per-table mutable state -/// directly (rows, widths, focus, drags, selection) — no separate `TableState` -/// HashMap. Lives in `EditorState::blocks` as a `Box`. +/// table block owning all per-table mutable state (rows, widths, focus, drags, selection). pub struct TableBlock { pub id: BlockId, pub start_line: usize, - /// User-assigned name from a ### or #### heading directly above this table. - /// H3 = global scope, H4 = block-scoped. None for unnamed tables. pub table_name: Option, pub rows: Vec>, pub col_widths: Vec, - /// Per-row explicit height override. None means use ROW_HEIGHT_ESTIMATE. pub row_heights: Vec>, - /// Last cell that had focus. PRESERVED across blur so keyboard shortcuts - /// (Cmd+Opt+Arrow, Cmd+Shift+T, Tab/Enter) keep targeting the right cell - /// even when the user has clicked elsewhere in the document. + /// preserved across blur for keyboard shortcut targeting. pub focused_cell: Option<(usize, usize)>, - /// True only on frames where iced's focus is currently inside one of this - /// table's cells. Used for "active editing chrome" (ABCD/123 headers) which - /// must DISAPPEAR when the user clicks out, even though focused_cell is kept. pub is_active: bool, - /// Whole-table selection mode. Set by clicking the corner select-all - /// affordance. All cells render highlighted; plain Backspace/Delete - /// clears every cell's content; Cmd+Backspace deletes the whole table. - /// Cleared the moment a single cell is clicked. pub table_selected: bool, - /// Eval-result tables set this so the widget disables cell editing while - /// keeping selection and Cmd-C intact. Markdown tables keep it false. pub read_only: bool, - /// True for eval-result tables (regenerated on every eval). Skipped during - /// markdown serialization. pub is_eval_result: bool, - /// Active column-resize drag: (col index, original width at drag start, drag-start x). pub resize_drag: Option<(usize, f32, f32)>, - /// Active row-resize drag: (row index, original height at drag start, drag-start y). pub row_resize_drag: Option<(usize, f32, f32)>, pub reorder_drag: Option, pub selection: std::collections::HashSet<(usize, usize)>, pub selection_anchor: Option<(usize, usize)>, - /// Drag-rectangle origin. Set on cell click; cleared on mouse release. - /// When `Some`, every cell-enter event extends the rectangle. pub drag_select_start: Option<(usize, usize)>, - /// SelectionMode captured at the moment the drag started — keeps the - /// modifier semantics constant for the duration of the drag, even if - /// the user releases the modifier mid-drag. pub drag_select_mode: Option, - /// Selection state at the moment the drag started. Each `cell_enter` - /// during the drag recomputes the selection by re-applying the mode - /// against the baseline + the current rectangle. Without a baseline, - /// repeated rectangle redraws would compound (e.g. Toggle mode would - /// flip cells multiple times as the rectangle grows and shrinks). + /// selection snapshot at drag start, prevents compounding on rectangle redraws. pub drag_select_baseline: std::collections::HashSet<(usize, usize)>, pub last_cursor_x: f32, pub last_cursor_y: f32, - /// Composite sort. Each entry is `(col_idx, dir)`. The first entry is - /// the dominant sort key; later entries break ties within groups of - /// equal dominant values. Empty = no sort active (visual neutral). + /// composite sort keys, first entry dominant, later entries break ties. pub sort_priority: Vec<(usize, SortDir)>, - /// When true (default), cell text word-wraps and each row grows to fit - /// the tallest wrapped cell — no content ever clips. When false, content - /// is hard-clipped at the cell bounds and the spillover popup reveals - /// the full text on click or hover. pub wrap: bool, - /// Currently spilled-over cell, if any. Only one popup at a time per - /// table. Set by click or 3s hover when `wrap == false`. pub spillover: Option<(usize, usize)>, - /// Cell currently being hovered with the dwell timer running. Captured - /// on CellEnter; consumed by `tick_hover` after the 3s threshold to - /// open the spillover popup. Cleared on any meaningful interaction - /// (click, edit, drag, scroll) so a brief mouseover never triggers. + /// dwell timer for hover-to-spillover, cleared on any interaction. pub hover_armed: Option<(usize, usize, std::time::Instant)>, } @@ -231,9 +143,6 @@ impl TableBlock { ) -> Self { let col_count = rows.iter().map(|r| r.len()).max().unwrap_or(0); let col_widths = col_widths_override.unwrap_or_else(|| { - // For eval result tables, size columns to fit content; for markdown - // tables, fit each column to its header so the new wrap-on default - // gives short headers a tight column and lets long body text wrap. if is_eval_result { (0..col_count) .map(|ci| { @@ -287,8 +196,7 @@ impl TableBlock { } } - /// 3s dwell threshold for hover-to-spillover. Independent of the eval - /// debounce so a slow-typing user doesn't accidentally trigger popups. + /// triggers spillover popup after 3s hover dwell on a clipped cell. pub fn check_hover_spillover(&mut self) -> bool { if self.wrap { self.hover_armed = None; return false; } let Some((r, c, started)) = self.hover_armed else { return false; }; @@ -303,16 +211,12 @@ impl TableBlock { true } - /// Has a hover dwell timer running. Used by `has_pending_eval`-equivalent - /// to keep the vsync loop ticking until the 3s threshold fires. + /// true when a hover dwell timer needs vsync ticks. pub fn has_pending_hover(&self) -> bool { !self.wrap && self.hover_armed.is_some() } - /// Build the canonical clipboard payload for the current selection. - /// Single cell: just the cell text. Multiple cells: TSV — tabs between - /// columns, newlines between rows. Excel/Numbers/Sheets parse this - /// natively when pasted back in. Returns None if nothing is selected. + /// builds a TSV clipboard payload from the current cell selection. pub fn copy_selection_payload(&self) -> Option { if self.selection.is_empty() { return None; @@ -341,11 +245,7 @@ impl TableBlock { Some(lines.join("\n")) } - /// Resize `col` to fit its widest cell content (header + body) at - /// `font_size`. Width = max char count × monospace char width + horizontal - /// padding, clamped to [MIN_COL_WIDTH, AUTO_FIT_MAX]. The cap keeps a - /// pathological cell from blowing the table off-screen — drag past it - /// for explicit override. + /// resizes column to fit widest cell content at the given font_size. pub fn auto_fit_col(&mut self, col: usize, font_size: f32) { if col >= self.col_widths.len() { return; } let max_chars = self.rows.iter() @@ -359,10 +259,7 @@ impl TableBlock { self.col_widths[col] = raw.max(MIN_COL_WIDTH).min(AUTO_FIT_MAX); } - /// Cycle the sort state of `col`: Neutral → Asc → Desc → Neutral. - /// First click on a previously-neutral column appends it to the - /// END of the priority list (least dominant). Re-clicking advances - /// its direction in place; the third click removes it. + /// cycles sort state for a column: Neutral -> Asc -> Desc -> Neutral. pub fn cycle_sort(&mut self, col: usize) { if let Some(idx) = self.sort_priority.iter().position(|(c, _)| *c == col) { match self.sort_priority[idx].1 { @@ -375,17 +272,14 @@ impl TableBlock { self.apply_sort(); } - /// Sort state for a column, if any. Used by the header chrome to pick - /// the arrow tint and the optional precedence badge. + /// returns sort direction and priority index for a column, if sorted. pub fn sort_state_for(&self, col: usize) -> Option<(SortDir, usize)> { self.sort_priority.iter().enumerate().find_map(|(i, (c, d))| { if *c == col { Some((*d, i)) } else { None } }) } - /// Apply the composite sort to the data rows (everything below row 0, - /// which is the header). Stable across equal keys so existing intra- - /// group order is preserved. + /// stable-sorts data rows (below the header) by the composite sort keys. pub fn apply_sort(&mut self) { if self.sort_priority.is_empty() || self.rows.len() <= 2 { return; } let priority = self.sort_priority.clone(); @@ -410,19 +304,8 @@ impl TableBlock { self.rows.len() } - /// Apply a modifier-aware single-cell click to the multi-cell selection - /// set. Called by `EditorState::update`'s TableMsg arm after it reads - /// `self.mods` and derives the `SelectionMode`. Mutates `selection`, - /// `selection_anchor`, and `focused_cell` in place. - /// - /// Also arms a drag-select: snapshots the selection BEFORE this click as - /// the baseline so that if the user drags to other cells, the rectangle - /// can be re-applied against the baseline (without compounding repeated - /// applications of the modifier op). + /// applies a modifier-aware cell click, arming drag-select with a baseline snapshot. pub fn apply_click_selection(&mut self, row: usize, col: usize, mode: SelectionMode) { - // Baseline = selection state before the click. The drag handler - // recomputes from this so the rectangle can shrink/grow without - // compounding (e.g. toggling the same cell twice). let baseline = self.selection.clone(); match mode { @@ -432,7 +315,6 @@ impl TableBlock { self.selection_anchor = Some((row, col)); } SelectionMode::Toggle => { - // Cmd = invert this cell. if self.selection.contains(&(row, col)) { self.selection.remove(&(row, col)); if self.selection.is_empty() { @@ -446,16 +328,12 @@ impl TableBlock { } } SelectionMode::Extend => { - // Shift = add this cell (no removal). Drag extends with a - // rectangle (also additive). self.selection.insert((row, col)); if self.selection_anchor.is_none() { self.selection_anchor = Some((row, col)); } } SelectionMode::Subtract => { - // Cmd+Shift = remove this cell (no addition). Drag removes - // a rectangle. self.selection.remove(&(row, col)); if self.selection.is_empty() { self.selection_anchor = None; @@ -466,15 +344,12 @@ impl TableBlock { self.is_active = true; self.table_selected = false; - // Arm the drag. Subsequent CellEnter events extend the rectangle. self.drag_select_start = Some((row, col)); self.drag_select_mode = Some(mode); self.drag_select_baseline = baseline; } - /// Extend the active drag-rectangle to (row, col). Recomputes the - /// selection from the baseline + drag mode + current rectangle. No-op - /// if no drag is active. + /// extends the drag-rectangle to (row, col), recomputing selection from baseline. pub fn apply_drag_to(&mut self, row: usize, col: usize) { let Some(start) = self.drag_select_start else { return }; let Some(mode) = self.drag_select_mode else { return }; @@ -524,7 +399,6 @@ impl TableBlock { self.focused_cell = Some((row, col)); } - /// Clear the drag-select state. Called from EndDrag. pub fn end_drag_select(&mut self) { self.drag_select_start = None; self.drag_select_mode = None; @@ -548,21 +422,10 @@ impl TableBlock { self.focused_cell = Some((row, col)); } TableMessage::SelectCell(row, col) => { - // Single click — selected, not editing. The editor's - // `editing` field is cleared by the editor-level dispatch - // in `EditorState::update`'s `TableMsg` arm. The actual - // multi-cell selection update happens via `apply_click_selection`, - // called from the editor's TableMsg arm AFTER it has read - // `self.mods` to derive the SelectionMode. Here we only mark - // the cell as the focus point and clear table-level selection. self.focused_cell = Some((row, col)); self.is_active = true; self.table_selected = false; self.hover_armed = None; - // Wrap-off mode: a click that lands on a different cell - // re-targets the spillover popup. Clicking the same cell - // again toggles it closed so the user can dismiss without - // an explicit ESC. if !self.wrap { self.spillover = match self.spillover { Some(prev) if prev == (row, col) => None, @@ -573,30 +436,17 @@ impl TableBlock { } } TableMessage::EditCell(row, col) => { - // Double click — selected AND editing. The editor's - // `editing` field is set, and `pending_focus` is queued so - // iced moves keyboard focus to the cell's text_input on the - // next frame. self.focused_cell = Some((row, col)); self.is_active = true; self.hover_armed = None; self.spillover = None; } TableMessage::DeleteTable => { - // Handled at the editor level — the TableMsg arm in - // editor.rs promotes this to DeleteCurrentTable. The block - // itself does nothing here, but we still need to ensure the - // table is registered as focused so the editor's - // focused_table_index() finds it. if self.focused_cell.is_none() { self.focused_cell = Some((0, 0)); } } TableMessage::SelectAll => { - // Whole-table selection. Mark every cell as selected via the - // table_selected flag — cell rendering keys off this for the - // highlighted look. focused_cell stays where it was so - // arrow keys can drop back into single-cell mode naturally. self.table_selected = true; self.is_active = true; if self.focused_cell.is_none() { @@ -614,24 +464,11 @@ impl TableBlock { } } TableMessage::ContextMenu(_row, _col) => { - // Right-click is purely a menu trigger — it does NOT modify - // selection state. The context menu operates on whatever was - // already selected. The editor.rs TableMsg arm handles the - // overlay anchor; this branch is intentionally a no-op. } TableMessage::CellEnter(row, col) => { - // Drag-select extension: only acts when a drag is armed. - // Without an active drag, hovering over cells is a no-op - // (every cell still fires CellEnter on every mouse-over, - // which is a tiny per-frame cost). if self.drag_select_start.is_some() { self.apply_drag_to(row, col); } - // Hover-to-spillover dwell: only meaningful with wrap off - // (clipped cells are the ones that benefit). Re-arming on a - // different cell resets the timer; same-cell re-entry leaves - // the existing timer alone so a tiny twitch doesn't restart - // the dwell. if !self.wrap { let already_armed = matches!( self.hover_armed, @@ -664,7 +501,6 @@ impl TableBlock { return; } let Some((fr, _)) = self.focused_cell else { return }; - // Never insert above the header row — treat as insert-below-header. let insert_at = fr.max(1).min(self.rows.len()); let cols = self.col_count(); self.rows.insert(insert_at, vec![String::new(); cols]); @@ -815,8 +651,6 @@ impl TableBlock { TableMessage::ToggleWrap => { if self.read_only { return; } self.wrap = !self.wrap; - // Switching to wrap-on auto-closes any open spillover — - // wrapped content is no longer clipped, so the popup is moot. if self.wrap { self.spillover = None; } } TableMessage::OpenSpillover(row, col) => { @@ -851,9 +685,6 @@ impl TableBlock { } } } - // Also tear down the cell drag-select. The selection state - // has already been committed by the last `apply_drag_to`, - // so we just clear the bookkeeping. self.end_drag_select(); } TableMessage::PromoteCornerPress | TableMessage::PromoteCornerRelease => {} @@ -1019,11 +850,7 @@ impl TableBlock { self.focused_cell.map(|(r, c)| cell_id(self.id, r, c)) } - /// True if this table carries metadata that markdown alone can't - /// represent — non-default column widths, explicit row heights, or - /// cell formulas (markdown would otherwise serialize the raw `/=...` - /// text into the cell, but round-tripping via the sidecar keeps the - /// formula label separate from the computed display). + /// true when non-default widths, explicit row heights, or cell formulas need sidecar persistence. pub fn has_persistent_metadata(&self) -> bool { if self.col_widths.iter().any(|w| (*w - DEFAULT_COL_WIDTH).abs() > f32::EPSILON) { return true; @@ -1231,17 +1058,11 @@ where let mut col_elements: Vec> = Vec::new(); let read_only = block.read_only; let reserve_chrome = !read_only; - // Derived sizes that scale with the editor's zoom level. let chrome_font = font_size * 0.77; let corner_font = font_size * 0.69; let plus_font = font_size * 0.85; let row_h = font_size * 1.3 + CELL_PADDING.top + CELL_PADDING.bottom + 2.0; let header_h = chrome_font * 1.3; - // Chrome (ABCD column letters, 123 row numbers) appears whenever the table - // has a selected cell, not just when iced widget focus is in a cell. Without - // the focused_cell branch the chrome would vanish the moment selection mode - // takes over from edit mode, hiding the visual cue that the table is yours - // to manipulate. let chrome_active = !read_only && (block.focused_cell.is_some() || block.is_active @@ -1250,14 +1071,6 @@ where if reserve_chrome { let mut header_row_cells: Vec> = Vec::new(); - // Corner cell at (row-numbers ⨉ column-letters). The "select-all" - // affordance — click to mark the whole table as selected. With the - // table selected, plain Backspace clears every cell, Cmd+Backspace - // deletes the table outright. Eventually this same handle will also - // be the drag origin for moving the table around the document, in - // line with the broader plan to make every chunk-level node draggable. - // Visible whenever the chrome is active so the user always has a - // reachable affordance once they've touched the table once. let corner: Element<'a, Message, Theme, iced_wgpu::Renderer> = if chrome_active { let inner = container( text("\u{25A0}") @@ -1425,11 +1238,7 @@ where let c = ci; let is_editing_this = editing_cell == Some((ri, ci)); - // A cell renders selected ONLY because it's in the selection set - // (or the table-wide select-all mode is on). The HashSet is the - // sole source of truth — `focused_cell` is preserved across blur - // for keyboard targeting, so it's not a valid selection signal. - let is_focused_this = block.selection.contains(&(ri, ci)) + let is_focused_this = block.selection.contains(&(ri, ci)) || block.table_selected; let font = if is_header { @@ -1441,9 +1250,7 @@ where let cell_element: Element<'a, Message, Theme, iced_wgpu::Renderer> = if is_editing_this || read_only { - // Edit mode (or eval-result table that the user can still - // copy from) — use the real text_input. - let is_eval = read_only; + 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) } }; @@ -1457,28 +1264,16 @@ where if !read_only { input = input.on_input(move |val| on_msg(TableMessage::CellChanged(r, c, val))); } - // Pin the wrapper to row_h so a manually-resized row keeps its - // height when the user double-clicks to enter edit mode — - // text_input alone would snap back to its natural font-size height. container(input) .width(Length::Fixed(width)) .height(Length::Fixed(row_h)) .into() } else { - // Selected-but-not-editing or fully unfocused cell. Renders - // as a static text widget inside a container styled to match - // the text_input's bounds — visually identical to the edit - // form modulo a tinted background when this cell is the - // current selection. let label_color = if is_header { palette::widget_surface().header_accent } else { palette::widget_surface().body_text }; - // Show the computed formula value when this cell is a - // `/=...` formula and the eval loop produced a result. - // Any cell without a computed entry (plain values, eval - // errors during parse/topo pre-pass) falls back to raw. let display_text: String = if cell.trim_start().starts_with("/=") { match computed_cells.get(&(block_id, ci as u32, ri as u32)) { Some(v) => v.display(), @@ -1554,9 +1349,6 @@ where iced_widget::row(row_cells).spacing(0.0).into(); col_elements.push(row_el); - // Row resize band — 3px hit area below each row, drags row height. - // Skipped for read_only tables (eval results aren't meant to be - // structurally edited). if !read_only { let resize_row = ri; let band_w: f32 = (if reserve_chrome { ROW_NUMBER_WIDTH } else { 0.0 }) @@ -1653,9 +1445,7 @@ where outer } -/// Wrap-aware row height. Manual override wins. Then if wrap is on, fit to -/// the tallest wrapped cell (chars × char_w / col_width gives an approximate -/// line count). Otherwise fall back to the default single-line row height. +/// computes wrap-aware row height, manual override taking precedence. fn compute_row_height( block: &TableBlock, ri: usize, @@ -1701,8 +1491,7 @@ pub fn column_letter(mut idx: usize) -> String { s } -/// Natural alphanumeric comparison: contiguous digit runs compare as -/// integers so `R10` sorts after `R2`. Letter runs compare case-insensitive. +/// natural alphanumeric comparison, digit runs compared as integers. fn compare_alphanumeric(a: &str, b: &str) -> std::cmp::Ordering { use std::cmp::Ordering; let mut ai = a.chars().peekable(); diff --git a/viewport/src/text_block.rs b/viewport/src/text_block.rs index 9caf28f..b2b6014 100644 --- a/viewport/src/text_block.rs +++ b/viewport/src/text_block.rs @@ -1,7 +1,4 @@ -//! `TextBlock` — the trait-implementing wrapper around `text_widget::Content`. -//! -//! Owns the editor content and language hint for syntax highlighting. Lives in -//! `EditorState::blocks` as a `Box`. +//! TextBlock: trait-implementing wrapper around text_widget::Content. use iced_wgpu::core::text::Wrapping; use iced_wgpu::core::text::highlighter::Format; @@ -91,9 +88,11 @@ impl Block for TextBlock { } }); + let source = self.content.text(); let settings = SyntaxSettings { lang: self.lang.clone(), - source: self.content.text(), + user_idents: syntax::scan_user_idents_in(&source), + source, }; let editor_el: Element<'a, Message, Theme, iced_wgpu::Renderer> = editor .highlight_with::( @@ -117,9 +116,6 @@ impl Block for TextBlock { } fn apply(&mut self, _cmd: BlockCommand) { - // Text mutations go through `text_editor::Action` routed via - // `Message::BlockAction` in the editor's update loop. BlockCommand - // on a text block is a no-op. } fn selectable_paths(&self) -> Box + '_> { diff --git a/viewport/src/text_widget.rs b/viewport/src/text_widget.rs index d24ad02..2138ebe 100644 --- a/viewport/src/text_widget.rs +++ b/viewport/src/text_widget.rs @@ -66,33 +66,25 @@ pub use text::editor::{ Action, Cursor, Edit, Line, LineEnding, Motion, Position, Selection, }; -/// An anchored child element rendered at a line boundary within the text widget. -/// The caller builds these using existing rendering code; the widget just draws them in order. +/// anchored child element rendered at a line boundary within the text widget. pub struct AnchoredItem<'a, Message, Theme = iced_wgpu::core::Theme> { pub after_line: usize, pub height: f32, pub element: Element<'a, Message, Theme, iced_wgpu::Renderer>, } -/// Per-logical-line metrics. Stored on State so layout publishes once -/// and every consumer (draw, cursor, hit-test) reads the same data. +/// per-logical-line metrics published once by layout, read by all consumers. #[derive(Clone, Default, Debug)] pub struct LineMetric { - /// Widget-y of this line's first visual row (relative to text_bounds.y). + /// widget-y of first visual row (relative to text_bounds.y). pub widget_y: f32, - /// Cosmic-text's viewport-relative y of this line's first visual row — - /// matches the y produced by `Selection::Caret(position).y` and what - /// `Action::Click { y }` consumes (already scroll-adjusted, items - /// invisible to it). Diverges from `widget_y` whenever an anchored - /// item sits between this line and `scroll.line`. + /// cosmic-text viewport-relative y, diverges from widget_y around anchored items. pub viewport_y: f32, - /// Number of visual rows this logical line occupies after wrap. + /// visual rows after wrap. pub visual_rows: usize, } -/// Translate a cosmic-reported y (`Selection::Caret`, `Selection::Range`) -/// into our widget-y so cursor + selection rectangles draw on top of the -/// text rows the compositor actually rendered. +/// translates cosmic-reported y into widget-y for cursor/selection rendering. fn cosmic_y_to_widget_y(metrics: &[LineMetric], cosmic_y: f32, _line_h: f32) -> f32 { if metrics.is_empty() { return cosmic_y; } for i in (0..metrics.len() - 1).rev() { @@ -103,8 +95,7 @@ fn cosmic_y_to_widget_y(metrics: &[LineMetric], cosmic_y: f32, _line_h: f32) -> metrics[0].widget_y + (cosmic_y - metrics[0].viewport_y) } -/// Translate a widget-y (mouse coords) back into the y cosmic-text expects -/// for click/drag actions — the inverse of `cosmic_y_to_widget_y`. +/// translates widget-y (mouse coords) back into cosmic-text y for click/drag actions. fn widget_y_to_cosmic_y(metrics: &[LineMetric], widget_y: f32, line_h: f32) -> f32 { if metrics.len() < 2 { return widget_y; } let line_count = metrics.len() - 1; @@ -126,15 +117,13 @@ fn widget_y_to_cosmic_y(metrics: &[LineMetric], widget_y: f32, line_h: f32) -> f tail.viewport_y + (widget_y - tail.widget_y).max(0.0) } -/// Distance-driven fade ratio for the gutter rainbow. `0.0` at the cursor -/// (full saturation), `1.0` at the far end of the fade window. const GUTTER_FADE_CYCLES: f32 = 2.5; fn gutter_fade_t(distance: usize) -> f32 { let max_d = GUTTER_FADE_CYCLES * crate::syntax::USER_IDENT_PALETTE_SIZE as f32; (distance as f32 / max_d).min(1.0) } -/// builds iced Spans from cosmic glyphs, grouping by (color, weight, style) and skipping marker ranges. +/// builds iced Spans from cosmic glyphs, grouping by color/weight/style and skipping marker ranges. fn build_color_spans<'a>( text: &'a str, glyphs: &[cosmic_text::LayoutGlyph], @@ -172,7 +161,6 @@ fn build_color_spans<'a>( } } - /// span grouping key — color repr plus iced weight/style. type StyleKey = (Option, IcedWeight, IcedStyle); let style_at = |byte_idx: usize| -> (IcedWeight, IcedStyle) { @@ -201,7 +189,6 @@ fn build_color_spans<'a>( spans.push(span); }; - // subtract every marker range from the full line range. let visible_segments: Vec> = if marker_ranges.is_empty() { vec![0..text.len()] } else { @@ -226,7 +213,6 @@ fn build_color_spans<'a>( }; if glyphs.is_empty() { - // empty line — fall back to line-level attrs. let (w, s) = style_at(0); let key: StyleKey = (None, w, s); let mut spans = Vec::new(); @@ -547,33 +533,31 @@ where self } - /// Sets the anchored child elements to draw at line boundaries. - /// Items must be sorted by after_line. + /// sets the anchored child elements to draw at line boundaries, sorted by after_line. pub fn anchored(mut self, items: Vec>) -> Self { self.anchored_children = items; self } - /// Sets the global line offset for gutter numbering. + /// sets the global line offset for gutter numbering. pub fn gutter_offset(mut self, offset: usize) -> Self { self.gutter_offset = offset; self } - /// Marks this widget as the focused editing block. + /// marks the widget as the focused editing block. pub fn focused(mut self, focused: bool) -> Self { self.is_focused_block = focused; self } - /// Reserve a left strip for line numbers + decoration stripes. + /// reserves a left strip for line numbers and decoration stripes. pub fn show_gutter(mut self, show: bool) -> Self { self.show_gutter = show; self } - /// Cursor's current line within this block. `None` when not focused. - /// Drives both the cursorline tint and the gutter rainbow center. + /// cursor's current line within the block, driving cursorline tint and gutter rainbow. pub fn cursor_line(mut self, line: Option) -> Self { self.cursor_line = line; self @@ -594,10 +578,7 @@ where self } - /// Width of the gutter strip given a line count. Caller passes the - /// count so this never touches `self.content` (which would deadlock - /// when called from inside layout/draw — those already hold the - /// content's RefCell). + /// gutter strip width for the given line count. fn gutter_width_for(&self, line_count: usize) -> f32 { if !self.show_gutter { return 0.0; } let total = self.gutter_offset + line_count; @@ -624,6 +605,11 @@ where use crate::syntax::LineDecor; use crate::editor::LineIndicator; + let widget_bottom = bounds.y + bounds.height - self.padding.bottom; + if y + line_h > widget_bottom { + return; + } + let gutter_left = bounds.x + self.padding.left; let gutter_right = gutter_left + gw; @@ -887,7 +873,7 @@ impl Content { self.0.borrow().editor.buffer().scroll().line } - /// scrolls cosmic-text so the given logical line lands at viewport top. + /// repositions cosmic-text's scroll to the given logical line. pub fn jump_to_line(&mut self, target: usize) { let current = self.scroll_line() as i32; let delta = target as i32 - current; @@ -932,17 +918,10 @@ pub struct State { highlighter: RefCell, highlighter_settings: Highlighter::Settings, highlighter_format_address: usize, - /// Paragraphs built during draw() — kept alive so the renderer's Weak refs - /// survive until the prepare() phase processes them. + /// paragraphs kept alive across draw->prepare for the renderer's Weak refs. retained_paragraphs: RefCell>, - /// Per-logical-line metrics published by `layout()`. Every consumer - /// (draw, cursor caret, click/drag hit-testing, IME) reads from this - /// same Vec — there is no parallel computation. Length = line_count - /// + 1, with the trailing sentinel marking widget/buffer y past the - /// last line. + /// per-logical-line metrics published by layout, read by all consumers. line_metrics: RefCell>, - /// Gutter strip width, also published by layout. Same single-source - /// rule: events translate click x by reading this, never recomputing. gutter_width: std::cell::Cell, } @@ -1092,11 +1071,6 @@ where self.text_size.unwrap_or_else(|| renderer.default_size()), ).into(); - // Single source-of-truth: walk lines + anchored children once and - // build per-line metrics. Each LineMetric records the widget-y + - // buffer-y of that line's first visual row, plus the wrap count. - // Draw, cursor positioning, click/drag — every consumer reads from - // this same Vec so they cannot drift. let mut child_nodes = Vec::with_capacity(self.anchored_children.len()); let child_limits = layout::Limits::new( Size::ZERO, @@ -1104,32 +1078,14 @@ where ); let buffer = internal.editor.buffer(); let line_count = buffer.lines.len(); - // Seed widget_y from cosmic-text's internal scroll so the metrics - // we publish reflect ACTUAL on-screen positions, not no-scroll - // positions. Without this seeding, draw renders text at unscrolled - // y while the cursor (computed via cosmic's scroll-aware selection) - // appears to drift — the classic "two sources of truth" violation. - // Anchor cosmic's viewport-y at scroll.line top: cosmic's - // `Selection::Caret(position).y` for a cursor sitting on the very - // first visible visual row equals `-scroll.vertical`, regardless - // of how many logical lines came before. Pre-scroll lines are not - // shaped (`layout_opt() == None`) and contribute 0 visual rows in - // cosmic's own bookkeeping — mirror that so the two y-spaces - // agree. let scroll = buffer.scroll(); let mut metrics: Vec = Vec::with_capacity(line_count + 1); let mut widget_y = -scroll.vertical; let mut viewport_y = -scroll.vertical; - // Pre-scroll lines: cosmic treats them as 0 rows, but we still - // need a widget-y so any bottom-anchored items render relative to - // a stable bottom. For the cursor/selection mapping to work the - // viewport_y must stay parked at -scroll.vertical for them - // (cosmic has no addressable y above scroll.line). for _ in 0..scroll.line.min(line_count) { metrics.push(LineMetric { widget_y, viewport_y, visual_rows: 0 }); } let mut next_child = 0; - // Skip anchored children that sit above the scroll line. while next_child < self.anchored_children.len() && self.anchored_children[next_child].after_line < scroll.line { @@ -1164,7 +1120,6 @@ where next_child += 1; } } - // Remaining children after last line — they sit below all text. while next_child < self.anchored_children.len() { let child = &mut self.anchored_children[next_child]; let mut node = child.element.as_widget_mut().layout( @@ -1181,17 +1136,12 @@ where child_nodes.push(node); next_child += 1; } - // Push sentinel AFTER trailing children are placed, so the - // sentinel widget_y reflects the true bottom of the stream. metrics.push(LineMetric { widget_y, viewport_y, visual_rows: 0 }); let extra = widget_y - viewport_y; *state.line_metrics.borrow_mut() = metrics; match self.height { Length::Fill | Length::FillPortion(_) | Length::Fixed(_) => { - // Fixed/Fill: caller specified the height. Honor it as-is — - // anchored items live within that height; trailing space - // would otherwise create phantom gaps below the block. layout::Node::with_children(limits.max(), child_nodes) } Length::Shrink => { @@ -1219,7 +1169,6 @@ where shell: &mut Shell<'_, Message>, _viewport: &Rectangle, ) { - // Forward events to anchored children first if !self.anchored_children.is_empty() { let children_layouts: Vec<_> = layout.children().collect(); for (i, child) in self.anchored_children.iter_mut().enumerate() { @@ -1562,8 +1511,6 @@ where let text_size = self.text_size.unwrap_or_else(|| renderer.default_size()); let line_h: f32 = self.line_height.to_absolute(text_size).into(); - // Gutter background — only the strip below top_pad so the title-bar - // / traffic-light area doesn't get painted. if self.show_gutter && self.padding.top < bounds.height { let p = crate::palette::current(); renderer.fill_quad( @@ -1599,16 +1546,11 @@ where ); } } else { - // Sequential stream: text lines (layer 0) interleaved with - // anchored children (layer 1) in one continuous pass. Cursorline - // tint and gutter line numbers are drawn on the SAME y as the - // line's paragraph — single source of truth. let buffer = internal.editor.buffer(); let line_count = buffer.lines.len(); let mut child_idx = 0; let children_layouts: Vec<_> = layout.children().collect(); - // cursor line draws raw cosmic glyphs, other lines strip marker bytes for WYS. let active_cursor_line = if self.is_focused_block { self.cursor_line } else { @@ -1624,8 +1566,6 @@ where let mut paras = state.retained_paragraphs.borrow_mut(); paras.clear(); - // viewport-y bounds in screen space — anything outside is clipped - // away by the renderer, so skip the shape + paragraph build. let view_top = viewport.y; let view_bot = viewport.y + viewport.height; @@ -1634,9 +1574,6 @@ where Some(m) => m, None => continue, }; - // Pre-scroll lines carry visual_rows == 0 (cosmic hasn't - // shaped them, layout_opt returns None) — skip so we don't - // pile unshaped paragraphs at the same y. if m.visual_rows == 0 { continue; } @@ -1709,9 +1646,6 @@ where ); } - // children are advanced regardless of visibility so they stay - // associated with the correct line; the child's own draw will - // be culled by the renderer when off-screen. while child_idx < self.anchored_children.len() && self.anchored_children[child_idx].after_line == line_i { diff --git a/viewport/src/tree_block.rs b/viewport/src/tree_block.rs index 01ec6fe..e54d19a 100644 --- a/viewport/src/tree_block.rs +++ b/viewport/src/tree_block.rs @@ -224,10 +224,7 @@ impl canvas::Program for Tr } } -/// Builds the framed canvas Element for a tree block. Returns `'static` -/// because `TreeProgram::from_json` clones the labels into an owned `Vec` — -/// nothing in the returned widget tree borrows from `data`. -/// Total rendered height of a tree element including padding and border. +/// total rendered height of a tree element including padding and border. pub fn element_height(data: &serde_json::Value, font_size: f32) -> f32 { let program = TreeProgram::from_json_scaled(data, font_size); program.height() @@ -278,9 +275,7 @@ pub fn build( .into() } -/// Trait-implementing struct for a tree block. Owns the JSON value; the -/// canvas program is rebuilt fresh on each `view` call (cheap — flatten_tree -/// is O(nodes) and the JSON is already parsed). +/// tree block backed by an owned JSON value. pub struct TreeBlock { pub id: BlockId, pub data: serde_json::Value, @@ -327,8 +322,6 @@ impl Block for TreeBlock { } fn to_md(&self) -> String { - // Trees aren't currently round-tripped through markdown — they only - // appear as eval results. String::new() } @@ -337,7 +330,6 @@ impl Block for TreeBlock { } fn apply(&mut self, _cmd: BlockCommand) { - // Trees are read-only. } fn selectable_paths(&self) -> Box + '_> { diff --git a/viewport/src/widgets/dialog.rs b/viewport/src/widgets/dialog.rs index 20f5850..7c1f07d 100644 --- a/viewport/src/widgets/dialog.rs +++ b/viewport/src/widgets/dialog.rs @@ -1,7 +1,4 @@ -//! Modal dialog scaffolding — matches Acord's settings panel. -//! -//! [`overlay`] dims the underlying view and centers a panel; [`segmented_row`] -//! is the labeled multi-button row used for theme/line-indicator toggles. +//! modal dialog scaffolding matching Acord's settings panel. use iced_wgpu::Renderer; use iced_wgpu::core::{Background, Border, Color, Element, Length, Padding, Shadow, Theme}; @@ -10,10 +7,7 @@ use iced_widget::{button, container, row, text}; use crate::palette; use crate::syntax; -/// Wraps `panel` content in a centered card over a translucent dim backdrop. -/// `panel` is rendered as-is — the caller controls its content, padding, and -/// width. Set `width` to a fixed value (e.g. `Length::Fixed(font_size * 28.0)`) -/// for a stable layout. +/// centers a panel card over a translucent dim backdrop. pub fn overlay<'a, Message>( panel: Element<'a, Message, Theme, Renderer>, width: Length, @@ -56,9 +50,7 @@ where .into() } -/// A "label … [Option1] [Option2] [Option3]" row with the current option -/// highlighted. `options` is `(display_label, value)`; `current` is the -/// active value; `on_select(value)` fires when one is clicked. +/// labeled row of toggle buttons with the active option highlighted. pub fn segmented_row<'a, Message>( label: &str, options: &[(&'a str, &'a str)], diff --git a/viewport/src/widgets/menu.rs b/viewport/src/widgets/menu.rs index ed9ce4a..22db1fd 100644 --- a/viewport/src/widgets/menu.rs +++ b/viewport/src/widgets/menu.rs @@ -1,9 +1,4 @@ -//! Menu strip + dropdown panel, generic over the host's `Message` type. -//! -//! `strip()` builds the horizontal category bar; `dropdown()` builds an -//! anchored panel of label+shortcut rows. Hosts handle "is this category -//! open?" themselves and stack the dropdown over their content with -//! `iced_widget::stack!` after picking the click position. +//! menu strip and dropdown panel, generic over the host's message type. use iced_wgpu::Renderer; use iced_wgpu::core::{Background, Border, Element, Length, Padding, Shadow, Theme}; @@ -13,7 +8,7 @@ use crate::palette; use crate::syntax; use crate::widgets::style; -/// One row in a dropdown — either a clickable item or a horizontal separator. +/// one row in a dropdown: clickable item or horizontal separator. #[derive(Clone)] pub enum Row { Item { @@ -47,16 +42,14 @@ impl Category { } } -/// Approximate button width for a category label, useful when computing -/// horizontal anchor offsets for dropdowns. +/// approximate button width for a category label given a font size. pub fn category_button_width(label: &str, font_size: f32) -> f32 { let char_w = font_size * 0.6; let pad_x = font_size * 0.85; label.len() as f32 * char_w + pad_x * 2.0 } -/// Renders the horizontal category bar. `is_active` tells whether the dropdown -/// for that category is currently open (renders a highlighted background). +/// renders the horizontal category bar with active-state highlighting. pub fn strip<'a, K, Message, F>( categories: &'a [Category], is_active: impl Fn(&K) -> bool, @@ -111,9 +104,7 @@ where .into() } -/// Renders a dropdown panel of rows. Caller positions the panel over the -/// strip via `iced_widget::stack!` (typically with a `column!` of [empty -/// padding, dropdown]). +/// renders a dropdown panel of label+shortcut rows. pub fn dropdown<'a, Message>( rows: Vec>, font_size: f32, diff --git a/viewport/src/widgets/mod.rs b/viewport/src/widgets/mod.rs index fcd0b29..ba5910f 100644 --- a/viewport/src/widgets/mod.rs +++ b/viewport/src/widgets/mod.rs @@ -1,9 +1,4 @@ -//! Reusable iced widgets pulled out of the Acord editor for use in other apps. -//! -//! Each submodule is generic over the host's `Message` type and renders against -//! the `iced_wgpu::Renderer`. The widgets read from the global Acord palette -//! (see [`crate::palette`]) so callers can theme them by calling -//! `palette::set_theme(...)` before building their UI. +//! reusable iced widgets shared across Acord shells. pub mod dialog; pub mod menu; diff --git a/viewport/src/widgets/style.rs b/viewport/src/widgets/style.rs index 6538a34..3f49096 100644 --- a/viewport/src/widgets/style.rs +++ b/viewport/src/widgets/style.rs @@ -1,7 +1,4 @@ -//! iced style functions matching Acord's look. -//! -//! Each reads from the global Acord palette ([`crate::palette::current`]) at -//! call time, so they automatically follow theme switches. +//! iced style functions matching Acord's palette. use iced_wgpu::core::{Background, Border, Color, Shadow, Theme}; use iced_widget::{button, text_input}; @@ -25,7 +22,7 @@ pub fn menu_item(_theme: &Theme, status: button::Status) -> button::Style { } } -/// solid surface-1 button with a 1px outline; used by Acord's find bar. +/// solid surface-1 button with a 1px outline. pub fn outlined_button(_theme: &Theme, _status: button::Status) -> button::Style { let p = palette::current(); button::Style { diff --git a/windows/build.rs b/windows/build.rs index e5c49c5..1cf5b96 100644 --- a/windows/build.rs +++ b/windows/build.rs @@ -9,7 +9,7 @@ fn main() { println!("cargo:rerun-if-changed={svg}"); - // Rasterize SVG → PNGs → ICO. + // rasterize SVG to PNGs to ICO. let _ = std::fs::create_dir_all(tmp); let sizes = [16, 24, 32, 48, 64, 128, 256]; let mut pngs = Vec::new(); @@ -39,7 +39,7 @@ fn main() { return; } - // Tell the linker to include the compiled resource. + // link the compiled resource into the binary. let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap(); println!("cargo:rustc-link-arg-bins={manifest_dir}/{res}"); println!("cargo:warning=icon embedded via llvm-windres"); diff --git a/windows/src/app.rs b/windows/src/app.rs index 1bf4257..0488bc8 100644 --- a/windows/src/app.rs +++ b/windows/src/app.rs @@ -412,10 +412,7 @@ impl App { self.last_autosaved_hash = None; } - /// Hash-gated autosave. Mirrors the macOS Swift `persistViewportToNotesDir`: - /// fires on a poll cadence, skips the disk write when the buffer hash - /// matches the last saved value. Without the hash gate this would rewrite - /// the note every poll tick (~MB/s on a busy doc). + /// hash-gated autosave, skipping the disk write when the buffer hash matches. fn try_autosave(&mut self) { if self.handle.is_null() { return; } let text_ptr = viewport_get_text(self.handle); @@ -428,7 +425,7 @@ impl App { let hash = text_hash(&text); if Some(hash) == self.last_autosaved_hash { return; } - // skip the launch stub so it can't overwrite last session's Untitled.md. + // skip the launch stub when no meaningful content exists yet. if self.current_file.is_none() && is_effectively_blank(&text) { return; } @@ -574,8 +571,7 @@ impl ApplicationHandler for App { .with_title("Acord") .with_inner_size(LogicalSize::new(1100.0, 750.0)); - // Load window icon from the bundled PNG (rasterized from SVG at build - // time or shipped alongside the exe). Falls back to no icon silently. + // load window icon from the bundled PNG, falling back to none. if let Some(icon) = load_window_icon() { attrs = attrs.with_window_icon(Some(icon)); } @@ -587,7 +583,7 @@ impl ApplicationHandler for App { let w = size.width as f32 / self.scale; let h = size.height as f32 / self.scale; - // Get raw HWND and pass to viewport. + // extract raw HWND. use raw_window_handle::HasWindowHandle; let wh = window.window_handle().expect("window handle"); let raw = wh.as_raw(); @@ -742,7 +738,7 @@ fn text_hash(s: &str) -> u64 { h.finish() } -/// true when the buffer is empty or just leading heading markers. +/// true for empty buffers or buffers containing only heading markers. fn is_effectively_blank(text: &str) -> bool { let trimmed = text.trim(); if trimmed.is_empty() { @@ -751,7 +747,7 @@ fn is_effectively_blank(text: &str) -> bool { trimmed.trim_start_matches('#').trim().is_empty() } -/// Map winit logical keys to iced keyboard keys for direct iced event push. +/// maps winit logical keys to iced keyboard keys. 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 { @@ -777,10 +773,7 @@ fn winit_key_to_iced(key: &Key) -> iced_wgpu::core::keyboard::Key { } } -/// 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 -/// we return 0 (unmapped). +/// maps winit logical keys to macOS-style keycodes for the bridge. fn winit_key_to_code(key: &Key) -> u32 { match key { Key::Named(n) => match n { @@ -821,9 +814,7 @@ fn encode_modifiers(state: ModifiersState) -> u32 { if state.control_key() { flags |= 1 << 18; } if state.alt_key() { flags |= 1 << 19; } if state.super_key() { flags |= 1 << 20; } - // Mirror Ctrl→LOGO so the viewport's `modifiers.logo()` shortcut arms fire. - // Matches `decode_winit_modifiers` below; without this, only menu-accelerated - // shortcuts (B/I/T) reach the viewport on Windows. + // mirror Ctrl to LOGO for viewport shortcut matching. if state.control_key() { flags |= 1 << 20; } flags } @@ -833,31 +824,27 @@ fn decode_winit_modifiers(state: ModifiersState) -> iced_wgpu::core::keyboard::M if state.shift_key() { m |= iced_wgpu::core::keyboard::Modifiers::SHIFT; } if state.control_key() { m |= iced_wgpu::core::keyboard::Modifiers::CTRL; } if state.alt_key() { m |= iced_wgpu::core::keyboard::Modifiers::ALT; } - // On Windows, Ctrl is the action modifier (not Logo/Super). - // Map Ctrl to LOGO so iced's Cmd+C/V/X bindings work via Ctrl on Windows. + // map Ctrl to LOGO as the primary action modifier. if state.control_key() { m |= iced_wgpu::core::keyboard::Modifiers::LOGO; } m } -/// Load the app icon from `assets/Acord.svg` (relative to exe) or a -/// pre-rasterized PNG next to the exe. Returns None on any failure. +/// loads the app icon from a PNG or SVG near the exe. fn load_window_icon() -> Option { - // Try loading a PNG icon next to the exe first. let exe_dir = std::env::current_exe().ok()?.parent()?.to_path_buf(); - // Try pre-rasterized icon.png next to exe. let png_path = exe_dir.join("icon.png"); let bytes = if png_path.exists() { std::fs::read(&png_path).ok()? } else { - // Fall back to the SVG in the assets dir (repo layout). + // fall back to the SVG in the assets dir. let svg_path = exe_dir.join("../assets/Acord.svg") .canonicalize().ok() .or_else(|| { // Running from repo root via cargo run. std::path::PathBuf::from("assets/Acord.svg").canonicalize().ok() })?; - // Use rsvg-convert at runtime as a fallback. + // rasterize with rsvg-convert. let output = std::process::Command::new("rsvg-convert") .args(["--width", "256", "--height", "256"]) .arg(&svg_path) diff --git a/windows/src/main.rs b/windows/src/main.rs index 60942fc..be05573 100644 --- a/windows/src/main.rs +++ b/windows/src/main.rs @@ -1,4 +1,3 @@ -// Hide the console window on release builds. #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] mod app;