From 4e6141cbd2d82e26c3e74e15d23388f37f87f630 Mon Sep 17 00:00:00 2001 From: jess Date: Mon, 8 Jun 2026 03:27:25 -0700 Subject: [PATCH] fixed a bunch of codegen bugs/missing operators from cordial --- compile/src/lib.rs | 368 +++++++++++++++++++++++++----- linux/src/app.rs | 1 + macos/src/IcedBrowserView.swift | 42 ++++ viewport/Cargo.toml | 5 + viewport/include/acord.h | 15 ++ viewport/src/browser/handle.rs | 53 +++++ viewport/src/browser/state.rs | 29 +++ viewport/src/editor/eval.rs | 1 + viewport/src/editor/mod.rs | 59 +++++ viewport/src/editor/sidecar_io.rs | 141 +++++++++++- viewport/src/editor/state.rs | 7 +- viewport/src/editor/types.rs | 16 ++ viewport/src/editor/update.rs | 11 + viewport/src/lib.rs | 23 ++ viewport/src/math.rs | 73 ++++++ viewport/src/syntax.rs | 4 + viewport/src/text_widget.rs | 77 +++++++ windows/src/app.rs | 1 + 18 files changed, 864 insertions(+), 62 deletions(-) create mode 100644 viewport/src/math.rs diff --git a/compile/src/lib.rs b/compile/src/lib.rs index 73d2e43..f8c168a 100644 --- a/compile/src/lib.rs +++ b/compile/src/lib.rs @@ -1,10 +1,17 @@ //! cordial source decomposer -- produces self-contained Rust from Cordial. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::Path; use acord_core::interp::{parse_program, read_module_source, Expr, Op, Stmt}; +/// decomposition context threaded through every emit step. +struct Ctx<'a> { + hook: &'a dyn DecomposeHook, + /// user-defined function/solve names — these shadow same-named builtins. + user_fns: HashSet, +} + /// extension point for external projects adding custom decomposition rules. pub trait DecomposeHook { /// extra Rust source appended after the value prelude, before generated code. @@ -128,27 +135,38 @@ fn emit_program(out: &mut String, stmts: &[Stmt], deps: &mut Vec, ho } } + // names that resolve to user code first, so a same-named builtin is suppressed. + let mut user_fns = HashSet::new(); + for s in stmts { + match s { + Stmt::FnDef { name, .. } => { user_fns.insert(name.clone()); } + Stmt::SolveDef { name, .. } => { user_fns.insert(name.clone()); } + _ => {} + } + } + let cx = Ctx { hook, user_fns }; + for s in &uses { - emit_stmt(out, s, 0, deps, hook)?; + emit_stmt(out, s, 0, deps, &cx)?; } if !uses.is_empty() { out.push('\n'); } for s in &fns { - emit_stmt(out, s, 0, deps, hook)?; + emit_stmt(out, s, 0, deps, &cx)?; out.push('\n'); } let mut regs: Vec = Vec::new(); for s in &impls { if let Stmt::ImplBlock { type_name, methods, .. } = s { - emit_impl(out, type_name, methods, &mut regs, deps, hook)?; + emit_impl(out, type_name, methods, &mut regs, deps, &cx)?; out.push('\n'); } } indent(out, 0, "pub fn run() -> V {"); for s in &rest { - emit_stmt(out, s, 1, deps, hook)?; + emit_stmt(out, s, 1, deps, &cx)?; } indent(out, 1, "V::Void"); indent(out, 0, "}"); @@ -165,7 +183,7 @@ fn emit_impl( methods: &[Stmt], regs: &mut Vec, deps: &mut Vec, - hook: &dyn DecomposeHook, + cx: &Ctx, ) -> Result<(), String> { for m in methods { let Stmt::FnDef { name, params, body, .. } = m else { continue }; @@ -176,7 +194,7 @@ fn emit_impl( .join(", "); indent(out, 0, &format!("pub fn {}({}) -> V {{", fn_name, param_list)); for s in body { - emit_stmt(out, s, 1, deps, hook)?; + emit_stmt(out, s, 1, deps, cx)?; } indent(out, 1, "V::Void"); indent(out, 0, "}"); @@ -208,35 +226,35 @@ fn emit_dispatch(out: &mut String, regs: &[MethodReg]) { out.push_str(" }\n}\n"); } -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) { +fn emit_stmt(out: &mut String, stmt: &Stmt, depth: usize, deps: &mut Vec, cx: &Ctx) -> Result<(), String> { + if let Some(custom) = cx.hook.stmt(stmt) { indent(out, depth, &custom); return Ok(()); } match stmt { Stmt::Let(name, _ann, expr) => { - let rhs = emit_expr(expr, hook)?; + let rhs = emit_expr(expr, cx)?; indent(out, depth, &format!("let mut {} = {};", ident(name), rhs)); } Stmt::Assign(name, expr) => { - let rhs = emit_expr(expr, hook)?; + let rhs = emit_expr(expr, cx)?; indent(out, depth, &format!("{} = {};", ident(name), rhs)); } Stmt::PathAssign(lhs, expr) => { let mut steps = Vec::new(); - let root = collect_lvalue_path(lhs, &mut steps, hook)?; - let rhs = emit_expr(expr, hook)?; + let root = collect_lvalue_path(lhs, &mut steps, cx)?; + let rhs = emit_expr(expr, cx)?; indent(out, depth, &format!( "v_assign_path(&mut {}, &[{}], {});", root, steps.join(", "), rhs )); } Stmt::Return(expr) => { - let rhs = emit_expr(expr, hook)?; + let rhs = emit_expr(expr, cx)?; indent(out, depth, &format!("return {};", rhs)); } Stmt::ExprStmt(expr) => { - let rendered = emit_expr(expr, hook)?; + let rendered = emit_expr(expr, cx)?; indent(out, depth, &format!("let _ = {};", rendered)); } Stmt::FnDef { name, params, body, .. } => { @@ -246,44 +264,47 @@ fn emit_stmt(out: &mut String, stmt: &Stmt, depth: usize, deps: &mut Vec V {{", ident(name), param_list)); for s in body { - emit_stmt(out, s, depth + 1, deps, hook)?; + emit_stmt(out, s, depth + 1, deps, cx)?; } indent(out, depth + 1, "V::Void"); indent(out, depth, "}"); } Stmt::While(cond, body) => { - let c = emit_expr(cond, hook)?; + let c = emit_expr(cond, cx)?; indent(out, depth, &format!("while v_truthy(&{}) {{", c)); for s in body { - emit_stmt(out, s, depth + 1, deps, hook)?; + emit_stmt(out, s, depth + 1, deps, cx)?; } indent(out, depth, "}"); } Stmt::IfElse(cond, then_body, else_body) => { - let c = emit_expr(cond, hook)?; + let c = emit_expr(cond, cx)?; indent(out, depth, &format!("if v_truthy(&{}) {{", c)); for s in then_body { - emit_stmt(out, s, depth + 1, deps, hook)?; + emit_stmt(out, s, depth + 1, deps, cx)?; } if let Some(els) = else_body { indent(out, depth, "} else {"); for s in els { - emit_stmt(out, s, depth + 1, deps, hook)?; + emit_stmt(out, s, depth + 1, deps, cx)?; } } indent(out, depth, "}"); } Stmt::ForLoop(var, iter_expr, body) => { - let iter_s = emit_expr(iter_expr, hook)?; + let iter_s = emit_expr(iter_expr, cx)?; indent(out, depth, &format!("for {} in v_iter(&{}) {{", ident(var), iter_s)); for s in body { - emit_stmt(out, s, depth + 1, deps, hook)?; + emit_stmt(out, s, depth + 1, deps, cx)?; } indent(out, depth, "}"); } Stmt::Use(segments, wildcard) => { - deps.push(Dependency { segments: segments.clone(), wildcard: *wildcard }); let Some(root) = segments.first() else { return Ok(()); }; + // `use spice` / `use ring` are language directives, not importable modules — + // spice literals and ring values are baked into the runtime, so emit nothing. + if is_builtin_use(root) { return Ok(()); } + deps.push(Dependency { segments: segments.clone(), wildcard: *wildcard }); let mod_ident = root.replace('-', "_"); indent(out, depth, &format!("mod {};", mod_ident)); let mut path = mod_ident.clone(); @@ -298,7 +319,7 @@ fn emit_stmt(out: &mut String, stmt: &Stmt, depth: usize, deps: &mut Vec { - let v = emit_expr(value, hook)?; + let v = emit_expr(value, cx)?; indent(out, depth, &format!( "v_cell_set({:?}, {}, {}, &{});", table, cell.0, cell.1, v @@ -323,17 +344,17 @@ fn emit_stmt(out: &mut String, stmt: &Stmt, depth: usize, deps: &mut Vec, hook: &dyn DecomposeHook) -> Result { +fn collect_lvalue_path(expr: &Expr, steps: &mut Vec, cx: &Ctx) -> Result { match expr { Expr::Ident(name) => Ok(ident(name)), Expr::Field(inner, field) => { - let root = collect_lvalue_path(inner, steps, hook)?; + let root = collect_lvalue_path(inner, steps, cx)?; steps.push(format!("Step::Field({:?}.into())", field)); Ok(root) } Expr::Index(inner, idx) => { - let root = collect_lvalue_path(inner, steps, hook)?; - let i = emit_expr(idx, hook)?; + let root = collect_lvalue_path(inner, steps, cx)?; + let i = emit_expr(idx, cx)?; steps.push(format!("Step::Index(v_num(&{}) as i64)", i)); Ok(root) } @@ -341,8 +362,74 @@ fn collect_lvalue_path(expr: &Expr, steps: &mut Vec, hook: &dyn Decompos } } -fn emit_expr(expr: &Expr, hook: &dyn DecomposeHook) -> Result { - if let Some(custom) = hook.expr(expr) { +/// value builtins routed through the runtime `v_builtin_call` dispatcher. +const VALUE_BUILTINS: &[&str] = &[ + "sin", "cos", "tan", "asin", "acos", "atan", "sqrt", "abs", "ln", "log", + "floor", "ceil", "round", "rand", "seed", + "sum", "avg", "min", "max", "count", "std_devp", "std_devs", + "len", "range", "push", + "take", "skip", "drop", "chunk", "window", "zip", "flatten", "distinct", "unique", "delta", + "sort", "hooks", "module_paths", "module_subpaths", +]; + +/// maps a higher-order builtin name to its runtime fn, or None if not a HOF. +fn hof_vname(lname: &str) -> Option<&'static str> { + Some(match lname { + "map" => "v_map", + "each" => "v_each", + "filter" => "v_filter", + "find" => "v_find", + "all" => "v_all", + "any" => "v_any", + "take_while" => "v_take_while", + "skip_while" => "v_skip_while", + "inspect" | "tap" => "v_inspect", + "flat_map" => "v_flat_map", + "sort_by" => "v_sort_by", + "reduce" => "v_reduce", + "fold" => "v_fold", + "scan" => "v_scan", + _ => return None, + }) +} + +/// emits a HOF call: leading value args (first by ref), trailing function name as a fn pointer. +fn emit_hof_core(vname: &str, val_args: &[Expr], fn_ident: &str, cx: &Ctx) -> Result { + let mut parts = Vec::with_capacity(val_args.len() + 1); + for (i, a) in val_args.iter().enumerate() { + let s = emit_expr(a, cx)?; + parts.push(if i == 0 { format!("&{}", s) } else { s }); + } + parts.push(fn_ident.to_string()); + Ok(format!("{}({})", vname, parts.join(", "))) +} + +/// HOF in free-call position — the last argument must be a bare function name. +fn try_hof_call(lname: &str, args: &[Expr], cx: &Ctx) -> Result, String> { + let Some(vname) = hof_vname(lname) else { return Ok(None); }; + let Some((fn_arg, val_args)) = args.split_last() else { + return Err(format!("{}() requires arguments", lname)); + }; + let Expr::Ident(fname) = fn_arg else { + return Err(format!("{}() expects a function name as its last argument", lname)); + }; + Ok(Some(emit_hof_core(vname, val_args, &ident(fname), cx)?)) +} + +/// HOF in method position — only fires when the trailing arg names a user function, +/// so `obj.find(some_var)` on a struct still routes as a normal method call. +fn try_hof_method(lname: &str, recv: &Expr, args: &[Expr], cx: &Ctx) -> Result, String> { + let Some(vname) = hof_vname(lname) else { return Ok(None); }; + let Some((fn_arg, mid)) = args.split_last() else { return Ok(None); }; + let Expr::Ident(fname) = fn_arg else { return Ok(None); }; + if !cx.user_fns.contains(fname) { return Ok(None); } + let mut val_args = vec![recv.clone()]; + val_args.extend(mid.iter().cloned()); + Ok(Some(emit_hof_core(vname, &val_args, &ident(fname), cx)?)) +} + +fn emit_expr(expr: &Expr, cx: &Ctx) -> Result { + if let Some(custom) = cx.hook.expr(expr) { return Ok(custom); } Ok(match expr { @@ -350,9 +437,11 @@ fn emit_expr(expr: &Expr, hook: &dyn DecomposeHook) -> Result { Expr::Spice(n, unit) => format!("V::Array(vec![V::Num({}), V::Str({:?}.into())])", fmt_num(*n), unit), Expr::Bool(b) => format!("V::Bool({})", b), Expr::Str(s) => format!("V::Str({:?}.into())", s), + // `pi` is the one builtin constant; everything else is a variable read. + Expr::Ident(name) if name == "pi" => "V::Num(std::f64::consts::PI)".to_string(), Expr::Ident(name) => format!("{}.clone()", ident(name)), Expr::UnaryOp(op, inner) => { - let inner_s = emit_expr(inner, hook)?; + let inner_s = emit_expr(inner, cx)?; match op { Op::Neg => format!("v_neg(&{})", inner_s), Op::Not => format!("v_not(&{})", inner_s), @@ -361,8 +450,8 @@ fn emit_expr(expr: &Expr, hook: &dyn DecomposeHook) -> Result { } } Expr::BinOp(op, l, r) => { - let ls = emit_expr(l, hook)?; - let rs = emit_expr(r, hook)?; + let ls = emit_expr(l, cx)?; + let rs = emit_expr(r, cx)?; let func = match op { Op::Add => "v_add", Op::Sub => "v_sub", @@ -383,33 +472,43 @@ fn emit_expr(expr: &Expr, hook: &dyn DecomposeHook) -> Result { format!("{}(&{}, &{})", func, ls, rs) } Expr::Call(name, args) => { - if let Some(custom) = hook.call(name, args) { + if let Some(custom) = cx.hook.call(name, args) { custom - } else { - let arg_list: Vec = args.iter() - .map(|a| emit_expr(a, hook)) - .collect::>()?; + } else if cx.user_fns.contains(name) { + // user-defined function wins over any same-named builtin. + let arg_list = emit_args(args, cx)?; format!("{}({})", ident(name), arg_list.join(", ")) + } else { + let lname = name.to_ascii_lowercase(); + if let Some(s) = try_hof_call(&lname, args, cx)? { + s + } else if VALUE_BUILTINS.contains(&lname.as_str()) { + let arg_list = emit_args(args, cx)?; + format!("v_builtin_call({:?}, &[{}]).unwrap_or(V::Void)", lname, arg_list.join(", ")) + } else { + let arg_list = emit_args(args, cx)?; + format!("{}({})", ident(name), arg_list.join(", ")) + } } } Expr::Array(items) => { let parts: Vec = items.iter() - .map(|e| emit_expr(e, hook)) + .map(|e| emit_expr(e, cx)) .collect::>()?; format!("V::Array(vec![{}])", parts.join(", ")) } Expr::Index(base, idx) => { - let b = emit_expr(base, hook)?; - let i = emit_expr(idx, hook)?; + let b = emit_expr(base, cx)?; + let i = emit_expr(idx, cx)?; format!("v_index(&{}, &{})", b, i) } Expr::Range(start, end) => { - let s = emit_expr(start, hook)?; - let e = emit_expr(end, hook)?; + let s = emit_expr(start, cx)?; + let e = emit_expr(end, cx)?; format!("v_range(&{}, &{})", s, e) } Expr::IsCheck(inner, type_name) => { - let i = emit_expr(inner, hook)?; + let i = emit_expr(inner, cx)?; format!("v_is(&{}, {:?})", i, type_name) } Expr::CellRef { table, target, .. } => { @@ -432,36 +531,48 @@ fn emit_expr(expr: &Expr, hook: &dyn DecomposeHook) -> Result { Expr::Struct(fields) => { let entries: Vec = fields.iter() .map(|(k, v)| { - let val = emit_expr(v, hook)?; + let val = emit_expr(v, cx)?; 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)?; + let b = emit_expr(base, cx)?; format!("v_field(&{}, {:?})", b, field) } Expr::MethodCall(recv, method, args) => { - let r = emit_expr(recv, hook)?; - let arg_list: Vec = args.iter() - .map(|a| emit_expr(a, hook)) - .collect::>()?; - if arg_list.is_empty() { - format!("v_method_call(&{}, {:?}, &[])", r, method) + let lname = method.to_ascii_lowercase(); + // `arr.map(f)` style — only when `f` is a known user function (see try_hof_method). + if let Some(s) = try_hof_method(&lname, recv, args, cx)? { + s } else { - format!("v_method_call(&{}, {:?}, &[{}])", r, method, arg_list.join(", ")) + let r = emit_expr(recv, cx)?; + let arg_list = emit_args(args, cx)?; + if arg_list.is_empty() { + format!("v_method_call(&{}, {:?}, &[])", r, method) + } else { + format!("v_method_call(&{}, {:?}, &[{}])", r, method, arg_list.join(", ")) + } } } Expr::StaticCall(type_name, method, args) => { - let arg_list: Vec = args.iter() - .map(|a| emit_expr(a, hook)) - .collect::>()?; + let arg_list = emit_args(args, cx)?; format!("{}__{}({})", ident(type_name), ident(method), arg_list.join(", ")) } }) } +/// emits each argument expression, in order. +fn emit_args(args: &[Expr], cx: &Ctx) -> Result, String> { + args.iter().map(|a| emit_expr(a, cx)).collect() +} + +/// `spice` and `ring` are built-in language directives, not resolvable modules. +fn is_builtin_use(root: &str) -> bool { + root == "spice" || root == "ring" +} + fn indent(out: &mut String, depth: usize, line: &str) { for _ in 0..depth { out.push_str(" "); } out.push_str(line); @@ -650,13 +761,77 @@ 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 } fn v_method_call(recv: &V, method: &str, args: &[V]) -> V { + let mut full = vec![recv.clone()]; + full.extend_from_slice(args); if let Some(t) = v_struct_type(recv) { - let mut full = vec![recv.clone()]; - full.extend_from_slice(args); if let Some(r) = v_dispatch(&t, method, &full) { return r; } } + // UFCS: a non-method call routes to the same builtins as a free call. + if let Some(r) = v_builtin_call(method, &full) { return r; } V::Void } +fn v_arg(a: &[V], i: usize) -> V { a.get(i).cloned().unwrap_or(V::Void) } +fn v_scalar_of(v: &V) -> f64 { match v { V::Num(n) => *n, V::Array(a) if a.len() == 2 => match &a[0] { V::Num(n) => *n, _ => 0.0 }, _ => 0.0 } } +fn v_vals_equal(a: &V, b: &V) -> bool { match (a, b) { (V::Num(x), V::Num(y)) => x == y, (V::Bool(x), V::Bool(y)) => x == y, (V::Str(x), V::Str(y)) => x == y, _ => false } } +fn v_flatten_nums(v: &V, out: &mut Vec) { match v { V::Num(n) => out.push(*n), V::Array(items) => { for it in items { v_flatten_nums(it, out); } }, V::Str(s) => { if let Ok(n) = s.trim().parse::() { out.push(n); } }, _ => {} } } +fn v_sort_vals(items: &mut Vec) { let all_num = items.iter().all(|v| matches!(v, V::Num(_)) || matches!(v, V::Array(a) if a.len() == 2 && matches!(&a[0], V::Num(_)))); if all_num { items.sort_by(|a, b| v_scalar_of(a).partial_cmp(&v_scalar_of(b)).unwrap_or(std::cmp::Ordering::Equal)); } else { items.sort_by(|a, b| format!("{}", a).cmp(&format!("{}", b))); } } +fn v_aggregate(name: &str, nums: &[f64]) -> V { match name { "sum" => V::Num(nums.iter().sum()), "count" => V::Num(nums.len() as f64), "avg" => if nums.is_empty() { V::Void } else { V::Num(nums.iter().sum::() / nums.len() as f64) }, "min" => nums.iter().copied().fold(None, |acc, n| Some(match acc { Some(a) => f64::min(a, n), None => n })).map(V::Num).unwrap_or(V::Void), "max" => nums.iter().copied().fold(None, |acc, n| Some(match acc { Some(a) => f64::max(a, n), None => n })).map(V::Num).unwrap_or(V::Void), "std_devp" | "std_devs" => { let n = nums.len(); if n == 0 || (name == "std_devs" && n < 2) { return V::Void; } let mean = nums.iter().sum::() / n as f64; let ss: f64 = nums.iter().map(|v| (v - mean).powi(2)).sum(); let div = if name == "std_devp" { n as f64 } else { (n - 1) as f64 }; V::Num((ss / div).sqrt()) }, _ => V::Void } } +fn v_chunk(v: &V, n: usize) -> V { if n == 0 { return V::Void; } match v { V::Array(a) => V::Array(a.chunks(n).map(|c| V::Array(c.to_vec())).collect()), _ => V::Void } } +fn v_window(v: &V, n: usize) -> V { if n == 0 { return V::Void; } match v { V::Array(a) => { if a.len() < n { return V::Array(vec![]); } V::Array((0..=a.len() - n).map(|i| V::Array(a[i..i + n].to_vec())).collect()) }, _ => V::Void } } +fn v_zip_vals(l: &V, r: &V) -> V { match (l, r) { (V::Array(x), V::Array(y)) => V::Array(x.iter().enumerate().map(|(i, e)| V::Array(vec![e.clone(), y.get(i).cloned().unwrap_or(V::Void)])).collect()), _ => V::Void } } +fn v_flatten_vals(v: &V) -> V { match v { V::Array(a) => { let mut out = Vec::new(); for e in a { match e { V::Array(inner) => out.extend(inner.clone()), o => out.push(o.clone()) } } V::Array(out) }, _ => V::Void } } +fn v_distinct(v: &V) -> V { match v { V::Array(a) => { let mut out: Vec = Vec::new(); for e in a { if !out.iter().any(|s| v_vals_equal(s, e)) { out.push(e.clone()); } } V::Array(out) }, _ => V::Void } } +fn v_delta(v: &V) -> V { match v { V::Array(a) => { let nums: Vec = a.iter().map(v_scalar_of).collect(); V::Array(nums.windows(2).map(|w| V::Num(w[1] - w[0])).collect()) }, _ => V::Void } } +fn v_rng_state(set: Option) -> u64 { use std::cell::Cell; thread_local!(static S: Cell = Cell::new({ use std::time::{SystemTime, UNIX_EPOCH}; let nanos = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_nanos() as u64).unwrap_or(0x9E3779B97F4A7C15); nanos.wrapping_mul(0x9E3779B97F4A7C15) ^ 0xDEADBEEFCAFEBABE })); S.with(|c| { if let Some(v) = set { c.set(if v == 0 { 0x9E3779B97F4A7C15 } else { v }); return 0; } let mut x = c.get(); if x == 0 { x = 0x9E3779B97F4A7C15; } x ^= x >> 12; x ^= x << 25; x ^= x >> 27; c.set(x); x.wrapping_mul(0x2545F4914F6CDD1D) }) } +fn v_rng_unit() -> f64 { (v_rng_state(None) >> 11) as f64 / (1u64 << 53) as f64 } +fn v_rand(a: &[V]) -> V { match a.len() { 0 => V::Num(v_rng_unit()), 1 => match v_arg(a, 0) { V::Num(n) => { if n <= 0.0 { V::Void } else { V::Num((v_rng_unit() * n).floor()) } }, V::Array(x) => { if x.is_empty() { V::Void } else { let i = (v_rng_unit() * x.len() as f64) as usize; x[i.min(x.len() - 1)].clone() } }, _ => V::Void }, _ => { let lo = v_num(&v_arg(a, 0)); let hi = v_num(&v_arg(a, 1)); if hi <= lo { V::Void } else { V::Num(lo + v_rng_unit() * (hi - lo)) } } } } +fn v_map(a: &V, f: fn(V) -> V) -> V { match a { V::Array(x) => V::Array(x.iter().cloned().map(f).collect()), _ => V::Void } } +fn v_each(a: &V, f: fn(V) -> V) -> V { if let V::Array(x) = a { for v in x { f(v.clone()); } } V::Void } +fn v_filter(a: &V, f: fn(V) -> V) -> V { match a { V::Array(x) => V::Array(x.iter().filter(|v| v_truthy(&f((*v).clone()))).cloned().collect()), _ => V::Void } } +fn v_find(a: &V, f: fn(V) -> V) -> V { if let V::Array(x) = a { for v in x { if v_truthy(&f(v.clone())) { return v.clone(); } } } V::Void } +fn v_all(a: &V, f: fn(V) -> V) -> V { if let V::Array(x) = a { for v in x { if !v_truthy(&f(v.clone())) { return V::Bool(false); } } } V::Bool(true) } +fn v_any(a: &V, f: fn(V) -> V) -> V { if let V::Array(x) = a { for v in x { if v_truthy(&f(v.clone())) { return V::Bool(true); } } } V::Bool(false) } +fn v_take_while(a: &V, f: fn(V) -> V) -> V { let mut out = Vec::new(); if let V::Array(x) = a { for v in x { if !v_truthy(&f(v.clone())) { break; } out.push(v.clone()); } } V::Array(out) } +fn v_skip_while(a: &V, f: fn(V) -> V) -> V { let mut out = Vec::new(); let mut dropping = true; if let V::Array(x) = a { for v in x { if dropping { if v_truthy(&f(v.clone())) { continue; } dropping = false; } out.push(v.clone()); } } V::Array(out) } +fn v_inspect(a: &V, f: fn(V) -> V) -> V { if let V::Array(x) = a { for v in x { f(v.clone()); } } a.clone() } +fn v_flat_map(a: &V, f: fn(V) -> V) -> V { let mut out = Vec::new(); if let V::Array(x) = a { for v in x { match f(v.clone()) { V::Array(inner) => out.extend(inner), V::Void => {}, o => out.push(o) } } } V::Array(out) } +fn v_sort_by(a: &V, f: fn(V) -> V) -> V { match a { V::Array(x) => { let mut keyed: Vec<(V, V)> = x.iter().map(|v| (f(v.clone()), v.clone())).collect(); let mut keys: Vec = keyed.iter().map(|(k, _)| k.clone()).collect(); v_sort_vals(&mut keys); let mut out = Vec::new(); for k in keys { if let Some(p) = keyed.iter().position(|(kk, _)| v_vals_equal(kk, &k)) { out.push(keyed.remove(p).1); } } V::Array(out) }, _ => V::Void } } +fn v_reduce(a: &V, f: fn(V, V) -> V) -> V { match a { V::Array(x) => { let mut it = x.iter().cloned(); let mut acc = match it.next() { Some(v) => v, None => return V::Void }; for v in it { acc = f(acc, v); } acc }, _ => V::Void } } +fn v_fold(a: &V, seed: V, f: fn(V, V) -> V) -> V { let mut acc = seed; if let V::Array(x) = a { for v in x { acc = f(acc, v.clone()); } } acc } +fn v_scan(a: &V, seed: V, f: fn(V, V) -> V) -> V { let mut acc = seed; let mut out = Vec::new(); if let V::Array(x) = a { for v in x { acc = f(acc, v.clone()); out.push(acc.clone()); } } V::Array(out) } +fn v_builtin_call(name: &str, a: &[V]) -> Option { + let r = match name { + "sin" => V::Num(v_num(&v_arg(a, 0)).sin()), + "cos" => V::Num(v_num(&v_arg(a, 0)).cos()), + "tan" => V::Num(v_num(&v_arg(a, 0)).tan()), + "asin" => V::Num(v_num(&v_arg(a, 0)).asin()), + "acos" => V::Num(v_num(&v_arg(a, 0)).acos()), + "atan" => V::Num(v_num(&v_arg(a, 0)).atan()), + "sqrt" => V::Num(v_num(&v_arg(a, 0)).sqrt()), + "abs" => V::Num(v_num(&v_arg(a, 0)).abs()), + "ln" => V::Num(v_num(&v_arg(a, 0)).ln()), + "log" => V::Num(v_num(&v_arg(a, 0)).log10()), + "floor" | "ceil" | "round" => { let n = v_num(&v_arg(a, 0)); let digits = if a.len() >= 2 { v_num(&v_arg(a, 1)) as i32 } else { 0 }; let f = 10f64.powi(digits); let s = n * f; V::Num(match name { "floor" => s.floor() / f, "ceil" => s.ceil() / f, _ => s.round() / f }) } + "rand" => v_rand(a), + "seed" => { v_rng_state(Some(v_num(&v_arg(a, 0)).to_bits())); V::Void } + "sum" | "avg" | "min" | "max" | "count" | "std_devp" | "std_devs" => { let mut nums = Vec::new(); v_flatten_nums(&v_arg(a, 0), &mut nums); v_aggregate(name, &nums) } + "len" => match v_arg(a, 0) { V::Str(s) => V::Num(s.len() as f64), V::Array(x) => V::Num(x.len() as f64), _ => V::Void }, + "range" => { let s = v_num(&v_arg(a, 0)) as i64; let e = v_num(&v_arg(a, 1)) as i64; V::Array((s..e).map(|n| V::Num(n as f64)).collect()) } + "push" => match v_arg(a, 0) { V::Array(mut x) => { x.push(v_arg(a, 1)); V::Array(x) }, _ => V::Void }, + "take" => match v_arg(a, 0) { V::Array(x) => V::Array(x.into_iter().take(v_num(&v_arg(a, 1)).max(0.0) as usize).collect()), _ => V::Void }, + "skip" | "drop" => match v_arg(a, 0) { V::Array(x) => V::Array(x.into_iter().skip(v_num(&v_arg(a, 1)).max(0.0) as usize).collect()), _ => V::Void }, + "chunk" => v_chunk(&v_arg(a, 0), v_num(&v_arg(a, 1)).max(0.0) as usize), + "window" => v_window(&v_arg(a, 0), v_num(&v_arg(a, 1)).max(0.0) as usize), + "zip" => v_zip_vals(&v_arg(a, 0), &v_arg(a, 1)), + "flatten" => v_flatten_vals(&v_arg(a, 0)), + "distinct" | "unique" => v_distinct(&v_arg(a, 0)), + "delta" => v_delta(&v_arg(a, 0)), + "sort" => { let mut x = match v_arg(a, 0) { V::Array(x) => x, _ => return Some(V::Void) }; v_sort_vals(&mut x); V::Array(x) } + "hooks" | "module_paths" | "module_subpaths" => V::Array(vec![]), + _ => return None, + }; + Some(r) +} "#; #[cfg(test)] @@ -806,6 +981,81 @@ mod tests { assert!(out.contains("fn v_method_call(")); } + #[test] + fn use_spice_is_not_a_module() { + let r = dec_full("use spice\nlet x = 1"); + assert!(!r.code.contains("mod spice"), "spice must not become a rust module"); + assert!(r.deps.is_empty(), "spice must not be reported as a dependency"); + } + + #[test] + fn use_ring_is_not_a_module() { + let r = dec_full("use ring"); + assert!(!r.code.contains("mod ring")); + assert!(r.deps.is_empty()); + } + + #[test] + fn builtins_compile_and_run() { + let src = "\ +fn dbl(x) { + return x * 2 +} +fn add(a, b) { + return a + b +} +fn scalars() { + let arr = [1, 2, 3, 4] + return sum(arr) + sqrt(16) + cos(0) +} +fn doubled() { + let arr = [1, 2, 3] + return map(arr, dbl) +} +fn folded() { + let arr = [1, 2, 3, 4] + return fold(arr, 0, add) +} +fn pushed() { + let a = [1] + return push(a, 2) +} +fn pi_val() { + return pi +} +"; + let mut code = decompose(src).unwrap().code; + code.push_str(r#" +fn main() { + assert_eq!(scalars(), V::Num(15.0)); + assert_eq!(doubled(), V::Array(vec![V::Num(2.0), V::Num(4.0), V::Num(6.0)])); + assert_eq!(folded(), V::Num(10.0)); + assert_eq!(pushed(), V::Array(vec![V::Num(1.0), V::Num(2.0)])); + assert_eq!(pi_val(), V::Num(std::f64::consts::PI)); + println!("OK"); +} +"#); + let dir = std::env::temp_dir().join(format!("acord-dec-bi-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let src_path = dir.join("prog.rs"); + let bin_path = dir.join("prog"); + std::fs::write(&src_path, &code).unwrap(); + + let compile = std::process::Command::new("rustc") + .arg("--edition").arg("2021").arg("-O") + .arg(&src_path).arg("-o").arg(&bin_path) + .output(); + let compile = match compile { + Ok(c) => c, + Err(_) => { std::fs::remove_dir_all(&dir).ok(); return; } + }; + assert!(compile.status.success(), "rustc failed:\n{}", String::from_utf8_lossy(&compile.stderr)); + let run = std::process::Command::new(&bin_path).output().unwrap(); + assert_eq!(String::from_utf8_lossy(&run.stdout).trim(), "OK"); + std::fs::remove_dir_all(&dir).ok(); + } + #[test] fn overridden_prelude_compiles_and_runs() { let mut code = decompose_with("let x = 1", &FieldOverride).unwrap().code; diff --git a/linux/src/app.rs b/linux/src/app.rs index c50af42..4f39cfb 100644 --- a/linux/src/app.rs +++ b/linux/src/app.rs @@ -328,6 +328,7 @@ impl App { } WindowEvent::RedrawRequested => { browser::handle::render(handle); + browser::handle::flush_pending_copy(handle); } WindowEvent::CursorMoved { position, .. } => { self.browser_cursor = position; diff --git a/macos/src/IcedBrowserView.swift b/macos/src/IcedBrowserView.swift index 40ee8c2..5f29ed3 100644 --- a/macos/src/IcedBrowserView.swift +++ b/macos/src/IcedBrowserView.swift @@ -83,6 +83,34 @@ class IcedBrowserView: NSView { viewport_free_string(cstr) onOpenPath?(path) } + let copyCount = browser_pending_copy_count(h) + if copyCount > 0 { + var items: [String] = [] + for i in 0.. Bool { + guard let h = browserHandle else { return super.performKeyEquivalent(with: event) } + let mods = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + if mods == .command, event.charactersIgnoringModifiers?.lowercased() == "c" { + let text = event.characters ?? "c" + text.withCString { cstr in + browser_key_event(h, UInt32(event.keyCode), UInt32(event.modifierFlags.rawValue), true, cstr) + } + return true + } + return super.performKeyEquivalent(with: event) + } + override func keyDown(with event: NSEvent) { guard let h = browserHandle else { return } let text = event.characters ?? "" diff --git a/viewport/Cargo.toml b/viewport/Cargo.toml index 199c58b..dd163ca 100644 --- a/viewport/Cargo.toml +++ b/viewport/Cargo.toml @@ -28,6 +28,11 @@ ureq = "3" filetime = "0.2" regex = "1" printpdf = { version = "0.9", default-features = false } +ratex-parser = "0.1.11" +ratex-layout = "0.1.11" +ratex-types = "0.1.11" +ratex-render = { version = "0.1.11", features = ["embed-fonts"] } +resvg = "0.47.0" [features] default = ["native-shell"] diff --git a/viewport/include/acord.h b/viewport/include/acord.h index 80fdeba..b047fcb 100644 --- a/viewport/include/acord.h +++ b/viewport/include/acord.h @@ -159,6 +159,21 @@ void browser_key_event(struct BrowserHandle *handle, char *browser_take_pending_open(struct BrowserHandle *handle); +/** + * count of staged clipboard writes awaiting the shell. + */ +uint32_t browser_pending_copy_count(struct BrowserHandle *handle); + +/** + * returns the staged clipboard write at the index, or null when out of range. + */ +char *browser_pending_copy_item(struct BrowserHandle *handle, uint32_t index); + +/** + * drops the staged clipboard writes once the shell has read them. + */ +void browser_clear_pending_copy(struct BrowserHandle *handle); + void browser_refresh(struct BrowserHandle *handle); /** diff --git a/viewport/src/browser/handle.rs b/viewport/src/browser/handle.rs index 0fbf9ea..2678643 100644 --- a/viewport/src/browser/handle.rs +++ b/viewport/src/browser/handle.rs @@ -48,6 +48,52 @@ impl clipboard::Clipboard for NoopClipboard { fn write(&mut self, _kind: clipboard::Kind, _contents: String) {} } +/// spacing between staged clipboard writes so a history manager records each. +#[cfg(feature = "native-shell")] +const CLIP_STAGGER_MS: u64 = 150; + +/// drains staged documents and writes each, then the joined whole, to the clipboard. +#[cfg(feature = "native-shell")] +pub fn flush_pending_copy(handle: &mut BrowserHandle) { + let items = std::mem::take(&mut handle.state.pending_copy); + if items.is_empty() { + return; + } + std::thread::spawn(move || { + let Ok(mut board) = arboard::Clipboard::new() else { return }; + let last = items.len() - 1; + for (i, item) in items.into_iter().enumerate() { + if i == last { + // final entry has to outlive the writer thread on x11/wayland + #[cfg(target_os = "linux")] + { + use arboard::SetExtLinux; + let _ = board.set().wait().text(item); + } + #[cfg(not(target_os = "linux"))] + { + let _ = board.set_text(item); + } + } else { + let _ = board.set_text(item); + std::thread::sleep(std::time::Duration::from_millis(CLIP_STAGGER_MS)); + } + } + }); +} + +#[cfg(not(feature = "native-shell"))] +pub fn flush_pending_copy(_handle: &mut BrowserHandle) {} + +/// matches a logo/control + c keystroke against the keyboard event. +fn is_copy_chord(ev: &Event) -> bool { + use iced_wgpu::core::keyboard::{Event as Kbd, Key}; + if let Event::Keyboard(Kbd::KeyPressed { key: Key::Character(c), modifiers, .. }) = ev { + return (modifiers.logo() || modifiers.control()) && c.as_str().eq_ignore_ascii_case("c"); + } + false +} + /// creates the wgpu surface, iced renderer, and browser state from raw window handles. pub fn create( raw_display: RawDisplayHandle, @@ -175,6 +221,7 @@ pub fn render(handle: &mut BrowserHandle) { .push(Event::Window(window::Event::RedrawRequested(iced_wgpu::core::time::Instant::now()))); // pre-scan: update modifier and cursor state before message dispatch + let mut do_copy = false; for ev in &handle.events { match ev { Event::Keyboard(iced_wgpu::core::keyboard::Event::ModifiersChanged(m)) => { @@ -185,6 +232,12 @@ pub fn render(handle: &mut BrowserHandle) { } _ => {} } + if is_copy_chord(ev) { + do_copy = true; + } + } + if do_copy { + handle.state.queue_copy(); } let cache = std::mem::take(&mut handle.cache); diff --git a/viewport/src/browser/state.rs b/viewport/src/browser/state.rs index 5924ec4..e5f5afa 100644 --- a/viewport/src/browser/state.rs +++ b/viewport/src/browser/state.rs @@ -21,6 +21,8 @@ pub struct BrowserState { pub drag: Option, pub current_modifiers: Modifiers, pub cursor_pos: Point, + /// staged clipboard writes the shell drains each frame. + pub pending_copy: Vec, } /// pixels the cursor has to move past the press point before a drag activates. @@ -110,6 +112,7 @@ impl BrowserState { drag: None, current_modifiers: Modifiers::empty(), cursor_pos: Point::ORIGIN, + pending_copy: Vec::new(), } } @@ -387,6 +390,32 @@ impl BrowserState { } } + /// reads selected files in row order, returning each note's markdown with the binary sidecar stripped. + pub fn selected_documents_in_order(&self) -> Vec { + self.items + .iter() + .filter(|it| matches!(it.kind, BrowserItemKind::File) && self.selected.contains(&it.path)) + .filter_map(|it| std::fs::read(&it.path).ok()) + .map(|bytes| { + let (text, _archive) = crate::sidecar::extract_from_md(&bytes); + String::from_utf8_lossy(&text).into_owned() + }) + .collect() + } + + /// stages each selected document, then the joined whole, for the shell to write. + pub fn queue_copy(&mut self) { + let mut seq = self.selected_documents_in_order(); + if seq.is_empty() { + return; + } + if seq.len() > 1 { + let joined = seq.join("\n\n"); + seq.push(joined); + } + self.pending_copy = seq; + } + /// applies command/shift/plain selection rules to the clicked path. fn apply_selection(&mut self, path: PathBuf) { let mods = self.current_modifiers; diff --git a/viewport/src/editor/eval.rs b/viewport/src/editor/eval.rs index 6ef1b29..f2a33d8 100644 --- a/viewport/src/editor/eval.rs +++ b/viewport/src/editor/eval.rs @@ -619,6 +619,7 @@ impl super::EditorState { let source = source_parts.join("\n"); self.scan_images(&boundaries, &block_ids); + self.scan_math(&boundaries, &block_ids); let has_text_eval = source.lines().any(|l| l.trim_start().starts_with("/=")); let has_cell_formulas = self.any_visible_cell_formulas(); diff --git a/viewport/src/editor/mod.rs b/viewport/src/editor/mod.rs index 27f3e88..5957d49 100644 --- a/viewport/src/editor/mod.rs +++ b/viewport/src/editor/mod.rs @@ -236,6 +236,7 @@ impl EditorState { .wrapping(Wrapping::Word) .key_binding(macos_key_binding) .anchored(anchored_items) + .inline_math(self.build_inline_math(tb.id)) .show_gutter(true) .gutter_offset(0) .focused(is_focused) @@ -543,10 +544,38 @@ impl EditorState { items.push((img.anchor.after_line, LayerItem::Image(img))); } } + for m in &self.computed_maths { + if m.display && m.anchor.block_id == block_id { + items.push((m.anchor.after_line, LayerItem::Math(m))); + } + } items.sort_by_key(|(line, _)| *line); items } + /// builds inline math placements for the text widget from cached fragments. + fn build_inline_math(&self, block_id: crate::selection::BlockId) -> Vec { + let mut v = Vec::new(); + for m in &self.computed_maths { + if m.display || m.anchor.block_id != block_id { + continue; + } + let Some((bs, be)) = m.range else { continue }; + if let Some(frag) = self.math_cache.get(&m.key) { + v.push(text_widget::InlineMath { + line: m.anchor.after_line, + byte_start: bs, + byte_end: be, + handle: frag.handle.clone(), + width: frag.width, + height: frag.height, + ascent: frag.ascent, + }); + } + } + v + } + /// builds anchored child elements for the text widget compositor fn build_anchored_items<'a>( &'a self, @@ -694,6 +723,35 @@ impl EditorState { element: wrapped, }); } + LayerItem::Math(m) => { + let el: Element<'a, Message, Theme, iced_wgpu::Renderer> = + if let Some(frag) = self.math_cache.get(&m.key) { + iced_widget::container( + iced_widget::image(frag.handle.clone()) + .width(Length::Fixed(frag.width)) + .height(Length::Fixed(frag.height)) + ) + .padding(Padding { top: IMAGE_VPAD, right: 8.0, bottom: IMAGE_VPAD, left: 8.0 }) + .width(Length::Fill) + .align_x(alignment::Horizontal::Center) + .into() + } else { + iced_widget::container( + iced_widget::text(format!("$$ {} $$", m.latex)) + .font(syntax::EDITOR_FONT) + .size(self.font_size) + .color(p.overlay0) + ) + .padding(Padding { top: 0.0, right: 8.0, bottom: 0.0, left: 40.0 }) + .width(Length::Fill) + .into() + }; + anchored.push(AnchoredItem { + after_line: *after_line, + height: item.element_height(lh, self.font_size), + element: el, + }); + } } } @@ -725,6 +783,7 @@ impl EditorState { .wrapping(Wrapping::Word) .key_binding(macos_key_binding) .anchored(anchored_items) + .inline_math(self.build_inline_math(tb.id)) .show_gutter(true) .gutter_offset(this_global_line) .focused(is_focused) diff --git a/viewport/src/editor/sidecar_io.rs b/viewport/src/editor/sidecar_io.rs index 08fa5c5..3f3d563 100644 --- a/viewport/src/editor/sidecar_io.rs +++ b/viewport/src/editor/sidecar_io.rs @@ -4,7 +4,7 @@ use crate::table_block::TableBlock; use crate::text_block::TextBlock; use super::types::{ - Anchor, ComputedImage, FreeNodeId, FreePlacement, ImageCacheEntry, LayoutMode, + Anchor, ComputedImage, ComputedMath, FreeNodeId, FreePlacement, ImageCacheEntry, LayoutMode, IMAGE_MAX_H, IMAGE_PADDING, IMAGE_PLACEHOLDER_H, }; use super::{strip_result_lines, RenderMode}; @@ -451,6 +451,107 @@ impl super::EditorState { }); } } + + /// scans text blocks for display math, rendering each into math_cache. + pub(super) fn scan_math( + &mut self, + boundaries: &[(usize, crate::selection::BlockId)], + block_ids: &[crate::selection::BlockId], + ) { + self.computed_maths.retain(|m| !block_ids.contains(&m.anchor.block_id)); + + let px = self.font_size; + let color = { + let c = crate::palette::current().text; + [c.r, c.g, c.b] + }; + + let mut found: Vec<(Anchor, String, bool, Option<(usize, usize)>)> = Vec::new(); + for &(_start, block_id) in boundaries { + let block = match self.registry.get(&block_id) { + Some(b) => b, + None => continue, + }; + let text = if let Some(tb) = block.as_any().downcast_ref::() { + tb.content.text() + } else { + continue; + }; + for (line_idx, line) in text.lines().enumerate() { + if let Some(latex) = parse_display_math(line) { + found.push((Anchor { block_id, after_line: line_idx }, latex, true, None)); + } else { + for (start, end, latex) in inline_math_spans(line) { + found.push(( + Anchor { block_id, after_line: line_idx }, + latex, + false, + Some((start, end)), + )); + } + } + } + } + + for (anchor, latex, display, range) in found { + let bucket = px.round() as u32; + let key = format!("{}|{}|{}", display as u8, bucket, latex); + if !self.math_cache.contains_key(&key) { + if let Some(frag) = crate::math::render_math(&latex, px, display, color) { + self.math_cache.insert(key.clone(), frag); + } + } + let height = self.math_cache.get(&key).map(|f| f.height).unwrap_or(px * 1.3); + self.computed_maths.push(ComputedMath { anchor, latex, display, key, height, range }); + } + } +} + +/// finds inline math spans in a line as (start_byte, end_byte_exclusive, inner_latex). +pub(super) fn inline_math_spans(line: &str) -> Vec<(usize, usize, String)> { + let bytes = line.as_bytes(); + let mut spans = Vec::new(); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'$' && (i == 0 || bytes[i - 1] != b'\\') { + // `$$` is display, never inline + if i + 1 < bytes.len() && bytes[i + 1] == b'$' { + i += 2; + continue; + } + let mut j = i + 1; + while j < bytes.len() { + if bytes[j] == b'$' && bytes[j - 1] != b'\\' { + break; + } + j += 1; + } + if j < bytes.len() && j > i + 1 { + let inner = line[i + 1..j].trim(); + if !inner.is_empty() { + spans.push((i, j + 1, inner.to_string())); + } + i = j + 1; + continue; + } else { + break; + } + } + i += 1; + } + spans +} + +/// extracts the body of a display-math line, or None. +pub(super) fn parse_display_math(line: &str) -> Option { + let t = line.trim(); + if t.len() >= 4 && t.starts_with("$$") && t.ends_with("$$") { + let inner = t[2..t.len() - 2].trim(); + if !inner.is_empty() { + return Some(inner.to_string()); + } + } + None } /// parses a markdown image reference ![alt](src) from a line @@ -485,7 +586,11 @@ pub(super) fn load_image_from_path(src: &str) -> Option { }; std::fs::read(&path).ok()? }; - let img = image::load_from_memory(&raw).ok()?; + let img = if looks_svg(src, &raw) { + rasterize_svg(&raw)? + } else { + image::load_from_memory(&raw).ok()? + }; let (width, height) = (img.width(), img.height()); let rgba = img.into_rgba8(); let pixels = rgba.into_raw(); @@ -493,6 +598,38 @@ pub(super) fn load_image_from_path(src: &str) -> Option { Some(ImageCacheEntry { handle, width, height }) } +/// detects SVG by extension or a leading-bytes sniff. +fn looks_svg(src: &str, raw: &[u8]) -> bool { + let ext_svg = std::path::Path::new(src) + .extension() + .map(|e| e.eq_ignore_ascii_case("svg")) + .unwrap_or(false); + if ext_svg { + return true; + } + let head = String::from_utf8_lossy(&raw[..raw.len().min(1024)]); + head.contains(" Option { + let opt = resvg::usvg::Options::default(); + let tree = resvg::usvg::Tree::from_data(raw, &opt).ok()?; + let size = tree.size(); + let base = size.width().max(size.height()).max(1.0); + let scale = 2.0_f32.min(3000.0 / base).max(0.1); + let w = (size.width() * scale).ceil().max(1.0) as u32; + let h = (size.height() * scale).ceil().max(1.0) as u32; + let mut pixmap = resvg::tiny_skia::Pixmap::new(w, h)?; + resvg::render( + &tree, + resvg::tiny_skia::Transform::from_scale(scale, scale), + &mut pixmap.as_mut(), + ); + let png = pixmap.encode_png().ok()?; + image::load_from_memory(&png).ok() +} + /// encodes a clipboard image to PNG and writes it into the on-disk cache #[cfg(all(not(target_os = "ios"), feature = "native-shell"))] pub fn write_clipboard_image_to_cache(img: &arboard::ImageData) -> Option { diff --git a/viewport/src/editor/state.rs b/viewport/src/editor/state.rs index 734c545..c6ba0c1 100644 --- a/viewport/src/editor/state.rs +++ b/viewport/src/editor/state.rs @@ -11,7 +11,7 @@ use crate::table_block::TableBlock; use crate::text_widget; use super::types::{ - ComputedImage, ComputedTable, ComputedTree, ContextMenuState, EditKind, FindState, + ComputedImage, ComputedMath, ComputedTable, ComputedTree, ContextMenuState, EditKind, FindState, FreeNodeId, FreePlacement, ImageCacheEntry, InlinePressState, InlineResult, LayoutMode, LineIndicator, MenuCategory, PromoteDragState, RenderMode, ResizeDragState, SettingsView, ShellAction, UndoSnapshot, @@ -87,6 +87,9 @@ pub struct EditorState { pub computed_images: Vec, pub image_cache: HashMap, + pub computed_maths: Vec, + pub math_cache: HashMap, + /// previous global cursor line, used to detect line changes pub(super) prev_cursor_line: usize, @@ -174,6 +177,8 @@ impl EditorState { pending_clipboard: None, computed_images: Vec::new(), image_cache: HashMap::new(), + computed_maths: Vec::new(), + math_cache: HashMap::new(), prev_cursor_line: 0, menu_open: None, pending_shell_action: None, diff --git a/viewport/src/editor/types.rs b/viewport/src/editor/types.rs index 0f04f6e..7d3fd22 100644 --- a/viewport/src/editor/types.rs +++ b/viewport/src/editor/types.rs @@ -288,6 +288,20 @@ pub struct ComputedImage { pub display_height: f32, } +/// rendered LaTeX math, display or inline +#[derive(Debug, Clone)] +pub struct ComputedMath { + pub anchor: Anchor, + pub latex: String, + pub display: bool, + /// math_cache lookup key + pub key: String, + /// logical fragment height for layout + pub height: f32, + /// byte range of the `$...$` span within its line, for inline math + pub range: Option<(usize, usize)>, +} + /// cached image data keyed by source path or URL pub struct ImageCacheEntry { pub handle: iced_widget::image::Handle, @@ -306,6 +320,7 @@ pub(super) enum LayerItem<'a> { Table(&'a ComputedTable), Tree(&'a ComputedTree), Image(&'a ComputedImage), + Math(&'a ComputedMath), } #[derive(Debug, Clone, PartialEq, Eq, Hash)] @@ -355,6 +370,7 @@ impl LayerItem<'_> { Self::Table(t) => t.element_height(line_h), Self::Tree(t) => t.element_height(font_size), Self::Image(img) => img.display_height + IMAGE_VPAD * 2.0, + Self::Math(m) => m.height + IMAGE_VPAD * 2.0, } } } diff --git a/viewport/src/editor/update.rs b/viewport/src/editor/update.rs index 0bd8277..c98f7a2 100644 --- a/viewport/src/editor/update.rs +++ b/viewport/src/editor/update.rs @@ -53,6 +53,14 @@ impl super::EditorState { } /// dispatches a text-widget action through the editor's edit pipeline. + /// re-renders all math at the current font size and refreshes the eval layers. + fn rescale_math(&mut self) { + self.math_cache.clear(); + if matches!(self.render_mode, RenderMode::Live | RenderMode::View) { + self.run_eval_all(); + } + } + pub(super) fn handle_editor_action(&mut self, action: text_widget::Action) { let is_edit = action.is_edit(); let is_enter = matches!(&action, Action::Edit(text_widget::Edit::Enter)); @@ -486,12 +494,15 @@ impl super::EditorState { Message::MarkdownLink(_url) => {} Message::ZoomIn => { self.font_size = (self.font_size + 1.0).min(48.0); + self.rescale_math(); } Message::ZoomOut => { self.font_size = (self.font_size - 1.0).max(8.0); + self.rescale_math(); } Message::ZoomReset => { self.font_size = 14.0; + self.rescale_math(); } Message::Undo => { self.perform_undo(); diff --git a/viewport/src/lib.rs b/viewport/src/lib.rs index ae57754..835bc78 100644 --- a/viewport/src/lib.rs +++ b/viewport/src/lib.rs @@ -22,6 +22,7 @@ pub mod export; pub mod handle; pub mod heading_block; pub mod hr_block; +pub mod math; pub mod minimap; pub mod module; pub mod oklab; @@ -602,6 +603,28 @@ pub extern "C" fn browser_take_pending_open(handle: *mut BrowserHandle) -> *mut CString::new(s).map(|c| c.into_raw()).unwrap_or(std::ptr::null_mut()) } +/// count of staged clipboard writes awaiting the shell. +#[unsafe(no_mangle)] +pub extern "C" fn browser_pending_copy_count(handle: *mut BrowserHandle) -> u32 { + let h = match unsafe { handle.as_mut() } { Some(h) => h, None => return 0 }; + h.state.pending_copy.len() as u32 +} + +/// returns the staged clipboard write at the index, or null when out of range. +#[unsafe(no_mangle)] +pub extern "C" fn browser_pending_copy_item(handle: *mut BrowserHandle, index: u32) -> *mut c_char { + let h = match unsafe { handle.as_mut() } { Some(h) => h, None => return std::ptr::null_mut() }; + let Some(s) = h.state.pending_copy.get(index as usize) else { return std::ptr::null_mut() }; + CString::new(s.clone()).map(|c| c.into_raw()).unwrap_or(std::ptr::null_mut()) +} + +/// drops the staged clipboard writes once the shell has read them. +#[unsafe(no_mangle)] +pub extern "C" fn browser_clear_pending_copy(handle: *mut BrowserHandle) { + let h = match unsafe { handle.as_mut() } { Some(h) => h, None => return }; + h.state.pending_copy.clear(); +} + #[unsafe(no_mangle)] pub extern "C" fn browser_refresh(handle: *mut BrowserHandle) { let h = match unsafe { handle.as_mut() } { Some(h) => h, None => return }; diff --git a/viewport/src/math.rs b/viewport/src/math.rs new file mode 100644 index 0000000..bae2f51 --- /dev/null +++ b/viewport/src/math.rs @@ -0,0 +1,73 @@ +//! isolated LaTeX math content generator backed by RaTeX. + +use ratex_layout::{layout, to_display_list, LayoutOptions}; +use ratex_parser::parser::parse; +use ratex_render::{render_to_png, RenderOptions}; +use ratex_types::color::Color as RxColor; +use ratex_types::math_style::MathStyle; + +/// supersampling factor applied to the rendered bitmap +const DPR: f32 = 2.0; + +/// typeset math: RGBA bitmap plus baseline metrics in logical pixels +pub struct MathFragment { + pub handle: iced_widget::image::Handle, + pub width: f32, + pub height: f32, + pub ascent: f32, + pub depth: f32, +} + +/// typesets a delimiter-free LaTeX math string into a fragment, None on parse failure. +pub fn render_math(latex: &str, px: f32, display: bool, color: [f32; 3]) -> Option { + let ast = parse(latex).ok()?; + let style = if display { MathStyle::Display } else { MathStyle::Text }; + let glyph_color = RxColor::new(color[0], color[1], color[2], 1.0); + let opts = LayoutOptions::default().with_style(style).with_color(glyph_color); + let lbox = layout(&ast, &opts); + let dl = to_display_list(&lbox); + + let render_opts = RenderOptions { + font_size: px, + padding: 2.0, + background_color: RxColor::new(0.0, 0.0, 0.0, 0.0), + font_dir: String::new(), + device_pixel_ratio: DPR, + }; + let png = render_to_png(&dl, &render_opts).ok()?; + + let rgba = image::load_from_memory(&png).ok()?.into_rgba8(); + let (pw, ph) = (rgba.width(), rgba.height()); + if pw == 0 || ph == 0 { + return None; + } + let handle = iced_widget::image::Handle::from_rgba(pw, ph, rgba.into_raw()); + + let w = pw as f32 / DPR; + let h = ph as f32 / DPR; + let depth = (dl.depth as f32) * px; + Some(MathFragment { + handle, + width: w, + height: h, + ascent: (h - depth).max(0.0), + depth, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn renders_a_formula() { + let f = render_math("R_{TOP} = R_{BOT}\\left(\\frac{V_{OUT}}{V_{FB}} - 1\\right)", 16.0, true, [0.9, 0.9, 0.9]); + let f = f.expect("display formula should render"); + eprintln!("display: w={} h={} ascent={} depth={}", f.width, f.height, f.ascent, f.depth); + assert!(f.width > 10.0 && f.height > 5.0); + let m = render_math("\\begin{bmatrix} a & b \\\\ c & d \\end{bmatrix}", 16.0, true, [0.9,0.9,0.9]).expect("matrix"); + eprintln!("matrix: w={} h={}", m.width, m.height); + let inl = render_math("G_{22}", 14.0, false, [0.9,0.9,0.9]).expect("inline"); + eprintln!("inline: w={} h={} ascent={} depth={}", inl.width, inl.height, inl.ascent, inl.depth); + assert!(render_math("\\frac{", 16.0, true, [0.9,0.9,0.9]).is_none() || true); // bad latex must not panic + } +} diff --git a/viewport/src/syntax.rs b/viewport/src/syntax.rs index 3739919..8f962c9 100644 --- a/viewport/src/syntax.rs +++ b/viewport/src/syntax.rs @@ -323,6 +323,10 @@ impl SyntaxHighlighter { _ => {} } } + let t = line_text.trim(); + if t.len() >= 4 && t.starts_with("$$") && t.ends_with("$$") { + return vec![0..line_text.len()]; + } parse_inline(line_text, 0) .into_iter() .filter(|(_, h)| h.kind == MD_FORMAT_MARKER) diff --git a/viewport/src/text_widget.rs b/viewport/src/text_widget.rs index 2138ebe..b144224 100644 --- a/viewport/src/text_widget.rs +++ b/viewport/src/text_widget.rs @@ -73,6 +73,17 @@ pub struct AnchoredItem<'a, Message, Theme = iced_wgpu::core::Theme> { pub element: Element<'a, Message, Theme, iced_wgpu::Renderer>, } +/// a rendered inline math bitmap drawn over its source span on the baseline. +pub struct InlineMath { + pub line: usize, + pub byte_start: usize, + pub byte_end: usize, + pub handle: iced_wgpu::core::image::Handle, + pub width: f32, + pub height: f32, + pub ascent: f32, +} + /// per-logical-line metrics published once by layout, read by all consumers. #[derive(Clone, Default, Debug)] pub struct LineMetric { @@ -332,6 +343,7 @@ pub struct TextEditor< line_indicator: crate::editor::LineIndicator, gutter_rainbow: bool, line_decors: Vec, + inline_math: Vec, } impl<'a, Message, Theme> @@ -370,6 +382,7 @@ where line_indicator: crate::editor::LineIndicator::On, gutter_rainbow: false, line_decors: Vec::new(), + inline_math: Vec::new(), } } @@ -502,6 +515,7 @@ where line_indicator: self.line_indicator, gutter_rainbow: self.gutter_rainbow, line_decors: self.line_decors, + inline_math: self.inline_math, } } @@ -539,6 +553,12 @@ where self } + /// sets inline math fragments drawn over their source spans. + pub fn inline_math(mut self, items: Vec) -> Self { + self.inline_math = items; + self + } + /// sets the global line offset for gutter numbering. pub fn gutter_offset(mut self, offset: usize) -> Self { self.gutter_offset = offset; @@ -1644,6 +1664,63 @@ where style.value, text_bounds, ); + + // erases the raw math glyphs and draws the fragment on the baseline + // skipped on the cursor line, where raw source stays editable + if active_cursor_line != Some(line_i) + && self.inline_math.iter().any(|im| im.line == line_i) + { + use iced_wgpu::core::image::{Image as MathImage, Renderer as _}; + for im in self.inline_math.iter().filter(|m| m.line == line_i) { + let mut span: Option<(f32, f32, usize, f32)> = None; + if let Some(lines) = buffer.lines[line_i].layout_opt() { + for (row, lline) in lines.iter().enumerate() { + for g in lline.glyphs.iter() { + if g.start >= im.byte_start && g.start < im.byte_end { + let (lo, hi, _, _) = span + .unwrap_or((g.x, g.x + g.w, row, lline.max_ascent)); + span = Some((lo.min(g.x), hi.max(g.x + g.w), row, lline.max_ascent)); + } + } + if span.is_some() { + break; + } + } + } + let Some((min_x, max_xw, row, max_ascent)) = span else { continue }; + let raw_w = (max_xw - min_x).max(0.0); + let row_top = y + row as f32 * line_h; + let frag_x = text_bounds.x + min_x; + renderer.fill_quad( + renderer::Quad { + bounds: Rectangle::new( + Point::new(frag_x, row_top), + Size::new(raw_w, line_h), + ), + border: Border::default(), + ..renderer::Quad::default() + }, + style.background, + ); + let baseline = row_top + max_ascent; + let frag_top = baseline - im.ascent; + renderer.draw_image( + MathImage { + handle: im.handle.clone(), + filter_method: iced_wgpu::core::image::FilterMethod::Linear, + rotation: iced_wgpu::core::Radians(0.0), + border_radius: iced_wgpu::core::border::Radius::default(), + opacity: 1.0, + snap: true, + }, + Rectangle::new( + Point::new(frag_x, frag_top), + Size::new(im.width, im.height), + ), + text_bounds, + ); + } + } } while child_idx < self.anchored_children.len() diff --git a/windows/src/app.rs b/windows/src/app.rs index 0488bc8..36a8065 100644 --- a/windows/src/app.rs +++ b/windows/src/app.rs @@ -483,6 +483,7 @@ impl App { } WindowEvent::RedrawRequested => { browser::handle::render(handle); + browser::handle::flush_pending_copy(handle); } WindowEvent::CursorMoved { position, .. } => { self.browser_cursor = position;