fixed a bunch of codegen bugs/missing operators from cordial

This commit is contained in:
jess 2026-06-08 03:27:25 -07:00
parent 3645df0dac
commit 4e6141cbd2
18 changed files with 864 additions and 62 deletions

View File

@ -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<String>,
}
/// 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<Dependency>, 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<MethodReg> = 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<MethodReg>,
deps: &mut Vec<Dependency>,
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<Dependency>, 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<Dependency>, 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<Depende
.join(", ");
indent(out, depth, &format!("pub fn {}({}) -> 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<Depende
}
}
Stmt::CellAssign { table, cell, value, .. } => {
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<Depende
}
/// walks a Field/Index lvalue chain into a root identifier plus emitted Step nodes.
fn collect_lvalue_path(expr: &Expr, steps: &mut Vec<String>, hook: &dyn DecomposeHook) -> Result<String, String> {
fn collect_lvalue_path(expr: &Expr, steps: &mut Vec<String>, cx: &Ctx) -> Result<String, String> {
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<String>, hook: &dyn Decompos
}
}
fn emit_expr(expr: &Expr, hook: &dyn DecomposeHook) -> Result<String, String> {
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<String, String> {
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<Option<String>, 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<Option<String>, 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<String, String> {
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<String, String> {
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<String, String> {
}
}
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<String, String> {
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<String> = args.iter()
.map(|a| emit_expr(a, hook))
.collect::<Result<_, _>>()?;
} 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<String> = items.iter()
.map(|e| emit_expr(e, hook))
.map(|e| emit_expr(e, cx))
.collect::<Result<_, _>>()?;
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<String, String> {
Expr::Struct(fields) => {
let entries: Vec<String> = fields.iter()
.map(|(k, v)| {
let val = emit_expr(v, hook)?;
let val = emit_expr(v, cx)?;
Ok(format!("({:?}.into(), {})", k, val))
})
.collect::<Result<Vec<_>, 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<String> = args.iter()
.map(|a| emit_expr(a, hook))
.collect::<Result<_, _>>()?;
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<String> = args.iter()
.map(|a| emit_expr(a, hook))
.collect::<Result<_, _>>()?;
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<Vec<String>, 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<f64>) { 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::<f64>() { out.push(n); } }, _ => {} } }
fn v_sort_vals(items: &mut Vec<V>) { 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::<f64>() / 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::<f64>() / 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<V> = 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<f64> = 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>) -> u64 { use std::cell::Cell; thread_local!(static S: Cell<u64> = 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<V> = 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<V> {
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;

View File

@ -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;

View File

@ -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..<copyCount {
if let c = browser_pending_copy_item(h, i) {
items.append(String(cString: c))
viewport_free_string(c)
}
}
browser_clear_pending_copy(h)
stageClipboardWrites(items)
}
}
/// spacing between staged pasteboard writes so a history manager records each.
private static let clipStaggerSeconds: TimeInterval = 0.6
/// schedules each queued document onto the pasteboard at a fixed spacing, the joined whole last.
private func stageClipboardWrites(_ items: [String]) {
guard !items.isEmpty else { return }
let pb = NSPasteboard.general
for (i, s) in items.enumerated() {
let delay = Double(i) * IcedBrowserView.clipStaggerSeconds
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
pb.clearContents()
pb.setString(s, forType: .string)
}
}
}
func refresh() {
@ -146,6 +174,20 @@ class IcedBrowserView: NSView {
browser_scroll_event(h, Float(event.scrollingDeltaX), Float(event.scrollingDeltaY))
}
/// claims command+c before the menu's copy item so the browser handles multi-copy.
override func performKeyEquivalent(with event: NSEvent) -> 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 ?? ""

View File

@ -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"]

View File

@ -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);
/**

View File

@ -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);

View File

@ -21,6 +21,8 @@ pub struct BrowserState {
pub drag: Option<DragState>,
pub current_modifiers: Modifiers,
pub cursor_pos: Point,
/// staged clipboard writes the shell drains each frame.
pub pending_copy: Vec<String>,
}
/// 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<String> {
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;

View File

@ -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();

View File

@ -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<text_widget::InlineMath> {
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)

View File

@ -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::<TextBlock>() {
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<String> {
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<ImageCacheEntry> {
};
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<ImageCacheEntry> {
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("<svg")
}
/// rasterizes SVG bytes into a straight-alpha image via resvg.
fn rasterize_svg(raw: &[u8]) -> Option<image::DynamicImage> {
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<String> {

View File

@ -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<ComputedImage>,
pub image_cache: HashMap<String, ImageCacheEntry>,
pub computed_maths: Vec<ComputedMath>,
pub math_cache: HashMap<String, crate::math::MathFragment>,
/// 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,

View File

@ -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,
}
}
}

View File

@ -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();

View File

@ -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 };

73
viewport/src/math.rs Normal file
View File

@ -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<MathFragment> {
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
}
}

View File

@ -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)

View File

@ -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<crate::syntax::LineDecor>,
inline_math: Vec<InlineMath>,
}
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<InlineMath>) -> 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()

View File

@ -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;