This is a complete overhaul.
- New decomposer / Compiler pre-processor - New Decomposer Public API allows custom external extensions of acord-core to provide their own DecomposeHook impl to extend what the compiler knows - .dot traversal of Stucts, etc. - Public Externs - Regex find and replace — capture groups ($1-$9) - Performance: syntax ident caching — moved scan_user_idents out of the per-frame render loop, cached on EditorState, recomputed on text change only - Performance: incremental highlighter rebuild — editor mode skips classify_document and tree-sitter when line count unchanged - Performance: minimap caching — classify_text cached, no longer computed every frame - Use declaration support in the decomposer — emits mod/use statements, returns a Dependency list for recursive decomposition of referenced notes - Tons of new operators (below) HOFs (closure-like, take a function name): - map, filter, reduce, fold, flat_map - iter (with callback) -peek (custom, allows optional arg1 to specify how far to look-ahead. uses a custom ringbuf function) - all, any, find - sort (with optional comparator) Collection ops: - peek (with optional look-ahead) - window, zip, chunk - take, skip/drop - flatten, distinct/unique Aggregates: - sum, avg, min, max, count, std_devp, std_devs - range
This commit is contained in:
parent
ba983e3776
commit
1a0508bc06
|
|
@ -1,4 +1,7 @@
|
|||
.DS_Store
|
||||
target/
|
||||
build/
|
||||
*.xcodeproj
|
||||
*.xcassets
|
||||
*.pbxproj
|
||||
dist/
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
[workspace]
|
||||
members = ["core", "viewport", "windows", "linux", "xtask"]
|
||||
# Excludes `linux` (deps don't build on macOS/Windows) and `xtask` (build-tool,
|
||||
# not part of the app). The Linux script invokes `cargo build -p acord-linux`
|
||||
# directly when running on a Linux host.
|
||||
members = ["core", "viewport", "windows", "linux", "xtask", "compile"]
|
||||
# linux and xtask excluded from default; linux built explicitly on linux hosts.
|
||||
default-members = ["core", "viewport", "windows"]
|
||||
resolver = "2"
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
[package]
|
||||
name = "acord-compile"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
crate-type = ["rlib"]
|
||||
|
||||
[dependencies]
|
||||
acord-core = { path = "../core" }
|
||||
|
|
@ -0,0 +1,563 @@
|
|||
//! cordial source decomposer -- produces self-contained Rust from Cordial.
|
||||
|
||||
use acord_core::interp::{parse_program, Expr, Op, Stmt};
|
||||
|
||||
/// extension point for external projects adding custom decomposition rules.
|
||||
pub trait DecomposeHook {
|
||||
/// extra Rust source appended after the value prelude, before generated code.
|
||||
fn preamble(&self) -> Option<String> { None }
|
||||
|
||||
/// intercepts a function call by name before the default decomposition.
|
||||
fn call(&self, _name: &str, _args: &[Expr]) -> Option<String> { None }
|
||||
|
||||
/// intercepts an expression before the default decomposition.
|
||||
fn expr(&self, _expr: &Expr) -> Option<String> { None }
|
||||
|
||||
/// intercepts a statement before the default decomposition.
|
||||
fn stmt(&self, _stmt: &Stmt) -> Option<String> { None }
|
||||
}
|
||||
|
||||
struct NoHook;
|
||||
impl DecomposeHook for NoHook {}
|
||||
|
||||
/// external module dependency discovered during decomposition.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct Dependency {
|
||||
/// module name (note filename stem, hyphen-form).
|
||||
pub module: String,
|
||||
/// specific item imported, or None / Some("*") for the whole module.
|
||||
pub item: Option<String>,
|
||||
}
|
||||
|
||||
/// result of decomposing a single cordial source.
|
||||
pub struct Decomposed {
|
||||
/// self-contained rust source for this module.
|
||||
pub code: String,
|
||||
/// external modules referenced by use declarations.
|
||||
pub deps: Vec<Dependency>,
|
||||
}
|
||||
|
||||
/// decomposes cordial source into standalone rust + a dependency list.
|
||||
pub fn decompose(source: &str) -> Result<Decomposed, String> {
|
||||
decompose_with(source, &NoHook)
|
||||
}
|
||||
|
||||
/// decomposes with a custom hook for external extensions.
|
||||
pub fn decompose_with(source: &str, hook: &dyn DecomposeHook) -> Result<Decomposed, String> {
|
||||
let stmts = parse_program(source)?;
|
||||
let mut deps = Vec::new();
|
||||
let mut out = String::new();
|
||||
out.push_str(GENERATED_PRELUDE);
|
||||
if let Some(extra) = hook.preamble() {
|
||||
out.push_str(&extra);
|
||||
out.push('\n');
|
||||
}
|
||||
emit_program(&mut out, &stmts, &mut deps, hook)?;
|
||||
Ok(Decomposed { code: out, deps })
|
||||
}
|
||||
|
||||
fn emit_program(out: &mut String, stmts: &[Stmt], deps: &mut Vec<Dependency>, hook: &dyn DecomposeHook) -> Result<(), String> {
|
||||
let mut uses = Vec::new();
|
||||
let mut fns = Vec::new();
|
||||
let mut rest = Vec::new();
|
||||
for s in stmts {
|
||||
match s {
|
||||
Stmt::Use(..) => uses.push(s),
|
||||
Stmt::FnDef { .. } => fns.push(s),
|
||||
_ => rest.push(s),
|
||||
}
|
||||
}
|
||||
|
||||
for s in &uses {
|
||||
emit_stmt(out, s, 0, deps, hook)?;
|
||||
}
|
||||
if !uses.is_empty() { out.push('\n'); }
|
||||
|
||||
for s in &fns {
|
||||
emit_stmt(out, s, 0, deps, hook)?;
|
||||
out.push('\n');
|
||||
}
|
||||
indent(out, 0, "pub fn run() -> V {");
|
||||
for s in &rest {
|
||||
emit_stmt(out, s, 1, deps, hook)?;
|
||||
}
|
||||
indent(out, 1, "V::Void");
|
||||
indent(out, 0, "}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn emit_stmt(out: &mut String, stmt: &Stmt, depth: usize, deps: &mut Vec<Dependency>, hook: &dyn DecomposeHook) -> Result<(), String> {
|
||||
if let Some(custom) = hook.stmt(stmt) {
|
||||
indent(out, depth, &custom);
|
||||
return Ok(());
|
||||
}
|
||||
match stmt {
|
||||
Stmt::Let(name, _ann, expr) => {
|
||||
let rhs = emit_expr(expr, hook)?;
|
||||
indent(out, depth, &format!("let mut {} = {};", ident(name), rhs));
|
||||
}
|
||||
Stmt::Assign(name, expr) => {
|
||||
let rhs = emit_expr(expr, hook)?;
|
||||
indent(out, depth, &format!("{} = {};", ident(name), rhs));
|
||||
}
|
||||
Stmt::PathAssign(lhs, expr) => {
|
||||
let lhs_s = emit_expr(lhs, hook)?;
|
||||
let rhs = emit_expr(expr, hook)?;
|
||||
indent(out, depth, &format!("v_path_assign(&mut {}, &{});", lhs_s, rhs));
|
||||
}
|
||||
Stmt::Return(expr) => {
|
||||
let rhs = emit_expr(expr, hook)?;
|
||||
indent(out, depth, &format!("return {};", rhs));
|
||||
}
|
||||
Stmt::ExprStmt(expr) => {
|
||||
let rendered = emit_expr(expr, hook)?;
|
||||
indent(out, depth, &format!("let _ = {};", rendered));
|
||||
}
|
||||
Stmt::FnDef { name, params, body, .. } => {
|
||||
let param_list: String = params.iter()
|
||||
.map(|(p, _)| format!("mut {}: V", ident(p)))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
indent(out, depth, &format!("pub fn {}({}) -> V {{", ident(name), param_list));
|
||||
for s in body {
|
||||
emit_stmt(out, s, depth + 1, deps, hook)?;
|
||||
}
|
||||
indent(out, depth + 1, "V::Void");
|
||||
indent(out, depth, "}");
|
||||
}
|
||||
Stmt::While(cond, body) => {
|
||||
let c = emit_expr(cond, hook)?;
|
||||
indent(out, depth, &format!("while v_truthy(&{}) {{", c));
|
||||
for s in body {
|
||||
emit_stmt(out, s, depth + 1, deps, hook)?;
|
||||
}
|
||||
indent(out, depth, "}");
|
||||
}
|
||||
Stmt::IfElse(cond, then_body, else_body) => {
|
||||
let c = emit_expr(cond, hook)?;
|
||||
indent(out, depth, &format!("if v_truthy(&{}) {{", c));
|
||||
for s in then_body {
|
||||
emit_stmt(out, s, depth + 1, deps, hook)?;
|
||||
}
|
||||
if let Some(els) = else_body {
|
||||
indent(out, depth, "} else {");
|
||||
for s in els {
|
||||
emit_stmt(out, s, depth + 1, deps, hook)?;
|
||||
}
|
||||
}
|
||||
indent(out, depth, "}");
|
||||
}
|
||||
Stmt::ForLoop(var, iter_expr, body) => {
|
||||
let iter_s = emit_expr(iter_expr, hook)?;
|
||||
indent(out, depth, &format!("for {} in v_iter(&{}) {{", ident(var), iter_s));
|
||||
for s in body {
|
||||
emit_stmt(out, s, depth + 1, deps, hook)?;
|
||||
}
|
||||
indent(out, depth, "}");
|
||||
}
|
||||
Stmt::Use(module, item) => {
|
||||
let mod_ident = module.replace('-', "_");
|
||||
deps.push(Dependency { module: module.clone(), item: item.clone() });
|
||||
indent(out, depth, &format!("mod {};", mod_ident));
|
||||
match item.as_deref() {
|
||||
None | Some("*") => indent(out, depth, &format!("use {}::*;", mod_ident)),
|
||||
Some(name) => indent(out, depth, &format!("use {}::{};", mod_ident, ident(name))),
|
||||
}
|
||||
}
|
||||
Stmt::CellAssign { table, cell, value, .. } => {
|
||||
let v = emit_expr(value, hook)?;
|
||||
indent(out, depth, &format!(
|
||||
"v_cell_set({:?}, {}, {}, &{});",
|
||||
table, cell.0, cell.1, v
|
||||
));
|
||||
}
|
||||
Stmt::SolveDef { name, params, target_var, source_fn, .. } => {
|
||||
let param_list: String = params.iter()
|
||||
.map(|p| format!("mut {}: V", ident(p)))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
indent(out, depth, &format!("pub fn {}({}) -> V {{", ident(name), param_list));
|
||||
indent(out, depth + 1, &format!(
|
||||
"v_solve_newton({:?}, {:?})",
|
||||
target_var, source_fn
|
||||
));
|
||||
indent(out, depth, "}");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn emit_expr(expr: &Expr, hook: &dyn DecomposeHook) -> Result<String, String> {
|
||||
if let Some(custom) = hook.expr(expr) {
|
||||
return Ok(custom);
|
||||
}
|
||||
Ok(match expr {
|
||||
Expr::Num(n) => format!("V::Num({})", fmt_num(*n)),
|
||||
Expr::Bool(b) => format!("V::Bool({})", b),
|
||||
Expr::Str(s) => format!("V::Str({:?}.into())", s),
|
||||
Expr::Ident(name) => format!("{}.clone()", ident(name)),
|
||||
Expr::UnaryOp(op, inner) => {
|
||||
let inner_s = emit_expr(inner, hook)?;
|
||||
match op {
|
||||
Op::Neg => format!("v_neg(&{})", inner_s),
|
||||
Op::Not => format!("v_not(&{})", inner_s),
|
||||
Op::Strip => format!("v_strip(&{})", inner_s),
|
||||
_ => format!("v_neg(&{})", inner_s),
|
||||
}
|
||||
}
|
||||
Expr::BinOp(op, l, r) => {
|
||||
let ls = emit_expr(l, hook)?;
|
||||
let rs = emit_expr(r, hook)?;
|
||||
let func = match op {
|
||||
Op::Add => "v_add",
|
||||
Op::Sub => "v_sub",
|
||||
Op::Mul => "v_mul",
|
||||
Op::Div => "v_div",
|
||||
Op::Mod => "v_rem",
|
||||
Op::Pow => "v_pow",
|
||||
Op::Eq => "v_eq",
|
||||
Op::Neq => "v_neq",
|
||||
Op::Lt => "v_lt",
|
||||
Op::Gt => "v_gt",
|
||||
Op::Lte => "v_lte",
|
||||
Op::Gte => "v_gte",
|
||||
Op::And => "v_and",
|
||||
Op::Or => "v_or",
|
||||
_ => "v_add",
|
||||
};
|
||||
format!("{}(&{}, &{})", func, ls, rs)
|
||||
}
|
||||
Expr::Call(name, args) => {
|
||||
if let Some(custom) = hook.call(name, args) {
|
||||
custom
|
||||
} else {
|
||||
let arg_list: Vec<String> = args.iter()
|
||||
.map(|a| emit_expr(a, hook))
|
||||
.collect::<Result<_, _>>()?;
|
||||
format!("{}({})", ident(name), arg_list.join(", "))
|
||||
}
|
||||
}
|
||||
Expr::Array(items) => {
|
||||
let parts: Vec<String> = items.iter()
|
||||
.map(|e| emit_expr(e, hook))
|
||||
.collect::<Result<_, _>>()?;
|
||||
format!("V::Array(vec![{}])", parts.join(", "))
|
||||
}
|
||||
Expr::Index(base, idx) => {
|
||||
let b = emit_expr(base, hook)?;
|
||||
let i = emit_expr(idx, hook)?;
|
||||
format!("v_index(&{}, &{})", b, i)
|
||||
}
|
||||
Expr::Range(start, end) => {
|
||||
let s = emit_expr(start, hook)?;
|
||||
let e = emit_expr(end, hook)?;
|
||||
format!("v_range(&{}, &{})", s, e)
|
||||
}
|
||||
Expr::IsCheck(inner, type_name) => {
|
||||
let i = emit_expr(inner, hook)?;
|
||||
format!("v_is(&{}, {:?})", i, type_name)
|
||||
}
|
||||
Expr::CellRef { table, target, .. } => {
|
||||
let t = table.as_deref().unwrap_or("_default");
|
||||
match target {
|
||||
acord_core::interp::CellRefTarget::Cell(col, row) => {
|
||||
format!("v_cell_get({:?}, {}, {})", t, col, row)
|
||||
}
|
||||
acord_core::interp::CellRefTarget::Range(c1, r1, c2, r2) => {
|
||||
format!("v_cell_range({:?}, {}, {}, {}, {})", t, c1, r1, c2, r2)
|
||||
}
|
||||
acord_core::interp::CellRefTarget::Whole => {
|
||||
format!("v_cell_table({:?})", t)
|
||||
}
|
||||
}
|
||||
}
|
||||
Expr::SolveMacro { var, source_fn } => {
|
||||
format!("v_solve_call({:?}, {:?})", var, source_fn)
|
||||
}
|
||||
Expr::Struct(fields) => {
|
||||
let entries: Vec<String> = fields.iter()
|
||||
.map(|(k, v)| {
|
||||
let val = emit_expr(v, hook)?;
|
||||
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)?;
|
||||
format!("v_field(&{}, {:?})", b, field)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn indent(out: &mut String, depth: usize, line: &str) {
|
||||
for _ in 0..depth { out.push_str(" "); }
|
||||
out.push_str(line);
|
||||
out.push('\n');
|
||||
}
|
||||
|
||||
fn ident(name: &str) -> String {
|
||||
if RESERVED.contains(&name) { format!("r#{}", name) } else { name.replace('-', "_") }
|
||||
}
|
||||
|
||||
fn fmt_num(n: f64) -> String {
|
||||
if n.is_nan() { return "f64::NAN".into(); }
|
||||
if n.is_infinite() {
|
||||
return if n > 0.0 { "f64::INFINITY".into() } else { "f64::NEG_INFINITY".into() };
|
||||
}
|
||||
let s = format!("{}", n);
|
||||
if s.contains('.') || s.contains('e') || s.contains('E') { s } else { format!("{}.0", s) }
|
||||
}
|
||||
|
||||
const RESERVED: &[&str] = &[
|
||||
"as", "break", "const", "continue", "crate", "else", "enum", "extern",
|
||||
"false", "fn", "for", "if", "impl", "in", "let", "loop", "match", "mod",
|
||||
"move", "mut", "pub", "ref", "return", "self", "Self", "static", "struct",
|
||||
"super", "trait", "true", "type", "unsafe", "use", "where", "while",
|
||||
"async", "await", "dyn",
|
||||
];
|
||||
|
||||
const GENERATED_PRELUDE: &str = r#"#![allow(unused_variables, unused_mut, dead_code, unused_imports, non_snake_case)]
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum V {
|
||||
Num(f64),
|
||||
Str(String),
|
||||
Bool(bool),
|
||||
Array(Vec<V>),
|
||||
Struct(BTreeMap<String, V>),
|
||||
Void,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for V {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
match self {
|
||||
V::Num(n) => write!(f, "{}", n),
|
||||
V::Str(s) => write!(f, "{}", s),
|
||||
V::Bool(b) => write!(f, "{}", b),
|
||||
V::Array(a) => {
|
||||
write!(f, "[")?;
|
||||
for (i, v) in a.iter().enumerate() {
|
||||
if i > 0 { write!(f, ", ")?; }
|
||||
write!(f, "{}", v)?;
|
||||
}
|
||||
write!(f, "]")
|
||||
}
|
||||
V::Struct(m) => {
|
||||
write!(f, "{{")?;
|
||||
for (i, (k, v)) in m.iter().enumerate() {
|
||||
if i > 0 { write!(f, ", ")?; }
|
||||
write!(f, "{}: {}", k, v)?;
|
||||
}
|
||||
write!(f, "}}")
|
||||
}
|
||||
V::Void => write!(f, "()"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn v_num(v: &V) -> f64 { match v { V::Num(n) => *n, V::Bool(b) => if *b { 1.0 } else { 0.0 }, _ => 0.0 } }
|
||||
fn v_truthy(v: &V) -> bool { match v { V::Bool(b) => *b, V::Num(n) => *n != 0.0, V::Str(s) => !s.is_empty(), V::Array(a) => !a.is_empty(), _ => false } }
|
||||
|
||||
fn v_neg(a: &V) -> V { V::Num(-v_num(a)) }
|
||||
fn v_not(a: &V) -> V { V::Bool(!v_truthy(a)) }
|
||||
fn v_strip(a: &V) -> V { match a { V::Str(s) => V::Str(s.trim().into()), _ => a.clone() } }
|
||||
|
||||
fn v_add(a: &V, b: &V) -> V { match (a, b) { (V::Str(s1), V::Str(s2)) => V::Str(format!("{}{}", s1, s2)), (V::Array(a1), V::Array(a2)) => V::Array([a1.as_slice(), a2.as_slice()].concat()), _ => V::Num(v_num(a) + v_num(b)) } }
|
||||
fn v_sub(a: &V, b: &V) -> V { V::Num(v_num(a) - v_num(b)) }
|
||||
fn v_mul(a: &V, b: &V) -> V { V::Num(v_num(a) * v_num(b)) }
|
||||
fn v_div(a: &V, b: &V) -> V { let d = v_num(b); V::Num(if d == 0.0 { f64::NAN } else { v_num(a) / d }) }
|
||||
fn v_rem(a: &V, b: &V) -> V { V::Num(v_num(a) % v_num(b)) }
|
||||
fn v_pow(a: &V, b: &V) -> V { V::Num(v_num(a).powf(v_num(b))) }
|
||||
|
||||
fn v_eq(a: &V, b: &V) -> V { V::Bool(a == b) }
|
||||
fn v_neq(a: &V, b: &V) -> V { V::Bool(a != b) }
|
||||
fn v_lt(a: &V, b: &V) -> V { V::Bool(v_num(a) < v_num(b)) }
|
||||
fn v_gt(a: &V, b: &V) -> V { V::Bool(v_num(a) > v_num(b)) }
|
||||
fn v_lte(a: &V, b: &V) -> V { V::Bool(v_num(a) <= v_num(b)) }
|
||||
fn v_gte(a: &V, b: &V) -> V { V::Bool(v_num(a) >= v_num(b)) }
|
||||
fn v_and(a: &V, b: &V) -> V { V::Bool(v_truthy(a) && v_truthy(b)) }
|
||||
fn v_or(a: &V, b: &V) -> V { V::Bool(v_truthy(a) || v_truthy(b)) }
|
||||
|
||||
fn v_index(base: &V, idx: &V) -> V {
|
||||
match (base, idx) {
|
||||
(V::Array(arr), V::Num(n)) => arr.get(*n as usize).cloned().unwrap_or(V::Void),
|
||||
(V::Struct(m), V::Str(k)) => m.get(k).cloned().unwrap_or(V::Void),
|
||||
_ => V::Void,
|
||||
}
|
||||
}
|
||||
|
||||
fn v_range(start: &V, end: &V) -> V {
|
||||
let s = v_num(start) as i64;
|
||||
let e = v_num(end) as i64;
|
||||
V::Array((s..=e).map(|i| V::Num(i as f64)).collect())
|
||||
}
|
||||
|
||||
fn v_is(val: &V, type_name: &str) -> V {
|
||||
let matches = match (val, type_name) {
|
||||
(V::Num(_), "number") => true,
|
||||
(V::Str(_), "string") => true,
|
||||
(V::Bool(_), "bool") => true,
|
||||
(V::Array(_), "array") => true,
|
||||
(V::Struct(_), "struct") => true,
|
||||
(V::Void, "void") => true,
|
||||
_ => false,
|
||||
};
|
||||
V::Bool(matches)
|
||||
}
|
||||
|
||||
fn v_field(base: &V, name: &str) -> V {
|
||||
match base {
|
||||
V::Struct(m) => m.get(name).cloned().unwrap_or(V::Void),
|
||||
V::Array(a) => match name {
|
||||
"len" => V::Num(a.len() as f64),
|
||||
_ => V::Void,
|
||||
},
|
||||
_ => V::Void,
|
||||
}
|
||||
}
|
||||
|
||||
fn v_field_set(base: &mut V, _val: &V) {
|
||||
let _ = base; // path assignment placeholder
|
||||
}
|
||||
|
||||
fn v_iter(val: &V) -> Vec<V> {
|
||||
match val {
|
||||
V::Array(a) => a.clone(),
|
||||
V::Num(n) => (0..(*n as i64).max(0)).map(|i| V::Num(i as f64)).collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn v_path_assign(target: &mut V, val: &V) { *target = val.clone(); }
|
||||
fn v_cell_get(_table: &str, _col: u32, _row: u32) -> V { V::Void }
|
||||
fn v_cell_range(_table: &str, _c1: u32, _r1: u32, _c2: u32, _r2: u32) -> V { V::Array(Vec::new()) }
|
||||
fn v_cell_table(_table: &str) -> V { V::Array(Vec::new()) }
|
||||
fn v_cell_set(_table: &str, _col: u32, _row: u32, _val: &V) {}
|
||||
fn v_solve_newton(_target_var: &str, _source_fn: &str) -> V { V::Void }
|
||||
fn v_solve_call(_var: &str, _source_fn: &str) -> V { V::Void }
|
||||
|
||||
// --- generated code below ---
|
||||
|
||||
"#;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn dec(src: &str) -> String {
|
||||
decompose(src).expect("decompose failed").code
|
||||
}
|
||||
|
||||
fn dec_full(src: &str) -> Decomposed {
|
||||
decompose(src).expect("decompose failed")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_produces_run() {
|
||||
let out = dec("");
|
||||
assert!(out.contains("pub fn run() -> V"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn let_binding() {
|
||||
let out = dec("let x = 5");
|
||||
assert!(out.contains("let mut x = V::Num(5.0)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binop() {
|
||||
let out = dec("let x = 1 + 2");
|
||||
assert!(out.contains("v_add("));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fn_def() {
|
||||
let out = dec("fn add(a, b) {\n return a + b\n}");
|
||||
assert!(out.contains("pub fn add(mut a: V, mut b: V) -> V"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn if_else() {
|
||||
let out = dec("if true {\n let x = 1\n} else {\n let x = 2\n}");
|
||||
assert!(out.contains("if v_truthy("));
|
||||
assert!(out.contains("} else {"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn while_loop() {
|
||||
let out = dec("let i = 0\nwhile i < 10 {\n i = i + 1\n}");
|
||||
assert!(out.contains("while v_truthy("));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn for_loop() {
|
||||
let out = dec("for x in [1, 2, 3] {\n let _ = x\n}");
|
||||
assert!(out.contains("for x in v_iter("));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn array_literal() {
|
||||
let out = dec("let a = [1, 2, 3]");
|
||||
assert!(out.contains("V::Array(vec!["));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn index_access() {
|
||||
let out = dec("let a = [1, 2]\nlet b = a[0]");
|
||||
assert!(out.contains("v_index("));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn struct_literal() {
|
||||
let out = dec("let p = {x: 1, y: 2}");
|
||||
assert!(out.contains("V::Struct("));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_access() {
|
||||
let out = dec("let p = {x: 1}\nlet v = p.x");
|
||||
assert!(out.contains("v_field("));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reserved_word_raw_ident() {
|
||||
let out = dec("let type = 1");
|
||||
assert!(out.contains("r#type"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn self_contained_compiles() {
|
||||
let out = dec("fn double(x) {\n return x * 2\n}\nlet r = double(5)");
|
||||
assert!(out.contains("pub fn double("));
|
||||
assert!(out.contains("pub fn run()"));
|
||||
assert!(!out.contains("acord"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn use_emits_mod_and_reports_dep() {
|
||||
let r = dec_full("use math");
|
||||
assert!(r.code.contains("mod math;"));
|
||||
assert!(r.code.contains("use math::*;"));
|
||||
assert_eq!(r.deps.len(), 1);
|
||||
assert_eq!(r.deps[0].module, "math");
|
||||
assert_eq!(r.deps[0].item, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn use_specific_item() {
|
||||
let r = dec_full("use utils::double");
|
||||
assert!(r.code.contains("mod utils;"));
|
||||
assert!(r.code.contains("use utils::double;"));
|
||||
assert_eq!(r.deps[0].item.as_deref(), Some("double"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn use_with_underscored_name() {
|
||||
let r = dec_full("use my_lib");
|
||||
assert!(r.code.contains("mod my_lib;"));
|
||||
assert!(r.code.contains("use my_lib::*;"));
|
||||
assert_eq!(r.deps[0].module, "my_lib");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
language = "C"
|
||||
header = "/* Generated by cbindgen — do not edit */"
|
||||
header = "/* Generated by cbindgen - do not edit */"
|
||||
include_guard = "SWIFTLY_H"
|
||||
include_version = false
|
||||
tab_width = 4
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
/* Generated by cbindgen — do not edit */
|
||||
/* Generated by cbindgen - do not edit */
|
||||
|
||||
#ifndef SWIFTLY_H
|
||||
#define SWIFTLY_H
|
||||
|
|
|
|||
|
|
@ -51,12 +51,7 @@ fn is_cordial(line: &str) -> bool {
|
|||
let after_eq = rest.as_bytes().get(eq_pos + 1);
|
||||
if after_eq != Some(&b'=') {
|
||||
let name = rest[..eq_pos].trim();
|
||||
// Plain binding: `let x = …`. Covers every RHS — plain
|
||||
// expressions, struct/macro-looking constructions like
|
||||
// `let lfreq = solve!(l, f0)`, and the function-inversion
|
||||
// math form `let f(a, b) = expr where …` (where the LHS
|
||||
// is a function-def-shaped name+params, same as Cordial's
|
||||
// existing top-level `f(x) = …` short form).
|
||||
// accepts plain bindings, solve macros, and function-def short forms.
|
||||
if is_ident(name) || is_assignment_target(name) {
|
||||
return true;
|
||||
}
|
||||
|
|
@ -96,21 +91,16 @@ fn is_cordial(line: &str) -> bool {
|
|||
}
|
||||
|
||||
fn is_assignment_target(s: &str) -> bool {
|
||||
// simple variable: `x`
|
||||
if is_ident(s) {
|
||||
return true;
|
||||
}
|
||||
// function def: `f(x)` or `f(x, y)`
|
||||
if let Some(paren) = s.find('(') {
|
||||
let name = &s[..paren];
|
||||
if is_ident(name) && s.ends_with(')') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// cell-ref target: `@Table:A1`, `@Block::Table:A1`, or even bare
|
||||
// `@Table` / `@Table:A1:B2`. The interpreter's parser surfaces
|
||||
// whole-table / range mis-assignments as errors, so the classifier
|
||||
// only needs to recognize the `@name…` shape here.
|
||||
// @-prefixed cell-ref targets; validity checked downstream by the parser.
|
||||
if let Some(rest) = s.strip_prefix('@') {
|
||||
if let Some(first) = rest.chars().next() {
|
||||
if first.is_alphabetic() || first == '_' {
|
||||
|
|
|
|||
|
|
@ -103,7 +103,7 @@ pub fn evaluate_line(text: &str) -> Result<String, String> {
|
|||
match interp.eval_expr_str(text) {
|
||||
Ok(v) => Ok(v.display()),
|
||||
Err(_) => {
|
||||
// fall back to cord-expr/cord-trig for trig and CORDIC expressions
|
||||
// cord-expr/cord-trig fallback path
|
||||
let graph = cord_expr::parse_expr(text)?;
|
||||
let val = cord_trig::eval::evaluate(&graph, 0.0, 0.0, 0.0);
|
||||
Ok(format_value(val))
|
||||
|
|
@ -124,45 +124,35 @@ fn format_value(val: f64) -> String {
|
|||
|
||||
// --- Module evaluation pipeline ---
|
||||
|
||||
/// Source material for a single module (block).
|
||||
/// source material for a single module (block).
|
||||
pub struct ModuleSource {
|
||||
/// Module name (from heading text, normalized).
|
||||
pub name: String,
|
||||
/// Raw text content of all text blocks in this module, joined.
|
||||
pub text: String,
|
||||
/// True for the root module (H1 section). Its exports are auto-imported
|
||||
/// into every other module.
|
||||
pub is_root: bool,
|
||||
}
|
||||
|
||||
/// Per-module evaluation result.
|
||||
/// per-module evaluation result.
|
||||
pub struct ModuleResult {
|
||||
pub name: String,
|
||||
pub doc_result: DocumentResult,
|
||||
pub exports: interp::ModuleExports,
|
||||
}
|
||||
|
||||
/// Evaluate modules in dependency order. Root module is evaluated first
|
||||
/// and its exports are auto-imported into every other module. `use`
|
||||
/// declarations are resolved via topological sort. Failed `use` (module
|
||||
/// name doesn't match any source) is silently dropped.
|
||||
/// evaluates modules in topological dependency order, auto-importing root exports.
|
||||
pub fn evaluate_modules(sources: &[ModuleSource]) -> Vec<ModuleResult> {
|
||||
use std::collections::HashMap;
|
||||
|
||||
// Index modules by name
|
||||
let name_to_idx: HashMap<&str, usize> = sources.iter().enumerate()
|
||||
.map(|(i, s)| (s.name.as_str(), i))
|
||||
.collect();
|
||||
|
||||
// Extract use declarations from each module
|
||||
let use_decls: Vec<Vec<interp::UseDecl>> = sources.iter()
|
||||
.map(|s| interp::extract_use_declarations(&s.text))
|
||||
.collect();
|
||||
|
||||
// Build adjacency list for topo sort (dependency edges: module -> modules it depends on)
|
||||
let n = sources.len();
|
||||
let mut in_degree = vec![0usize; n];
|
||||
let mut dependents: Vec<Vec<usize>> = vec![Vec::new(); n]; // dep -> modules that depend on it
|
||||
let mut dependents: Vec<Vec<usize>> = vec![Vec::new(); n]; // dep -> dependent module indices
|
||||
|
||||
for (i, decls) in use_decls.iter().enumerate() {
|
||||
for decl in decls {
|
||||
|
|
@ -172,12 +162,10 @@ pub fn evaluate_modules(sources: &[ModuleSource]) -> Vec<ModuleResult> {
|
|||
in_degree[i] += 1;
|
||||
}
|
||||
}
|
||||
// Unknown module names are silently ignored (failed use = prose)
|
||||
}
|
||||
}
|
||||
|
||||
// Kahn's algorithm for topological sort. Root modules get priority
|
||||
// (pushed to front of queue).
|
||||
// Kahn's algorithm; root modules get queue priority.
|
||||
let mut queue: std::collections::VecDeque<usize> = std::collections::VecDeque::new();
|
||||
for (i, s) in sources.iter().enumerate() {
|
||||
if in_degree[i] == 0 {
|
||||
|
|
@ -200,17 +188,13 @@ pub fn evaluate_modules(sources: &[ModuleSource]) -> Vec<ModuleResult> {
|
|||
}
|
||||
}
|
||||
|
||||
// Any modules not in `order` are part of a cycle. Append them at
|
||||
// the end — they'll evaluate without their cyclic dependencies
|
||||
// (which means their `use`d bindings won't be available, producing
|
||||
// natural "undefined variable" errors downstream).
|
||||
// cyclic modules appended at the end; unresolved deps produce undefined-variable errors.
|
||||
for i in 0..n {
|
||||
if !order.contains(&i) {
|
||||
order.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
// Evaluate in topological order
|
||||
let mut exports_by_name: HashMap<String, interp::ModuleExports> = HashMap::new();
|
||||
let mut root_exports: Option<interp::ModuleExports> = None;
|
||||
let mut results: Vec<Option<ModuleResult>> = (0..n).map(|_| None).collect();
|
||||
|
|
@ -218,17 +202,14 @@ pub fn evaluate_modules(sources: &[ModuleSource]) -> Vec<ModuleResult> {
|
|||
for &idx in &order {
|
||||
let source = &sources[idx];
|
||||
|
||||
// Create interpreter with imported scope
|
||||
let mut interp = interp::Interpreter::new();
|
||||
|
||||
// Auto-import root module exports (unless this IS the root)
|
||||
if !source.is_root {
|
||||
if let Some(ref root_exp) = root_exports {
|
||||
interp.import_all(root_exp);
|
||||
}
|
||||
}
|
||||
|
||||
// Import use'd modules' exports
|
||||
for decl in &use_decls[idx] {
|
||||
if let Some(module_exports) = exports_by_name.get(&decl.module) {
|
||||
match &decl.item {
|
||||
|
|
@ -245,7 +226,6 @@ pub fn evaluate_modules(sources: &[ModuleSource]) -> Vec<ModuleResult> {
|
|||
}
|
||||
}
|
||||
|
||||
// Evaluate this module's text
|
||||
let doc_result = evaluate_document_with_interp(&mut interp, &source.text);
|
||||
let module_exports = interp.exports();
|
||||
|
||||
|
|
@ -264,7 +244,7 @@ pub fn evaluate_modules(sources: &[ModuleSource]) -> Vec<ModuleResult> {
|
|||
results.into_iter().flatten().collect()
|
||||
}
|
||||
|
||||
/// Evaluate a document's text using an existing (pre-populated) interpreter.
|
||||
/// evaluates a document's text using a pre-populated interpreter.
|
||||
pub fn evaluate_document_with_interp(interp: &mut interp::Interpreter, text: &str) -> DocumentResult {
|
||||
let classified = classify_document(text);
|
||||
let mut results = Vec::new();
|
||||
|
|
@ -411,7 +391,6 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn eval_type_annotation_int_lossy_rejected() {
|
||||
// Round-trip rule: lossy coercion is rejected.
|
||||
let doc = "let x: int = 3.7\n/= x";
|
||||
let result = evaluate_document(doc);
|
||||
assert!(result.errors.len() >= 1, "should error on lossy int");
|
||||
|
|
@ -548,7 +527,6 @@ mod tests {
|
|||
];
|
||||
let results = evaluate_modules(&sources);
|
||||
assert_eq!(results.len(), 3);
|
||||
// "main" should see both root's `pi` (auto-import) and math's `double` (via use)
|
||||
let main_result = results.iter().find(|r| r.name == "main").unwrap();
|
||||
assert_eq!(main_result.doc_result.results.len(), 1);
|
||||
assert_eq!(main_result.doc_result.results[0].result, "6.28");
|
||||
|
|
@ -611,7 +589,6 @@ mod tests {
|
|||
ModuleSource { name: "a".into(), text: "use b\nlet x = 1".into(), is_root: false },
|
||||
ModuleSource { name: "b".into(), text: "use a\nlet y = 2".into(), is_root: false },
|
||||
];
|
||||
// Shouldn't panic. One of them evaluates without the other's exports.
|
||||
let results = evaluate_modules(&sources);
|
||||
assert_eq!(results.len(), 3);
|
||||
}
|
||||
|
|
|
|||
1620
core/src/interp.rs
1620
core/src/interp.rs
File diff suppressed because it is too large
Load Diff
|
|
@ -16,8 +16,7 @@ settings:
|
|||
TARGETED_DEVICE_FAMILY: "2,1"
|
||||
IPHONEOS_DEPLOYMENT_TARGET: "17.0"
|
||||
SWIFT_OBJC_BRIDGING_HEADER: ../viewport/include/acord.h
|
||||
# Cargo writes to /tmp/acord/target by default (see scripts/_build-dirs.sh).
|
||||
# xcodeproj.sh runs cargo with both targets and copies their output here.
|
||||
# xcodeproj.sh builds both targets into /tmp/acord/target and copies output here.
|
||||
"LIBRARY_SEARCH_PATHS[sdk=iphoneos*]": /tmp/acord/target/aarch64-apple-ios/release
|
||||
"LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]": /tmp/acord/target/aarch64-apple-ios-sim/release
|
||||
OTHER_LDFLAGS:
|
||||
|
|
|
|||
|
|
@ -9,9 +9,7 @@ struct AcordApp: App {
|
|||
dlog("AcordApp.init")
|
||||
}
|
||||
|
||||
/// Pipes Rust staticlib stderr into both NSLog (for Console.app) and the
|
||||
/// real stdout (for `xcrun devicectl --console`, which only forwards
|
||||
/// stdout/stderr). Without this, `eprintln!()` from Rust is silently dropped.
|
||||
/// redirects Rust staticlib stderr into NSLog and real stdout.
|
||||
private static func captureStderr() {
|
||||
let realStdout = dup(fileno(stdout))
|
||||
guard realStdout != -1 else { return }
|
||||
|
|
|
|||
|
|
@ -1,9 +1,6 @@
|
|||
import Foundation
|
||||
|
||||
/// Gated logging — every diagnostic print in the iOS shell goes through here.
|
||||
/// Release builds compile this to a no-op so no log lines leak into shipping.
|
||||
/// Define DEBUG via `-D DEBUG` when invoking swiftc (debug.sh does this; the
|
||||
/// release path used by install.sh does not).
|
||||
/// conditional diagnostic logging for the iOS shell.
|
||||
@inline(__always)
|
||||
func dlog(_ message: @autoclosure () -> String, file: StaticString = #file, line: UInt = #line) {
|
||||
#if DEBUG
|
||||
|
|
|
|||
|
|
@ -1,10 +1,7 @@
|
|||
import UIKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
/// Bridges UIDocumentPickerViewController into the Rust viewport.
|
||||
/// Open and Save flows rely on iOS's per-file permission grant — a
|
||||
/// security-scoped URL is what the picker hands back, and we copy bytes in
|
||||
/// or out under `startAccessingSecurityScopedResource` while it's in scope.
|
||||
/// bridges UIDocumentPickerViewController into the Rust viewport for Open and Save flows.
|
||||
enum DocumentPicker {
|
||||
private static var openDelegate: OpenDelegate?
|
||||
private static var saveDelegate: SaveDelegate?
|
||||
|
|
@ -15,10 +12,7 @@ enum DocumentPicker {
|
|||
dlog("presentOpen: topViewController returned nil — picker NOT shown")
|
||||
return
|
||||
}
|
||||
// .item is the broadest "any file" UTI — without this, files whose UTI
|
||||
// doesn't exactly match get rendered grey/unselectable in the picker.
|
||||
// asCopy:true sidesteps the security-scoped-resource entitlement dance:
|
||||
// iOS hands us a copy in our sandbox tmp dir we can just read.
|
||||
// .item catches files with non-exact UTI matches; asCopy gives a sandbox-local copy.
|
||||
var types: [UTType] = [.plainText, .utf8PlainText, .text, .sourceCode, .data, .item]
|
||||
if let md = UTType(filenameExtension: "md") { types.insert(md, at: 0) }
|
||||
if let md = UTType("net.daringfireball.markdown") { types.insert(md, at: 0) }
|
||||
|
|
@ -92,7 +86,7 @@ private final class OpenDelegate: NSObject, UIDocumentPickerDelegate {
|
|||
return
|
||||
}
|
||||
dlog("open: url=\(url.path)")
|
||||
// asCopy:true means url is already in our sandbox tmp dir — no scoped access needed.
|
||||
// asCopy url lives in sandbox tmp, no scoped access needed.
|
||||
do {
|
||||
let data = try Data(contentsOf: url)
|
||||
guard let text = String(data: data, encoding: .utf8) else {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
/// SwiftUI wrapper around the UIView that hosts the Rust viewport.
|
||||
/// Stashes the underlying view into the shared ViewportController so the
|
||||
/// menu bar can dispatch commands against the same handle.
|
||||
/// wraps the Rust viewport UIView and shares it with ViewportController.
|
||||
struct IcedViewportRepresentable: UIViewRepresentable {
|
||||
let controller: ViewportController
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
import UIKit
|
||||
import QuartzCore
|
||||
|
||||
/// CAMetalLayer-backed UIView that owns the Rust viewport handle and pumps
|
||||
/// CADisplayLink ticks into `viewport_render`. UIKeyInput conformance is what
|
||||
/// makes the soft keyboard appear when the view becomes first responder.
|
||||
/// CAMetalLayer-backed UIView owning the Rust viewport handle and driving the render loop.
|
||||
class IcedViewportView: UIView, UIKeyInput {
|
||||
override class var layerClass: AnyClass { CAMetalLayer.self }
|
||||
|
||||
|
|
@ -42,10 +40,7 @@ class IcedViewportView: UIView, UIKeyInput {
|
|||
if window != nil && viewportHandle == nil && !isTornDown {
|
||||
createViewport()
|
||||
startDisplayLink()
|
||||
// intentionally NOT becoming first responder here — claiming it on
|
||||
// appear conflicts with SwiftUI Menu popovers (see the
|
||||
// _UIReparentingView warning when clicking the menu strip).
|
||||
// touchesBegan claims it instead, which is the natural moment.
|
||||
// touchesBegan claims first responder to avoid SwiftUI Menu popover conflicts.
|
||||
} else if window == nil {
|
||||
teardown()
|
||||
}
|
||||
|
|
@ -140,7 +135,7 @@ class IcedViewportView: UIView, UIKeyInput {
|
|||
guard let handle = viewportHandle else { return }
|
||||
viewport_render(handle)
|
||||
renderCount += 1
|
||||
// first frame, then 60th (~1s), then every 600 (~10s) to confirm the loop is alive.
|
||||
// logs at frame 1, 60, and every 600th frame.
|
||||
if renderCount == 1 || renderCount == 60 || renderCount % 600 == 0 {
|
||||
let ml = layer as? CAMetalLayer
|
||||
dlog("renderFrame #\(renderCount) bounds=\(bounds.width)x\(bounds.height) drawable=\(ml?.drawableSize ?? .zero) scale=\(ml?.contentsScale ?? 0)")
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
import SwiftUI
|
||||
|
||||
/// Top toolbar with File / Edit / Render / View menus, mirroring the
|
||||
/// editor's MenuCategory layout. Uses SwiftUI Menu so each label opens a
|
||||
/// dropdown of buttons; each button dispatches through ViewportController.
|
||||
/// top toolbar with File / Edit / Render / View menus dispatching through ViewportController.
|
||||
struct MenuBar: View {
|
||||
@ObservedObject var controller: ViewportController
|
||||
|
||||
|
|
@ -72,7 +70,7 @@ struct MenuBar: View {
|
|||
}
|
||||
|
||||
private extension View {
|
||||
/// Consistent Menu chrome — slightly padded, light hit target.
|
||||
/// applies consistent menu label padding and hit target.
|
||||
var menuLabel: some View {
|
||||
self
|
||||
.padding(.horizontal, 10)
|
||||
|
|
|
|||
|
|
@ -2,16 +2,9 @@ import UIKit
|
|||
import Photos
|
||||
import AVFoundation
|
||||
|
||||
/// Drives the iOS permission dialogs that fire on first launch.
|
||||
/// Photos / Camera / Microphone trigger native system prompts when their
|
||||
/// `requestAuthorization` is called, the Info.plist usage strings exist,
|
||||
/// and the corresponding framework is linked.
|
||||
/// File-system access on iOS isn't a global permission — DocumentPicker's
|
||||
/// per-file picker IS the consent moment for that, and asCopy:true means
|
||||
/// no entitlements beyond the picker itself are required.
|
||||
/// sequences Photos / Camera / Microphone permission prompts on first launch.
|
||||
enum PermissionsManager {
|
||||
/// Sequentially asks for Photos → Camera → Microphone access.
|
||||
/// Each call shows the system prompt only when status is .notDetermined.
|
||||
/// requests Photos, Camera, Microphone access in order, skipping already-determined ones.
|
||||
static func requestSystemPermissions() {
|
||||
dlog("requestSystemPermissions called")
|
||||
let photosStatus = PHPhotoLibrary.authorizationStatus(for: .readWrite)
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
import SwiftUI
|
||||
|
||||
/// Bridges SwiftUI menu buttons to the Rust viewport handle.
|
||||
/// Holds a weak reference to the IcedViewportView so the menu can dispatch
|
||||
/// commands without owning the rendering surface.
|
||||
/// bridges SwiftUI menu buttons to the Rust viewport handle.
|
||||
final class ViewportController: ObservableObject {
|
||||
weak var view: IcedViewportView?
|
||||
|
||||
|
|
@ -15,7 +13,7 @@ final class ViewportController: ObservableObject {
|
|||
viewport_send_command(h, code)
|
||||
}
|
||||
|
||||
/// Editor commands (mirror viewport/src/lib.rs::viewport_send_command codes).
|
||||
/// editor commands matching viewport_send_command codes.
|
||||
func toggleBold() { send(1) }
|
||||
func toggleItalic() { send(2) }
|
||||
func insertTable() { send(3) }
|
||||
|
|
@ -28,8 +26,7 @@ final class ViewportController: ObservableObject {
|
|||
func setViewMode() { send(13) }
|
||||
func toggleSettings() { send(16) }
|
||||
|
||||
/// Hand-rolled key events for shortcuts that flow through iced's text bindings
|
||||
/// rather than the cmd dispatcher (Find, Undo, Redo, etc.).
|
||||
/// sends raw key events for shortcuts handled by iced text bindings (Find, Undo, Redo).
|
||||
private func sendKey(keyCode: UInt32, modifiers: UInt32, character: String) {
|
||||
guard let h = view?.viewportHandle else {
|
||||
dlog("sendKey: no handle (key=\(character.debugDescription) mods=\(modifiers))")
|
||||
|
|
@ -42,20 +39,16 @@ final class ViewportController: ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
/// `f` / cmd. The viewport reads .super_key as cmd via iced's modifier mapping.
|
||||
/// keycode 3 is the macOS keycode for `f`; iced doesn't actually use the
|
||||
/// platform keycode on macOS — it pulls the Key from the characters string.
|
||||
/// So we pass 0 and let the character drive it.
|
||||
/// cmd+character shortcuts; keycode 0 defers key resolution to the characters string.
|
||||
func toggleFind() { sendKey(keyCode: 0, modifiers: cmdMask, character: "f") }
|
||||
func undo() { sendKey(keyCode: 0, modifiers: cmdMask, character: "z") }
|
||||
func redo() { sendKey(keyCode: 0, modifiers: cmdMask | shiftMask, character: "Z") }
|
||||
|
||||
// UIKeyModifierFlags bits; copied here so the controller doesn't import UIKit.
|
||||
// UIKeyModifierFlags bit positions.
|
||||
private var cmdMask: UInt32 { 1 << 20 }
|
||||
private var shiftMask: UInt32 { 1 << 17 }
|
||||
|
||||
/// File operations — Open and Save go through UIDocumentPicker so iOS
|
||||
/// prompts the user to grant per-file access.
|
||||
/// file operations routed through UIDocumentPicker for per-file access grants.
|
||||
func newNote() {
|
||||
guard let h = view?.viewportHandle else {
|
||||
dlog("newNote: no handle")
|
||||
|
|
|
|||
|
|
@ -7,10 +7,7 @@ edition = "2024"
|
|||
name = "acord"
|
||||
path = "src/main.rs"
|
||||
|
||||
# x11 and wayland are independent winit backends. Default builds enable both
|
||||
# so a single binary runs on either display server. The build script can pass
|
||||
# `--no-default-features --features x11` (or `wayland`) to force one — useful
|
||||
# for flatpak builds or stripped-down distros.
|
||||
# default enables both x11 and wayland; pass --no-default-features --features x11 (or wayland) to force one.
|
||||
[features]
|
||||
default = ["x11", "wayland"]
|
||||
x11 = ["winit/x11"]
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@ impl App {
|
|||
MenuAction::NewNote => self.new_note(),
|
||||
MenuAction::Settings => {
|
||||
let cfg = config_path();
|
||||
// Prefer xdg-open; fall back to $EDITOR or nano.
|
||||
// opens config with xdg-open, falling back to $EDITOR or nano.
|
||||
let opened = std::process::Command::new("xdg-open").arg(&cfg).spawn().is_ok();
|
||||
if !opened {
|
||||
let editor = std::env::var("EDITOR").unwrap_or_else(|_| "nano".into());
|
||||
|
|
@ -243,7 +243,7 @@ impl App {
|
|||
self.last_autosaved_hash = None;
|
||||
}
|
||||
|
||||
/// true when path is an .md inside the configured notes library (recursive).
|
||||
/// checks whether path resolves to an .md inside the configured notes library.
|
||||
fn is_acord_note(&self, path: &std::path::Path) -> bool {
|
||||
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
|
||||
if !ext.eq_ignore_ascii_case("md") {
|
||||
|
|
@ -255,7 +255,7 @@ impl App {
|
|||
canon_path.starts_with(&canon_dir)
|
||||
}
|
||||
|
||||
/// path under <notes_dir>/.external/ for storing an external file's archive companion.
|
||||
/// computes the <notes_dir>/.external/ path hosting an external file's archive companion.
|
||||
fn external_sidecar_path(&self, original: &std::path::Path) -> PathBuf {
|
||||
let canon = std::fs::canonicalize(original).unwrap_or_else(|_| original.to_path_buf());
|
||||
let s = canon.to_string_lossy();
|
||||
|
|
@ -524,7 +524,7 @@ impl App {
|
|||
let hash = text_hash(&text);
|
||||
if Some(hash) == self.last_autosaved_hash { return; }
|
||||
|
||||
// skip the launch stub so it can't overwrite last session's Untitled.md.
|
||||
// skip the empty launch stub to preserve last session's Untitled.md.
|
||||
if self.current_file.is_none() && is_effectively_blank(&text) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -665,9 +665,7 @@ impl ApplicationHandler for App {
|
|||
WindowEvent::KeyboardInput { event, .. } => {
|
||||
let pressed = event.state == ElementState::Pressed;
|
||||
|
||||
// App-level shortcut? Fire on press only and short-circuit so the
|
||||
// viewport's text_editor doesn't also see the keystroke (otherwise
|
||||
// Ctrl+S would type 's' as well as save).
|
||||
// app-level shortcuts consume the press event before the viewport's text editor.
|
||||
if pressed {
|
||||
if let Some(action) = match_shortcut(self.modifiers, &event.logical_key) {
|
||||
self.dispatch_menu(action, event_loop);
|
||||
|
|
@ -722,9 +720,7 @@ impl ApplicationHandler for App {
|
|||
if let Some(w) = &self.browser_window {
|
||||
w.request_redraw();
|
||||
}
|
||||
// request_redraw alone doesn't reliably wake the loop on Wayland
|
||||
// when idle; pin a hard wake-up so the autosave check actually
|
||||
// runs even with no input or compositor frame callback arriving.
|
||||
// hard wake-up at 500ms keeps the autosave check running on idle Wayland sessions.
|
||||
event_loop.set_control_flow(ControlFlow::WaitUntil(
|
||||
Instant::now() + Duration::from_millis(500),
|
||||
));
|
||||
|
|
@ -738,7 +734,7 @@ fn text_hash(s: &str) -> u64 {
|
|||
h.finish()
|
||||
}
|
||||
|
||||
/// true when the buffer is empty or just leading heading markers.
|
||||
/// detects buffers containing only whitespace or bare heading markers.
|
||||
fn is_effectively_blank(text: &str) -> bool {
|
||||
let trimmed = text.trim();
|
||||
if trimmed.is_empty() {
|
||||
|
|
@ -747,9 +743,7 @@ fn is_effectively_blank(text: &str) -> bool {
|
|||
trimmed.trim_start_matches('#').trim().is_empty()
|
||||
}
|
||||
|
||||
/// Translates winit logical keys into iced keyboard keys for direct iced
|
||||
/// event push (used by the second browser window, which speaks iced
|
||||
/// directly rather than through the C bridge).
|
||||
/// translates winit logical keys into iced keyboard keys for direct event push.
|
||||
fn winit_key_to_iced(key: &Key) -> iced_wgpu::core::keyboard::Key {
|
||||
use iced_wgpu::core::keyboard::{key as ikey, Key as IKey};
|
||||
match key {
|
||||
|
|
@ -775,8 +769,7 @@ fn winit_key_to_iced(key: &Key) -> iced_wgpu::core::keyboard::Key {
|
|||
}
|
||||
}
|
||||
|
||||
/// Maps winit logical keys to the macOS-style virtual keycodes the bridge
|
||||
/// expects. Character keys go through `text` instead, so 0 is fine for those.
|
||||
/// maps winit logical keys to macOS-style virtual keycodes for the bridge.
|
||||
fn winit_key_to_code(key: &Key) -> u32 {
|
||||
match key {
|
||||
Key::Named(n) => match n {
|
||||
|
|
@ -817,9 +810,7 @@ fn encode_modifiers(state: ModifiersState) -> u32 {
|
|||
if state.control_key() { flags |= 1 << 18; }
|
||||
if state.alt_key() { flags |= 1 << 19; }
|
||||
if state.super_key() { flags |= 1 << 20; }
|
||||
// Mirror Ctrl→LOGO so iced text_editor's Cmd+C/V/X/Z/A bindings fire on
|
||||
// Ctrl. Same trick the Windows shell uses; both action-modifier-on-Ctrl
|
||||
// platforms need it.
|
||||
// alias Ctrl as LOGO so iced text_editor Cmd+C/V/X/Z/A bindings fire on Ctrl.
|
||||
if state.control_key() { flags |= 1 << 20; }
|
||||
flags
|
||||
}
|
||||
|
|
@ -848,8 +839,7 @@ fn config_path() -> std::path::PathBuf {
|
|||
.join("config.json")
|
||||
}
|
||||
|
||||
/// Loads `icon.png` next to the exe. Returns None on any failure — winit
|
||||
/// silently uses the WM default in that case.
|
||||
/// loads icon.png from the executable directory, returning None on failure.
|
||||
fn load_window_icon() -> Option<winit::window::Icon> {
|
||||
let exe_dir = std::env::current_exe().ok()?.parent()?.to_path_buf();
|
||||
let png_path = exe_dir.join("icon.png");
|
||||
|
|
|
|||
|
|
@ -2,9 +2,7 @@ use winit::keyboard::{Key, ModifiersState, SmolStr};
|
|||
|
||||
#[derive(Clone, Copy)]
|
||||
#[allow(dead_code)]
|
||||
// LiveMode/EditorMode/ViewMode are dispatched but not yet bound to a shortcut
|
||||
// — Linux has no menu bar to expose them via, so they wait for either a key
|
||||
// binding decision or an iced-rendered menu inside the viewport.
|
||||
// LiveMode/EditorMode/ViewMode dispatched but not yet bound to a shortcut key.
|
||||
pub enum MenuAction {
|
||||
NewNote,
|
||||
Open,
|
||||
|
|
@ -28,13 +26,9 @@ pub enum MenuAction {
|
|||
ToggleBrowser,
|
||||
}
|
||||
|
||||
/// Matches an app-level shortcut. Returns Some(action) for combos that should
|
||||
/// fire a MenuAction; None for combos that should fall through to the
|
||||
/// viewport (cut/copy/paste/undo/redo/select-all are handled inside iced via
|
||||
/// the Ctrl→LOGO modifier alias, plain typing, navigation, etc.).
|
||||
/// matches Ctrl-chord shortcuts to MenuAction, returning None for combos handled by iced.
|
||||
pub fn match_shortcut(modifiers: ModifiersState, key: &Key) -> Option<MenuAction> {
|
||||
// Alt+B mirrors macOS Ctrl+B for the document browser. Mac-Cmd maps to
|
||||
// Ctrl on Linux/Windows, so Mac-Ctrl gets bumped to Alt to avoid collision.
|
||||
// Alt+B opens the document browser (mirrors macOS Ctrl+B).
|
||||
if modifiers.alt_key() && !modifiers.control_key() && !modifiers.super_key() {
|
||||
if let Key::Character(s) = key {
|
||||
if ascii_lower(s) == 'b' {
|
||||
|
|
|
|||
|
|
@ -22,102 +22,40 @@ func applyThemeAppearance() {
|
|||
}
|
||||
}
|
||||
|
||||
class WindowController {
|
||||
let window: NSWindow
|
||||
let appState: AppState
|
||||
init(window: NSWindow, appState: AppState) {
|
||||
self.window = window
|
||||
self.appState = appState
|
||||
}
|
||||
}
|
||||
|
||||
class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation {
|
||||
var window: NSWindow!
|
||||
var appState: AppState!
|
||||
private var titleCancellable: AnyCancellable?
|
||||
private var textCancellable: AnyCancellable?
|
||||
private var titleBarView: TitleBarView?
|
||||
private var focusTitleObserver: NSObjectProtocol?
|
||||
private var windowControllers: [WindowController] = []
|
||||
/// Writes the viewport's current text to the notes directory on a
|
||||
/// tight interval. Deliberately bypasses `appState.documentText` — the
|
||||
/// Combine sink on that property pushes text back into the viewport
|
||||
/// via `vp.setText`, which rebuilds viewport state and clears the
|
||||
/// eval overlay. By writing straight to disk, autosave can't disturb
|
||||
/// what the user sees.
|
||||
private var autosaveTimer: Timer?
|
||||
/// Hash of the viewport text the last time autosave actually wrote to
|
||||
/// disk. The 100ms timer compares against this and skips the write if
|
||||
/// nothing has changed — without this gate, autosave rewrites the
|
||||
/// entire file every tick (~500 KB/s sustained on a 50 KB doc, which
|
||||
/// macOS flags as a disk-writes throttle event).
|
||||
private var lastAutosavedHash: Int?
|
||||
|
||||
private var viewport: IcedViewportView? {
|
||||
window?.contentView as? IcedViewportView
|
||||
}
|
||||
private var editorWindows: [EditorWindow] = []
|
||||
private var pendingOpenURLs: [URL] = []
|
||||
private var didFinishLaunching = false
|
||||
private var windowCloseObservers: [NSObjectProtocol] = []
|
||||
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
_ = ConfigManager.shared
|
||||
appState = AppState()
|
||||
|
||||
let viewport = IcedViewportView(frame: NSRect(x: 0, y: 0, width: 1200, height: 800))
|
||||
viewport.autoresizingMask = [.width, .height]
|
||||
|
||||
window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 1200, height: 800),
|
||||
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
window.isReleasedWhenClosed = false
|
||||
window.titlebarAppearsTransparent = true
|
||||
window.titleVisibility = .hidden
|
||||
window.backgroundColor = Theme.current.base
|
||||
window.title = "Acord"
|
||||
window.contentView = viewport
|
||||
window.center()
|
||||
window.setFrameAutosaveName("AcordMainWindow")
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
|
||||
applyThemeAppearance()
|
||||
setupTitleBar()
|
||||
setupMenuBar()
|
||||
observeDocumentTitle()
|
||||
|
||||
observeDocumentText()
|
||||
wireLoadedTextSync()
|
||||
syncThemeToViewport()
|
||||
syncGutterPrefsToViewport()
|
||||
syncSettingsToViewport()
|
||||
startAutosaveTimer()
|
||||
|
||||
DocumentBrowserController.shared = DocumentBrowserController(appState: appState)
|
||||
|
||||
NotificationCenter.default.addObserver(
|
||||
self, selector: #selector(settingsDidChange),
|
||||
name: .settingsChanged, object: nil
|
||||
)
|
||||
|
||||
NotificationCenter.default.addObserver(
|
||||
self, selector: #selector(handleNewNoteSeeded),
|
||||
name: .newNoteSeeded, object: nil
|
||||
)
|
||||
|
||||
if let url = pendingOpenURLs.first {
|
||||
pendingOpenURLs = []
|
||||
appState.loadNoteFromFile(url)
|
||||
DocumentBrowserController.shared = DocumentBrowserController { [weak self] path in
|
||||
self?.openInActiveEditor(URL(fileURLWithPath: path))
|
||||
}
|
||||
|
||||
// first URL loads into the main window, extras spawn new ones.
|
||||
let urls = pendingOpenURLs
|
||||
pendingOpenURLs = []
|
||||
let initialURL = urls.first
|
||||
spawnEditorWindow(loadingURL: initialURL, isMain: true)
|
||||
for url in urls.dropFirst() {
|
||||
spawnEditorWindow(loadingURL: url, isMain: false)
|
||||
}
|
||||
|
||||
didFinishLaunching = true
|
||||
}
|
||||
|
||||
private var pendingOpenURLs: [URL] = []
|
||||
|
||||
func application(_ application: NSApplication, open urls: [URL]) {
|
||||
guard let url = urls.first else { return }
|
||||
if appState != nil {
|
||||
appState.loadNoteFromFile(url)
|
||||
if didFinishLaunching {
|
||||
for url in urls {
|
||||
openInNewWindow(url)
|
||||
}
|
||||
} else {
|
||||
pendingOpenURLs = [url]
|
||||
pendingOpenURLs.append(contentsOf: urls)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -125,41 +63,73 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation {
|
|||
return true
|
||||
}
|
||||
|
||||
// Runs before AppKit tears the window down. We must front-run the window
|
||||
// teardown so the Rust-backed viewport releases its wgpu/Metal resources
|
||||
// while the NSView + CAMetalLayer it holds raw pointers to are still
|
||||
// alive. `applicationWillTerminate` is too late: by the time that fires,
|
||||
// AppKit has already started deallocating the window/contentView graph
|
||||
// and the delegate can no longer safely read `self.window`.
|
||||
/// releases every viewport's GPU resources before AppKit deallocates the window graph.
|
||||
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
|
||||
// Pull out any unsaved text before tearing down. `getText` refreshes
|
||||
// the viewport's own `cachedText`, so later reads during teardown
|
||||
// can fall back to it if the handle is already gone.
|
||||
syncTextFromViewport()
|
||||
appState.saveNote()
|
||||
|
||||
// Explicit, ordered teardown of every viewport we own, while the
|
||||
// views + window graph are still fully alive.
|
||||
if let vp = viewport {
|
||||
vp.teardown()
|
||||
for editor in editorWindows {
|
||||
editor.syncTextFromViewport()
|
||||
editor.appState.saveNote()
|
||||
editor.teardown()
|
||||
}
|
||||
for controller in windowControllers {
|
||||
if let vp = controller.window.contentView as? IcedViewportView {
|
||||
vp.teardown()
|
||||
editorWindows.removeAll()
|
||||
for observer in windowCloseObservers {
|
||||
NotificationCenter.default.removeObserver(observer)
|
||||
}
|
||||
windowCloseObservers.removeAll()
|
||||
return .terminateNow
|
||||
}
|
||||
|
||||
// MARK: - Window management
|
||||
|
||||
private func spawnEditorWindow(loadingURL url: URL?, isMain: Bool) {
|
||||
let autosaveName = isMain ? "AcordMainWindow" : nil
|
||||
let editor = EditorWindow(frameAutosaveName: autosaveName)
|
||||
editorWindows.append(editor)
|
||||
|
||||
let observer = NotificationCenter.default.addObserver(
|
||||
forName: NSWindow.willCloseNotification,
|
||||
object: editor.window,
|
||||
queue: .main
|
||||
) { [weak self, weak editor] _ in
|
||||
guard let self = self, let editor = editor else { return }
|
||||
self.handleEditorWindowClose(editor)
|
||||
}
|
||||
windowCloseObservers.append(observer)
|
||||
|
||||
if let url = url {
|
||||
editor.loadFromURL(url)
|
||||
}
|
||||
}
|
||||
|
||||
/// opens a URL in a new editor window.
|
||||
func openInNewWindow(_ url: URL) {
|
||||
spawnEditorWindow(loadingURL: url, isMain: false)
|
||||
}
|
||||
|
||||
/// opens a URL in the frontmost editor window, spawning one when none exist.
|
||||
func openInActiveEditor(_ url: URL) {
|
||||
guard let target = frontmostEditor() else {
|
||||
spawnEditorWindow(loadingURL: url, isMain: false)
|
||||
return
|
||||
}
|
||||
target.window.makeKeyAndOrderFront(nil)
|
||||
target.loadFromURL(url)
|
||||
}
|
||||
|
||||
private func handleEditorWindowClose(_ editor: EditorWindow) {
|
||||
editor.syncTextFromViewport()
|
||||
editor.appState.saveNote()
|
||||
editor.teardown()
|
||||
editorWindows.removeAll { $0 === editor }
|
||||
}
|
||||
|
||||
/// returns the topmost editor window in z-order, falling back to the first in the list.
|
||||
private func frontmostEditor() -> EditorWindow? {
|
||||
for w in NSApp.orderedWindows where w.isVisible {
|
||||
if let editor = editorWindows.first(where: { $0.window === w }) {
|
||||
return editor
|
||||
}
|
||||
}
|
||||
|
||||
// Drop strong refs so AppKit doesn't try to replay anything through
|
||||
// the delegate during its own terminate phases.
|
||||
titleCancellable = nil
|
||||
textCancellable = nil
|
||||
if let observer = focusTitleObserver {
|
||||
NotificationCenter.default.removeObserver(observer)
|
||||
focusTitleObserver = nil
|
||||
}
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
|
||||
return .terminateNow
|
||||
return editorWindows.first
|
||||
}
|
||||
|
||||
// MARK: - Menu bar
|
||||
|
|
@ -319,7 +289,9 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation {
|
|||
flags ^= bit
|
||||
ConfigManager.shared.autoPairFlags = flags
|
||||
sender.state = (flags & bit) != 0 ? .on : .off
|
||||
viewport?.setAutoPairFlags(flags)
|
||||
for editor in editorWindows {
|
||||
editor.viewport.setAutoPairFlags(flags)
|
||||
}
|
||||
}
|
||||
|
||||
private func buildRenderMenu() -> NSMenuItem {
|
||||
|
|
@ -411,31 +383,11 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation {
|
|||
// MARK: - Actions
|
||||
|
||||
@objc private func newNote() {
|
||||
appState.newNote()
|
||||
frontmostEditor()?.newNote()
|
||||
}
|
||||
|
||||
@objc private func newWindow() {
|
||||
let state = AppState()
|
||||
let viewport = IcedViewportView(frame: NSRect(x: 0, y: 0, width: 1200, height: 800))
|
||||
viewport.autoresizingMask = [.width, .height]
|
||||
|
||||
let win = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 1200, height: 800),
|
||||
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
win.isReleasedWhenClosed = false
|
||||
win.titlebarAppearsTransparent = true
|
||||
win.titleVisibility = .hidden
|
||||
win.backgroundColor = Theme.current.base
|
||||
win.title = "Acord"
|
||||
win.contentView = viewport
|
||||
win.center()
|
||||
win.makeKeyAndOrderFront(nil)
|
||||
|
||||
let controller = WindowController(window: win, appState: state)
|
||||
windowControllers.append(controller)
|
||||
spawnEditorWindow(loadingURL: nil, isMain: false)
|
||||
}
|
||||
|
||||
@objc private func openStorageDirectory() {
|
||||
|
|
@ -445,172 +397,165 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation {
|
|||
}
|
||||
|
||||
@objc private func boldSelection() {
|
||||
viewport?.sendCommand(1)
|
||||
frontmostEditor()?.viewport.sendCommand(1)
|
||||
}
|
||||
|
||||
@objc private func italicizeSelection() {
|
||||
viewport?.sendCommand(2)
|
||||
frontmostEditor()?.viewport.sendCommand(2)
|
||||
}
|
||||
|
||||
@objc private func insertTable() {
|
||||
viewport?.sendCommand(3)
|
||||
frontmostEditor()?.viewport.sendCommand(3)
|
||||
}
|
||||
|
||||
@objc private func smartEval() {
|
||||
viewport?.sendCommand(4)
|
||||
frontmostEditor()?.viewport.sendCommand(4)
|
||||
}
|
||||
|
||||
@objc private func openNote() {
|
||||
let panel = NSOpenPanel()
|
||||
panel.allowedContentTypes = Self.supportedContentTypes
|
||||
panel.canChooseFiles = true
|
||||
panel.canChooseDirectories = false
|
||||
panel.allowsMultipleSelection = false
|
||||
panel.beginSheetModal(for: window) { [weak self] response in
|
||||
guard response == .OK, let url = panel.url else { return }
|
||||
self?.appState.loadNoteFromFile(url)
|
||||
}
|
||||
frontmostEditor()?.openNotePanel()
|
||||
}
|
||||
|
||||
@objc private func saveNote() {
|
||||
syncTextFromViewport()
|
||||
appState.bindAutoSaveURL()
|
||||
appState.saveNote()
|
||||
frontmostEditor()?.saveNote()
|
||||
}
|
||||
|
||||
@objc private func saveNoteAs() {
|
||||
syncTextFromViewport()
|
||||
let panel = NSSavePanel()
|
||||
panel.allowedContentTypes = Self.supportedContentTypes
|
||||
panel.nameFieldStringValue = defaultFilename()
|
||||
if let url = appState.currentFileURL {
|
||||
panel.directoryURL = url.deletingLastPathComponent()
|
||||
panel.nameFieldStringValue = url.lastPathComponent
|
||||
}
|
||||
panel.beginSheetModal(for: window) { [weak self] response in
|
||||
guard response == .OK, let url = panel.url else { return }
|
||||
self?.appState.saveNoteToFile(url)
|
||||
}
|
||||
frontmostEditor()?.saveNoteAs()
|
||||
}
|
||||
|
||||
/// renders the document to a PDF chosen via save dialog and opens it for printing.
|
||||
@objc private func printNote() {
|
||||
guard let w = window, let vp = w.contentView as? IcedViewportView,
|
||||
let handle = vp.viewportHandle else { return }
|
||||
syncTextFromViewport()
|
||||
|
||||
let title = appState.currentFileURL?.deletingPathExtension().lastPathComponent ?? "Acord Document"
|
||||
var len: UInt = 0
|
||||
guard let ptr = title.withCString({ t in viewport_render_pdf(handle, t, &len) }), len > 0 else {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Print failed"
|
||||
alert.informativeText = "Could not render this document to PDF."
|
||||
alert.runModal()
|
||||
return
|
||||
}
|
||||
let data = Data(bytes: ptr, count: Int(len))
|
||||
viewport_free_bytes(ptr, len)
|
||||
|
||||
let panel = NSSavePanel()
|
||||
panel.title = "Print to PDF"
|
||||
panel.prompt = "Save"
|
||||
panel.allowedContentTypes = [.pdf]
|
||||
panel.nameFieldStringValue = "\(title).pdf"
|
||||
panel.beginSheetModal(for: w) { response in
|
||||
guard response == .OK, let url = panel.url else { return }
|
||||
do {
|
||||
try data.write(to: url)
|
||||
NSWorkspace.shared.open(url)
|
||||
} catch {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Print failed"
|
||||
alert.informativeText = error.localizedDescription
|
||||
alert.runModal()
|
||||
}
|
||||
}
|
||||
frontmostEditor()?.printNote()
|
||||
}
|
||||
|
||||
@objc private func exportCrate() {
|
||||
syncTextFromViewport()
|
||||
guard let w = window, let vp = w.contentView as? IcedViewportView,
|
||||
let handle = vp.viewportHandle else { return }
|
||||
frontmostEditor()?.exportCrate()
|
||||
}
|
||||
|
||||
let panel = NSSavePanel()
|
||||
panel.title = "Export as Rust Library"
|
||||
panel.message = "Choose a location and name for your exported crate"
|
||||
panel.prompt = "Export"
|
||||
panel.nameFieldLabel = "Crate name:"
|
||||
panel.nameFieldStringValue = defaultCrateName()
|
||||
panel.canCreateDirectories = true
|
||||
@objc private func formatDocument() {
|
||||
frontmostEditor()?.viewport.sendCommand(10)
|
||||
}
|
||||
|
||||
panel.beginSheetModal(for: w) { response in
|
||||
guard response == .OK, let url = panel.url else { return }
|
||||
let parentDir = url.deletingLastPathComponent().path
|
||||
let name = url.lastPathComponent
|
||||
parentDir.withCString { pd in
|
||||
name.withCString { n in
|
||||
if let cstr = viewport_export_crate(handle, pd, n) {
|
||||
let resultPath = String(cString: cstr)
|
||||
viewport_free_string(cstr)
|
||||
self.notifyExportComplete(at: resultPath)
|
||||
} else {
|
||||
self.notifyExportFailed()
|
||||
}
|
||||
}
|
||||
@objc private func openSettings() {
|
||||
guard let editor = frontmostEditor() else { return }
|
||||
editor.syncSettingsToViewport()
|
||||
editor.viewport.sendCommand(16)
|
||||
}
|
||||
|
||||
@objc private func setLiveMode() {
|
||||
frontmostEditor()?.viewport.sendCommand(11)
|
||||
}
|
||||
|
||||
@objc private func setEditorMode() {
|
||||
frontmostEditor()?.viewport.sendCommand(12)
|
||||
}
|
||||
|
||||
@objc private func setViewMode() {
|
||||
frontmostEditor()?.viewport.sendCommand(13)
|
||||
}
|
||||
|
||||
@objc private func setLayoutModeFree() {
|
||||
frontmostEditor()?.viewport.sendCommand(17)
|
||||
}
|
||||
|
||||
@objc private func setLayoutModeRelative() {
|
||||
frontmostEditor()?.viewport.sendCommand(18)
|
||||
}
|
||||
|
||||
@objc private func setLayoutModeAnchored() {
|
||||
frontmostEditor()?.viewport.sendCommand(19)
|
||||
}
|
||||
|
||||
@objc private func toggleSnapping() {
|
||||
frontmostEditor()?.viewport.sendCommand(20)
|
||||
}
|
||||
|
||||
@objc private func toggleBrowser() {
|
||||
DocumentBrowserController.shared?.toggle()
|
||||
}
|
||||
|
||||
@objc private func zoomIn() {
|
||||
if let browser = DocumentBrowserController.shared, browser.isKeyWindow {
|
||||
browser.sendCommand(7)
|
||||
return
|
||||
}
|
||||
ConfigManager.shared.zoomLevel += 1
|
||||
NotificationCenter.default.post(name: .settingsChanged, object: nil)
|
||||
}
|
||||
|
||||
@objc private func zoomOut() {
|
||||
if let browser = DocumentBrowserController.shared, browser.isKeyWindow {
|
||||
browser.sendCommand(8)
|
||||
return
|
||||
}
|
||||
let current = ConfigManager.shared.zoomLevel
|
||||
if 11 + current > 8 {
|
||||
ConfigManager.shared.zoomLevel -= 1
|
||||
NotificationCenter.default.post(name: .settingsChanged, object: nil)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func zoomReset() {
|
||||
if let browser = DocumentBrowserController.shared, browser.isKeyWindow {
|
||||
browser.sendCommand(9)
|
||||
return
|
||||
}
|
||||
ConfigManager.shared.zoomLevel = 0
|
||||
NotificationCenter.default.post(name: .settingsChanged, object: nil)
|
||||
}
|
||||
|
||||
// MARK: - Menu validation
|
||||
|
||||
func validateMenuItem(_ menuItem: NSMenuItem) -> Bool {
|
||||
let editor = frontmostEditor()
|
||||
let mode = editor?.viewport.renderMode() ?? 0
|
||||
let layout = editor?.viewport.layoutMode() ?? 0
|
||||
switch menuItem.action {
|
||||
case #selector(setLiveMode):
|
||||
menuItem.state = mode == 0 ? .on : .off
|
||||
case #selector(setEditorMode):
|
||||
menuItem.state = mode == 1 ? .on : .off
|
||||
case #selector(setViewMode):
|
||||
menuItem.state = mode == 2 ? .on : .off
|
||||
case #selector(setLayoutModeFree):
|
||||
menuItem.state = layout == 0 ? .on : .off
|
||||
case #selector(setLayoutModeRelative):
|
||||
menuItem.state = layout == 1 ? .on : .off
|
||||
case #selector(setLayoutModeAnchored):
|
||||
menuItem.state = layout == 2 ? .on : .off
|
||||
case #selector(toggleSnapping):
|
||||
menuItem.state = (editor?.viewport.snapping() ?? false) ? .on : .off
|
||||
default:
|
||||
break
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - File-format helpers
|
||||
|
||||
static let supportedContentTypes: [UTType] = {
|
||||
let extensions = [
|
||||
"md", "markdown", "mdown",
|
||||
"csv", "json", "toml", "yaml", "yml", "xml", "svg",
|
||||
"rs", "c", "cpp", "cc", "cxx", "h", "hpp", "hxx",
|
||||
"js", "jsx", "ts", "tsx",
|
||||
"html", "htm", "css", "scss", "less",
|
||||
"py", "go", "rb", "php", "lua",
|
||||
"sh", "bash", "zsh", "fish",
|
||||
"java", "kt", "kts", "swift", "zig", "sql",
|
||||
"mk", "ini", "cfg", "conf", "env",
|
||||
"lock", "txt", "text", "log"
|
||||
]
|
||||
var types: [UTType] = [.plainText]
|
||||
for ext in extensions {
|
||||
if let t = UTType(filenameExtension: ext) {
|
||||
types.append(t)
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array(Set(types))
|
||||
}()
|
||||
|
||||
private func defaultCrateName() -> String {
|
||||
let firstLine = appState.documentText
|
||||
.components(separatedBy: "\n").first?
|
||||
.trimmingCharacters(in: .whitespaces) ?? ""
|
||||
let stripped = firstLine.replacingOccurrences(
|
||||
of: "^#+\\s*", with: "", options: .regularExpression
|
||||
)
|
||||
let words = stripped.split(separator: " ").prefix(2).joined(separator: "-")
|
||||
let sanitized = words.lowercased()
|
||||
.map { $0.isLetter || $0.isNumber || $0 == "-" ? String($0) : "" }.joined()
|
||||
return sanitized.isEmpty ? "my-note" : sanitized
|
||||
}
|
||||
|
||||
private func notifyExportComplete(at path: String) {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Export complete"
|
||||
alert.informativeText = "Crate written to:\n\(path)\n\nCheck the README for build and install instructions."
|
||||
alert.addButton(withTitle: "Reveal in Finder")
|
||||
alert.addButton(withTitle: "OK")
|
||||
if alert.runModal() == .alertFirstButtonReturn {
|
||||
NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: path)])
|
||||
}
|
||||
}
|
||||
|
||||
private func notifyExportFailed() {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Export failed"
|
||||
alert.informativeText = "Could not export the note. Check the folder permissions and that the crate name doesn't collide with an existing folder."
|
||||
alert.addButton(withTitle: "OK")
|
||||
alert.runModal()
|
||||
}
|
||||
|
||||
private func defaultFilename() -> String {
|
||||
if let url = appState.currentFileURL {
|
||||
return url.lastPathComponent
|
||||
}
|
||||
let firstLine = appState.documentText
|
||||
.components(separatedBy: "\n").first?
|
||||
.trimmingCharacters(in: .whitespaces) ?? ""
|
||||
let stripped = firstLine.replacingOccurrences(
|
||||
of: "^#+\\s*", with: "", options: .regularExpression
|
||||
)
|
||||
let trimmed = stripped.trimmingCharacters(in: .whitespaces)
|
||||
let ext = extensionForFormat(appState.currentFileFormat)
|
||||
guard !trimmed.isEmpty, trimmed != "Untitled" else { return "note.\(ext)" }
|
||||
let sanitized = trimmed.map { "/:\\\\".contains($0) ? "-" : String($0) }.joined()
|
||||
return sanitized.prefix(80) + ".\(ext)"
|
||||
}
|
||||
|
||||
private func extensionForFormat(_ format: FileFormat) -> String {
|
||||
static func extensionForFormat(_ format: FileFormat) -> String {
|
||||
switch format {
|
||||
case .markdown: return "md"
|
||||
case .csv: return "csv"
|
||||
|
|
@ -649,331 +594,4 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation {
|
|||
case .plainText, .unknown: return "txt"
|
||||
}
|
||||
}
|
||||
|
||||
private static let supportedContentTypes: [UTType] = {
|
||||
let extensions = [
|
||||
"md", "markdown", "mdown",
|
||||
"csv", "json", "toml", "yaml", "yml", "xml", "svg",
|
||||
"rs", "c", "cpp", "cc", "cxx", "h", "hpp", "hxx",
|
||||
"js", "jsx", "ts", "tsx",
|
||||
"html", "htm", "css", "scss", "less",
|
||||
"py", "go", "rb", "php", "lua",
|
||||
"sh", "bash", "zsh", "fish",
|
||||
"java", "kt", "kts", "swift", "zig", "sql",
|
||||
"mk", "ini", "cfg", "conf", "env",
|
||||
"lock", "txt", "text", "log"
|
||||
]
|
||||
var types: [UTType] = [.plainText]
|
||||
for ext in extensions {
|
||||
if let t = UTType(filenameExtension: ext) {
|
||||
types.append(t)
|
||||
}
|
||||
}
|
||||
return Array(Set(types))
|
||||
}()
|
||||
|
||||
func validateMenuItem(_ menuItem: NSMenuItem) -> Bool {
|
||||
let mode = viewport?.renderMode() ?? 0
|
||||
let layout = viewport?.layoutMode() ?? 0
|
||||
switch menuItem.action {
|
||||
case #selector(setLiveMode):
|
||||
menuItem.state = mode == 0 ? .on : .off
|
||||
case #selector(setEditorMode):
|
||||
menuItem.state = mode == 1 ? .on : .off
|
||||
case #selector(setViewMode):
|
||||
menuItem.state = mode == 2 ? .on : .off
|
||||
case #selector(setLayoutModeFree):
|
||||
menuItem.state = layout == 0 ? .on : .off
|
||||
case #selector(setLayoutModeRelative):
|
||||
menuItem.state = layout == 1 ? .on : .off
|
||||
case #selector(setLayoutModeAnchored):
|
||||
menuItem.state = layout == 2 ? .on : .off
|
||||
case #selector(toggleSnapping):
|
||||
menuItem.state = (viewport?.snapping() ?? false) ? .on : .off
|
||||
default:
|
||||
break
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@objc private func setLiveMode() {
|
||||
viewport?.sendCommand(11)
|
||||
}
|
||||
|
||||
@objc private func setEditorMode() {
|
||||
viewport?.sendCommand(12)
|
||||
}
|
||||
|
||||
@objc private func handleNewNoteSeeded() {
|
||||
viewport?.sendCommand(12)
|
||||
}
|
||||
|
||||
@objc private func setViewMode() {
|
||||
viewport?.sendCommand(13)
|
||||
}
|
||||
|
||||
@objc private func setLayoutModeFree() {
|
||||
viewport?.sendCommand(17)
|
||||
}
|
||||
|
||||
@objc private func setLayoutModeRelative() {
|
||||
viewport?.sendCommand(18)
|
||||
}
|
||||
|
||||
@objc private func setLayoutModeAnchored() {
|
||||
viewport?.sendCommand(19)
|
||||
}
|
||||
|
||||
@objc private func toggleSnapping() {
|
||||
viewport?.sendCommand(20)
|
||||
}
|
||||
|
||||
@objc private func formatDocument() {
|
||||
viewport?.sendCommand(10)
|
||||
}
|
||||
|
||||
@objc private func openSettings() {
|
||||
syncSettingsToViewport()
|
||||
viewport?.sendCommand(16)
|
||||
}
|
||||
|
||||
@objc private func settingsDidChange() {
|
||||
window.backgroundColor = Theme.current.base
|
||||
syncThemeToViewport()
|
||||
syncGutterPrefsToViewport()
|
||||
syncSettingsToViewport()
|
||||
window.contentView?.needsDisplay = true
|
||||
}
|
||||
|
||||
private func syncSettingsToViewport() {
|
||||
viewport?.setSettingsView(
|
||||
themeMode: ConfigManager.shared.themeMode,
|
||||
lineIndicator: ConfigManager.shared.lineIndicatorMode,
|
||||
gutterRainbow: ConfigManager.shared.gutterRainbow,
|
||||
autoSaveDir: ConfigManager.shared.autoSaveDirectory
|
||||
)
|
||||
}
|
||||
|
||||
private func drainShellActions() {
|
||||
guard let vp = viewport else { return }
|
||||
while let raw = vp.takeShellAction() {
|
||||
let parts = raw.split(separator: ":", maxSplits: 1).map(String.init)
|
||||
let kind = parts[0]
|
||||
let value = parts.count > 1 ? parts[1] : ""
|
||||
switch kind {
|
||||
case "new_note": newNote()
|
||||
case "open": openNote()
|
||||
case "save": saveNote()
|
||||
case "save_as": saveNoteAs()
|
||||
case "quit": NSApp.terminate(nil)
|
||||
case "settings": break
|
||||
case "export_crate": exportCrate()
|
||||
case "toggle_browser": toggleBrowser()
|
||||
case "set_theme_mode":
|
||||
ConfigManager.shared.themeMode = value
|
||||
NotificationCenter.default.post(name: .settingsChanged, object: nil)
|
||||
case "set_line_indicator":
|
||||
ConfigManager.shared.lineIndicatorMode = value
|
||||
NotificationCenter.default.post(name: .settingsChanged, object: nil)
|
||||
case "set_gutter_rainbow":
|
||||
ConfigManager.shared.gutterRainbow = (value == "true")
|
||||
NotificationCenter.default.post(name: .settingsChanged, object: nil)
|
||||
case "pick_auto_save_dir":
|
||||
pickAutoSaveDirectory()
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func pickAutoSaveDirectory() {
|
||||
let panel = NSOpenPanel()
|
||||
panel.canChooseFiles = false
|
||||
panel.canChooseDirectories = true
|
||||
panel.canCreateDirectories = true
|
||||
panel.allowsMultipleSelection = false
|
||||
panel.directoryURL = URL(fileURLWithPath: ConfigManager.shared.autoSaveDirectory)
|
||||
panel.beginSheetModal(for: window) { response in
|
||||
guard response == .OK, let url = panel.url else { return }
|
||||
ConfigManager.shared.autoSaveDirectory = url.path
|
||||
NotificationCenter.default.post(name: .settingsChanged, object: nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func syncThemeToViewport() {
|
||||
let mode = ConfigManager.shared.themeMode
|
||||
let name: String
|
||||
switch mode {
|
||||
case "dark": name = "kicad"
|
||||
case "light": name = "latte"
|
||||
default:
|
||||
let appearance = NSApp.effectiveAppearance
|
||||
let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua
|
||||
name = isDark ? "kicad" : "latte"
|
||||
}
|
||||
viewport?.setTheme(name)
|
||||
}
|
||||
|
||||
private func syncGutterPrefsToViewport() {
|
||||
viewport?.setLineIndicator(ConfigManager.shared.lineIndicatorMode)
|
||||
viewport?.setGutterRainbow(ConfigManager.shared.gutterRainbow)
|
||||
viewport?.setAutoPairFlags(ConfigManager.shared.autoPairFlags)
|
||||
}
|
||||
|
||||
@objc private func toggleBrowser() {
|
||||
DocumentBrowserController.shared?.toggle()
|
||||
}
|
||||
|
||||
@objc private func zoomIn() {
|
||||
if let browser = DocumentBrowserController.shared, browser.isKeyWindow {
|
||||
browser.sendCommand(7)
|
||||
return
|
||||
}
|
||||
ConfigManager.shared.zoomLevel += 1
|
||||
NotificationCenter.default.post(name: .settingsChanged, object: nil)
|
||||
}
|
||||
|
||||
@objc private func zoomOut() {
|
||||
if let browser = DocumentBrowserController.shared, browser.isKeyWindow {
|
||||
browser.sendCommand(8)
|
||||
return
|
||||
}
|
||||
let current = ConfigManager.shared.zoomLevel
|
||||
if 11 + current > 8 {
|
||||
ConfigManager.shared.zoomLevel -= 1
|
||||
NotificationCenter.default.post(name: .settingsChanged, object: nil)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func zoomReset() {
|
||||
if let browser = DocumentBrowserController.shared, browser.isKeyWindow {
|
||||
browser.sendCommand(9)
|
||||
return
|
||||
}
|
||||
ConfigManager.shared.zoomLevel = 0
|
||||
NotificationCenter.default.post(name: .settingsChanged, object: nil)
|
||||
}
|
||||
|
||||
private func setupTitleBar() {
|
||||
let accessory = TitleBarAccessoryController()
|
||||
window.addTitlebarAccessoryViewController(accessory)
|
||||
|
||||
let tbv = accessory.titleView
|
||||
tbv.onCommit = { [weak self] rawTitle in
|
||||
guard let self = self else { return }
|
||||
// Only drop the document's first line if it actually IS a title
|
||||
// (starts with `#`). Normalize whatever the user typed in the
|
||||
// title bar to a `# ` prefix so the saved markdown is valid.
|
||||
let trimmed = rawTitle.trimmingCharacters(in: .whitespaces)
|
||||
let normalizedTitle: String
|
||||
if trimmed.isEmpty {
|
||||
normalizedTitle = ""
|
||||
} else if trimmed.hasPrefix("#") {
|
||||
normalizedTitle = trimmed
|
||||
} else {
|
||||
normalizedTitle = "# " + trimmed
|
||||
}
|
||||
|
||||
let lines = self.appState.documentText.components(separatedBy: "\n")
|
||||
let firstIsTitle = lines.first
|
||||
.map { $0.trimmingCharacters(in: .whitespaces).hasPrefix("#") }
|
||||
?? false
|
||||
let body: [String] = firstIsTitle ? Array(lines.dropFirst()) : lines
|
||||
|
||||
let newLines: [String]
|
||||
if normalizedTitle.isEmpty {
|
||||
newLines = body
|
||||
} else {
|
||||
newLines = [normalizedTitle] + body
|
||||
}
|
||||
self.appState.documentText = newLines.joined(separator: "\n")
|
||||
}
|
||||
|
||||
titleBarView = tbv
|
||||
|
||||
focusTitleObserver = NotificationCenter.default.addObserver(
|
||||
forName: .focusTitle, object: nil, queue: .main
|
||||
) { [weak self] _ in
|
||||
self?.titleBarView?.beginEditing()
|
||||
}
|
||||
}
|
||||
|
||||
private func observeDocumentText() {
|
||||
textCancellable = appState.$documentText
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] text in
|
||||
guard let self = self, let vp = self.viewport else { return }
|
||||
// Idempotent: when the sync timer pulls text FROM the
|
||||
// viewport and assigns it to `documentText`, this sink
|
||||
// fires again and would push the identical text back in —
|
||||
// and `vp.setText` rebuilds viewport state, clearing eval
|
||||
// results. Skip the round-trip when vp already has it.
|
||||
if vp.getText() == text { return }
|
||||
vp.setText(text)
|
||||
}
|
||||
}
|
||||
|
||||
/// pushes loaded note text into the viewport synchronously so the autosave timer and quit handler can never observe stale viewport state across a note swap.
|
||||
private func wireLoadedTextSync() {
|
||||
appState.onLoadedTextChanged = { [weak self] text in
|
||||
guard let self = self, let vp = self.viewport else { return }
|
||||
if vp.getText() != text {
|
||||
vp.setText(text)
|
||||
}
|
||||
self.lastAutosavedHash = text.hashValue
|
||||
}
|
||||
appState.takeArchiveBytesFromViewport = { [weak self] in
|
||||
self?.viewport?.takeSidecarBytes()
|
||||
}
|
||||
appState.applyArchiveBytesToViewport = { [weak self] data in
|
||||
self?.viewport?.applySidecarBytes(data)
|
||||
}
|
||||
}
|
||||
|
||||
private func syncTextFromViewport() {
|
||||
guard let w = window, let vp = w.contentView as? IcedViewportView else { return }
|
||||
let text = vp.getText()
|
||||
if !text.isEmpty || appState.documentText.isEmpty {
|
||||
appState.documentText = text
|
||||
}
|
||||
}
|
||||
|
||||
/// 100ms autosave loop. Reads straight from the viewport and writes a
|
||||
/// file in the notes directory — no Combine publishers, no `setText`,
|
||||
/// no viewport-state rebuilds. The existing explicit flows (Cmd+S,
|
||||
/// note switch, quit) still route through `syncTextFromViewport` so
|
||||
/// `appState.documentText` stays current when Swift actually needs it.
|
||||
private func startAutosaveTimer() {
|
||||
autosaveTimer?.invalidate()
|
||||
autosaveTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in
|
||||
self?.drainShellActions()
|
||||
self?.persistViewportToNotesDir()
|
||||
}
|
||||
}
|
||||
|
||||
private func persistViewportToNotesDir() {
|
||||
guard let w = window, let vp = w.contentView as? IcedViewportView else { return }
|
||||
let text = vp.getText()
|
||||
guard !AppState.isEffectivelyBlank(text) else { return }
|
||||
let hash = text.hashValue
|
||||
if hash == lastAutosavedHash { return }
|
||||
appState.writeAutosavedCopy(text: text)
|
||||
lastAutosavedHash = hash
|
||||
}
|
||||
|
||||
private func observeDocumentTitle() {
|
||||
titleCancellable = appState.$documentText
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] text in
|
||||
guard let self = self else { return }
|
||||
let firstLine = text.components(separatedBy: "\n").first?
|
||||
.trimmingCharacters(in: .whitespaces) ?? ""
|
||||
let clean = firstLine.replacingOccurrences(
|
||||
of: "^#+\\s*", with: "", options: .regularExpression
|
||||
)
|
||||
let displayTitle = clean.isEmpty ? "Acord" : String(clean.prefix(60))
|
||||
self.window.title = displayTitle
|
||||
self.titleBarView?.title = firstLine
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -132,16 +132,13 @@ class AppState: ObservableObject {
|
|||
private var autoSaveDirty = false
|
||||
private var autoSaveCoolingDown = false
|
||||
private let autoSaveQueue = DispatchQueue(label: "com.acord.autosave")
|
||||
/// fires synchronously after a load/new note swap so the host shell can push the new text into the viewport before the autosave timer reads stale viewport state.
|
||||
/// fires synchronously after a load/new-note swap, delivering text before the autosave timer polls.
|
||||
var onLoadedTextChanged: ((String) -> Void)?
|
||||
/// drains the document's archive zip from the viewport for embed-on-save.
|
||||
/// drains the document's archive zip from the viewport.
|
||||
var takeArchiveBytesFromViewport: (() -> Data?)?
|
||||
/// applies an archive zip's metadata back into the viewport.
|
||||
var applyArchiveBytesToViewport: ((Data) -> Void)?
|
||||
/// Per-note autosave file path, established on the first write and never
|
||||
/// changed for the rest of the session. Stops the title-derived filename
|
||||
/// from re-deriving on every keystroke and littering the notes directory
|
||||
/// with `u.md`, `us.md`, `use.md`, ...
|
||||
/// per-note autosave file path, locked on the first write to prevent keystroke-driven renames.
|
||||
private var autoSavePaths: [UUID: URL] = [:]
|
||||
|
||||
init() {
|
||||
|
|
@ -202,23 +199,11 @@ class AppState: ObservableObject {
|
|||
}
|
||||
|
||||
private func shouldAutoSave() -> Bool {
|
||||
// Autosave only when the note has real user content. A freshly-
|
||||
// created doc that picked up the default `Header 1 | Header 2 |
|
||||
// Header 3` table from Cmd+T without the user typing anything
|
||||
// still reads as "blank" by this check — that's what stops the
|
||||
// ~/.acord/notes directory from accumulating `{uuid}.md` phantoms.
|
||||
//
|
||||
// Explicit saves (Cmd+S → `saveNote`) skip this gate, so a user
|
||||
// who genuinely wants to keep a note with only an empty table
|
||||
// can still force it.
|
||||
// gates on non-blank content to prevent phantom .md files from empty tables.
|
||||
!AppState.isEffectivelyBlank(documentText)
|
||||
}
|
||||
|
||||
/// Shared blank-detection used by both the autosave gate and (via its
|
||||
/// `static` form) the browser's `(empty note)` preview label. A note
|
||||
/// is "blank" when, after the `<!-- acord-archive … -->` sidecar is
|
||||
/// stripped, nothing remains except whitespace or default empty-table
|
||||
/// scaffolding (all-empty cells or the `Header N` placeholder row).
|
||||
/// returns true for notes containing only whitespace or default empty-table scaffolding.
|
||||
static func isEffectivelyBlank(_ text: String) -> Bool {
|
||||
let body = stripSidecarArchive(text)
|
||||
let trimmed = body.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
|
@ -267,11 +252,7 @@ class AppState: ObservableObject {
|
|||
return cleaned.isEmpty ? UUID().uuidString : cleaned
|
||||
}
|
||||
|
||||
/// Resolve the autosave file URL for `noteID`. First call for a noteID
|
||||
/// derives a filename from the title (or the UUID when there's no title);
|
||||
/// the resulting path is then locked in for the rest of the session, so
|
||||
/// later keystrokes can't spawn a fresh file each time the title grows.
|
||||
/// Must be called on the main thread (mutates `autoSavePaths`).
|
||||
/// resolves the autosave file URL, locking the derived filename on first call per noteID.
|
||||
private func resolveAutoSaveURL(noteID: UUID, text: String) -> URL {
|
||||
if let url = autoSavePaths[noteID] {
|
||||
return url
|
||||
|
|
@ -297,14 +278,12 @@ class AppState: ObservableObject {
|
|||
currentFileFormat = .markdown
|
||||
}
|
||||
|
||||
/// Background-safe atomic write. No path resolution here — the URL was
|
||||
/// resolved on the main thread before dispatch.
|
||||
/// atomically writes text to a pre-resolved URL from a background queue.
|
||||
private static func writeAutoSaveFile(at url: URL, text: String) {
|
||||
try? text.write(to: url, atomically: true, encoding: .utf8)
|
||||
}
|
||||
|
||||
/// magic separating the markdown body from the appended raw zip; surrounding
|
||||
/// NULs trip text editors into binary mode so the archive shows as garbage.
|
||||
/// byte sequence separating the markdown body from the appended raw zip.
|
||||
static let binarySentinel: Data = {
|
||||
var d = Data()
|
||||
d.append(0x0A) // \n
|
||||
|
|
@ -338,7 +317,7 @@ class AppState: ObservableObject {
|
|||
return out
|
||||
}
|
||||
|
||||
/// true when url is an .md inside the configured notes library (recursive).
|
||||
/// checks whether url resolves to an .md inside the configured notes library.
|
||||
func isAcordNote(_ url: URL) -> Bool {
|
||||
let format = FileFormat.from(filename: url.lastPathComponent)
|
||||
guard format.isMarkdown else { return false }
|
||||
|
|
@ -349,7 +328,21 @@ class AppState: ObservableObject {
|
|||
|| standardized.path == dir.path
|
||||
}
|
||||
|
||||
/// path under <notes_dir>/.external/ for an external file's archive companion.
|
||||
/// computes the <notes_dir>/.external/ autosave shadow path of an external file.
|
||||
func externalAutosaveShadowURL(for original: URL) -> URL {
|
||||
let trimmed = original.standardizedFileURL.path
|
||||
.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
let encoded = trimmed.map { ch -> Character in
|
||||
(ch == "/" || ch == "\\" || ch == ":") ? "." : ch
|
||||
}
|
||||
let name = String(encoded) + ".md"
|
||||
let dir = URL(fileURLWithPath: ConfigManager.shared.autoSaveDirectory)
|
||||
.appendingPathComponent(".external", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
return dir.appendingPathComponent(name)
|
||||
}
|
||||
|
||||
/// computes the <notes_dir>/.external/ archive companion path of an external file.
|
||||
func externalSidecarPath(for original: URL) -> URL {
|
||||
let trimmed = original.standardizedFileURL.path
|
||||
.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
|
|
@ -362,12 +355,12 @@ class AppState: ObservableObject {
|
|||
.appendingPathComponent(name)
|
||||
}
|
||||
|
||||
/// reads the companion archive zip for an external file, if it exists.
|
||||
/// reads the companion archive zip of an external file, returning nil when absent.
|
||||
func readExternalSidecar(for original: URL) -> Data? {
|
||||
try? Data(contentsOf: externalSidecarPath(for: original))
|
||||
}
|
||||
|
||||
/// writes (or clears) the companion archive for an external file.
|
||||
/// writes or clears the companion archive of an external file.
|
||||
func writeExternalSidecar(for original: URL, archive: Data?) {
|
||||
let path = externalSidecarPath(for: original)
|
||||
if let archive = archive, !archive.isEmpty {
|
||||
|
|
@ -379,14 +372,10 @@ class AppState: ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
/// Strip the `<!-- acord-archive ... -->` sidecar comment from `text`.
|
||||
/// The markdown body before the comment is the user's actual content;
|
||||
/// non-markdown destinations (.rs, .json, .csv-source, etc.) must not
|
||||
/// inherit the comment because it isn't valid syntax in those formats.
|
||||
/// strips the sidecar archive comment from text, leaving only the markdown body.
|
||||
private static func stripArchiveForExternalSave(_ text: String) -> String {
|
||||
var body = stripSidecarArchive(text)
|
||||
// `stripSidecarArchive` keeps trailing whitespace — trim so we don't
|
||||
// leave a flapping blank line where the comment used to be.
|
||||
// trim trailing blank lines left behind by the stripped comment.
|
||||
while body.hasSuffix("\n\n") {
|
||||
body.removeLast()
|
||||
}
|
||||
|
|
@ -466,17 +455,16 @@ class AppState: ObservableObject {
|
|||
writeNoteFile(text: textToSave, to: url)
|
||||
currentFileURL = url
|
||||
currentFileFormat = format
|
||||
// An explicit save-to-disk locks the autosave path to the same file
|
||||
// for the rest of the session — keystrokes after Save As shouldn't
|
||||
// start a fresh autosave file under the old name.
|
||||
// pins the autosave target, routing external destinations to a local shadow.
|
||||
if format.isMarkdown {
|
||||
autoSavePaths[currentNoteID] = url
|
||||
autoSavePaths[currentNoteID] = isAcordNote(url)
|
||||
? url
|
||||
: externalAutosaveShadowURL(for: url)
|
||||
}
|
||||
modified = false
|
||||
}
|
||||
|
||||
/// embeds the binary archive into in-library .md files; for everything else
|
||||
/// writes plain text and stashes the archive at <notes_dir>/.external/.
|
||||
/// writes note content to url, embedding the archive inline or stashing it externally.
|
||||
private func writeNoteFile(text: String, to url: URL) {
|
||||
let archive = takeArchiveBytesFromViewport?()
|
||||
let inLibrary = isAcordNote(url)
|
||||
|
|
@ -493,10 +481,7 @@ class AppState: ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
/// Project the in-memory `documentText` onto the right shape for an
|
||||
/// external file format. CSV gets converted from the markdown table,
|
||||
/// non-markdown formats get the sidecar archive comment stripped (the
|
||||
/// HTML comment isn't valid in .rs/.json/etc.), markdown passes through.
|
||||
/// projects documentText into the target format, converting CSV or stripping the archive comment.
|
||||
private func textForExternalSave(format: FileFormat) -> String {
|
||||
if format.isCSV { return markdownTableToCSV(documentText) }
|
||||
if format.isMarkdown { return documentText }
|
||||
|
|
@ -516,8 +501,9 @@ class AppState: ObservableObject {
|
|||
|
||||
guard let id = bridge.installDocument(text: text) else { return }
|
||||
|
||||
// routes external files to a local shadow under .external/.
|
||||
if format.isMarkdown {
|
||||
autoSavePaths[id] = url
|
||||
autoSavePaths[id] = inLibrary ? url : externalAutosaveShadowURL(for: url)
|
||||
}
|
||||
currentNoteID = id
|
||||
currentFileURL = url
|
||||
|
|
@ -680,12 +666,7 @@ class AppState: ObservableObject {
|
|||
evalResults = bridge.evaluate(currentNoteID)
|
||||
}
|
||||
|
||||
/// Write a caller-provided text snapshot to the notes directory,
|
||||
/// bypassing the `documentText` pipeline entirely. Used by the
|
||||
/// AppDelegate's 100ms autosave timer, which reads text directly
|
||||
/// from the viewport — routing through `documentText.didSet` would
|
||||
/// trip the Combine → `vp.setText` round-trip and wipe viewport
|
||||
/// state (including visible eval results).
|
||||
/// writes a caller-provided text snapshot to disk, bypassing the documentText pipeline.
|
||||
func writeAutosavedCopy(text: String) {
|
||||
let noteID = currentNoteID
|
||||
let url = resolveAutoSaveURL(noteID: noteID, text: text)
|
||||
|
|
|
|||
|
|
@ -5,10 +5,12 @@ class DocumentBrowserController {
|
|||
|
||||
let window: NSWindow
|
||||
private let view: IcedBrowserView
|
||||
private let appState: AppState
|
||||
|
||||
init(appState: AppState) {
|
||||
self.appState = appState
|
||||
/// forwards the picked note path to the caller.
|
||||
private let onOpenPath: (String) -> Void
|
||||
|
||||
init(onOpenPath: @escaping (String) -> Void) {
|
||||
self.onOpenPath = onOpenPath
|
||||
let dir = ConfigManager.shared.autoSaveDirectory
|
||||
let frame = NSRect(x: 0, y: 0, width: 900, height: 650)
|
||||
view = IcedBrowserView(frame: frame, notesDir: dir)
|
||||
|
|
@ -29,9 +31,8 @@ class DocumentBrowserController {
|
|||
|
||||
view.onOpenPath = { [weak self] path in
|
||||
guard let self = self else { return }
|
||||
let url = URL(fileURLWithPath: path)
|
||||
DispatchQueue.main.async {
|
||||
self.appState.loadNoteFromFile(url)
|
||||
self.onOpenPath(path)
|
||||
self.window.orderOut(nil)
|
||||
}
|
||||
}
|
||||
|
|
@ -46,7 +47,6 @@ class DocumentBrowserController {
|
|||
}
|
||||
}
|
||||
|
||||
/// true while the browser window is the focused window.
|
||||
var isKeyWindow: Bool { window.isKeyWindow }
|
||||
|
||||
/// forwards a numeric command to the embedded browser view.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,449 @@
|
|||
import Cocoa
|
||||
import Combine
|
||||
|
||||
class EditorWindow: NSObject {
|
||||
let window: NSWindow
|
||||
let appState: AppState
|
||||
let viewport: IcedViewportView
|
||||
private(set) var titleBarView: TitleBarView?
|
||||
|
||||
private var titleCancellable: AnyCancellable?
|
||||
private var textCancellable: AnyCancellable?
|
||||
private var autosaveTimer: Timer?
|
||||
private var lastAutosavedHash: Int?
|
||||
private var observers: [NSObjectProtocol] = []
|
||||
|
||||
init(frameAutosaveName: String?) {
|
||||
self.appState = AppState()
|
||||
self.viewport = IcedViewportView(frame: NSRect(x: 0, y: 0, width: 1200, height: 800))
|
||||
viewport.autoresizingMask = [.width, .height]
|
||||
|
||||
self.window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 1200, height: 800),
|
||||
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
window.isReleasedWhenClosed = false
|
||||
window.titlebarAppearsTransparent = true
|
||||
window.titleVisibility = .hidden
|
||||
window.backgroundColor = Theme.current.base
|
||||
window.title = "Acord"
|
||||
window.contentView = viewport
|
||||
if let autosave = frameAutosaveName {
|
||||
window.setFrameAutosaveName(autosave)
|
||||
}
|
||||
window.center()
|
||||
|
||||
super.init()
|
||||
|
||||
setupTitleBar()
|
||||
observeDocumentText()
|
||||
observeDocumentTitle()
|
||||
wireLoadedTextSync()
|
||||
syncThemeToViewport()
|
||||
syncGutterPrefsToViewport()
|
||||
syncSettingsToViewport()
|
||||
startAutosaveTimer()
|
||||
registerObservers()
|
||||
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
}
|
||||
|
||||
func loadFromURL(_ url: URL) {
|
||||
appState.loadNoteFromFile(url)
|
||||
viewport.setLang(url.pathExtension)
|
||||
}
|
||||
|
||||
// MARK: - Title bar
|
||||
|
||||
private func setupTitleBar() {
|
||||
let accessory = TitleBarAccessoryController()
|
||||
window.addTitlebarAccessoryViewController(accessory)
|
||||
|
||||
let tbv = accessory.titleView
|
||||
tbv.onCommit = { [weak self] rawTitle in
|
||||
guard let self = self else { return }
|
||||
let trimmed = rawTitle.trimmingCharacters(in: .whitespaces)
|
||||
let normalizedTitle: String
|
||||
if trimmed.isEmpty {
|
||||
normalizedTitle = ""
|
||||
} else if trimmed.hasPrefix("#") {
|
||||
normalizedTitle = trimmed
|
||||
} else {
|
||||
normalizedTitle = "# " + trimmed
|
||||
}
|
||||
|
||||
let lines = self.appState.documentText.components(separatedBy: "\n")
|
||||
let firstIsTitle = lines.first
|
||||
.map { $0.trimmingCharacters(in: .whitespaces).hasPrefix("#") }
|
||||
?? false
|
||||
let body: [String] = firstIsTitle ? Array(lines.dropFirst()) : lines
|
||||
|
||||
let newLines: [String]
|
||||
if normalizedTitle.isEmpty {
|
||||
newLines = body
|
||||
} else {
|
||||
newLines = [normalizedTitle] + body
|
||||
}
|
||||
self.appState.documentText = newLines.joined(separator: "\n")
|
||||
}
|
||||
|
||||
titleBarView = tbv
|
||||
}
|
||||
|
||||
// MARK: - Combine bindings
|
||||
|
||||
private func observeDocumentText() {
|
||||
textCancellable = appState.$documentText
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] text in
|
||||
guard let self = self else { return }
|
||||
if self.viewport.getText() == text { return }
|
||||
self.viewport.setText(text)
|
||||
}
|
||||
}
|
||||
|
||||
private func observeDocumentTitle() {
|
||||
titleCancellable = appState.$documentText
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] text in
|
||||
guard let self = self else { return }
|
||||
let firstLine = text.components(separatedBy: "\n").first?
|
||||
.trimmingCharacters(in: .whitespaces) ?? ""
|
||||
let clean = firstLine.replacingOccurrences(
|
||||
of: "^#+\\s*", with: "", options: .regularExpression
|
||||
)
|
||||
let displayTitle = clean.isEmpty ? "Acord" : String(clean.prefix(60))
|
||||
self.window.title = displayTitle
|
||||
self.titleBarView?.title = firstLine
|
||||
}
|
||||
}
|
||||
|
||||
private func wireLoadedTextSync() {
|
||||
appState.onLoadedTextChanged = { [weak self] text in
|
||||
guard let self = self else { return }
|
||||
if self.viewport.getText() != text {
|
||||
self.viewport.setText(text)
|
||||
}
|
||||
self.lastAutosavedHash = text.hashValue
|
||||
}
|
||||
appState.takeArchiveBytesFromViewport = { [weak self] in
|
||||
self?.viewport.takeSidecarBytes()
|
||||
}
|
||||
appState.applyArchiveBytesToViewport = { [weak self] data in
|
||||
self?.viewport.applySidecarBytes(data)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Notifications
|
||||
|
||||
private func registerObservers() {
|
||||
let settings = NotificationCenter.default.addObserver(
|
||||
forName: .settingsChanged, object: nil, queue: .main
|
||||
) { [weak self] _ in
|
||||
self?.applySettingsChange()
|
||||
}
|
||||
observers.append(settings)
|
||||
|
||||
let newNote = NotificationCenter.default.addObserver(
|
||||
forName: .newNoteSeeded, object: appState, queue: .main
|
||||
) { [weak self] _ in
|
||||
self?.viewport.sendCommand(12)
|
||||
}
|
||||
observers.append(newNote)
|
||||
|
||||
let focusTitle = NotificationCenter.default.addObserver(
|
||||
forName: .focusTitle, object: nil, queue: .main
|
||||
) { [weak self] _ in
|
||||
guard let self = self, self.window.isKeyWindow else { return }
|
||||
self.titleBarView?.beginEditing()
|
||||
}
|
||||
observers.append(focusTitle)
|
||||
}
|
||||
|
||||
private func applySettingsChange() {
|
||||
window.backgroundColor = Theme.current.base
|
||||
syncThemeToViewport()
|
||||
syncGutterPrefsToViewport()
|
||||
syncSettingsToViewport()
|
||||
window.contentView?.needsDisplay = true
|
||||
}
|
||||
|
||||
// MARK: - Viewport sync
|
||||
|
||||
func syncSettingsToViewport() {
|
||||
viewport.setSettingsView(
|
||||
themeMode: ConfigManager.shared.themeMode,
|
||||
lineIndicator: ConfigManager.shared.lineIndicatorMode,
|
||||
gutterRainbow: ConfigManager.shared.gutterRainbow,
|
||||
autoSaveDir: ConfigManager.shared.autoSaveDirectory
|
||||
)
|
||||
}
|
||||
|
||||
func syncThemeToViewport() {
|
||||
let mode = ConfigManager.shared.themeMode
|
||||
let name: String
|
||||
switch mode {
|
||||
case "dark": name = "kicad"
|
||||
case "light": name = "latte"
|
||||
default:
|
||||
let appearance = NSApp.effectiveAppearance
|
||||
let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua
|
||||
name = isDark ? "kicad" : "latte"
|
||||
}
|
||||
viewport.setTheme(name)
|
||||
}
|
||||
|
||||
func syncGutterPrefsToViewport() {
|
||||
viewport.setLineIndicator(ConfigManager.shared.lineIndicatorMode)
|
||||
viewport.setGutterRainbow(ConfigManager.shared.gutterRainbow)
|
||||
viewport.setAutoPairFlags(ConfigManager.shared.autoPairFlags)
|
||||
}
|
||||
|
||||
func syncTextFromViewport() {
|
||||
let text = viewport.getText()
|
||||
if !text.isEmpty || appState.documentText.isEmpty {
|
||||
appState.documentText = text
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Autosave loop
|
||||
|
||||
private func startAutosaveTimer() {
|
||||
autosaveTimer?.invalidate()
|
||||
autosaveTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in
|
||||
self?.drainShellActions()
|
||||
self?.persistViewportToNotesDir()
|
||||
}
|
||||
}
|
||||
|
||||
private func drainShellActions() {
|
||||
while let raw = viewport.takeShellAction() {
|
||||
let parts = raw.split(separator: ":", maxSplits: 1).map(String.init)
|
||||
let kind = parts[0]
|
||||
let value = parts.count > 1 ? parts[1] : ""
|
||||
handleShellAction(kind: kind, value: value)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleShellAction(kind: String, value: String) {
|
||||
switch kind {
|
||||
case "new_note": newNote()
|
||||
case "open": openNotePanel()
|
||||
case "save": saveNote()
|
||||
case "save_as": saveNoteAs()
|
||||
case "quit": NSApp.terminate(nil)
|
||||
case "settings": break
|
||||
case "export_crate": exportCrate()
|
||||
case "toggle_browser": DocumentBrowserController.shared?.toggle()
|
||||
case "set_theme_mode":
|
||||
ConfigManager.shared.themeMode = value
|
||||
NotificationCenter.default.post(name: .settingsChanged, object: nil)
|
||||
case "set_line_indicator":
|
||||
ConfigManager.shared.lineIndicatorMode = value
|
||||
NotificationCenter.default.post(name: .settingsChanged, object: nil)
|
||||
case "set_gutter_rainbow":
|
||||
ConfigManager.shared.gutterRainbow = (value == "true")
|
||||
NotificationCenter.default.post(name: .settingsChanged, object: nil)
|
||||
case "pick_auto_save_dir":
|
||||
pickAutoSaveDirectory()
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func persistViewportToNotesDir() {
|
||||
let text = viewport.getText()
|
||||
guard !AppState.isEffectivelyBlank(text) else { return }
|
||||
let hash = text.hashValue
|
||||
if hash == lastAutosavedHash { return }
|
||||
appState.writeAutosavedCopy(text: text)
|
||||
lastAutosavedHash = hash
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
func newNote() {
|
||||
appState.newNote()
|
||||
viewport.setLang("")
|
||||
}
|
||||
|
||||
func saveNote() {
|
||||
syncTextFromViewport()
|
||||
appState.bindAutoSaveURL()
|
||||
appState.saveNote()
|
||||
}
|
||||
|
||||
func saveNoteAs() {
|
||||
syncTextFromViewport()
|
||||
let panel = NSSavePanel()
|
||||
panel.allowedContentTypes = AppDelegate.supportedContentTypes
|
||||
panel.nameFieldStringValue = defaultFilename()
|
||||
if let url = appState.currentFileURL {
|
||||
panel.directoryURL = url.deletingLastPathComponent()
|
||||
panel.nameFieldStringValue = url.lastPathComponent
|
||||
}
|
||||
panel.beginSheetModal(for: window) { [weak self] response in
|
||||
guard response == .OK, let url = panel.url else { return }
|
||||
self?.appState.saveNoteToFile(url)
|
||||
}
|
||||
}
|
||||
|
||||
/// presents the file picker and forwards the chosen URL to the AppDelegate router.
|
||||
func openNotePanel() {
|
||||
let panel = NSOpenPanel()
|
||||
panel.allowedContentTypes = AppDelegate.supportedContentTypes
|
||||
panel.canChooseFiles = true
|
||||
panel.canChooseDirectories = false
|
||||
panel.allowsMultipleSelection = false
|
||||
panel.beginSheetModal(for: window) { response in
|
||||
guard response == .OK, let url = panel.url else { return }
|
||||
(NSApp.delegate as? AppDelegate)?.openInNewWindow(url)
|
||||
}
|
||||
}
|
||||
|
||||
func printNote() {
|
||||
guard let handle = viewport.viewportHandle else { return }
|
||||
syncTextFromViewport()
|
||||
|
||||
let title = appState.currentFileURL?.deletingPathExtension().lastPathComponent ?? "Acord Document"
|
||||
var len: UInt = 0
|
||||
guard let ptr = title.withCString({ t in viewport_render_pdf(handle, t, &len) }), len > 0 else {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Print failed"
|
||||
alert.informativeText = "Could not render this document to PDF."
|
||||
alert.runModal()
|
||||
return
|
||||
}
|
||||
let data = Data(bytes: ptr, count: Int(len))
|
||||
viewport_free_bytes(ptr, len)
|
||||
|
||||
let panel = NSSavePanel()
|
||||
panel.title = "Print to PDF"
|
||||
panel.prompt = "Save"
|
||||
panel.allowedContentTypes = [.pdf]
|
||||
panel.nameFieldStringValue = "\(title).pdf"
|
||||
panel.beginSheetModal(for: window) { response in
|
||||
guard response == .OK, let url = panel.url else { return }
|
||||
do {
|
||||
try data.write(to: url)
|
||||
NSWorkspace.shared.open(url)
|
||||
} catch {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Print failed"
|
||||
alert.informativeText = error.localizedDescription
|
||||
alert.runModal()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func exportCrate() {
|
||||
syncTextFromViewport()
|
||||
guard let handle = viewport.viewportHandle else { return }
|
||||
|
||||
let panel = NSSavePanel()
|
||||
panel.title = "Export as Rust Library"
|
||||
panel.message = "Choose a location and name for your exported crate"
|
||||
panel.prompt = "Export"
|
||||
panel.nameFieldLabel = "Crate name:"
|
||||
panel.nameFieldStringValue = defaultCrateName()
|
||||
panel.canCreateDirectories = true
|
||||
|
||||
panel.beginSheetModal(for: window) { [weak self] response in
|
||||
guard response == .OK, let url = panel.url else { return }
|
||||
let parentDir = url.deletingLastPathComponent().path
|
||||
let name = url.lastPathComponent
|
||||
parentDir.withCString { pd in
|
||||
name.withCString { n in
|
||||
if let cstr = viewport_export_crate(handle, pd, n) {
|
||||
let resultPath = String(cString: cstr)
|
||||
viewport_free_string(cstr)
|
||||
self?.notifyExportComplete(at: resultPath)
|
||||
} else {
|
||||
self?.notifyExportFailed()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func pickAutoSaveDirectory() {
|
||||
let panel = NSOpenPanel()
|
||||
panel.canChooseFiles = false
|
||||
panel.canChooseDirectories = true
|
||||
panel.canCreateDirectories = true
|
||||
panel.allowsMultipleSelection = false
|
||||
panel.directoryURL = URL(fileURLWithPath: ConfigManager.shared.autoSaveDirectory)
|
||||
panel.beginSheetModal(for: window) { response in
|
||||
guard response == .OK, let url = panel.url else { return }
|
||||
ConfigManager.shared.autoSaveDirectory = url.path
|
||||
NotificationCenter.default.post(name: .settingsChanged, object: nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func notifyExportComplete(at path: String) {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Export complete"
|
||||
alert.informativeText = "Crate written to:\n\(path)\n\nCheck the README for build and install instructions."
|
||||
alert.addButton(withTitle: "Reveal in Finder")
|
||||
alert.addButton(withTitle: "OK")
|
||||
if alert.runModal() == .alertFirstButtonReturn {
|
||||
NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: path)])
|
||||
}
|
||||
}
|
||||
|
||||
private func notifyExportFailed() {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Export failed"
|
||||
alert.informativeText = "Could not export the note. Check the folder permissions and that the crate name doesn't collide with an existing folder."
|
||||
alert.addButton(withTitle: "OK")
|
||||
alert.runModal()
|
||||
}
|
||||
|
||||
private func defaultCrateName() -> String {
|
||||
let firstLine = appState.documentText
|
||||
.components(separatedBy: "\n").first?
|
||||
.trimmingCharacters(in: .whitespaces) ?? ""
|
||||
let stripped = firstLine.replacingOccurrences(
|
||||
of: "^#+\\s*", with: "", options: .regularExpression
|
||||
)
|
||||
let words = stripped.split(separator: " ").prefix(2).joined(separator: "-")
|
||||
let sanitized = words.lowercased()
|
||||
.map { $0.isLetter || $0.isNumber || $0 == "-" ? String($0) : "" }.joined()
|
||||
return sanitized.isEmpty ? "my-note" : sanitized
|
||||
}
|
||||
|
||||
private func defaultFilename() -> String {
|
||||
if let url = appState.currentFileURL {
|
||||
return url.lastPathComponent
|
||||
}
|
||||
let firstLine = appState.documentText
|
||||
.components(separatedBy: "\n").first?
|
||||
.trimmingCharacters(in: .whitespaces) ?? ""
|
||||
let stripped = firstLine.replacingOccurrences(
|
||||
of: "^#+\\s*", with: "", options: .regularExpression
|
||||
)
|
||||
let trimmed = stripped.trimmingCharacters(in: .whitespaces)
|
||||
let ext = AppDelegate.extensionForFormat(appState.currentFileFormat)
|
||||
guard !trimmed.isEmpty, trimmed != "Untitled" else { return "note.\(ext)" }
|
||||
let sanitized = trimmed.map { "/:\\\\".contains($0) ? "-" : String($0) }.joined()
|
||||
return sanitized.prefix(80) + ".\(ext)"
|
||||
}
|
||||
|
||||
// MARK: - Teardown
|
||||
|
||||
func teardown() {
|
||||
autosaveTimer?.invalidate()
|
||||
autosaveTimer = nil
|
||||
for observer in observers {
|
||||
NotificationCenter.default.removeObserver(observer)
|
||||
}
|
||||
observers = []
|
||||
titleCancellable = nil
|
||||
textCancellable = nil
|
||||
viewport.teardown()
|
||||
}
|
||||
}
|
||||
|
|
@ -5,9 +5,7 @@ class IcedViewportView: NSView {
|
|||
private(set) var viewportHandle: OpaquePointer?
|
||||
private var displayLink: CVDisplayLink?
|
||||
private var isTornDown = false
|
||||
// Last text pulled out of Rust. Refreshed on every edit tick via the
|
||||
// render loop, so terminate/save paths can read a current-enough value
|
||||
// without touching the viewport once teardown has begun.
|
||||
// latest text snapshot from Rust, read each render tick
|
||||
private var cachedText: String = ""
|
||||
|
||||
override init(frame frameRect: NSRect) {
|
||||
|
|
@ -51,10 +49,7 @@ class IcedViewportView: NSView {
|
|||
viewport_destroy(handle)
|
||||
}
|
||||
|
||||
/// Ordered shutdown: stop the display link first (joins in-flight CV
|
||||
/// callbacks), then snapshot the text into `cachedText`, then drop the
|
||||
/// Rust handle. Idempotent — safe to call from both the terminate hook
|
||||
/// and `deinit`.
|
||||
/// stops display link, snapshots text, drops the Rust handle. idempotent.
|
||||
func teardown() {
|
||||
if isTornDown { return }
|
||||
isTornDown = true
|
||||
|
|
@ -146,9 +141,7 @@ class IcedViewportView: NSView {
|
|||
override func mouseDragged(with event: NSEvent) {
|
||||
guard let h = viewportHandle else { return }
|
||||
let pt = convert(event.locationInWindow, from: nil)
|
||||
// Use the 255 sentinel — pointer move only, no button event. mouseDown
|
||||
// already fired ButtonPressed; sending another one per drag tick would
|
||||
// restart iced's selection on every frame and make click+drag twitch.
|
||||
// 255 sentinel = pointer move only, no button state change
|
||||
viewport_mouse_event(h, Float(pt.x), Float(pt.y), 255, false)
|
||||
}
|
||||
|
||||
|
|
@ -241,7 +234,7 @@ class IcedViewportView: NSView {
|
|||
return result
|
||||
}
|
||||
|
||||
/// drains the document's archive zip bytes for embed-on-save.
|
||||
/// drains the document's archive zip bytes.
|
||||
func takeSidecarBytes() -> Data? {
|
||||
if isTornDown { return nil }
|
||||
guard let h = viewportHandle else { return nil }
|
||||
|
|
@ -275,6 +268,14 @@ class IcedViewportView: NSView {
|
|||
}
|
||||
}
|
||||
|
||||
/// configures the syntax highlighter to a file extension or clears when empty.
|
||||
func setLang(_ ext: String) {
|
||||
guard let h = viewportHandle else { return }
|
||||
ext.withCString { cstr in
|
||||
viewport_set_lang(h, cstr)
|
||||
}
|
||||
}
|
||||
|
||||
func setLineIndicator(_ mode: String) {
|
||||
guard let h = viewportHandle else { return }
|
||||
mode.withCString { cstr in
|
||||
|
|
|
|||
|
|
@ -90,8 +90,7 @@ class RustBridge {
|
|||
return (id, text)
|
||||
}
|
||||
|
||||
/// installs a doc from already-decoded text — used when the shell read raw bytes
|
||||
/// itself (so it could split off the binary archive trailer) and just needs a UUID.
|
||||
/// installs a doc from already-decoded text and returns a UUID.
|
||||
func installDocument(text: String) -> UUID? {
|
||||
guard let ptr = acord_doc_new() else { return nil }
|
||||
text.withCString { cstr in
|
||||
|
|
|
|||
|
|
@ -1,9 +1,6 @@
|
|||
import Cocoa
|
||||
|
||||
// The delegate is stored in a static so it outlives `app.run()`. NSApplication
|
||||
// holds its delegate weakly; if the compiler decides the local `delegate`
|
||||
// binding isn't needed past `app.delegate = ...` it can be released early,
|
||||
// tearing down state mid-run. A static keeps a concrete strong reference.
|
||||
// static strong ref prevents the weakly-held delegate from early release
|
||||
enum AcordAppMain {
|
||||
static let delegate = AppDelegate()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,5 @@
|
|||
#!/usr/bin/env bash
|
||||
# Sourced by build / install / debug scripts to redirect cargo's target dir
|
||||
# to the boot SSD instead of the repo's external spinning disk.
|
||||
# Scripts read compiled artifacts from $CARGO_TARGET_DIR and copy the final
|
||||
# .app / .exe / binary into $ROOT/build/ as real files at the end.
|
||||
#
|
||||
# Override the SSD location with: export CARGO_TARGET_DIR=/some/other/path
|
||||
# redirects CARGO_TARGET_DIR to the boot SSD instead of the repo's external disk
|
||||
|
||||
if [ -n "${ACORD_BUILD_DIRS_DONE:-}" ]; then
|
||||
return 0 2>/dev/null || exit 0
|
||||
|
|
|
|||
|
|
@ -1,15 +1,12 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Stub — the Android shell hasn't been started yet.
|
||||
# When it lands, this should mirror scripts/ios/select.sh: list `adb devices`
|
||||
# entries plus available emulators (`emulator -list-avds`), let the user
|
||||
# pick one, and write the choice to $HOME/.acord/android-target.
|
||||
# stub -- Android shell not yet implemented
|
||||
|
||||
cat <<'EOF' >&2
|
||||
android select is a stub — the Android shell isn't built yet.
|
||||
android select is a stub -- the Android shell has not been built yet.
|
||||
|
||||
when it ships, this will:
|
||||
planned behavior:
|
||||
- list connected devices (adb devices)
|
||||
- list available emulators (emulator -list-avds)
|
||||
- write the picked target to $HOME/.acord/android-target
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ case "$(uname -s)" in
|
|||
*) echo "wrong platform: $(uname -s) — iOS build requires macOS" >&2; exit 1;;
|
||||
esac
|
||||
|
||||
# Default to simulator; override with `bash scripts/ios/build.sh device`.
|
||||
# defaults to simulator, pass "device" for physical hardware
|
||||
TARGET="${1:-sim}"
|
||||
|
||||
case "$TARGET" in
|
||||
|
|
@ -30,8 +30,7 @@ case "$TARGET" in
|
|||
;;
|
||||
esac
|
||||
|
||||
# debug.sh sets ACORD_IOS_CONFIG=debug to flip cargo to the dev profile and
|
||||
# pass -D DEBUG / -Onone / -g to swiftc. install.sh leaves it unset → release.
|
||||
# ACORD_IOS_CONFIG=debug flips to dev profile + debug swiftc flags
|
||||
CONFIG="${ACORD_IOS_CONFIG:-release}"
|
||||
case "$CONFIG" in
|
||||
release)
|
||||
|
|
@ -56,7 +55,7 @@ RUST_LIB="$CARGO_TARGET_DIR/$RUST_TARGET/$CARGO_PROFILE_DIR"
|
|||
|
||||
SDK="$(xcrun --sdk "$SDK_NAME" --show-sdk-path)"
|
||||
|
||||
# the user has esp-clang on PATH; fall through to apple's so cc-rs picks the right one.
|
||||
# force apple clang over esp-clang on PATH
|
||||
export CC=/usr/bin/clang
|
||||
export CXX=/usr/bin/clang++
|
||||
export IPHONEOS_DEPLOYMENT_TARGET=17.0
|
||||
|
|
@ -69,14 +68,12 @@ if [ ! -f "$RUST_LIB/libacord_viewport.a" ]; then
|
|||
exit 1
|
||||
fi
|
||||
|
||||
# build app bundle (iOS bundles are flat — Info.plist, executable and resources live at the root)
|
||||
# build flat app bundle
|
||||
rm -rf "$APP"
|
||||
mkdir -p "$APP"
|
||||
cp "$ROOT/ios/Info.plist" "$APP/Info.plist"
|
||||
|
||||
# asset catalog → compiled Assets.car + partial Info.plist via actool.
|
||||
# regenerate the source PNGs from the SVG so a fresh checkout doesn't need
|
||||
# a manual icon-rebuild step.
|
||||
# regenerate icon PNGs from SVG, compile asset catalog via actool
|
||||
bash "$ROOT/scripts/ios/generate-icons.sh"
|
||||
|
||||
ACTOOL_PARTIAL="$BUILD/ios/actool-partial-info.plist"
|
||||
|
|
@ -135,9 +132,7 @@ else
|
|||
| plutil -extract Entitlements xml1 -o "$ENT" - \
|
||||
|| { echo "ERROR: could not extract entitlements from profile" >&2; exit 1; }
|
||||
|
||||
# find a codesigning identity that's in the profile's DeveloperCertificates list.
|
||||
# we pull each cert's SHA1 out of the profile, then pick whichever one find-identity
|
||||
# also lists as valid in the keychain. fail loudly if none match.
|
||||
# match a keychain identity against the profile's DeveloperCertificates
|
||||
TMPDIR_PROF="$(mktemp -d)"
|
||||
PROFILE_PLIST="$TMPDIR_PROF/profile.plist"
|
||||
security cms -D -i "$PROFILE" > "$PROFILE_PLIST" 2>/dev/null
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Builds debug, installs to the chosen target, and launches with stdio attached
|
||||
# so Swift print() AND Rust eprintln!() (via captureStderr in AcordApp) stream
|
||||
# straight into this terminal.
|
||||
# debug build + install + launch with stdio attached
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
source "$ROOT/scripts/_build-dirs.sh"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
#!/usr/bin/env bash
|
||||
# Generates ios/Assets.xcassets/AppIcon.appiconset/ from assets/Acord.svg.
|
||||
# Used by both the CLI build (build.sh) and the Xcode project path
|
||||
# (xcodeproj.sh). Idempotent — re-running just overwrites.
|
||||
# generates ios app icon assets from assets/Acord.svg at all required sizes.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
|
|
@ -21,8 +19,7 @@ fi
|
|||
|
||||
mkdir -p "$APPICON"
|
||||
|
||||
# (filename, pixel size) pairs covering iPhone + iPad icon slots through iOS 17.
|
||||
# 1024 is the marketing icon; the rest are point-size@scale variants.
|
||||
# filename and pixel size pairs for iPhone + iPad icon slots through iOS 17.
|
||||
SIZES=(
|
||||
"Icon-20.png 20"
|
||||
"Icon-20@2x.png 40"
|
||||
|
|
@ -47,7 +44,7 @@ for entry in "${SIZES[@]}"; do
|
|||
rsvg-convert --width="$size" --height="$size" "$SVG" -o "$APPICON/$name"
|
||||
done
|
||||
|
||||
# Top-level Assets.xcassets/Contents.json (xcode requires it even if empty-ish).
|
||||
# top-level Assets.xcassets/Contents.json required by xcode.
|
||||
cat > "$ASSETS/Contents.json" <<'EOF'
|
||||
{
|
||||
"info" : {
|
||||
|
|
@ -57,7 +54,7 @@ cat > "$ASSETS/Contents.json" <<'EOF'
|
|||
}
|
||||
EOF
|
||||
|
||||
# AppIcon.appiconset/Contents.json — maps every Icon-*.png to its slot.
|
||||
# AppIcon.appiconset/Contents.json mapping each Icon-*.png to a slot.
|
||||
cat > "$APPICON/Contents.json" <<'EOF'
|
||||
{
|
||||
"images" : [
|
||||
|
|
|
|||
|
|
@ -7,10 +7,7 @@ cd "$ROOT"
|
|||
|
||||
CONFIG_FILE="$HOME/.acord/ios-target"
|
||||
|
||||
# resolve target. priority:
|
||||
# 1. explicit cli arg ("sim" / "device")
|
||||
# 2. saved selection from `cargo xtask select-ios`
|
||||
# 3. auto-detect (paired physical device wins)
|
||||
# resolve target from cli arg, saved config, or auto-detect.
|
||||
TARGET=""
|
||||
SELECTED_ID=""
|
||||
SELECTED_LABEL=""
|
||||
|
|
@ -60,7 +57,7 @@ case "$TARGET" in
|
|||
fi
|
||||
fi
|
||||
|
||||
# boot it if it isn't already.
|
||||
# boot the simulator if not already running.
|
||||
STATE="$(xcrun simctl list devices | awk -v id="$DEV" '$0 ~ id { for (i=1; i<=NF; i++) if ($i ~ /^\(/) state=$i } END { gsub(/[()]/, "", state); print state }')"
|
||||
if [ "$STATE" != "Booted" ]; then
|
||||
xcrun simctl boot "$DEV" 2>/dev/null || true
|
||||
|
|
|
|||
|
|
@ -1,10 +1,7 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Interactive picker for the iOS deploy target. Lists every paired physical
|
||||
# device and every available iPad simulator, lets the user pick one, then
|
||||
# stores the choice at $HOME/.acord/ios-target so install.sh / debug.sh can
|
||||
# read it back without prompting again.
|
||||
# pick an iOS deploy target and persist the selection to $HOME/.acord/ios-target.
|
||||
|
||||
CONFIG_DIR="$HOME/.acord"
|
||||
CONFIG_FILE="$CONFIG_DIR/ios-target"
|
||||
|
|
@ -14,7 +11,7 @@ ALL_FILE="$(mktemp)"
|
|||
trap 'rm -f "$ALL_FILE"' EXIT
|
||||
|
||||
echo "Scanning paired devices..."
|
||||
# devicectl columns are aligned with 2+ spaces. fields: name | host | uuid | state | model
|
||||
# devicectl columns aligned with 2+ spaces: name | host | uuid | state | model
|
||||
xcrun devicectl list devices 2>/dev/null \
|
||||
| awk -F' +' '/available \(paired\)/ {
|
||||
gsub(/^[[:space:]]+|[[:space:]]+$/, "", $1)
|
||||
|
|
@ -24,8 +21,7 @@ xcrun devicectl list devices 2>/dev/null \
|
|||
}' >> "$ALL_FILE" || true
|
||||
|
||||
echo "Scanning iPad simulators..."
|
||||
# simctl line: " iPad Pro 11-inch (M4) (UUID) (Shutdown)"
|
||||
# strip whitespace, peel off "(state)" then "(uuid)" from the right.
|
||||
# simctl line format: " iPad Pro 11-inch (M4) (UUID) (Shutdown)"
|
||||
xcrun simctl list devices available 2>/dev/null \
|
||||
| awk '/iPad/ {
|
||||
line=$0
|
||||
|
|
|
|||
|
|
@ -11,17 +11,17 @@ if ! command -v xcodegen >/dev/null 2>&1; then
|
|||
exit 1
|
||||
fi
|
||||
|
||||
# the user has esp-clang on PATH; make sure cc-rs picks apple's.
|
||||
# force apple clang over esp-clang on PATH.
|
||||
export CC=/usr/bin/clang
|
||||
export CXX=/usr/bin/clang++
|
||||
export IPHONEOS_DEPLOYMENT_TARGET=17.0
|
||||
|
||||
# build the staticlibs xcode will link against — both arches so xcode can target either.
|
||||
# build staticlibs for both iOS targets (device + sim).
|
||||
echo "Building Rust staticlibs for both iOS targets (release)..."
|
||||
cargo build --release --target aarch64-apple-ios -p acord-viewport
|
||||
cargo build --release --target aarch64-apple-ios-sim -p acord-viewport
|
||||
|
||||
# regenerate the asset catalog so xcode picks up the latest svg.
|
||||
# regenerate the asset catalog from the latest svg.
|
||||
bash "$ROOT/scripts/ios/generate-icons.sh"
|
||||
|
||||
cd "$ROOT/ios"
|
||||
|
|
|
|||
|
|
@ -10,17 +10,12 @@ case "$(uname -s)" in
|
|||
*) echo "wrong platform: $(uname -s) — use cargo xtask build" >&2; exit 1;;
|
||||
esac
|
||||
|
||||
# Pick the winit backend(s). Default builds enable both x11 and wayland so a
|
||||
# single binary works on either; ACORD_FEATURES overrides for cases where
|
||||
# only one backend is available (flatpak, stripped distros, debugging one
|
||||
# backend in isolation).
|
||||
# pick winit backend(s), defaulting to both x11 and wayland unless ACORD_FEATURES overrides.
|
||||
detect_features() {
|
||||
if [ -n "${ACORD_FEATURES:-}" ]; then
|
||||
echo "$ACORD_FEATURES"
|
||||
return
|
||||
fi
|
||||
# No detection — both backends are linked by default. Override only when
|
||||
# you need to force one.
|
||||
echo ""
|
||||
}
|
||||
|
||||
|
|
@ -41,7 +36,7 @@ mkdir -p "$STAGE"
|
|||
cp "$CARGO_TARGET_DIR/release/acord" "$STAGE/Acord"
|
||||
chmod +x "$STAGE/Acord"
|
||||
|
||||
# Rasterize the SVG icon next to the binary so load_window_icon picks it up.
|
||||
# rasterize the SVG icon next to the binary.
|
||||
if command -v rsvg-convert >/dev/null 2>&1 && [ -f "$ROOT/assets/Acord.svg" ]; then
|
||||
rsvg-convert --width 256 --height 256 "$ROOT/assets/Acord.svg" -o "$STAGE/icon.png"
|
||||
else
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Debug build — same wiring as build.sh but unoptimised, with -g, and
|
||||
# launched in the foreground so Rust panics print straight to this terminal.
|
||||
# unoptimised build with -g, launched in the foreground.
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
source "$ROOT/scripts/_build-dirs.sh"
|
||||
|
|
@ -23,7 +22,7 @@ fi
|
|||
|
||||
EXE="$CARGO_TARGET_DIR/debug/acord"
|
||||
|
||||
# Rasterize the icon next to the exe so the dev binary has a window icon too.
|
||||
# rasterize the icon next to the exe.
|
||||
if command -v rsvg-convert >/dev/null 2>&1 && [ -f "$ROOT/assets/Acord.svg" ]; then
|
||||
rsvg-convert --width 256 --height 256 "$ROOT/assets/Acord.svg" -o "$CARGO_TARGET_DIR/debug/icon.png"
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -12,15 +12,14 @@ esac
|
|||
|
||||
bash "$ROOT/scripts/linux/build.sh"
|
||||
|
||||
# XDG-correct install: binary into ~/.local/bin (PATH on most distros), icon
|
||||
# + .desktop into ~/.local/share for the launcher menu.
|
||||
# XDG-correct install: binary, icon, and .desktop entry into ~/.local/.
|
||||
BIN_DIR="${XDG_BIN_HOME:-$HOME/.local/bin}"
|
||||
APP_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/applications"
|
||||
ICON_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/icons/hicolor/256x256/apps"
|
||||
|
||||
mkdir -p "$BIN_DIR" "$APP_DIR" "$ICON_DIR"
|
||||
|
||||
# Kill running instance before replacing the binary.
|
||||
# kill running instance before replacing the binary.
|
||||
pkill -x Acord 2>/dev/null || true
|
||||
sleep 0.3
|
||||
|
||||
|
|
@ -42,8 +41,7 @@ Categories=Utility;TextEditor;Office;
|
|||
MimeType=text/markdown;text/plain;
|
||||
EOF
|
||||
|
||||
# Update the desktop database so the launcher picks up the new entry. Quiet
|
||||
# fallback if the tool isn't installed.
|
||||
# update the desktop database, skipping quietly if the tool isn't installed.
|
||||
if command -v update-desktop-database >/dev/null 2>&1; then
|
||||
update-desktop-database "$APP_DIR" >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ lipo -create \
|
|||
"$CARGO_TARGET_DIR/x86_64-apple-darwin/release/libacord_viewport.a" \
|
||||
-output "$CARGO_TARGET_DIR/universal/libacord_viewport.a"
|
||||
|
||||
# TODO: regenerate AppIcon.icns from assets/Acord.svg here (see build.sh).
|
||||
# TODO(icon): regenerate AppIcon.icns from assets/Acord.svg here.
|
||||
|
||||
mkdir -p "$MACOS" "$RESOURCES"
|
||||
cp "$ROOT/macos/Info.plist" "$CONTENTS/Info.plist"
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Debug build — same wiring as build.sh but unoptimised, with -g, and
|
||||
# launched in the foreground so Rust panics print straight to this terminal
|
||||
# (the panic hook in viewport/src/lib.rs flushes stderr before SIGABRT).
|
||||
# unoptimised build with -g, launched in the foreground.
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
source "$ROOT/scripts/_build-dirs.sh"
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ DEST="/Applications/Acord.app"
|
|||
|
||||
bash "$ROOT/scripts/macos/build.sh"
|
||||
|
||||
# Kill running instance before replacing.
|
||||
# kill running instance
|
||||
pkill -f "Acord.app/Contents/MacOS/Acord" 2>/dev/null || true
|
||||
sleep 0.5
|
||||
|
||||
|
|
|
|||
|
|
@ -1,21 +1,7 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Cross-compile + zip distributables from a single macOS host.
|
||||
#
|
||||
# Six targets:
|
||||
# macos-aarch64 macos-x86_64
|
||||
# windows-aarch64 windows-x86_64
|
||||
# linux-aarch64 linux-x86_64
|
||||
#
|
||||
# Output: dist/acord-<target>.zip per target.
|
||||
#
|
||||
# Tooling:
|
||||
# - rustup, swiftc, zip, codesign — assumed present on a dev mac
|
||||
# - rsvg-convert — brew install librsvg (for the macOS app icon)
|
||||
# - zig + cargo-zigbuild — used for windows/linux cross-compile
|
||||
# brew install zig
|
||||
# cargo install cargo-zigbuild
|
||||
# cross-compile + zip distributables for all six platform targets from a macOS host
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
source "$ROOT/scripts/_build-dirs.sh"
|
||||
|
|
@ -26,7 +12,7 @@ case "$(uname -s)" in
|
|||
*) echo "package.sh: macOS host only (need swiftc + codesign)" >&2; exit 1;;
|
||||
esac
|
||||
|
||||
# raises the per-process file descriptor limit for the linker
|
||||
# raise per-process file descriptor limit
|
||||
ulimit -n 65536 2>/dev/null || ulimit -n 8192 2>/dev/null || true
|
||||
|
||||
ALL_TARGETS=(
|
||||
|
|
@ -78,8 +64,7 @@ PKG="$ROOT/build/package"
|
|||
DIST="$ROOT/dist"
|
||||
mkdir -p "$PKG" "$DIST"
|
||||
|
||||
# Generate a 256px PNG once, reused by Windows + Linux. macOS uses a separate
|
||||
# .icns set generated below.
|
||||
# generate a 256px PNG for Windows + Linux icons
|
||||
ICON_PNG="$ROOT/build/icon.png"
|
||||
if [ ! -f "$ICON_PNG" ] || [ "$ROOT/assets/Acord.svg" -nt "$ICON_PNG" ]; then
|
||||
if command -v rsvg-convert >/dev/null 2>&1 && [ -f "$ROOT/assets/Acord.svg" ]; then
|
||||
|
|
@ -207,8 +192,7 @@ build_linux() {
|
|||
|
||||
echo "==> Linux $arch (${rust_target}.2.17 via cargo-zigbuild — both x11+wayland linked)"
|
||||
|
||||
# The .2.17 suffix targets glibc 2.17 (CentOS 7 baseline) for max distro
|
||||
# compatibility. zigbuild handles the symbol versioning via zig cc.
|
||||
# .2.17 suffix pins glibc 2.17 (CentOS 7 baseline)
|
||||
cargo zigbuild --release -p acord-linux --target "${rust_target}.2.17"
|
||||
|
||||
local stage="$PKG/linux-${arch}/acord"
|
||||
|
|
@ -221,9 +205,7 @@ build_linux() {
|
|||
[ -f "$ROOT/LICENCE" ] && cp "$ROOT/LICENCE" "$stage/LICENCE"
|
||||
[ -f "$ROOT/README.md" ] && cp "$ROOT/README.md" "$stage/README.md"
|
||||
|
||||
# Self-contained installer the user runs after unzipping. Outer EOF is
|
||||
# single-quoted so $BIN_DIR / $HERE / etc. survive into the install
|
||||
# script verbatim and expand only when the user runs it.
|
||||
# single-quoted heredoc preserves $ expansions for the generated install script
|
||||
cat > "$stage/install.sh" <<'INSTALLER_EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
|
|
|||
|
|
@ -10,8 +10,7 @@ if ($LASTEXITCODE -ne 0) { throw "cargo build failed" }
|
|||
$exe = Join-Path $root "target\release\acord.exe"
|
||||
if (-not (Test-Path $exe)) { throw "binary not found at $exe" }
|
||||
|
||||
# Rasterize the SVG icon next to the exe so load_window_icon picks it up.
|
||||
# Falls back silently if rsvg-convert isn't installed.
|
||||
# rasterize the SVG icon next to the exe
|
||||
$svg = Join-Path $root "assets\Acord.svg"
|
||||
$png = Join-Path (Split-Path -Parent $exe) "icon.png"
|
||||
if (Test-Path $svg) {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ if ($LASTEXITCODE -ne 0) { throw "cargo build failed" }
|
|||
$exe = Join-Path $root "target\debug\acord.exe"
|
||||
if (-not (Test-Path $exe)) { throw "binary not found at $exe" }
|
||||
|
||||
# Same icon rasterization as build.ps1 — debug builds want the icon too.
|
||||
# rasterize the SVG icon next to the exe
|
||||
$svg = Join-Path $root "assets\Acord.svg"
|
||||
$png = Join-Path (Split-Path -Parent $exe) "icon.png"
|
||||
if (Test-Path $svg) {
|
||||
|
|
@ -20,8 +20,6 @@ if (Test-Path $svg) {
|
|||
}
|
||||
}
|
||||
|
||||
# Foreground exec so panic output (RUST_BACKTRACE=1) lands in this terminal
|
||||
# rather than vanishing on a detached console. Debug builds use the
|
||||
# `console` subsystem by default so stderr is wired up automatically.
|
||||
# foreground exec, panic output stays in the terminal
|
||||
Write-Host "Launching $exe ..."
|
||||
& $exe
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ zip = { version = "2", default-features = false, features = ["deflate"] }
|
|||
base64 = "0.22"
|
||||
ureq = "3"
|
||||
filetime = "0.2"
|
||||
regex = "1"
|
||||
printpdf = { version = "0.9", default-features = false }
|
||||
|
||||
[target.'cfg(not(target_os = "ios"))'.dependencies]
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@
|
|||
#define USER_IDENT_HOP 3
|
||||
|
||||
/**
|
||||
* Owns the browser window's wgpu surface, iced renderer, and BrowserState.
|
||||
* wgpu surface, iced renderer, and state for one browser window.
|
||||
*/
|
||||
typedef struct BrowserHandle BrowserHandle;
|
||||
|
||||
|
|
@ -85,7 +85,7 @@ char *viewport_get_text(struct ViewportHandle *handle);
|
|||
void viewport_free_string(char *s);
|
||||
|
||||
/**
|
||||
* returns the archive zip bytes (or null when empty); writes the length to len_out.
|
||||
* returns the archive zip bytes or null; writes the length to len_out.
|
||||
*/
|
||||
uint8_t *viewport_take_sidecar_bytes(struct ViewportHandle *handle, uintptr_t *len_out);
|
||||
|
||||
|
|
@ -102,11 +102,9 @@ void viewport_apply_sidecar_bytes(struct ViewportHandle *handle,
|
|||
void viewport_free_bytes(uint8_t *ptr, uintptr_t len);
|
||||
|
||||
/**
|
||||
* renders the current document as a printable PDF; returns owned bytes the shell must free with `viewport_free_bytes`.
|
||||
* renders the document to PDF bytes; free with viewport_free_bytes.
|
||||
*/
|
||||
uint8_t *viewport_render_pdf(struct ViewportHandle *handle,
|
||||
const char *title,
|
||||
uintptr_t *out_len);
|
||||
uint8_t *viewport_render_pdf(struct ViewportHandle *handle, const char *title, uintptr_t *out_len);
|
||||
|
||||
void viewport_set_theme(struct ViewportHandle *handle, const char *name);
|
||||
|
||||
|
|
@ -129,10 +127,7 @@ void viewport_set_settings_view(struct ViewportHandle *handle,
|
|||
char *viewport_take_shell_action(struct ViewportHandle *handle);
|
||||
|
||||
/**
|
||||
* Export the note as a standalone Rust crate at `out_dir/name/`. Returns
|
||||
* a heap-allocated C string on success (the absolute path of the created
|
||||
* folder), or null on failure. Free the returned string with
|
||||
* `viewport_free_string`.
|
||||
* exports the note as a Rust crate at out_dir/name/; returns the path or null.
|
||||
*/
|
||||
char *viewport_export_crate(struct ViewportHandle *handle, const char *out_dir, const char *name);
|
||||
|
||||
|
|
@ -167,7 +162,7 @@ char *browser_take_pending_open(struct BrowserHandle *handle);
|
|||
void browser_refresh(struct BrowserHandle *handle);
|
||||
|
||||
/**
|
||||
* dispatches a numeric zoom command into the browser's scale state.
|
||||
* dispatches a zoom command into the browser scale state.
|
||||
*/
|
||||
void browser_send_command(struct BrowserHandle *handle, uint32_t command);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,18 +1,4 @@
|
|||
//! The `Block` trait that all block kinds implement.
|
||||
//!
|
||||
//! Each concrete struct (TextBlock, TableBlock, HeadingBlock, HrBlock,
|
||||
//! TreeBlock) keeps its own internal data but exposes a common interface for
|
||||
//! iteration, dispatch, selection participation, hit-testing, and serialization.
|
||||
//!
|
||||
//! Two rules apply throughout the trait:
|
||||
//!
|
||||
//! 1. **Iteration over recursion.** `selectable_paths` returns an iterator and
|
||||
//! must be implemented without self-recursion. When nesting (cells containing
|
||||
//! blocks) lands, this protects deep documents from stack overflow.
|
||||
//! 2. **Layered draw order.** `view` returns a `LayeredView`, not a single
|
||||
//! element. The document compositor merges overlays from every block into
|
||||
//! shared layers, so cursorline (Below) and selection borders (Above) end up
|
||||
//! in the right z-order regardless of where they were declared.
|
||||
//! block trait and layered view compositor interface.
|
||||
|
||||
use iced_wgpu::core::{Element, Point, Theme};
|
||||
use crate::text_widget;
|
||||
|
|
@ -20,48 +6,36 @@ use crate::text_widget;
|
|||
use crate::selection::{BlockId, InnerPath, NodePath, Selection};
|
||||
use crate::table_block::TableMessage;
|
||||
|
||||
/// Z-ordering for the document compositor. Explicit draw order decoupled from
|
||||
/// the structural list, so overlays render in the right place regardless of
|
||||
/// where they were declared.
|
||||
/// z-ordering layers for the document compositor.
|
||||
#[repr(u8)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum Layer {
|
||||
/// Drawn behind block content. Cursorline highlights, selection background tints.
|
||||
/// cursorline highlights, selection background tints.
|
||||
Below = 0,
|
||||
/// The block's structural body. Most blocks emit their main element here.
|
||||
/// structural body of the block.
|
||||
Content = 1,
|
||||
/// Drawn in front of content. Selection borders, focus rings, eval result tags.
|
||||
/// selection borders, focus rings, eval result tags.
|
||||
Above = 2,
|
||||
/// The very top. Drag previews, ghosted reorder anchors.
|
||||
/// drag previews, ghosted reorder anchors.
|
||||
Floating = 3,
|
||||
}
|
||||
|
||||
/// What a block returns from `view`. The compositor merges layered output across
|
||||
/// all blocks, drawing in layer order rather than source order.
|
||||
/// layered output from a block's view, merged by the compositor in layer order.
|
||||
pub struct LayeredView<'a, Message> {
|
||||
/// The block's main structural element. Always rendered in document order
|
||||
/// in a column; conceptually at `Layer::Content`.
|
||||
/// main structural element, rendered in document order.
|
||||
pub base: Element<'a, Message, Theme, iced_wgpu::Renderer>,
|
||||
/// Decorative overlays tagged with their target layer.
|
||||
/// overlays tagged with their target layer.
|
||||
pub overlays: Vec<(Layer, Element<'a, Message, Theme, iced_wgpu::Renderer>)>,
|
||||
}
|
||||
|
||||
impl<'a, Message> LayeredView<'a, Message> {
|
||||
/// Convenience for blocks with no overlays (HR, headings without cursorline).
|
||||
/// constructs a LayeredView with no overlays.
|
||||
pub fn just(base: Element<'a, Message, Theme, iced_wgpu::Renderer>) -> Self {
|
||||
Self { base, overlays: Vec::new() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared rendering context passed by reference into every `Block::view` call.
|
||||
/// Instead of embedding selection or focus state on every block, blocks query a
|
||||
/// shared context.
|
||||
///
|
||||
/// `on_text_action` and `on_table_msg` are plain function pointers, not boxed
|
||||
/// closures: the editor only needs message constructors that wrap an index, no
|
||||
/// captured state. This makes `ViewCtx` cheap to copy and avoids the
|
||||
/// invariant-lifetime trap that capturing closures would impose on the trait
|
||||
/// `view` signature.
|
||||
/// shared rendering context passed into every Block::view call.
|
||||
pub struct ViewCtx<'a, Message: 'a> {
|
||||
pub block_index: usize,
|
||||
pub selection: &'a Selection,
|
||||
|
|
@ -71,19 +45,14 @@ pub struct ViewCtx<'a, Message: 'a> {
|
|||
pub is_dark: bool,
|
||||
pub on_text_action: fn(usize, text_widget::Action) -> Message,
|
||||
pub on_table_msg: fn(usize, TableMessage) -> Message,
|
||||
/// Computed values for cells whose raw text is a `/=...` formula.
|
||||
/// Keyed by (table block id, col, row). A `Some(Value)` here means:
|
||||
/// show the computed display form when not editing; a missing entry
|
||||
/// means render the cell's raw text.
|
||||
/// evaluated formula results keyed by (block id, col, row).
|
||||
pub computed_cells: &'a std::collections::HashMap<
|
||||
(BlockId, u32, u32),
|
||||
acord_core::interp::Value,
|
||||
>,
|
||||
}
|
||||
|
||||
/// Structural commands that mutate a block. The editor routes a `BlockCommand`
|
||||
/// to a specific block based on the active focus / selection rather than
|
||||
/// dispatching kind-specific messages directly.
|
||||
/// structural mutation commands routed to a specific block by the editor.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum BlockCommand {
|
||||
// Table commands
|
||||
|
|
@ -106,68 +75,39 @@ pub enum BlockCommand {
|
|||
Clear,
|
||||
}
|
||||
|
||||
/// The protocol every block kind implements.
|
||||
///
|
||||
/// Generic over `Message` so concrete impls can plug in the editor's message
|
||||
/// type without `block.rs` taking a hard dependency on `editor.rs`. The trait
|
||||
/// stays dyn-compatible because the generic is at the trait level (not on
|
||||
/// individual methods) — `Box<dyn Block<crate::editor::Message>>` works.
|
||||
/// protocol every block kind implements, generic over the editor's message type.
|
||||
pub trait Block<Message> {
|
||||
fn id(&self) -> BlockId;
|
||||
fn kind_tag(&self) -> &'static str;
|
||||
|
||||
/// Document-relative line where this block begins. Maintained by
|
||||
/// `recount_lines` after any structural mutation.
|
||||
/// document-relative line where the block begins.
|
||||
fn start_line(&self) -> usize;
|
||||
fn set_start_line(&mut self, line: usize);
|
||||
|
||||
/// Line count this block contributes to the document. For text blocks
|
||||
/// this is the editor `Content::line_count()`; for tables it's
|
||||
/// `rows.len() + 1` (header + separator + data); fixed at 1 for
|
||||
/// headings, HRs, and trees.
|
||||
/// number of lines the block contributes to the document.
|
||||
fn line_count(&self) -> usize;
|
||||
|
||||
/// True if this block was produced by an eval `/=` table result rather
|
||||
/// than parsed from markdown. Drives read-only behaviour and skips
|
||||
/// markdown serialization. Defaults to false; only `TableBlock` overrides.
|
||||
/// marks blocks produced by eval rather than parsed from markdown.
|
||||
fn is_eval_result(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Downcast hooks for the editor to access kind-specific fields
|
||||
/// (`TextBlock::content`, `TableBlock::rows`, ...) from a `Box<dyn Block>`.
|
||||
/// Every concrete impl just returns `self` — these are required because
|
||||
/// `Block` is generic over `Message` so we can't use Rust's trait
|
||||
/// upcasting to `Any` directly.
|
||||
/// downcast to concrete block types.
|
||||
fn as_any(&self) -> &dyn std::any::Any;
|
||||
fn as_any_mut(&mut self) -> &mut dyn std::any::Any;
|
||||
|
||||
/// Render the block. `ctx` carries selection / focus / theme / font as
|
||||
/// shared state. Returns layered output rather than a single element so
|
||||
/// the document compositor can merge overlays from multiple blocks into
|
||||
/// shared layers.
|
||||
///
|
||||
/// `ctx`'s lifetime is independent from the returned `LayeredView`'s `'a`
|
||||
/// (which is tied to `&self`). Implementations must read what they need
|
||||
/// from `ctx` eagerly into Copy locals — they may NOT capture `ctx` into
|
||||
/// the returned element. This is what lets the editor's `view_blocks` build
|
||||
/// a fresh per-iteration `ViewCtx` on the stack and return elements that
|
||||
/// outlive the loop.
|
||||
/// renders the block into layered output for the compositor.
|
||||
fn view<'a>(&'a self, ctx: &ViewCtx<'_, Message>) -> LayeredView<'a, Message>;
|
||||
|
||||
/// Markdown serialization. Rich side-channel state (col widths, row
|
||||
/// heights, cell formulas) is serialized separately into the embedded
|
||||
/// sidecar archive — this method returns plain markdown only.
|
||||
/// serializes the block as plain markdown.
|
||||
fn to_md(&self) -> String;
|
||||
|
||||
/// Cursor coordinate (in this block's local space) -> innermost selectable
|
||||
/// path. Returns `None` if the point doesn't hit anything selectable.
|
||||
/// resolves a local-space point to the innermost selectable path.
|
||||
fn hit_test(&self, point: Point) -> Option<InnerPath>;
|
||||
|
||||
/// Apply a structural mutation. Unsupported commands are silently ignored.
|
||||
/// applies a structural mutation, ignoring unsupported commands.
|
||||
fn apply(&mut self, cmd: BlockCommand);
|
||||
|
||||
/// Iterate (NOT recurse) over all selectable inner paths in this block.
|
||||
/// MUST be iterative — when nesting lands this gets exercised on deep trees.
|
||||
/// iterates all selectable inner paths in the block (non-recursive).
|
||||
fn selectable_paths(&self) -> Box<dyn Iterator<Item = InnerPath> + '_>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,4 @@
|
|||
//! Document parsing and block-list utilities.
|
||||
//!
|
||||
//! Owns the markdown -> `Vec<BoxedBlock>` parser plus the round-trip helpers
|
||||
//! (serialize, recount lines, locate by line). Every block kind lives in its
|
||||
//! own module behind the `Block` trait; this file deals with document-wide
|
||||
//! concerns only.
|
||||
//! markdown to block-list parser and document-wide utilities.
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
|
|
@ -22,7 +17,7 @@ pub fn next_id() -> u64 {
|
|||
NEXT_BLOCK_ID.fetch_add(1, Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Split text into lines, preserving trailing empty lines that `str::lines()` drops.
|
||||
/// splits text into lines, preserving trailing empty lines.
|
||||
fn split_lines(text: &str) -> Vec<&str> {
|
||||
let mut lines: Vec<&str> = text.lines().collect();
|
||||
if text.ends_with('\n') {
|
||||
|
|
@ -231,11 +226,7 @@ pub fn parse_blocks(text: &str, lang: &str) -> Vec<BoxedBlock> {
|
|||
blocks
|
||||
}
|
||||
|
||||
/// Incremental reparse: compare existing block kinds + spans to the new
|
||||
/// classification, reuse boxed instances where the slot matches, rebuild the
|
||||
/// rest. Preserves cursor state for unchanged text blocks because the
|
||||
/// `text_editor::Content` instance is moved (not recreated) when both the
|
||||
/// kind tag and the line span match.
|
||||
/// reparses blocks incrementally, reusing existing instances at matching slots.
|
||||
pub fn reparse_incremental(old_blocks: &mut Vec<BoxedBlock>, text: &str, lang: &str) {
|
||||
let lines: Vec<&str> = if text.is_empty() {
|
||||
Vec::new()
|
||||
|
|
@ -255,12 +246,7 @@ pub fn reparse_incremental(old_blocks: &mut Vec<BoxedBlock>, text: &str, lang: &
|
|||
.collect();
|
||||
|
||||
if old_descriptors == new_descriptors {
|
||||
// Same structure: update text content in place for text blocks; for
|
||||
// non-text kinds, rebuild from the span only when the serialized form
|
||||
// actually changed — preserves heading/table/tree internal state on
|
||||
// typical edits while still picking up content swaps (e.g. opening
|
||||
// a note whose first heading lives at the same line as the previous
|
||||
// note's heading).
|
||||
// same structure: update text in place, rebuild non-text only on content change
|
||||
for (block, span) in old_blocks.iter_mut().zip(spans.iter()) {
|
||||
block.set_start_line(span.start);
|
||||
let block_text = lines[span.start..span.end].join("\n");
|
||||
|
|
@ -278,9 +264,7 @@ pub fn reparse_incremental(old_blocks: &mut Vec<BoxedBlock>, text: &str, lang: &
|
|||
return;
|
||||
}
|
||||
|
||||
// Structure changed: rebuild, reusing blocks at matching positions. Match
|
||||
// is by (kind_tag, start_line); the boxed instance is `mem::replace`'d so
|
||||
// any preserved state (text editor cursor, table focus, drag) survives.
|
||||
// structure changed: rebuild, reusing blocks at matching (kind_tag, start_line) positions
|
||||
let placeholder = || -> BoxedBlock {
|
||||
Box::new(TextBlock::new(0, "", 0, String::new()))
|
||||
};
|
||||
|
|
@ -328,8 +312,7 @@ fn span_kind_tag(kind: SpanKind) -> &'static str {
|
|||
}
|
||||
}
|
||||
|
||||
/// Serialize blocks back to document text. Eval-result tables and trees are
|
||||
/// skipped — they're regenerated from source on every load.
|
||||
/// serializes blocks back to document text, skipping eval-result tables and trees.
|
||||
pub fn serialize_blocks(blocks: &[BoxedBlock]) -> String {
|
||||
let mut parts = Vec::new();
|
||||
for block in blocks {
|
||||
|
|
@ -341,8 +324,7 @@ pub fn serialize_blocks(blocks: &[BoxedBlock]) -> String {
|
|||
continue;
|
||||
}
|
||||
let md = block.to_md();
|
||||
// For text blocks, push even empty strings — they preserve the empty
|
||||
// line gap between adjacent non-text blocks.
|
||||
// empty text blocks preserve line gaps between adjacent non-text blocks
|
||||
if tag == "text" || !md.is_empty() {
|
||||
parts.push(md);
|
||||
}
|
||||
|
|
@ -350,7 +332,7 @@ pub fn serialize_blocks(blocks: &[BoxedBlock]) -> String {
|
|||
parts.join("\n")
|
||||
}
|
||||
|
||||
/// Update document-relative start_line on every block based on its line_count.
|
||||
/// recalculates document-relative start_line on every block.
|
||||
pub fn recount_lines(blocks: &mut [BoxedBlock]) {
|
||||
let mut line = 0;
|
||||
for block in blocks.iter_mut() {
|
||||
|
|
@ -359,7 +341,7 @@ pub fn recount_lines(blocks: &mut [BoxedBlock]) {
|
|||
}
|
||||
}
|
||||
|
||||
/// Find the block index containing a given global line number.
|
||||
/// finds the block index containing a given global line number.
|
||||
pub fn block_at_line(blocks: &[BoxedBlock], global_line: usize) -> Option<usize> {
|
||||
for (i, block) in blocks.iter().enumerate() {
|
||||
let start = block.start_line();
|
||||
|
|
|
|||
|
|
@ -11,12 +11,7 @@ pub fn push_mouse_event(handle: &mut ViewportHandle, x: f32, y: f32, button: u8,
|
|||
|
||||
handle.events.push(Event::Mouse(mouse::Event::CursorMoved { position }));
|
||||
|
||||
// Sentinel: button == 255 means "pointer move only — do not fire any
|
||||
// ButtonPressed/Released event." Used by mouseMoved and mouseDragged in
|
||||
// the Swift shell. Without this, every drag tick would re-fire
|
||||
// ButtonPressed(Left) and iced's text_editor would interpret each tick as
|
||||
// a new click, restarting the active selection on every frame and making
|
||||
// click+drag selection twitch / over-highlight.
|
||||
// button==255 sentinel: pointer move only, no press/release event
|
||||
if button == 255 {
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ use crate::palette;
|
|||
use super::state::{BrowserMessage, BrowserState};
|
||||
use super::ui;
|
||||
|
||||
/// Owns the browser window's wgpu surface, iced renderer, and BrowserState.
|
||||
/// wgpu surface, iced renderer, and state for one browser window.
|
||||
pub struct BrowserHandle {
|
||||
pub surface: wgpu::Surface<'static>,
|
||||
pub device: wgpu::Device,
|
||||
|
|
@ -40,7 +40,7 @@ pub struct BrowserHandle {
|
|||
pub needs_redraw: bool,
|
||||
}
|
||||
|
||||
/// The browser doesn't read or write the system clipboard.
|
||||
/// clipboard stub, discards all reads and writes.
|
||||
struct NoopClipboard;
|
||||
|
||||
impl clipboard::Clipboard for NoopClipboard {
|
||||
|
|
@ -48,7 +48,7 @@ impl clipboard::Clipboard for NoopClipboard {
|
|||
fn write(&mut self, _kind: clipboard::Kind, _contents: String) {}
|
||||
}
|
||||
|
||||
/// Caller must keep the underlying winit Window alive for the surface's lifetime.
|
||||
/// creates the wgpu surface, iced renderer, and browser state from raw window handles.
|
||||
pub fn create(
|
||||
raw_display: RawDisplayHandle,
|
||||
raw_window: RawWindowHandle,
|
||||
|
|
@ -61,7 +61,7 @@ pub fn create(
|
|||
let backends = wgpu::Backends::METAL;
|
||||
#[cfg(target_os = "windows")]
|
||||
let backends = wgpu::Backends::DX12;
|
||||
// accept GL alongside Vulkan so distros without a Vulkan ICD still get a browser surface.
|
||||
// Vulkan + GL fallback
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
let backends = wgpu::Backends::VULKAN | wgpu::Backends::GL;
|
||||
|
||||
|
|
@ -156,7 +156,7 @@ pub fn create(
|
|||
})
|
||||
}
|
||||
|
||||
/// One frame: drains pending events into messages, applies them, then redraws.
|
||||
/// drains pending events into messages, applies state updates, and redraws.
|
||||
pub fn render(handle: &mut BrowserHandle) {
|
||||
let pending = !handle.events.is_empty();
|
||||
if !handle.needs_redraw && !pending {
|
||||
|
|
@ -174,7 +174,7 @@ pub fn render(handle: &mut BrowserHandle) {
|
|||
.events
|
||||
.push(Event::Window(window::Event::RedrawRequested(iced_wgpu::core::time::Instant::now())));
|
||||
|
||||
// pre-scans events so the modifier and cursor state are visible to message handlers fired this frame.
|
||||
// pre-scan: update modifier and cursor state before message dispatch
|
||||
for ev in &handle.events {
|
||||
match ev {
|
||||
Event::Keyboard(iced_wgpu::core::keyboard::Event::ModifiersChanged(m)) => {
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ pub struct BrowserItem {
|
|||
pub preview_lines: Vec<PreviewLine>,
|
||||
}
|
||||
|
||||
/// Folders first, then files; both in date-modified descending order.
|
||||
/// sorts folders above files, both in date-modified descending order.
|
||||
pub fn scan_directory(dir: &Path) -> Vec<BrowserItem> {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new() };
|
||||
|
||||
|
|
@ -81,7 +81,6 @@ pub fn scan_directory(dir: &Path) -> Vec<BrowserItem> {
|
|||
}
|
||||
|
||||
pub fn file_preview(path: &Path) -> String {
|
||||
// bytes-first so the binary archive trailer doesn't trip the utf-8 decode.
|
||||
let Ok(bytes) = std::fs::read(path) else { return String::new() };
|
||||
let (text_bytes, _) = crate::sidecar::extract_from_md(&bytes);
|
||||
let text = String::from_utf8_lossy(&text_bytes);
|
||||
|
|
@ -130,7 +129,7 @@ fn strip_sidecar_archive(text: &str) -> &str {
|
|||
}
|
||||
}
|
||||
|
||||
/// True when the body has nothing but whitespace, separator rows, or default-header tables.
|
||||
/// detects bodies containing only whitespace, separator rows, or default-header tables.
|
||||
fn body_looks_blank(body: &str) -> bool {
|
||||
let trimmed = body.trim();
|
||||
if trimmed.is_empty() { return true; }
|
||||
|
|
@ -176,7 +175,7 @@ pub fn rename(item_path: &Path, new_name: &str, is_file: bool) -> std::io::Resul
|
|||
Ok(dest)
|
||||
}
|
||||
|
||||
/// Copies the file to a sibling with a `name N.ext` suffix, picking the lowest free N.
|
||||
/// copies the file to a sibling with a name N.ext suffix, picking the lowest free N.
|
||||
pub fn duplicate(item_path: &Path) -> std::io::Result<PathBuf> {
|
||||
let parent = item_path.parent().unwrap_or_else(|| Path::new(""));
|
||||
let stem = item_path.file_stem().and_then(|s| s.to_str()).unwrap_or("copy");
|
||||
|
|
@ -207,14 +206,10 @@ pub fn move_into(item_path: &Path, folder: &Path) -> std::io::Result<PathBuf> {
|
|||
Ok(dest)
|
||||
}
|
||||
|
||||
/// Bumps each path's mtime so it sorts immediately above `anchor` in date-descending order.
|
||||
/// Items keep their relative order: the first path in `items` lands closest above the anchor.
|
||||
/// bumps each path's mtime to sort immediately above the anchor in date-descending order.
|
||||
pub fn reorder_before(items: &[PathBuf], anchor: &Path) -> std::io::Result<()> {
|
||||
let anchor_meta = std::fs::metadata(anchor)?;
|
||||
let anchor_mtime = anchor_meta.modified().unwrap_or(SystemTime::UNIX_EPOCH);
|
||||
// Spread the dragged items across one second of mtime above the anchor.
|
||||
// The earliest in `items` gets the highest mtime so it sorts first under
|
||||
// descending order — matches the user's drag-order expectation.
|
||||
let n = items.len();
|
||||
if n == 0 { return Ok(()); }
|
||||
let step_ms: u64 = (1000 / n.max(1) as u64).max(1);
|
||||
|
|
@ -227,8 +222,7 @@ pub fn reorder_before(items: &[PathBuf], anchor: &Path) -> std::io::Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Bumps each path's mtime above every existing item in `parent`, preserving drag order.
|
||||
/// Used when dropping items at the very top of the grid.
|
||||
/// bumps each path's mtime above every existing item in the parent, preserving drag order.
|
||||
pub fn reorder_to_top(items: &[PathBuf], parent: &Path) -> std::io::Result<()> {
|
||||
let mut max_mtime = SystemTime::UNIX_EPOCH;
|
||||
if let Ok(entries) = std::fs::read_dir(parent) {
|
||||
|
|
@ -263,8 +257,7 @@ pub fn create_folder(parent: &Path) -> std::io::Result<PathBuf> {
|
|||
Ok(dest)
|
||||
}
|
||||
|
||||
/// Creates a fresh folder next to `items` and moves each one inside.
|
||||
/// Items already living in the destination are skipped to avoid same-name self-moves.
|
||||
/// creates a fresh folder in parent and moves each item inside, skipping same-directory paths.
|
||||
pub fn create_folder_with_items(parent: &Path, items: &[PathBuf]) -> std::io::Result<PathBuf> {
|
||||
let folder = create_folder(parent)?;
|
||||
for item in items {
|
||||
|
|
@ -274,7 +267,7 @@ pub fn create_folder_with_items(parent: &Path, items: &[PathBuf]) -> std::io::Re
|
|||
Ok(folder)
|
||||
}
|
||||
|
||||
/// Sends the path to the OS trash; falls back to permanent delete on platforms without trash support.
|
||||
/// sends the path to the OS trash, falling back to permanent delete on unsupported platforms.
|
||||
pub fn trash(item_path: &Path) -> std::io::Result<()> {
|
||||
match trash_crate_remove(item_path) {
|
||||
Ok(()) => Ok(()),
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ pub fn highlight_preview(source: &str) -> Vec<PreviewLine> {
|
|||
let settings = SyntaxSettings {
|
||||
lang: "rust".to_string(),
|
||||
source: source.to_string(),
|
||||
user_idents: crate::syntax::scan_user_idents_in(source),
|
||||
};
|
||||
let mut highlighter = SyntaxHighlighter::new(&settings);
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ pub struct BrowserState {
|
|||
pub scale: f32,
|
||||
pub renaming: Option<PathBuf>,
|
||||
pub rename_text: String,
|
||||
/// holds the next path the host shell should open; drained each frame.
|
||||
/// next path the host shell should open, drained each frame.
|
||||
pub pending_open: Option<PathBuf>,
|
||||
pub context_menu: Option<ContextMenu>,
|
||||
pub drag: Option<DragState>,
|
||||
|
|
@ -31,7 +31,7 @@ pub struct DragState {
|
|||
pub items: Vec<PathBuf>,
|
||||
pub start: Point,
|
||||
pub current: Point,
|
||||
/// false until the cursor has moved past DRAG_THRESHOLD_PX from start.
|
||||
/// set once the cursor moves past DRAG_THRESHOLD_PX from start.
|
||||
pub active: bool,
|
||||
pub hover: Option<DragHover>,
|
||||
}
|
||||
|
|
@ -51,7 +51,7 @@ impl DragState {
|
|||
#[derive(Debug, Clone)]
|
||||
pub struct ContextMenu {
|
||||
pub anchor: Point,
|
||||
/// None when the right-click landed between cards.
|
||||
/// absent for right-clicks between cards.
|
||||
pub target: Option<ContextTarget>,
|
||||
}
|
||||
|
||||
|
|
@ -138,8 +138,7 @@ impl BrowserState {
|
|||
let plain_press = !mods.command() && !mods.shift();
|
||||
let already_selected = self.selected.contains(&path) && self.selected.len() >= 1;
|
||||
if plain_press && already_selected && self.renaming.is_none() {
|
||||
// Path is already part of the selection; arm a drag instead
|
||||
// of collapsing the selection to just this one card.
|
||||
// arm a drag rather than collapsing a multi-selection.
|
||||
let items: Vec<PathBuf> = self.selected.iter().cloned().collect();
|
||||
self.drag = Some(DragState {
|
||||
items,
|
||||
|
|
@ -348,7 +347,7 @@ impl BrowserState {
|
|||
self.selected.contains(&item.path)
|
||||
}
|
||||
|
||||
/// True when `item` is part of an active (cursor moved past threshold) drag.
|
||||
/// active-drag membership check for the given item.
|
||||
pub fn is_dragging(&self, item: &BrowserItem) -> bool {
|
||||
match self.drag.as_ref() {
|
||||
Some(d) if d.active => d.items.iter().any(|p| p == &item.path),
|
||||
|
|
@ -356,8 +355,7 @@ impl BrowserState {
|
|||
}
|
||||
}
|
||||
|
||||
/// True when `item` is the currently hovered drop target during an active drag,
|
||||
/// and dropping there would actually do something (target isn't part of the drag).
|
||||
/// valid drop-target check for the given item during an active drag.
|
||||
pub fn is_drop_target(&self, item: &BrowserItem) -> bool {
|
||||
match self.drag.as_ref() {
|
||||
Some(d) if d.active => match &d.hover {
|
||||
|
|
@ -372,7 +370,7 @@ impl BrowserState {
|
|||
item.kind == BrowserItemKind::File
|
||||
}
|
||||
|
||||
/// True when a context menu was opened on an item that's part of the live selection.
|
||||
/// context menu target overlaps the live selection.
|
||||
pub fn context_acts_on_selection(&self) -> bool {
|
||||
match self.context_menu.as_ref().and_then(|m| m.target.as_ref()) {
|
||||
Some(t) => self.selected.contains(&t.item_path),
|
||||
|
|
|
|||
|
|
@ -28,9 +28,7 @@ pub fn view(state: &BrowserState) -> Element<'_, BrowserMessage, Theme, iced_wgp
|
|||
responsive(|size| scrollable(grid(state, size)).height(Length::Fill).into()).into()
|
||||
};
|
||||
|
||||
// Captures right-clicks between cards plus drag motion/release that
|
||||
// happens off-card. Cards have their own on_right_press / on_press, so
|
||||
// this body-level mouse_area only sees the gaps for those.
|
||||
// body-level mouse_area catches inter-card right-clicks and drag motion/release.
|
||||
let body: Element<_, _, _> = mouse_area(body_inner)
|
||||
.on_right_press(BrowserMessage::ShowEmptyContextMenu)
|
||||
.on_move(BrowserMessage::DragMove)
|
||||
|
|
@ -134,7 +132,7 @@ fn empty_state() -> Element<'static, BrowserMessage, Theme, iced_wgpu::Renderer>
|
|||
.into()
|
||||
}
|
||||
|
||||
/// picks the column count whose card-width sits closest to the scale-adjusted target.
|
||||
/// picks the column count with card-width closest to the scale-adjusted target.
|
||||
fn columns_for_width(avail_w: f32, scale: f32) -> usize {
|
||||
let target = TARGET_CARD_W * scale;
|
||||
let min_w = MIN_CARD_W * scale;
|
||||
|
|
@ -451,8 +449,7 @@ fn positioned_menu<'a>(
|
|||
.into()
|
||||
}
|
||||
|
||||
/// renders the unified menu used by both the context menu and the menu bar.
|
||||
/// `full` decides whether to show selection-dependent items beyond New Folder.
|
||||
/// renders the context/menu-bar menu, optionally including selection-dependent items.
|
||||
fn menu_column<'a>(
|
||||
state: &'a BrowserState,
|
||||
full: bool,
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ impl super::EditorState {
|
|||
}
|
||||
}
|
||||
|
||||
/// escapes the table at `table_idx` upward into the previous text block
|
||||
/// escapes the table upward into the previous text block
|
||||
pub(super) fn escape_table_up(&mut self, table_idx: usize) {
|
||||
if table_idx > 0 {
|
||||
if let Some(tb) = self.text_block_at(table_idx - 1) {
|
||||
|
|
@ -82,7 +82,7 @@ impl super::EditorState {
|
|||
self.reparse();
|
||||
}
|
||||
|
||||
/// escapes the table at `table_idx` downward into the next text block
|
||||
/// escapes the table downward into the next text block
|
||||
pub(super) fn escape_table_down(&mut self, table_idx: usize) {
|
||||
let next_idx = table_idx + 1;
|
||||
if next_idx < self.block_count() {
|
||||
|
|
@ -109,7 +109,7 @@ impl super::EditorState {
|
|||
self.reparse();
|
||||
}
|
||||
|
||||
/// fills the first empty `[]` slot in the editing cell's formula with the clicked cell's address; returns true when the click was consumed.
|
||||
/// fills the first empty [] slot in the editing cell's formula with the clicked cell's address.
|
||||
pub(super) fn try_fill_formula_slot(
|
||||
&mut self,
|
||||
click_block_idx: usize,
|
||||
|
|
@ -202,7 +202,7 @@ impl super::EditorState {
|
|||
parts.join("\n")
|
||||
}
|
||||
|
||||
/// moves the focused content's cursor to `target`, clamping line and column
|
||||
/// moves the focused content's cursor to the target position, clamping line and column
|
||||
pub(super) fn safe_move_to(&mut self, mut cursor: Cursor) {
|
||||
{
|
||||
let content = self.content();
|
||||
|
|
@ -332,12 +332,12 @@ impl super::EditorState {
|
|||
}
|
||||
}
|
||||
|
||||
/// returns true when a non-eval table has a selected cell
|
||||
/// index of the focused non-eval table with a selected cell, or None.
|
||||
pub(crate) fn active_table_index(&self) -> Option<usize> {
|
||||
self.focused_table_index()
|
||||
}
|
||||
|
||||
/// returns true when the focused block is a non-eval table
|
||||
/// focused block non-eval table check.
|
||||
pub(crate) fn table_is_focused_block(&self) -> bool {
|
||||
if let Some(block) = self.block_at(self.focused_block) {
|
||||
if let Some(tb) = block.as_any().downcast_ref::<TableBlock>() {
|
||||
|
|
@ -347,7 +347,7 @@ impl super::EditorState {
|
|||
false
|
||||
}
|
||||
|
||||
/// returns true when the focused block is a table in whole-table-select mode
|
||||
/// focused table whole-selection-mode check.
|
||||
pub(crate) fn focused_table_is_select_all(&self) -> bool {
|
||||
if let Some(block) = self.block_at(self.focused_block) {
|
||||
if let Some(tb) = block.as_any().downcast_ref::<TableBlock>() {
|
||||
|
|
@ -365,7 +365,7 @@ impl super::EditorState {
|
|||
Some((idx, r, tb.rows.len()))
|
||||
}
|
||||
|
||||
/// returns the focused block index when it's a table
|
||||
/// returns the focused block index for a non-eval table with a focused cell.
|
||||
pub(crate) fn focused_table_index(&self) -> Option<usize> {
|
||||
let block = self.block_at(self.focused_block)?;
|
||||
let tb = block.as_any().downcast_ref::<TableBlock>()?;
|
||||
|
|
@ -376,7 +376,7 @@ impl super::EditorState {
|
|||
}
|
||||
}
|
||||
|
||||
/// returns true when the focused block is a table with a focused cell
|
||||
/// focused-table selected-cell check, excluding edit mode.
|
||||
pub(crate) fn has_selected_cell_not_editing(&self) -> bool {
|
||||
if self.editing.is_some() {
|
||||
return false;
|
||||
|
|
@ -390,7 +390,7 @@ impl super::EditorState {
|
|||
!tb.is_eval_result && tb.focused_cell.is_some()
|
||||
}
|
||||
|
||||
/// returns true when Cmd+C should copy the table selection instead of cell text
|
||||
/// table-selection copy intercept check for Cmd+C.
|
||||
pub(crate) fn should_intercept_table_copy(&self) -> bool {
|
||||
if self.editing.is_some() { return false; }
|
||||
let Some(block) = self.block_at(self.focused_block) else { return false; };
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
|
|
@ -83,6 +84,7 @@ impl super::EditorState {
|
|||
let text = self.get_clean_text();
|
||||
self.parsed = markdown::parse(&text).collect();
|
||||
self.rebuild_modules();
|
||||
self.refresh_text_caches();
|
||||
}
|
||||
|
||||
pub(super) fn build_block_infos(&self) -> Vec<crate::module::BlockInfo> {
|
||||
|
|
@ -352,7 +354,7 @@ impl super::EditorState {
|
|||
}
|
||||
}
|
||||
|
||||
/// returns true when an edit changed the block structure
|
||||
/// reparses the full text and reconciles block structure changes.
|
||||
pub(super) fn check_block_structure(&mut self) {
|
||||
let cursor = self.content().cursor();
|
||||
let full = self.full_text();
|
||||
|
|
@ -372,6 +374,7 @@ impl super::EditorState {
|
|||
}
|
||||
}
|
||||
self.rebuild_modules();
|
||||
self.refresh_text_caches();
|
||||
}
|
||||
|
||||
/// returns the concatenated text of all text blocks in a module
|
||||
|
|
@ -387,7 +390,7 @@ impl super::EditorState {
|
|||
parts.join("\n")
|
||||
}
|
||||
|
||||
/// builds an interpreter pre-populated with root and `use`'d module exports
|
||||
/// builds an interpreter pre-populated with root and use-declared module exports.
|
||||
pub(super) fn build_eval_interpreter(&self, block_idx: usize) -> acord_core::interp::Interpreter {
|
||||
use acord_core::interp;
|
||||
|
||||
|
|
@ -418,18 +421,11 @@ impl super::EditorState {
|
|||
let text = tb.content.text();
|
||||
let use_decls = interp::extract_use_declarations(&text);
|
||||
for decl in &use_decls {
|
||||
if let Some(dep_module) = self.modules.iter().find(|m| m.name == decl.module) {
|
||||
let mut visited: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
if !my_module_name.is_empty() {
|
||||
visited.insert(my_module_name.clone());
|
||||
}
|
||||
let dep_exports = self.resolve_module_exports(dep_module, &mut visited);
|
||||
match &decl.item {
|
||||
None => eval_interp.import_all(&dep_exports),
|
||||
Some(s) if s == "*" => eval_interp.import_all(&dep_exports),
|
||||
Some(item) => { eval_interp.import_item(&dep_exports, item); }
|
||||
}
|
||||
let mut visited: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
if !my_module_name.is_empty() {
|
||||
visited.insert(my_module_name.clone());
|
||||
}
|
||||
self.apply_use_decl(decl, &mut visited, &mut eval_interp);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -438,7 +434,7 @@ impl super::EditorState {
|
|||
eval_interp
|
||||
}
|
||||
|
||||
/// recursively evaluates a module with its `use` declarations resolved
|
||||
/// recursively evaluates a module with use declarations resolved.
|
||||
pub(super) fn resolve_module_exports(
|
||||
&self,
|
||||
module: &crate::module::Module,
|
||||
|
|
@ -464,20 +460,114 @@ impl super::EditorState {
|
|||
let module_text = self.module_source_text(module);
|
||||
let use_decls = interp::extract_use_declarations(&module_text);
|
||||
for decl in &use_decls {
|
||||
if let Some(dep) = self.modules.iter().find(|m| m.name == decl.module) {
|
||||
let dep_exports = self.resolve_module_exports(dep, visited);
|
||||
match &decl.item {
|
||||
None => interp.import_all(&dep_exports),
|
||||
Some(s) if s == "*" => interp.import_all(&dep_exports),
|
||||
Some(item) => { interp.import_item(&dep_exports, item); }
|
||||
}
|
||||
}
|
||||
self.apply_use_decl(decl, visited, &mut interp);
|
||||
}
|
||||
|
||||
crate::eval::evaluate_document_with_interp(&mut interp, &module_text);
|
||||
interp.exports()
|
||||
}
|
||||
|
||||
/// flags use targets the language handles internally.
|
||||
pub(super) fn is_builtin_use(module: &str) -> bool {
|
||||
module == "spice" || module.starts_with("spice::")
|
||||
|| module == "ring" || module.starts_with("ring::")
|
||||
}
|
||||
|
||||
/// merges a single use decl's exports into the given interpreter.
|
||||
pub(super) fn apply_use_decl(
|
||||
&self,
|
||||
decl: &acord_core::interp::UseDecl,
|
||||
visited: &mut std::collections::HashSet<String>,
|
||||
target: &mut acord_core::interp::Interpreter,
|
||||
) {
|
||||
if Self::is_builtin_use(&decl.module) { return; }
|
||||
|
||||
if let Some(local) = self.modules.iter().find(|m| m.name == decl.module) {
|
||||
let exports = self.resolve_module_exports(local, visited);
|
||||
match &decl.item {
|
||||
None => target.import_all(&exports),
|
||||
Some(s) if s == "*" => target.import_all(&exports),
|
||||
Some(item) => { target.import_item(&exports, item); }
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
self.apply_external_use_decl(decl, visited, target);
|
||||
}
|
||||
|
||||
/// resolves a sibling-note use decl against either the whole file or a named H2 submodule.
|
||||
fn apply_external_use_decl(
|
||||
&self,
|
||||
decl: &acord_core::interp::UseDecl,
|
||||
visited: &mut std::collections::HashSet<String>,
|
||||
target: &mut acord_core::interp::Interpreter,
|
||||
) {
|
||||
use acord_core::interp;
|
||||
|
||||
let cycle_key = format!("__ext__{}", decl.module);
|
||||
if !visited.insert(cycle_key) { return; }
|
||||
|
||||
let dir = self.notes_dir();
|
||||
let path = dir.join(format!("{}.md", decl.module));
|
||||
let Ok(bytes) = std::fs::read(&path) else { return };
|
||||
let (text_bytes, _archive) = crate::sidecar::extract_from_md(&bytes);
|
||||
let Ok(raw) = String::from_utf8(text_bytes) else { return };
|
||||
|
||||
let loaded = crate::sidecar::extract_archive(&raw);
|
||||
let clean = super::strip_result_lines(&loaded.markdown);
|
||||
|
||||
let lang = self.lang_str();
|
||||
let ext_blocks = blocks::parse_blocks(&clean, &lang);
|
||||
let ext_infos = build_block_infos_from(&ext_blocks);
|
||||
let ext_modules = crate::module::compute_modules(&ext_infos);
|
||||
|
||||
let submodule_match = decl.item.as_deref().and_then(|item| {
|
||||
if item == "*" { return None; }
|
||||
ext_modules.iter().find(|m| m.name == item)
|
||||
});
|
||||
|
||||
if let Some(submod) = submodule_match {
|
||||
let mut nested = interp::Interpreter::new();
|
||||
for d in interp::extract_use_declarations(&clean) {
|
||||
self.apply_use_decl(&d, visited, &mut nested);
|
||||
}
|
||||
if !submod.is_root {
|
||||
if let Some(root) = ext_modules.iter().find(|m| m.is_root) {
|
||||
let root_text = module_text_from_blocks(root, &ext_blocks);
|
||||
crate::eval::evaluate_document_with_interp(&mut nested, &root_text);
|
||||
}
|
||||
}
|
||||
let submod_text = module_text_from_blocks(submod, &ext_blocks);
|
||||
crate::eval::evaluate_document_with_interp(&mut nested, &submod_text);
|
||||
target.import_all(&nested.exports());
|
||||
return;
|
||||
}
|
||||
|
||||
let mut nested = interp::Interpreter::new();
|
||||
for d in interp::extract_use_declarations(&clean) {
|
||||
self.apply_use_decl(&d, visited, &mut nested);
|
||||
}
|
||||
crate::eval::evaluate_document_with_interp(&mut nested, &clean);
|
||||
let exports = nested.exports();
|
||||
match &decl.item {
|
||||
None => target.import_all(&exports),
|
||||
Some(s) if s == "*" => target.import_all(&exports),
|
||||
Some(item) => { target.import_item(&exports, item); }
|
||||
}
|
||||
}
|
||||
|
||||
/// resolves the directory sibling notes live in.
|
||||
pub(super) fn notes_dir(&self) -> PathBuf {
|
||||
let from_settings = self.settings_view.auto_save_dir.trim();
|
||||
if !from_settings.is_empty() {
|
||||
return PathBuf::from(from_settings);
|
||||
}
|
||||
if let Ok(home) = std::env::var("HOME") {
|
||||
return PathBuf::from(home).join(".acord").join("notes");
|
||||
}
|
||||
PathBuf::from(".")
|
||||
}
|
||||
|
||||
pub(super) fn run_eval(&mut self) {
|
||||
self.rebuild_modules();
|
||||
|
||||
|
|
@ -619,14 +709,14 @@ impl super::EditorState {
|
|||
Some(r.text.trim_start_matches(RESULT_PREFIX).trim().to_string())
|
||||
}
|
||||
|
||||
/// reads line `line_idx` from the text block with the given id
|
||||
/// reads one line from the text block with the given id.
|
||||
pub(super) fn read_line_at(&self, block_id: crate::selection::BlockId, line_idx: usize) -> Option<String> {
|
||||
let block = self.registry.get(&block_id)?;
|
||||
let tb = block.as_any().downcast_ref::<TextBlock>()?;
|
||||
tb.content.line(line_idx).map(|l| l.text.to_string())
|
||||
}
|
||||
|
||||
/// copies `{source} → {value}` to the clipboard
|
||||
/// copies the source line and result value pair to the clipboard.
|
||||
pub(super) fn copy_inline_result(&mut self, block_id: crate::selection::BlockId, after_line: usize) {
|
||||
let value = match self.inline_result_value(block_id, after_line) {
|
||||
Some(v) => v,
|
||||
|
|
@ -637,7 +727,7 @@ impl super::EditorState {
|
|||
self.pending_clipboard = Some(format!("{trimmed} {RESULT_PREFIX}{value}"));
|
||||
}
|
||||
|
||||
/// copies the result and drops a `let _ = value` line below the source
|
||||
/// copies the result and inserts a let binding below the source line.
|
||||
pub(super) fn handle_result_extract(&mut self, block_id: crate::selection::BlockId, after_line: usize) {
|
||||
let value = match self.inline_result_value(block_id, after_line) {
|
||||
Some(v) => v,
|
||||
|
|
@ -676,6 +766,35 @@ impl super::EditorState {
|
|||
}
|
||||
}
|
||||
|
||||
/// builds BlockInfos from a free-standing block list.
|
||||
fn build_block_infos_from(blocks: &[crate::blocks::BoxedBlock]) -> Vec<crate::module::BlockInfo> {
|
||||
use crate::heading_block::HeadingBlock;
|
||||
use crate::module::BlockInfo;
|
||||
blocks.iter().map(|block| {
|
||||
let tag = block.kind_tag();
|
||||
let (heading_level, heading_text) = if let Some(hb) = block.as_any().downcast_ref::<HeadingBlock>() {
|
||||
(hb.level.as_u8(), hb.text.clone())
|
||||
} else {
|
||||
(0, String::new())
|
||||
};
|
||||
let text_content = if tag == "text" { block.to_md() } else { String::new() };
|
||||
BlockInfo { id: block.id(), kind_tag: tag, heading_level, heading_text, text_content }
|
||||
}).collect()
|
||||
}
|
||||
|
||||
/// concatenates the text-block sources within a module against an arbitrary block list.
|
||||
fn module_text_from_blocks(module: &crate::module::Module, blocks: &[crate::blocks::BoxedBlock]) -> String {
|
||||
let mut parts = Vec::new();
|
||||
for &bid in &module.block_ids {
|
||||
if let Some(block) = blocks.iter().find(|b| b.id() == bid) {
|
||||
if block.kind_tag() == "text" {
|
||||
parts.push(block.to_md());
|
||||
}
|
||||
}
|
||||
}
|
||||
parts.join("\n")
|
||||
}
|
||||
|
||||
pub(super) fn parse_let_binding(line: &str) -> Option<String> {
|
||||
let rest = line.strip_prefix("let ")?;
|
||||
let eq_pos = rest.find('=')?;
|
||||
|
|
|
|||
|
|
@ -4,41 +4,92 @@ impl super::EditorState {
|
|||
pub(super) fn update_find_matches(&mut self) {
|
||||
self.find.matches.clear();
|
||||
self.find.current = 0;
|
||||
self.find.regex_error = None;
|
||||
if self.find.query.is_empty() {
|
||||
return;
|
||||
}
|
||||
let text = self.get_clean_text();
|
||||
if self.find.regex_mode {
|
||||
self.update_find_matches_regex(&text);
|
||||
} else {
|
||||
self.update_find_matches_plain(&text);
|
||||
}
|
||||
}
|
||||
|
||||
fn update_find_matches_plain(&mut self, text: &str) {
|
||||
use super::types::FindMatch;
|
||||
let query_lower = self.find.query.to_lowercase();
|
||||
let text_lower = text.to_lowercase();
|
||||
|
||||
let mut line = 0usize;
|
||||
let mut col = 0usize;
|
||||
let mut byte = 0usize;
|
||||
|
||||
for (i, ch) in text_lower.char_indices() {
|
||||
while byte < i {
|
||||
byte += 1;
|
||||
}
|
||||
if ch == '\n' {
|
||||
line += 1;
|
||||
col = 0;
|
||||
continue;
|
||||
}
|
||||
if text_lower[i..].starts_with(&query_lower) {
|
||||
self.find.matches.push((line, col));
|
||||
let byte_end = text.char_indices()
|
||||
.skip_while(|(bi, _)| *bi < i)
|
||||
.skip(query_lower.chars().count())
|
||||
.map(|(bi, _)| bi)
|
||||
.next()
|
||||
.unwrap_or(text.len());
|
||||
self.find.matches.push(FindMatch {
|
||||
line, col, byte_start: i, byte_end,
|
||||
});
|
||||
}
|
||||
col += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn update_find_matches_regex(&mut self, text: &str) {
|
||||
use super::types::FindMatch;
|
||||
let re = match regex::Regex::new(&self.find.query) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
self.find.regex_error = Some(format!("{}", e));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let line_starts: Vec<(usize, usize)> = {
|
||||
let mut starts = vec![(0usize, 0usize)];
|
||||
let mut ln = 0usize;
|
||||
for (i, ch) in text.char_indices() {
|
||||
if ch == '\n' {
|
||||
ln += 1;
|
||||
starts.push((ln, i + 1));
|
||||
}
|
||||
}
|
||||
starts
|
||||
};
|
||||
|
||||
for m in re.find_iter(text) {
|
||||
let byte_start = m.start();
|
||||
let byte_end = m.end();
|
||||
let (line, line_start) = line_starts
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|(_, bs)| *bs <= byte_start)
|
||||
.copied()
|
||||
.unwrap_or((0, 0));
|
||||
let col = text[line_start..byte_start].chars().count();
|
||||
self.find.matches.push(FindMatch {
|
||||
line, col, byte_start, byte_end,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn navigate_to_match(&mut self) {
|
||||
if self.find.matches.is_empty() {
|
||||
return;
|
||||
}
|
||||
let idx = self.find.current.min(self.find.matches.len() - 1);
|
||||
let (line, col) = self.find.matches[idx];
|
||||
let m = &self.find.matches[idx];
|
||||
self.safe_move_to(Cursor {
|
||||
position: Position { line, column: col },
|
||||
position: Position { line: m.line, column: m.col },
|
||||
selection: None,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -113,12 +113,10 @@ impl EditorState {
|
|||
.width(Length::Fill)
|
||||
.padding(Padding { top: 3.0, right: 10.0, bottom: 3.0, left: 10.0 })
|
||||
.style(move |_theme: &Theme| {
|
||||
let p = palette::current();
|
||||
let darken = |c: Color| Color { r: c.r * 0.45, g: c.g * 0.45, b: c.b * 0.45, a: c.a };
|
||||
let bg = match render_mode {
|
||||
RenderMode::Live => darken(p.mauve),
|
||||
RenderMode::Editor => darken(p.blue),
|
||||
RenderMode::View => darken(p.pink),
|
||||
RenderMode::Live => Color::from_rgb(0.643, 0.184, 0.996),
|
||||
RenderMode::Editor => Color::from_rgb(0.278, 0.745, 0.988),
|
||||
RenderMode::View => Color::from_rgb(0.996, 0.306, 0.910),
|
||||
};
|
||||
container::Style {
|
||||
background: Some(Background::Color(bg)),
|
||||
|
|
@ -247,6 +245,7 @@ impl EditorState {
|
|||
let settings = SyntaxSettings {
|
||||
lang: lang_for_block.clone(),
|
||||
source: tb.content.text(),
|
||||
user_idents: self.cached_user_idents.clone(),
|
||||
};
|
||||
let editor_el: Element<'_, Message, Theme, iced_wgpu::Renderer> = editor
|
||||
.highlight_with::<SyntaxHighlighter>(
|
||||
|
|
@ -386,12 +385,9 @@ impl EditorState {
|
|||
fn minimap_overlay(&self) -> Option<Element<'_, Message, Theme, iced_wgpu::Renderer>> {
|
||||
if !self.minimap_enabled { return None; }
|
||||
if self.render_mode != RenderMode::Editor { return None; }
|
||||
if self.cached_minimap_lines.is_empty() { return None; }
|
||||
|
||||
let text = self.full_text();
|
||||
if text.is_empty() { return None; }
|
||||
|
||||
let lines = crate::minimap::classify_text(&text);
|
||||
if lines.is_empty() { return None; }
|
||||
let lines = self.cached_minimap_lines.clone();
|
||||
|
||||
let scroll = self.content().scroll_line();
|
||||
let line_h = self.line_height().max(1.0);
|
||||
|
|
@ -422,7 +418,7 @@ impl EditorState {
|
|||
Some(aligned.into())
|
||||
}
|
||||
|
||||
/// renders the spillover popup of the first table that has one open
|
||||
/// renders the spillover popup for the first table with an open spillover cell.
|
||||
fn spillover_view(&self) -> Option<Element<'_, Message, Theme, iced_wgpu::Renderer>> {
|
||||
let p = palette::current();
|
||||
let cell_text = self.layout.iter()
|
||||
|
|
@ -724,6 +720,7 @@ impl EditorState {
|
|||
let settings = SyntaxSettings {
|
||||
lang: lang_for_block,
|
||||
source: tb.content.text(),
|
||||
user_idents: syntax::scan_user_idents_in(&self.full_text()),
|
||||
};
|
||||
editor
|
||||
.highlight_with::<SyntaxHighlighter>(
|
||||
|
|
@ -935,7 +932,7 @@ impl EditorState {
|
|||
.into()
|
||||
}
|
||||
|
||||
/// returns the dropdown panel for the open category, anchored under its strip button
|
||||
/// returns the dropdown panel for the open category, anchored under the strip button.
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
fn menu_dropdown(&self, cat: MenuCategory) -> Element<'_, Message, Theme, iced_wgpu::Renderer> {
|
||||
let p = palette::current();
|
||||
|
|
@ -1272,7 +1269,10 @@ impl EditorState {
|
|||
.width(Length::FillPortion(3))
|
||||
.style(find_input_style);
|
||||
|
||||
let match_label = if self.find.matches.is_empty() {
|
||||
let match_label = if let Some(ref err) = self.find.regex_error {
|
||||
let short = if err.len() > 20 { format!("{}...", &err[..20]) } else { err.clone() };
|
||||
short
|
||||
} else if self.find.matches.is_empty() {
|
||||
if self.find.query.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
|
|
@ -1299,7 +1299,10 @@ impl EditorState {
|
|||
.into()
|
||||
};
|
||||
|
||||
let regex_label = if self.find.regex_mode { ".*" } else { "Aa" };
|
||||
|
||||
let row = iced_widget::row![
|
||||
btn(regex_label.into(), Message::ToggleRegex),
|
||||
search_input,
|
||||
label,
|
||||
btn("Prev".into(), Message::FindPrev),
|
||||
|
|
@ -1402,7 +1405,7 @@ fn block_editor_id(block_id: u64) -> WidgetId {
|
|||
}
|
||||
|
||||
|
||||
/// finds the first empty `[]` (whitespace allowed inside) and replaces it with `[<addr>]`.
|
||||
/// finds the first empty [] slot and replaces the contents with the given address.
|
||||
fn splice_first_empty_slot(text: &str, addr: &str) -> Option<String> {
|
||||
let bytes = text.as_bytes();
|
||||
let mut i = 0;
|
||||
|
|
@ -1526,27 +1529,10 @@ fn lang_from_extension(ext: &str) -> Option<String> {
|
|||
"zig" => "zig",
|
||||
"sql" => "sql",
|
||||
"mk" => "make",
|
||||
"cord" | "cordial" => "rust",
|
||||
// cordial uses a dedicated line-classifier, no tree-sitter binding.
|
||||
"cord" | "cordial" => return None,
|
||||
_ => return None,
|
||||
};
|
||||
Some(lang.to_string())
|
||||
}
|
||||
|
||||
fn detect_lang_from_content(text: &str) -> Option<String> {
|
||||
let keywords = ["fn ", "let ", "if ", "else ", "while ", "for ", "/="];
|
||||
let mut hits = 0;
|
||||
for line in text.lines().take(50) {
|
||||
let trimmed = line.trim();
|
||||
for kw in &keywords {
|
||||
if trimmed.starts_with(kw) || trimmed.contains(&format!(" {kw}")) {
|
||||
hits += 1;
|
||||
}
|
||||
}
|
||||
if hits >= 2 {
|
||||
return Some("rust".into());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -28,12 +28,13 @@ impl super::EditorState {
|
|||
}
|
||||
}
|
||||
|
||||
/// returns the clean markdown body; the archive lives in a separate channel.
|
||||
/// returns the clean markdown body without the archive comment.
|
||||
pub fn save_doc(&mut self) -> String {
|
||||
self.refresh_notes_lib_rs();
|
||||
self.get_clean_text()
|
||||
}
|
||||
|
||||
/// returns the archive zip bytes the shell should embed for in-library .md files.
|
||||
/// returns the archive zip bytes the shell embeds into .md files.
|
||||
pub fn save_sidecar_bytes(&mut self) -> Option<Vec<u8>> {
|
||||
self.rebuild_modules();
|
||||
let sidecar = self.build_sidecar();
|
||||
|
|
@ -41,6 +42,33 @@ impl super::EditorState {
|
|||
sidecar::build_archive_bytes(&sidecar, &block_files)
|
||||
}
|
||||
|
||||
/// rewrites the notes-folder lib.rs to declare every sibling note as a pub mod.
|
||||
pub fn refresh_notes_lib_rs(&self) {
|
||||
let dir = self.notes_dir();
|
||||
if !dir.is_dir() { return; }
|
||||
|
||||
let mut names: Vec<String> = Vec::new();
|
||||
let Ok(entries) = std::fs::read_dir(&dir) else { return };
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|s| s.to_str()) != Some("md") { continue; }
|
||||
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else { continue };
|
||||
names.push(stem.to_string());
|
||||
}
|
||||
names.sort();
|
||||
names.dedup();
|
||||
|
||||
let mut body = String::from("// auto-generated by acord. every note in this directory becomes a public module.\n\n");
|
||||
for name in &names {
|
||||
let ident = note_name_to_ident(name);
|
||||
if ident.is_empty() { continue; }
|
||||
body.push_str(&format!("pub mod {};\n", ident));
|
||||
}
|
||||
|
||||
let target = dir.join("lib.rs");
|
||||
let _ = std::fs::write(target, body);
|
||||
}
|
||||
|
||||
/// applies an archive zip's metadata back into the document.
|
||||
pub fn apply_sidecar_bytes(&mut self, bytes: &[u8]) {
|
||||
if let Some(sc) = sidecar::extract_archive_bytes(bytes) {
|
||||
|
|
@ -48,7 +76,7 @@ impl super::EditorState {
|
|||
}
|
||||
}
|
||||
|
||||
/// builds the per-block `.cord` source files for the sidecar archive
|
||||
/// builds the per-block .cord source files included in the sidecar archive
|
||||
pub fn build_block_files(&self) -> Vec<sidecar::BlockFile> {
|
||||
use std::collections::HashSet;
|
||||
let mut files = Vec::with_capacity(self.modules.len());
|
||||
|
|
@ -208,7 +236,7 @@ impl super::EditorState {
|
|||
}
|
||||
}
|
||||
|
||||
let editor_w = 800.0f32; // approximate; TODO: pass actual width
|
||||
let editor_w = 800.0f32;
|
||||
|
||||
for (anchor, src, alt) in new_srcs {
|
||||
if !self.image_cache.contains_key(&src) {
|
||||
|
|
@ -234,7 +262,7 @@ impl super::EditorState {
|
|||
}
|
||||
}
|
||||
|
||||
/// parses a markdown image reference `` from a line
|
||||
/// parses a markdown image reference  from a line
|
||||
pub(super) fn parse_image_ref(line: &str) -> Option<(String, String)> {
|
||||
let trimmed = line.trim_start();
|
||||
if !trimmed.starts_with("![") { return None; }
|
||||
|
|
@ -298,3 +326,19 @@ pub fn write_clipboard_image_to_cache(img: &arboard::ImageData) -> Option<String
|
|||
}
|
||||
Some(path.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
/// converts a note filename stem into a valid rust identifier.
|
||||
fn note_name_to_ident(stem: &str) -> String {
|
||||
let mut out = String::with_capacity(stem.len());
|
||||
for ch in stem.chars() {
|
||||
if ch.is_ascii_alphanumeric() || ch == '_' {
|
||||
out.push(ch);
|
||||
} else {
|
||||
out.push('_');
|
||||
}
|
||||
}
|
||||
if out.chars().next().is_some_and(|c| c.is_ascii_digit()) {
|
||||
out.insert_str(0, "n_");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ pub struct EditorState {
|
|||
pub mods: Modifiers,
|
||||
|
||||
pub(crate) selection: crate::selection::Selection,
|
||||
/// the single path that keys are routed to
|
||||
/// current key-routing path
|
||||
pub(crate) focus: Option<crate::selection::NodePath>,
|
||||
/// path of the cell currently in text-input edit mode
|
||||
#[allow(dead_code)]
|
||||
|
|
@ -74,13 +74,11 @@ pub struct EditorState {
|
|||
pub line_indicator: LineIndicator,
|
||||
/// whether the gutter line numbers cycle through the rainbow palette
|
||||
pub gutter_rainbow: bool,
|
||||
/// minimap on/off master switch
|
||||
pub minimap_enabled: bool,
|
||||
/// minimap fades in only on hover (and only when no mouse button is held)
|
||||
/// hover-only fade gate.
|
||||
pub minimap_hover_only: bool,
|
||||
/// last frame any pointer button was held — suppresses the hover reveal
|
||||
/// suppresses the hover reveal during a pointer drag.
|
||||
pub minimap_drag_suppress: bool,
|
||||
/// pointer is currently inside the minimap region
|
||||
pub minimap_hovered: bool,
|
||||
|
||||
/// pending clipboard text, drained by the shell each frame
|
||||
|
|
@ -106,6 +104,11 @@ pub struct EditorState {
|
|||
pub active_free: Option<FreeNodeId>,
|
||||
pub layout_mode: LayoutMode,
|
||||
pub snapping: bool,
|
||||
|
||||
/// cached document-wide identifier rainbow map, recomputed on text change only.
|
||||
pub(super) cached_user_idents: HashMap<String, u8>,
|
||||
/// cached minimap line data, recomputed on text change only.
|
||||
pub(super) cached_minimap_lines: Vec<crate::minimap::MinimapLine>,
|
||||
}
|
||||
|
||||
impl EditorState {
|
||||
|
|
@ -122,7 +125,7 @@ impl EditorState {
|
|||
preview: false,
|
||||
render_mode: RenderMode::Live,
|
||||
parsed: Vec::new(),
|
||||
lang: Some("rust".into()),
|
||||
lang: None,
|
||||
scroll_offset: 0.0,
|
||||
eval_dirty: false,
|
||||
last_edit: Instant::now(),
|
||||
|
|
@ -170,9 +173,18 @@ impl EditorState {
|
|||
active_free: None,
|
||||
layout_mode: LayoutMode::Free,
|
||||
snapping: false,
|
||||
cached_user_idents: HashMap::new(),
|
||||
cached_minimap_lines: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// recomputes cached syntax idents and minimap lines from current document text.
|
||||
pub(super) fn refresh_text_caches(&mut self) {
|
||||
let text = self.full_text();
|
||||
self.cached_user_idents = crate::syntax::scan_user_idents_in(&text);
|
||||
self.cached_minimap_lines = crate::minimap::classify_text(&text);
|
||||
}
|
||||
|
||||
/// returns the queued shell action and clears it
|
||||
pub fn take_pending_shell_action(&mut self) -> Option<ShellAction> {
|
||||
self.pending_shell_action.take()
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ impl super::EditorState {
|
|||
self.push_block(Box::new(TextBlock::new(blocks::next_id(), text, 0, lang)));
|
||||
self.recount_block_lines();
|
||||
self.set_focused_block(0);
|
||||
self.refresh_text_caches();
|
||||
return;
|
||||
}
|
||||
let lang = self.lang_str();
|
||||
|
|
@ -110,7 +111,7 @@ impl super::EditorState {
|
|||
self.reparse();
|
||||
}
|
||||
|
||||
/// replaces a byte range in the current content with `replacement`
|
||||
/// replaces a byte range in the current content with the given string
|
||||
pub(super) fn replace_range(&mut self, start: usize, end: usize, replacement: &str) {
|
||||
let text = self.content().text();
|
||||
if start > end || end > text.len() { return; }
|
||||
|
|
@ -162,7 +163,7 @@ impl super::EditorState {
|
|||
}
|
||||
}
|
||||
|
||||
/// toggles the `> ` blockquote prefix on the current line
|
||||
/// toggles the blockquote prefix on the current line
|
||||
pub(super) fn toggle_blockquote(&mut self) {
|
||||
let text = self.content().text();
|
||||
let cursor = self.content().cursor();
|
||||
|
|
@ -190,14 +191,12 @@ impl super::EditorState {
|
|||
let text = self.content().text();
|
||||
let cursor = self.content().cursor();
|
||||
let pos = byte_offset_for_cursor(&text, &cursor.position);
|
||||
// 1. Innermost unclosed delimiter? Close it.
|
||||
if let Some(close) = innermost_unclosed_delim(&text[..pos]) {
|
||||
self.content_mut().perform(text_widget::Action::Edit(
|
||||
text_widget::Edit::Paste(Arc::new(close.to_string())),
|
||||
));
|
||||
return;
|
||||
}
|
||||
// 2. Forward to the next outer scope's closing delimiter and step past it.
|
||||
if let Some(jump_to) = next_closing_delim_after(&text, pos) {
|
||||
let target = line_col_for_byte(&text, jump_to + 1);
|
||||
self.content_mut().perform(text_widget::Action::Move(Motion::DocumentStart));
|
||||
|
|
@ -210,11 +209,10 @@ impl super::EditorState {
|
|||
}
|
||||
return;
|
||||
}
|
||||
// 3. At block scope: ensure newline sandwich.
|
||||
self.ensure_newline_sandwich();
|
||||
}
|
||||
|
||||
/// places the cursor on its own line with one blank line of padding above and below
|
||||
/// places the cursor on a blank line with one line of padding above and below
|
||||
pub(super) fn ensure_newline_sandwich(&mut self) {
|
||||
let text = self.content().text();
|
||||
let cursor = self.content().cursor();
|
||||
|
|
@ -258,17 +256,17 @@ pub(super) fn leading_whitespace(line: &str) -> &str {
|
|||
&line[..end]
|
||||
}
|
||||
|
||||
/// counts consecutive trailing occurrences of `c` in `s`
|
||||
/// counts consecutive trailing occurrences of a char in the string
|
||||
pub(super) fn count_trailing_char(s: &str, c: char) -> usize {
|
||||
s.chars().rev().take_while(|&x| x == c).count()
|
||||
}
|
||||
|
||||
/// counts consecutive leading occurrences of `c` in `s`
|
||||
/// counts consecutive leading occurrences of a char in the string
|
||||
pub(super) fn count_leading_char(s: &str, c: char) -> usize {
|
||||
s.chars().take_while(|&x| x == c).count()
|
||||
}
|
||||
|
||||
/// converts a line/column position to a byte offset in `text`
|
||||
/// converts a line/column position to a byte offset
|
||||
pub(super) fn byte_offset_for_cursor(text: &str, pos: &text_widget::Position) -> usize {
|
||||
let mut byte = 0usize;
|
||||
for (line_idx, line) in text.split_inclusive('\n').enumerate() {
|
||||
|
|
@ -283,7 +281,7 @@ pub(super) fn byte_offset_for_cursor(text: &str, pos: &text_widget::Position) ->
|
|||
text.len()
|
||||
}
|
||||
|
||||
/// inverse of `byte_offset_for_cursor`
|
||||
/// converts a byte offset to a line/column position
|
||||
pub(super) fn line_col_for_byte(text: &str, byte: usize) -> (usize, usize) {
|
||||
let mut acc = 0usize;
|
||||
let mut line_idx = 0usize;
|
||||
|
|
@ -299,7 +297,7 @@ pub(super) fn line_col_for_byte(text: &str, byte: usize) -> (usize, usize) {
|
|||
(last_line, text.lines().last().map(|l| l.chars().count()).unwrap_or(0))
|
||||
}
|
||||
|
||||
/// walks `text` left-to-right tracking a delimiter stack
|
||||
/// returns the innermost unclosed delimiter from a left-to-right stack walk
|
||||
pub(super) fn innermost_unclosed_delim(text: &str) -> Option<char> {
|
||||
let mut stack: Vec<char> = Vec::new();
|
||||
for c in text.chars() {
|
||||
|
|
|
|||
|
|
@ -66,9 +66,9 @@ pub enum Message {
|
|||
ToggleBlockquote,
|
||||
/// wraps the selection in matching delimiters, or unwraps an existing pair
|
||||
WrapWith(&'static str, &'static str),
|
||||
/// inserts paired `[]` or `{}` and places the cursor between them
|
||||
/// inserts paired brackets and places the cursor between them
|
||||
AutoPair(&'static str, &'static str),
|
||||
/// incremental scope exit, then newline-sandwich placement
|
||||
/// incremental scope exit with newline-sandwich placement
|
||||
FixUp,
|
||||
Evaluate,
|
||||
/// evaluates every module in document order
|
||||
|
|
@ -87,6 +87,7 @@ pub enum Message {
|
|||
ReplaceQueryChanged(String),
|
||||
ReplaceOne,
|
||||
ReplaceAll,
|
||||
ToggleRegex,
|
||||
TableMsg(usize, TableMessage),
|
||||
DeleteCurrentTable,
|
||||
FocusedTableOp(TableMessage),
|
||||
|
|
@ -156,9 +157,9 @@ pub enum Message {
|
|||
ToggleMinimap(bool),
|
||||
/// toggles between hover-only fade and always-on visibility.
|
||||
ToggleMinimapHoverOnly(bool),
|
||||
/// pointer entered or left the minimap region.
|
||||
/// reports pointer hover state over the minimap.
|
||||
MinimapHover(bool),
|
||||
/// click on the minimap, value is the y-fraction in 0.0..1.0.
|
||||
/// minimap click at y-fraction 0.0..1.0.
|
||||
MinimapJump(f32),
|
||||
}
|
||||
|
||||
|
|
@ -289,7 +290,7 @@ pub(super) const IMAGE_MAX_H: f32 = 600.0;
|
|||
pub(super) const IMAGE_PADDING: f32 = 48.0;
|
||||
pub(super) const IMAGE_VPAD: f32 = 4.0;
|
||||
|
||||
/// reference to a computed layer item for interleaved rendering
|
||||
/// reference to a computed layer item in the interleaved stream
|
||||
pub(super) enum LayerItem<'a> {
|
||||
Inline(&'a InlineResult),
|
||||
Table(&'a ComputedTable),
|
||||
|
|
@ -315,7 +316,7 @@ pub struct FreePlacement {
|
|||
pub h: f32,
|
||||
}
|
||||
|
||||
/// pending drag state for promoting a block onto a free layer.
|
||||
/// pending drag state during block-to-free-layer promotion.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PromoteDragState {
|
||||
pub node_id: FreeNodeId,
|
||||
|
|
@ -327,7 +328,7 @@ pub struct PromoteDragState {
|
|||
pub fallback_table_idx: Option<usize>,
|
||||
}
|
||||
|
||||
/// pending drag state for resizing a free-layer object from an edge band.
|
||||
/// pending drag state during edge-band resize of a free-layer object.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResizeDragState {
|
||||
pub node_id: FreeNodeId,
|
||||
|
|
@ -377,8 +378,19 @@ pub struct FindState {
|
|||
pub visible: bool,
|
||||
pub query: String,
|
||||
pub replacement: String,
|
||||
pub matches: Vec<(usize, usize)>,
|
||||
pub matches: Vec<FindMatch>,
|
||||
pub current: usize,
|
||||
pub regex_mode: bool,
|
||||
pub regex_error: Option<String>,
|
||||
}
|
||||
|
||||
/// byte-range match with the line/col of the start position.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FindMatch {
|
||||
pub line: usize,
|
||||
pub col: usize,
|
||||
pub byte_start: usize,
|
||||
pub byte_end: usize,
|
||||
}
|
||||
|
||||
impl FindState {
|
||||
|
|
@ -389,6 +401,8 @@ impl FindState {
|
|||
replacement: String::new(),
|
||||
matches: Vec::new(),
|
||||
current: 0,
|
||||
regex_mode: false,
|
||||
regex_error: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,12 +12,12 @@ use super::types::{
|
|||
ContextMenuState, InlinePressState, LayoutMode, Message, RenderMode,
|
||||
ResizeDragState, ShellAction, FIND_INPUT_ID,
|
||||
};
|
||||
use super::{block_editor_id, detect_lang_from_content};
|
||||
use super::block_editor_id;
|
||||
use super::eval::parse_let_binding;
|
||||
use super::text_ops::leading_whitespace;
|
||||
|
||||
impl super::EditorState {
|
||||
/// returns true when `message` is safe to dispatch in view mode
|
||||
/// returns true for messages safe to dispatch in view mode
|
||||
pub(super) fn message_is_view_safe(message: &Message) -> bool {
|
||||
match message {
|
||||
Message::SetRenderMode(_) => true,
|
||||
|
|
@ -28,7 +28,8 @@ impl super::EditorState {
|
|||
Message::ToggleFind | Message::HideFind => true,
|
||||
Message::FindQueryChanged(_)
|
||||
| Message::FindNext
|
||||
| Message::FindPrev => true,
|
||||
| Message::FindPrev
|
||||
| Message::ToggleRegex => true,
|
||||
Message::ReplaceQueryChanged(_) => true,
|
||||
Message::TableMoveUp
|
||||
| Message::TableMoveDown
|
||||
|
|
@ -165,9 +166,6 @@ impl super::EditorState {
|
|||
|
||||
if is_edit {
|
||||
self.last_edit = Instant::now();
|
||||
if self.lang.is_none() {
|
||||
self.lang = detect_lang_from_content(&self.content().text());
|
||||
}
|
||||
self.reparse();
|
||||
|
||||
if self.render_mode == RenderMode::Live {
|
||||
|
|
@ -187,36 +185,19 @@ impl super::EditorState {
|
|||
self.push_undo_snapshot();
|
||||
self.redo_stack.clear();
|
||||
|
||||
let (match_line, match_col) = self.find.matches[self.find.current];
|
||||
let clean = self.get_clean_text();
|
||||
let query_lower = self.find.query.to_lowercase();
|
||||
let query_char_count = query_lower.chars().count();
|
||||
let mut lines: Vec<String> = clean.lines().map(|l| l.to_string()).collect();
|
||||
if match_line < lines.len() {
|
||||
let line = &lines[match_line];
|
||||
let chars: Vec<(usize, char)> = line.char_indices().collect();
|
||||
if match_col < chars.len() {
|
||||
let window: String = chars[match_col..]
|
||||
.iter()
|
||||
.take(query_char_count)
|
||||
.map(|(_, c)| *c)
|
||||
.collect::<String>()
|
||||
.to_lowercase();
|
||||
if window == query_lower {
|
||||
let byte_start = chars[match_col].0;
|
||||
let byte_end = if match_col + query_char_count < chars.len() {
|
||||
chars[match_col + query_char_count].0
|
||||
} else {
|
||||
line.len()
|
||||
};
|
||||
let before = &line[..byte_start];
|
||||
let after = &line[byte_end..];
|
||||
lines[match_line] =
|
||||
format!("{before}{}{after}", self.find.replacement);
|
||||
}
|
||||
}
|
||||
let m = &self.find.matches[self.find.current];
|
||||
if m.byte_start > clean.len() || m.byte_end > clean.len() {
|
||||
return;
|
||||
}
|
||||
let new_text = lines.join("\n");
|
||||
|
||||
let replacement = if self.find.regex_mode {
|
||||
expand_regex_replacement(&self.find.query, &clean[m.byte_start..m.byte_end], &self.find.replacement)
|
||||
} else {
|
||||
self.find.replacement.clone()
|
||||
};
|
||||
|
||||
let new_text = format!("{}{}{}", &clean[..m.byte_start], replacement, &clean[m.byte_end..]);
|
||||
self.set_text(&new_text);
|
||||
self.run_eval();
|
||||
self.update_find_matches();
|
||||
|
|
@ -235,28 +216,27 @@ impl super::EditorState {
|
|||
self.redo_stack.clear();
|
||||
|
||||
let clean = self.get_clean_text();
|
||||
let query_lower = self.find.query.to_lowercase();
|
||||
let query_char_count = query_lower.chars().count();
|
||||
let chars: Vec<(usize, char)> = clean.char_indices().collect();
|
||||
let mut result = String::with_capacity(clean.len());
|
||||
let mut ci = 0;
|
||||
while ci < chars.len() {
|
||||
let remaining = chars.len() - ci;
|
||||
if remaining >= query_char_count {
|
||||
let window: String = chars[ci..ci + query_char_count]
|
||||
.iter()
|
||||
.map(|(_, c)| *c)
|
||||
.collect::<String>()
|
||||
.to_lowercase();
|
||||
if window == query_lower {
|
||||
result.push_str(&self.find.replacement);
|
||||
ci += query_char_count;
|
||||
continue;
|
||||
}
|
||||
|
||||
if self.find.regex_mode {
|
||||
if let Ok(re) = regex::Regex::new(&self.find.query) {
|
||||
let result = re.replace_all(&clean, self.find.replacement.as_str()).into_owned();
|
||||
self.set_text(&result);
|
||||
self.run_eval();
|
||||
self.update_find_matches();
|
||||
return;
|
||||
}
|
||||
result.push(chars[ci].1);
|
||||
ci += 1;
|
||||
}
|
||||
|
||||
let mut result = String::with_capacity(clean.len());
|
||||
let mut pos = 0;
|
||||
for m in &self.find.matches {
|
||||
if m.byte_start >= pos && m.byte_end <= clean.len() {
|
||||
result.push_str(&clean[pos..m.byte_start]);
|
||||
result.push_str(&self.find.replacement);
|
||||
pos = m.byte_end;
|
||||
}
|
||||
}
|
||||
result.push_str(&clean[pos..]);
|
||||
self.set_text(&result);
|
||||
self.run_eval();
|
||||
self.update_find_matches();
|
||||
|
|
@ -338,9 +318,6 @@ impl super::EditorState {
|
|||
None
|
||||
};
|
||||
|
||||
// formula-fill interception: clicking a different cell while
|
||||
// editing a `/=` formula fills its first empty `[]` slot with
|
||||
// the clicked cell's address instead of switching focus.
|
||||
if let Some((r, c)) = select_target {
|
||||
if self.try_fill_formula_slot(idx, r, c) {
|
||||
return;
|
||||
|
|
@ -552,6 +529,14 @@ impl super::EditorState {
|
|||
}
|
||||
Message::ReplaceOne => self.handle_replace_one(),
|
||||
Message::ReplaceAll => self.handle_replace_all(),
|
||||
Message::ToggleRegex => {
|
||||
self.find.regex_mode = !self.find.regex_mode;
|
||||
self.update_find_matches();
|
||||
if !self.find.matches.is_empty() {
|
||||
self.find.current = 0;
|
||||
self.navigate_to_match();
|
||||
}
|
||||
}
|
||||
Message::TableMsg(idx, tmsg) => self.handle_table_msg(idx, tmsg),
|
||||
Message::DeleteCurrentTable => {
|
||||
if let Some(target) = self.focused_table_index() {
|
||||
|
|
@ -950,3 +935,30 @@ impl super::EditorState {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn expand_regex_replacement(pattern: &str, matched_text: &str, replacement: &str) -> String {
|
||||
let Ok(re) = regex::Regex::new(pattern) else { return replacement.to_string() };
|
||||
let Some(caps) = re.captures(matched_text) else { return replacement.to_string() };
|
||||
let mut result = String::new();
|
||||
let bytes = replacement.as_bytes();
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] == b'$' && i + 1 < bytes.len() {
|
||||
if bytes[i + 1] == b'0' {
|
||||
result.push_str(caps.get(0).map(|m| m.as_str()).unwrap_or(""));
|
||||
i += 2;
|
||||
} else if bytes[i + 1].is_ascii_digit() {
|
||||
let idx = (bytes[i + 1] - b'0') as usize;
|
||||
result.push_str(caps.get(idx).map(|m| m.as_str()).unwrap_or(""));
|
||||
i += 2;
|
||||
} else {
|
||||
result.push('$');
|
||||
i += 1;
|
||||
}
|
||||
} else {
|
||||
result.push(bytes[i] as char);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,4 @@
|
|||
//! Export a note as a standalone Rust crate. The crate mirrors the sidecar
|
||||
//! ZIP's structure (src/blocks/*.cord + config.toml) but is written to a
|
||||
//! user-chosen folder on disk with the full Cargo scaffolding (Cargo.toml,
|
||||
//! build.sh, install.sh, README.md, src/main.rs, src/lib.rs).
|
||||
//!
|
||||
//! The main module (src/main.rs) runs a REPL using acord-core's interpreter.
|
||||
//! Each `.cord` block is a submodule loaded into the REPL's scope at startup.
|
||||
//! AOT codegen (Cordial → Rust source) is planned separately — build.sh is a
|
||||
//! stub that currently just does `cargo build --release` of the REPL binary.
|
||||
//! Exports a note as a standalone Rust crate with Cargo scaffolding and REPL binary.
|
||||
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
|
|
@ -16,8 +8,7 @@ use crate::editor::EditorState;
|
|||
use crate::heading_block::HeadingBlock;
|
||||
use crate::text_block::TextBlock;
|
||||
|
||||
/// Convert a free-form string to hyphen-form for use as a crate/folder name.
|
||||
/// Lowercase, spaces and underscores become `-`, non-alphanumeric stripped.
|
||||
/// converts a free-form string to lowercase hyphen-form crate name.
|
||||
pub fn to_hyphen_name(s: &str) -> String {
|
||||
s.trim()
|
||||
.to_lowercase()
|
||||
|
|
@ -29,9 +20,7 @@ pub fn to_hyphen_name(s: &str) -> String {
|
|||
.to_string()
|
||||
}
|
||||
|
||||
/// Export the current note as a standalone Rust crate at `out_dir`. The
|
||||
/// folder name is the crate name; spaces/underscores in the user-supplied
|
||||
/// name get converted to hyphens. Returns Ok(path) on success.
|
||||
/// writes a standalone Rust crate to out_dir/hyphenated-name/.
|
||||
pub fn export_crate(state: &EditorState, out_dir: &Path, name: &str) -> Result<PathBuf, String> {
|
||||
let crate_name = to_hyphen_name(name);
|
||||
if crate_name.is_empty() {
|
||||
|
|
@ -44,19 +33,16 @@ pub fn export_crate(state: &EditorState, out_dir: &Path, name: &str) -> Result<P
|
|||
fs::create_dir_all(&blocks_dir)
|
||||
.map_err(|e| format!("create {}: {}", blocks_dir.display(), e))?;
|
||||
|
||||
// Write per-block .cord files (reuses the same format as the sidecar)
|
||||
let block_files = state.build_block_files();
|
||||
for file in &block_files {
|
||||
let path = blocks_dir.join(&file.filename);
|
||||
write_file(&path, &file.content)?;
|
||||
}
|
||||
|
||||
// Write the three scaffolding files: Cargo.toml, main.rs, lib.rs
|
||||
write_file(&crate_dir.join("Cargo.toml"), &cargo_toml(&crate_name))?;
|
||||
write_file(&src_dir.join("main.rs"), &main_rs(&crate_name, &block_files))?;
|
||||
write_file(&src_dir.join("lib.rs"), &lib_rs(&block_files))?;
|
||||
|
||||
// Scripts + README + gitignore
|
||||
let build_path = crate_dir.join("build.sh");
|
||||
write_file(&build_path, &build_sh(&crate_name))?;
|
||||
make_executable(&build_path)?;
|
||||
|
|
|
|||
|
|
@ -28,20 +28,14 @@ struct AcordClipboard {
|
|||
#[cfg(not(target_os = "ios"))]
|
||||
impl clipboard::Clipboard for AcordClipboard {
|
||||
fn read(&self, _kind: clipboard::Kind) -> Option<String> {
|
||||
// arboard uses NSPasteboard on macOS, Win32 on Windows — no subprocess.
|
||||
// Image-first: if the pasteboard holds a bitmap, encode it to PNG in
|
||||
// the on-disk image cache and yield a markdown reference. Wrapping
|
||||
// newlines guarantee the `` lands as the only thing on its line
|
||||
// so `parse_image_ref` will pick it up.
|
||||
let mut board = self.board.borrow_mut();
|
||||
// encode pasteboard bitmap to PNG and yield a markdown image reference
|
||||
if let Ok(img) = board.get_image() {
|
||||
if let Some(path) = crate::editor::write_clipboard_image_to_cache(&img) {
|
||||
return Some(format!("\n\n", path));
|
||||
}
|
||||
}
|
||||
// Line-ending normalisation: web pages and cross-platform apps keep
|
||||
// `\r\n` in the pasteboard; collapse to `\n` so iced's buffer and
|
||||
// our gutter line counter agree.
|
||||
// normalize \r\n to \n
|
||||
board.get_text()
|
||||
.ok()
|
||||
.map(|s| s.replace("\r\n", "\n").replace('\r', "\n"))
|
||||
|
|
@ -52,8 +46,7 @@ impl clipboard::Clipboard for AcordClipboard {
|
|||
}
|
||||
}
|
||||
|
||||
/// iOS stub — UIPasteboard wiring lives on the Swift side, fed through the
|
||||
/// FFI shell-action bus when the user explicitly copies/pastes.
|
||||
/// iOS stub; UIPasteboard access handled on the Swift side via shell-action bus.
|
||||
#[cfg(target_os = "ios")]
|
||||
struct AcordClipboard;
|
||||
|
||||
|
|
@ -63,10 +56,7 @@ impl clipboard::Clipboard for AcordClipboard {
|
|||
fn write(&mut self, _kind: clipboard::Kind, _contents: String) {}
|
||||
}
|
||||
|
||||
/// Mac/Windows entry point used by the C FFI. Synthesizes the platform's
|
||||
/// display handle from the window pointer the Swift bridge provides.
|
||||
/// Returns None on platforms that need both display and window — those
|
||||
/// shells should call `create_native` directly.
|
||||
/// synthesizes platform display+window handles from a single native pointer.
|
||||
pub fn create(
|
||||
native_handle: *mut c_void,
|
||||
width: f32,
|
||||
|
|
@ -103,9 +93,7 @@ pub fn create(
|
|||
create_native(raw_display, raw_window, width, height, scale)
|
||||
}
|
||||
|
||||
/// Rust-native entry point. Takes typed handles directly so shells that own
|
||||
/// a winit Window (Linux, future Windows refactor) can build a surface
|
||||
/// without going through the C FFI's single-pointer compromise.
|
||||
/// creates the wgpu surface, device, queue, renderer, and viewport from typed handles.
|
||||
pub fn create_native(
|
||||
raw_display: RawDisplayHandle,
|
||||
raw_window: RawWindowHandle,
|
||||
|
|
@ -177,10 +165,7 @@ pub fn create_native(
|
|||
Shell::headless(),
|
||||
);
|
||||
|
||||
// NOTE: `Font::DEFAULT` is cosmic-text's hardcoded "Noto Sans Mono" which
|
||||
// iOS doesn't ship — we override per-span with EDITOR_FONT, but anything
|
||||
// unstyled falls back to this missing font. If iOS shows invisible text
|
||||
// while spans render, this is the suspect.
|
||||
// Font::DEFAULT = cosmic-text "Noto Sans Mono", absent on iOS; EDITOR_FONT overrides per-span
|
||||
crate::ios_dlog!("renderer init font=Font::DEFAULT (= cosmic 'Noto Sans Mono', iOS likely missing) editor_font={:?}", crate::syntax::EDITOR_FONT);
|
||||
let renderer = iced_wgpu::Renderer::new(engine, Font::DEFAULT, Pixels(16.0));
|
||||
|
||||
|
|
@ -208,17 +193,12 @@ pub fn create_native(
|
|||
state: EditorState::new(),
|
||||
events: initial_events,
|
||||
cursor: mouse::Cursor::Available(focus_point),
|
||||
// First frame must paint.
|
||||
needs_redraw: true,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn render(handle: &mut ViewportHandle) {
|
||||
// Idle-frame short circuit. The Swift CVDisplayLink ticks viewport_render() at
|
||||
// vsync regardless of activity. Without this gate we'd rebuild the entire widget
|
||||
// tree, run update + draw, and present a frame ~60 times per second forever.
|
||||
// We still wake up while `eval_dirty` is set so the eval debounce in
|
||||
// EditorState::tick() can fire after typing stops.
|
||||
// skip frame entirely when idle and no eval debounce pending
|
||||
let pending_events = !handle.events.is_empty();
|
||||
if !handle.needs_redraw && !handle.state.has_pending_eval() && !pending_events {
|
||||
return;
|
||||
|
|
@ -252,20 +232,10 @@ pub fn render(handle: &mut ViewportHandle) {
|
|||
let mut clipboard = AcordClipboard;
|
||||
let mut messages: Vec<Message> = Vec::new();
|
||||
let mut consumed: Vec<usize> = Vec::new();
|
||||
// Captured during the event scan, applied to `handle.state.mods` AFTER
|
||||
// `ui` is released — the UI build above borrows `&handle.state` so we
|
||||
// can't mutate any field of state while it's alive.
|
||||
let mut latest_mods: Option<keyboard::Modifiers> = None;
|
||||
// Cmd+A escalation: armed by the first press, escalates on the second.
|
||||
// Some(true) = arm for next press, Some(false) = disarm. None = unchanged.
|
||||
// Events are scanned in order so the LAST write wins — a Cmd+A followed
|
||||
// by a mouse click in the same frame correctly disarms.
|
||||
let mut new_cmd_a_armed: Option<bool> = None;
|
||||
|
||||
for (ev_idx, event) in handle.events.iter().enumerate() {
|
||||
// Default-disarm Cmd+A for any user input. The Cmd+A arm below
|
||||
// overwrites with Some(true) when it actually wants to arm. Events
|
||||
// are scanned in order so the LAST write wins for the frame.
|
||||
let is_user_input = matches!(
|
||||
event,
|
||||
Event::Keyboard(keyboard::Event::KeyPressed { .. })
|
||||
|
|
@ -275,11 +245,6 @@ pub fn render(handle: &mut ViewportHandle) {
|
|||
new_cmd_a_armed = Some(false);
|
||||
}
|
||||
|
||||
// View mode: drop the keys that would write to the document so
|
||||
// the iced widget never sees them. `i` and `/` (mode-switch)
|
||||
// fall through to their own match arms below; mouse events,
|
||||
// scroll, navigation/selection keys, and modifier-prefixed
|
||||
// shortcuts also pass through and get message-layer gated.
|
||||
if handle.state.render_mode == RenderMode::View {
|
||||
let is_typing_char = match event {
|
||||
Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
|
|
@ -322,32 +287,21 @@ pub fn render(handle: &mut ViewportHandle) {
|
|||
messages.push(Message::InsertTable);
|
||||
consumed.push(ev_idx);
|
||||
}
|
||||
// Cmd+A — first press lets the focused block do its
|
||||
// local select-all (text_editor selects its text;
|
||||
// table cells in select mode upgrade to whole-table
|
||||
// selection). Second press while still armed escalates
|
||||
// to whole-document selection.
|
||||
"a" => {
|
||||
if handle.state.cmd_a_armed {
|
||||
messages.push(Message::SelectAllBlocks);
|
||||
new_cmd_a_armed = Some(false);
|
||||
consumed.push(ev_idx);
|
||||
} else {
|
||||
// First press path. Decide what "local select all"
|
||||
// means for the focused block.
|
||||
if handle.state.table_is_focused_block()
|
||||
&& !handle.state.focused_table_is_select_all()
|
||||
&& handle.state.editing.is_none()
|
||||
{
|
||||
// Cell-selected table → escalate to whole-table.
|
||||
messages.push(Message::FocusedTableOp(
|
||||
TableMessage::SelectAll,
|
||||
));
|
||||
consumed.push(ev_idx);
|
||||
}
|
||||
// For text blocks (and table cells in edit mode),
|
||||
// do NOT consume — let iced's text_editor /
|
||||
// text_input handle Cmd+A natively.
|
||||
new_cmd_a_armed = Some(true);
|
||||
}
|
||||
}
|
||||
|
|
@ -364,29 +318,23 @@ pub fn render(handle: &mut ViewportHandle) {
|
|||
consumed.push(ev_idx);
|
||||
}
|
||||
"x" if modifiers.shift() => {
|
||||
// Cmd+Shift+X: strikethrough (Cmd+S is reserved for save)
|
||||
messages.push(Message::ToggleStrike);
|
||||
consumed.push(ev_idx);
|
||||
}
|
||||
"." if modifiers.shift() => {
|
||||
// Cmd+> : blockquote prefix
|
||||
messages.push(Message::ToggleBlockquote);
|
||||
consumed.push(ev_idx);
|
||||
}
|
||||
"\"" | "'" => {
|
||||
// Cmd+" / Cmd+' wrap selection in matching quotes.
|
||||
let q: &'static str = if c.as_str() == "\"" { "\"" } else { "'" };
|
||||
messages.push(Message::WrapWith(q, q));
|
||||
consumed.push(ev_idx);
|
||||
}
|
||||
"9" | "(" => {
|
||||
// Cmd+9 (or Cmd+Shift+9 = Cmd+( ) wraps in parens.
|
||||
messages.push(Message::WrapWith("(", ")"));
|
||||
consumed.push(ev_idx);
|
||||
}
|
||||
"0" if modifiers.shift() => {
|
||||
// Cmd+Shift+0: reset zoom (moved off Cmd+0 to make
|
||||
// room for the FixUp catch-all).
|
||||
messages.push(Message::ZoomReset);
|
||||
consumed.push(ev_idx);
|
||||
}
|
||||
|
|
@ -395,11 +343,6 @@ pub fn render(handle: &mut ViewportHandle) {
|
|||
consumed.push(ev_idx);
|
||||
}
|
||||
"c" => {
|
||||
// Table cell copy: when the focused block is a table
|
||||
// with a selection (or an open spillover popup), Cmd+C
|
||||
// copies the cell payload before iced's text widget
|
||||
// sees the event. Otherwise let it fall through so
|
||||
// text-block / cell-edit copy keep working.
|
||||
if handle.state.should_intercept_table_copy() {
|
||||
messages.push(Message::CopyFocusedTableSelection);
|
||||
consumed.push(ev_idx);
|
||||
|
|
@ -436,7 +379,6 @@ pub fn render(handle: &mut ViewportHandle) {
|
|||
_ => {}
|
||||
}
|
||||
}
|
||||
// Ctrl+I → Editor mode, Ctrl+/ → Live mode
|
||||
Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Character(c),
|
||||
modifiers,
|
||||
|
|
@ -454,7 +396,6 @@ pub fn render(handle: &mut ViewportHandle) {
|
|||
_ => {}
|
||||
}
|
||||
}
|
||||
// Ctrl+Escape → View mode
|
||||
Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Named(keyboard::key::Named::Escape),
|
||||
modifiers,
|
||||
|
|
@ -463,7 +404,6 @@ pub fn render(handle: &mut ViewportHandle) {
|
|||
messages.push(Message::SetRenderMode(RenderMode::View));
|
||||
consumed.push(ev_idx);
|
||||
}
|
||||
// View mode: `i` → Editor, `/` → Live
|
||||
Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Character(c),
|
||||
modifiers,
|
||||
|
|
@ -523,9 +463,6 @@ pub fn render(handle: &mut ViewportHandle) {
|
|||
messages.push(Message::TableEnter);
|
||||
consumed.push(ev_idx);
|
||||
}
|
||||
// Up arrow inside a table cell. If we're on a non-top row,
|
||||
// move the cell focus up. If we're on row 0, escape upward
|
||||
// into the previous text block (synthesize one if none).
|
||||
Named::ArrowUp => {
|
||||
if let Some((block_idx, row, _)) =
|
||||
handle.state.active_table_focused_row()
|
||||
|
|
@ -538,7 +475,6 @@ pub fn render(handle: &mut ViewportHandle) {
|
|||
consumed.push(ev_idx);
|
||||
}
|
||||
}
|
||||
// Mirror of Up — row navigation with edge-escape.
|
||||
Named::ArrowDown => {
|
||||
if let Some((block_idx, row, total)) =
|
||||
handle.state.active_table_focused_row()
|
||||
|
|
@ -551,8 +487,6 @@ pub fn render(handle: &mut ViewportHandle) {
|
|||
consumed.push(ev_idx);
|
||||
}
|
||||
}
|
||||
// Left/Right walk the row. No edge-escape — at column 0 or
|
||||
// the last column the move just no-ops; cell stays put.
|
||||
Named::ArrowLeft => {
|
||||
messages.push(Message::TableMoveLeft);
|
||||
consumed.push(ev_idx);
|
||||
|
|
@ -561,10 +495,6 @@ pub fn render(handle: &mut ViewportHandle) {
|
|||
messages.push(Message::TableMoveRight);
|
||||
consumed.push(ev_idx);
|
||||
}
|
||||
// Backspace/Delete behavior depends on selection scope:
|
||||
// - whole table selected → clear every cell's content
|
||||
// - single cell selected (not editing) → clear that cell
|
||||
// Edit mode is handled by text_input's own backspace.
|
||||
Named::Backspace | Named::Delete
|
||||
if handle.state.focused_table_is_select_all() =>
|
||||
{
|
||||
|
|
@ -580,8 +510,6 @@ pub fn render(handle: &mut ViewportHandle) {
|
|||
_ => {}
|
||||
}
|
||||
}
|
||||
// Cmd+Backspace with the whole document selected → wipe all
|
||||
// blocks down to one empty text block.
|
||||
Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Named(keyboard::key::Named::Backspace),
|
||||
modifiers,
|
||||
|
|
@ -592,10 +520,6 @@ pub fn render(handle: &mut ViewportHandle) {
|
|||
messages.push(Message::DeleteAllBlocks);
|
||||
consumed.push(ev_idx);
|
||||
}
|
||||
// Cmd+Backspace with the whole table selected → delete the table.
|
||||
// Mirrors the user's "Cmd+Delete deletes whatever's selected" rule
|
||||
// applied at table scope. Single-cell selection has its own
|
||||
// Cmd+Alt+Backspace = delete row binding below.
|
||||
Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Named(keyboard::key::Named::Backspace),
|
||||
modifiers,
|
||||
|
|
@ -606,8 +530,6 @@ pub fn render(handle: &mut ViewportHandle) {
|
|||
messages.push(Message::DeleteCurrentTable);
|
||||
consumed.push(ev_idx);
|
||||
}
|
||||
// Plain Backspace/Delete with whole document selected → clear all
|
||||
// block content but keep structure.
|
||||
Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Named(named),
|
||||
modifiers,
|
||||
|
|
@ -634,8 +556,6 @@ pub fn render(handle: &mut ViewportHandle) {
|
|||
messages.push(Message::ExitCellEdit);
|
||||
consumed.push(ev_idx);
|
||||
} else {
|
||||
// Nothing to dismiss — cycle modes:
|
||||
// Live → Editor → View → Live.
|
||||
match handle.state.render_mode {
|
||||
RenderMode::Live => {
|
||||
messages.push(Message::SetRenderMode(RenderMode::Editor));
|
||||
|
|
@ -652,10 +572,6 @@ pub fn render(handle: &mut ViewportHandle) {
|
|||
}
|
||||
}
|
||||
}
|
||||
// Printable-key entry into a selected cell. When a table cell is
|
||||
// selected (highlighted) but not yet in edit mode, hitting any
|
||||
// printable character should overwrite the cell with that
|
||||
// character and enter edit mode in one step.
|
||||
Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Character(c),
|
||||
modifiers,
|
||||
|
|
@ -677,8 +593,7 @@ pub fn render(handle: &mut ViewportHandle) {
|
|||
}
|
||||
}
|
||||
|
||||
// Strip keyboard events we've already routed into Messages, so iced's
|
||||
// text_input/text_editor doesn't also process them and corrupt cell content.
|
||||
// remove consumed events before passing the remainder to iced
|
||||
if !consumed.is_empty() {
|
||||
let consumed_set: std::collections::HashSet<usize> = consumed.into_iter().collect();
|
||||
let mut filtered: Vec<Event> = Vec::with_capacity(handle.events.len());
|
||||
|
|
@ -699,9 +614,6 @@ pub fn render(handle: &mut ViewportHandle) {
|
|||
);
|
||||
handle.events.clear();
|
||||
|
||||
// Snapshot which cell (if any) is currently focused in any table, so that
|
||||
// subsequent structural edit shortcuts (insert row, delete col, ...) can
|
||||
// target the right block without a separate focus-tracking field.
|
||||
let focused_id = {
|
||||
use iced_wgpu::core::widget::operation::{Focusable, Operation};
|
||||
use iced_wgpu::core::widget::Id as CoreId;
|
||||
|
|
@ -746,8 +658,6 @@ pub fn render(handle: &mut ViewportHandle) {
|
|||
if let Some(armed) = new_cmd_a_armed {
|
||||
handle.state.cmd_a_armed = armed;
|
||||
}
|
||||
// Update cursor pos BEFORE draining messages so right-click handlers can
|
||||
// anchor the context menu at the current position in the same frame.
|
||||
if let Some(pt) = handle.cursor.position() {
|
||||
handle.state.cursor_pos = pt;
|
||||
if handle.state.tick_promote_drag() {
|
||||
|
|
@ -763,7 +673,6 @@ pub fn render(handle: &mut ViewportHandle) {
|
|||
handle.state.update(msg);
|
||||
}
|
||||
|
||||
// Drain any clipboard write the editor queued during update/tick.
|
||||
#[cfg(not(target_os = "ios"))]
|
||||
if let Some(text) = handle.state.pending_clipboard.take() {
|
||||
if let Ok(mut board) = arboard::Clipboard::new() {
|
||||
|
|
@ -775,8 +684,6 @@ pub fn render(handle: &mut ViewportHandle) {
|
|||
|
||||
handle.state.tick();
|
||||
let pending_focus = handle.state.take_pending_focus();
|
||||
// Drain BEFORE the second `ui` is built — `view()` re-borrows state and
|
||||
// would block any subsequent mutable take.
|
||||
let pending_scroll = handle.state.take_pending_scroll();
|
||||
|
||||
let theme = Theme::Dark;
|
||||
|
|
@ -784,9 +691,6 @@ pub fn render(handle: &mut ViewportHandle) {
|
|||
text_color: Color::WHITE,
|
||||
};
|
||||
|
||||
// First-frame palette/theme dump so we can spot a "white text on white
|
||||
// background" misconfiguration. Then a periodic re-log so palette swaps
|
||||
// mid-session show up too.
|
||||
{
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
static FRAME: AtomicU64 = AtomicU64::new(0);
|
||||
|
|
@ -817,11 +721,7 @@ pub fn render(handle: &mut ViewportHandle) {
|
|||
ui.operate(&handle.renderer, &mut op);
|
||||
}
|
||||
|
||||
// Forward any wheel-scroll delta that an inner text_editor swallowed
|
||||
// (Action::Scroll) to the outer document scrollable. text_editor captures
|
||||
// WheelScrolled when the cursor is over its bounds, which would otherwise
|
||||
// leave the page stuck. The editor.rs Action::Scroll handler accumulates
|
||||
// pixel deltas into pending_scroll; here we drain and apply them.
|
||||
// forward accumulated wheel-scroll delta to the document scrollable
|
||||
if let Some(delta_y) = pending_scroll {
|
||||
use iced_wgpu::core::widget::operation::scrollable::{scroll_by, AbsoluteOffset};
|
||||
use iced_wgpu::core::widget::Id as CoreId;
|
||||
|
|
@ -841,8 +741,6 @@ pub fn render(handle: &mut ViewportHandle) {
|
|||
|
||||
frame.present();
|
||||
|
||||
// Frame is on screen. Clear dirty so the next vsync tick is a free no-op
|
||||
// unless something genuinely changed (input event, eval debounce, etc.).
|
||||
handle.needs_redraw = false;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -98,7 +98,6 @@ impl<Message: Clone + 'static> Block<Message> for HrBlock {
|
|||
}
|
||||
|
||||
fn apply(&mut self, _cmd: BlockCommand) {
|
||||
// HRs have no structural state to mutate.
|
||||
}
|
||||
|
||||
fn selectable_paths(&self) -> Box<dyn Iterator<Item = InnerPath> + '_> {
|
||||
|
|
|
|||
|
|
@ -1,28 +1,8 @@
|
|||
//! Acord viewport — the iced+wgpu editor surface and its supporting widgets.
|
||||
//!
|
||||
//! ## Reusing pieces in other apps
|
||||
//!
|
||||
//! - [`text_widget`] — the per-line `fill_paragraph` compositor. Can host any
|
||||
//! iced `Element` inline with text.
|
||||
//! - [`widgets::menu`] — generic-Message menu strip + dropdown panel.
|
||||
//! - [`widgets::dialog`] — modal overlay + segmented-row patterns.
|
||||
//! - [`widgets::style`] — iced style functions matching Acord's chrome.
|
||||
//! - [`browser`] — the file-grid document browser.
|
||||
//! - [`palette`] — the global theme palette ([`set_palette_theme`] swaps it).
|
||||
//! - [`syntax`] — tree-sitter highlighter producing iced `Span` colors.
|
||||
//! - [`oklab`] — OKLab colour-space utilities used for tone math.
|
||||
//! - [`bridge`] — FFI helpers translating native key/mouse codes to iced events.
|
||||
//!
|
||||
//! The full editor lives in [`editor::EditorState`]; lower-level block kinds
|
||||
//! (text, table, heading, tree, hr) live in their own modules and implement
|
||||
//! the [`block::Block`] trait.
|
||||
//! iced+wgpu editor surface, supporting widgets, and C-FFI entry points.
|
||||
|
||||
use std::ffi::{c_char, c_void, CStr, CString};
|
||||
|
||||
/// Gated diagnostic logging — `eprintln!` to stderr, prefixed with the
|
||||
/// callsite. Compiles to nothing outside debug builds on iOS.
|
||||
/// AcordApp.swift's captureStderr() pumps stderr → stdout + NSLog so output
|
||||
/// shows up under `cargo xtask debug-ios --console`.
|
||||
/// prefixed eprintln on iOS debug builds; compiles away otherwise.
|
||||
#[macro_export]
|
||||
macro_rules! ios_dlog {
|
||||
($($arg:tt)*) => {{
|
||||
|
|
@ -86,17 +66,11 @@ pub struct ViewportHandle {
|
|||
pub state: EditorState,
|
||||
pub events: Vec<Event>,
|
||||
cursor: iced_wgpu::core::mouse::Cursor,
|
||||
/// Set true on any FFI input or state-change call. handle::render() early-returns
|
||||
/// when this is false AND no pending eval debounce, so the vsync display link
|
||||
/// becomes a microsecond no-op while the editor is idle.
|
||||
/// dirty flag gating vsync frame presentation
|
||||
pub needs_redraw: bool,
|
||||
}
|
||||
|
||||
/// Install a panic hook that flushes a full backtrace to stderr AND to
|
||||
/// `~/.acord/crash.log` before the process aborts. Called once on first
|
||||
/// viewport_create. Without the file fallback, the Windows release build
|
||||
/// (`#![windows_subsystem = "windows"]`) detaches the console and stderr
|
||||
/// goes nowhere — users get a silent crash with no diagnostic surface.
|
||||
/// writes panics to stderr and ~/.acord/crash.log with full backtraces.
|
||||
fn install_panic_hook() {
|
||||
use std::sync::Once;
|
||||
static ONCE: Once = Once::new();
|
||||
|
|
@ -133,8 +107,7 @@ fn install_panic_hook() {
|
|||
});
|
||||
}
|
||||
|
||||
/// Best-effort timestamp for the crash log header. Avoids pulling chrono
|
||||
/// for one line — uses SystemTime::now() epoch seconds as a stable suffix.
|
||||
/// epoch-seconds timestamp for the crash log header.
|
||||
fn chrono_now() -> String {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
SystemTime::now()
|
||||
|
|
@ -267,8 +240,6 @@ pub extern "C" fn viewport_set_text(handle: *mut ViewportHandle, text: *const c_
|
|||
} else {
|
||||
unsafe { CStr::from_ptr(text) }.to_str().unwrap_or("")
|
||||
};
|
||||
// Goes through `load_doc` so any embedded sidecar archive comment is
|
||||
// pulled out before the markdown body reaches the parser.
|
||||
h.state.load_doc(s);
|
||||
h.needs_redraw = true;
|
||||
}
|
||||
|
|
@ -304,7 +275,7 @@ pub extern "C" fn viewport_free_string(s: *mut c_char) {
|
|||
unsafe { drop(CString::from_raw(s)); }
|
||||
}
|
||||
|
||||
/// returns the archive zip bytes (or null when empty); writes the length to len_out.
|
||||
/// returns the archive zip bytes or null; writes the length to len_out.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn viewport_take_sidecar_bytes(
|
||||
handle: *mut ViewportHandle,
|
||||
|
|
@ -355,7 +326,7 @@ pub extern "C" fn viewport_free_bytes(ptr: *mut u8, len: usize) {
|
|||
unsafe { drop(Box::from_raw(std::slice::from_raw_parts_mut(ptr, len))); }
|
||||
}
|
||||
|
||||
/// renders the current document as a printable PDF; returns owned bytes the shell must free with `viewport_free_bytes`.
|
||||
/// renders the document to PDF bytes; free with viewport_free_bytes.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn viewport_render_pdf(
|
||||
handle: *mut ViewportHandle,
|
||||
|
|
@ -512,10 +483,7 @@ pub extern "C" fn viewport_take_shell_action(handle: *mut ViewportHandle) -> *mu
|
|||
CString::new(s).map(|c| c.into_raw()).unwrap_or(std::ptr::null_mut())
|
||||
}
|
||||
|
||||
/// Export the note as a standalone Rust crate at `out_dir/name/`. Returns
|
||||
/// a heap-allocated C string on success (the absolute path of the created
|
||||
/// folder), or null on failure. Free the returned string with
|
||||
/// `viewport_free_string`.
|
||||
/// exports the note as a Rust crate at out_dir/name/; returns the path or null.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn viewport_export_crate(
|
||||
handle: *mut ViewportHandle,
|
||||
|
|
@ -639,7 +607,7 @@ pub extern "C" fn browser_refresh(handle: *mut BrowserHandle) {
|
|||
browser::handle::refresh(h);
|
||||
}
|
||||
|
||||
/// dispatches a numeric zoom command into the browser's scale state.
|
||||
/// dispatches a zoom command into the browser scale state.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn browser_send_command(handle: *mut BrowserHandle, command: u32) {
|
||||
let h = match unsafe { handle.as_mut() } { Some(h) => h, None => return };
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ pub struct MinimapLine {
|
|||
pub kind: LineKind,
|
||||
}
|
||||
|
||||
/// classifies a single source line for minimap colouring.
|
||||
/// classifies a single source line by minimap colour category.
|
||||
pub fn classify(line: &str) -> MinimapLine {
|
||||
let trimmed = line.trim_start();
|
||||
let kind = if trimmed.is_empty() {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
use crate::selection::BlockId;
|
||||
|
||||
/// A module groups consecutive blocks that share a scope.
|
||||
/// H2 headings start named modules; HRs close the current module
|
||||
/// and start an unnamed one; H1 marks the root module.
|
||||
/// consecutive blocks sharing a scope, bounded by H2/HR headings.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Module {
|
||||
pub name: String,
|
||||
|
|
@ -11,33 +9,19 @@ pub struct Module {
|
|||
pub is_root: bool,
|
||||
}
|
||||
|
||||
/// Lightweight descriptor used by compute_modules so it doesn't need
|
||||
/// access to the full Block trait (which is generic over Message).
|
||||
/// lightweight descriptor decoupled from the generic Block trait.
|
||||
pub struct BlockInfo {
|
||||
pub id: BlockId,
|
||||
pub kind_tag: &'static str,
|
||||
/// For heading blocks: the level (1, 2, 3). Zero for non-headings.
|
||||
/// heading level (1, 2, 3) or zero for non-headings.
|
||||
pub heading_level: u8,
|
||||
/// For heading blocks: the heading text. Empty for non-headings.
|
||||
/// heading text, empty for non-headings.
|
||||
pub heading_text: String,
|
||||
/// For text blocks: the raw markdown content (used to auto-name
|
||||
/// unnamed modules from first `fn`/`let`). Empty for non-text blocks.
|
||||
/// raw markdown content, empty for non-text blocks.
|
||||
pub text_content: String,
|
||||
}
|
||||
|
||||
/// Walk blocks in layout order and group them into modules based on
|
||||
/// heading/HR boundaries.
|
||||
///
|
||||
/// Rules:
|
||||
/// - H1 -> root module (is_root = true)
|
||||
/// - H2 -> close current, start named module
|
||||
/// - HR -> close current, start unnamed module
|
||||
/// - HR immediately followed by H1/H2 -> absorbed into the heading module
|
||||
/// so the divider counts as decoration, not its own dangling block.
|
||||
/// - Everything else -> append to current module
|
||||
///
|
||||
/// Unnamed modules are auto-named from their first `fn` or `let`
|
||||
/// declaration, falling back to `_unnamed_N`.
|
||||
/// groups blocks into modules by walking heading/HR boundaries in layout order.
|
||||
pub fn compute_modules(infos: &[BlockInfo]) -> Vec<Module> {
|
||||
let mut modules: Vec<Module> = Vec::new();
|
||||
let mut current = Module {
|
||||
|
|
@ -96,9 +80,7 @@ pub fn compute_modules(infos: &[BlockInfo]) -> Vec<Module> {
|
|||
modules
|
||||
}
|
||||
|
||||
/// Returns the HR block id if `current` is a freshly-opened HR-only module
|
||||
/// (one block, no heading) — meaning the HR immediately precedes the caller's
|
||||
/// heading and should be folded into it. None otherwise.
|
||||
/// extracts the lone HR id from a fresh HR-only module, folding it into the next heading.
|
||||
fn take_dangling_hr(current: &Module, infos: &[BlockInfo]) -> Option<BlockId> {
|
||||
if current.block_ids.len() != 1 || current.heading_block.is_some() {
|
||||
return None;
|
||||
|
|
@ -109,7 +91,7 @@ fn take_dangling_hr(current: &Module, infos: &[BlockInfo]) -> Option<BlockId> {
|
|||
.map(|i| i.id)
|
||||
}
|
||||
|
||||
/// If a module has no name, derive one from its first `fn`/`let` declaration.
|
||||
/// derives a name from the first fn/let declaration, falling back to _unnamed_N.
|
||||
fn finalize_unnamed(module: &mut Module, counter: &mut usize, infos: &[BlockInfo]) {
|
||||
if !module.name.is_empty() {
|
||||
return;
|
||||
|
|
@ -147,16 +129,16 @@ fn extract_ident(s: &str) -> Option<String> {
|
|||
if ident.is_empty() { None } else { Some(ident) }
|
||||
}
|
||||
|
||||
/// Scope of a table name assigned by a heading above it.
|
||||
/// scope of a heading-assigned table name.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TableNameScope {
|
||||
/// H3 heading — name is globally visible across all modules.
|
||||
/// H3: globally visible across all modules.
|
||||
Global,
|
||||
/// H4 heading — name is only visible within the owning module.
|
||||
/// H4: visible only within the owning module.
|
||||
BlockScoped,
|
||||
}
|
||||
|
||||
/// Result of scanning for heading-named tables.
|
||||
/// heading-to-table name assignment.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TableNameAssignment {
|
||||
pub table_id: BlockId,
|
||||
|
|
@ -164,8 +146,7 @@ pub struct TableNameAssignment {
|
|||
pub scope: TableNameScope,
|
||||
}
|
||||
|
||||
/// Scan the layout for H3/H4 headings directly above a table (only
|
||||
/// whitespace/empty blocks between) and return name assignments.
|
||||
/// scans for H3/H4 headings directly above a table, skipping whitespace blocks between.
|
||||
pub fn detect_table_names(infos: &[BlockInfo]) -> Vec<TableNameAssignment> {
|
||||
let mut assignments = Vec::new();
|
||||
let len = infos.len();
|
||||
|
|
@ -175,8 +156,6 @@ pub fn detect_table_names(infos: &[BlockInfo]) -> Vec<TableNameAssignment> {
|
|||
if infos[i].kind_tag != "heading" || (level != 3 && level != 4) {
|
||||
continue;
|
||||
}
|
||||
// Look ahead for the next non-heading block. Skip whitespace-only
|
||||
// text blocks between the heading and the table.
|
||||
for j in (i + 1)..len {
|
||||
match infos[j].kind_tag {
|
||||
"table" => {
|
||||
|
|
@ -203,7 +182,7 @@ pub fn detect_table_names(infos: &[BlockInfo]) -> Vec<TableNameAssignment> {
|
|||
assignments
|
||||
}
|
||||
|
||||
/// Lowercase, spaces to underscores, strip non-ident characters.
|
||||
/// lowercases, spaces to underscores, strips non-ident characters.
|
||||
pub fn normalize_name(heading_text: &str) -> String {
|
||||
heading_text
|
||||
.trim()
|
||||
|
|
@ -214,12 +193,7 @@ pub fn normalize_name(heading_text: &str) -> String {
|
|||
.collect()
|
||||
}
|
||||
|
||||
/// Positional fallback names for every block and table in the document,
|
||||
/// assigned globally in layout order (1-indexed: `block_1`, `table_1`, …).
|
||||
/// Headings and HRs count as blocks; tables also get their own sequence.
|
||||
/// Cross-block refs use `block_N::table_N`. Heading-derived names from
|
||||
/// `detect_table_names` take precedence — positional names are always
|
||||
/// available as an additional lookup key.
|
||||
/// assigns 1-indexed positional names (block_N, table_N) in layout order.
|
||||
pub fn compute_positional_ids(infos: &[BlockInfo]) -> PositionalIds {
|
||||
let mut blocks = Vec::new();
|
||||
let mut tables = Vec::new();
|
||||
|
|
@ -236,9 +210,7 @@ pub fn compute_positional_ids(infos: &[BlockInfo]) -> PositionalIds {
|
|||
PositionalIds { blocks, tables }
|
||||
}
|
||||
|
||||
/// Output of `compute_positional_ids`. `tables` entries also carry the
|
||||
/// 1-indexed block position the table appears in, so the caller can build
|
||||
/// the cross-block alias `block_N::table_M`.
|
||||
/// positional block and table IDs with their 1-indexed positions.
|
||||
pub struct PositionalIds {
|
||||
pub blocks: Vec<(BlockId, String)>,
|
||||
pub tables: Vec<(BlockId, String, usize)>,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,4 @@
|
|||
//! Perceptually uniform color operations.
|
||||
//!
|
||||
//! Wraps Björn Ottosson's OKLab. Used to compensate for the apparent dimming
|
||||
//! of small glyphs and thin strokes that arises from antialiased coverage
|
||||
//! blending against a near-black background. The compensation is calibrated
|
||||
//! by rendered pixel size: smaller -> bigger boost, with a knee at
|
||||
//! `THRESHOLD_PX`. Hue and chroma are preserved; only L is adjusted.
|
||||
//! Perceptually uniform color operations via OKLab.
|
||||
|
||||
use crate::palette;
|
||||
use iced_wgpu::core::Color;
|
||||
|
|
@ -62,14 +56,12 @@ fn from_oklab(lab: [f32; 3], alpha: f32) -> Color {
|
|||
}
|
||||
}
|
||||
|
||||
/// Linear-with-knee curve: full boost at `size_px = 0`, zero at and above
|
||||
/// `THRESHOLD_PX`. Returns the unsigned magnitude — the caller decides the
|
||||
/// sign (positive = lighten on dark bg, negative = darken on light bg).
|
||||
/// unsigned L boost magnitude, full at 0 px tapering to zero at THRESHOLD_PX.
|
||||
pub fn size_boost(size_px: f32) -> f32 {
|
||||
(BASE_BOOST * (1.0 - size_px / THRESHOLD_PX)).max(0.0)
|
||||
}
|
||||
|
||||
/// Add `l_delta` to OKLab L while preserving chroma.
|
||||
/// adds l_delta to OKLab L, preserving chroma.
|
||||
pub fn lighten(color: Color, l_delta: f32) -> Color {
|
||||
if l_delta == 0.0 {
|
||||
return color;
|
||||
|
|
@ -79,9 +71,7 @@ pub fn lighten(color: Color, l_delta: f32) -> Color {
|
|||
from_oklab(lab, color.a)
|
||||
}
|
||||
|
||||
/// Compensate for AA coverage blending: brighten on dark backgrounds
|
||||
/// (where AA dims), darken on light backgrounds (where AA washes out).
|
||||
/// Identity when `size_px >= THRESHOLD_PX`.
|
||||
/// compensates for AA coverage blending per glyph size and theme polarity.
|
||||
pub fn lighten_for_size(color: Color, size_px: f32) -> Color {
|
||||
let mag = size_boost(size_px);
|
||||
if mag == 0.0 {
|
||||
|
|
@ -91,10 +81,7 @@ pub fn lighten_for_size(color: Color, size_px: f32) -> Color {
|
|||
lighten(color, delta)
|
||||
}
|
||||
|
||||
/// Hue-rotate a color by 180° in OKLab while preserving lightness and
|
||||
/// chroma magnitude — produces the perceptual complement (red→cyan,
|
||||
/// blue→amber, yellow→indigo, green→magenta). Used by the gutter to render
|
||||
/// "lines above the cursor" as the inverse of the rainbow used below.
|
||||
/// rotates hue 180 degrees in OKLab, producing the perceptual complement.
|
||||
pub fn invert_hue(color: Color) -> Color {
|
||||
let mut lab = to_oklab(color);
|
||||
lab[1] = -lab[1];
|
||||
|
|
@ -102,10 +89,7 @@ pub fn invert_hue(color: Color) -> Color {
|
|||
from_oklab(lab, color.a)
|
||||
}
|
||||
|
||||
/// Drain chroma toward zero by `t` (0.0 = identity, 1.0 = grey at the same
|
||||
/// L). Lightness is preserved, so a "faded red" stays as bright as the red
|
||||
/// it came from — it just stops being red. Used by the gutter rainbow to
|
||||
/// dissolve into neutral without dimming.
|
||||
/// drains chroma toward zero by t (0.0 = identity, 1.0 = neutral grey at same L).
|
||||
pub fn desaturate(color: Color, t: f32) -> Color {
|
||||
let k = 1.0 - t.clamp(0.0, 1.0);
|
||||
if k == 1.0 { return color; }
|
||||
|
|
@ -115,8 +99,16 @@ pub fn desaturate(color: Color, t: f32) -> Color {
|
|||
from_oklab(lab, color.a)
|
||||
}
|
||||
|
||||
/// Perceptual interpolation between two colors. `t = 0` returns `a`,
|
||||
/// `t = 1` returns `b`.
|
||||
/// scales chroma outward by the given factor, preserving lightness.
|
||||
pub fn saturate(color: Color, factor: f32) -> Color {
|
||||
if factor == 1.0 { return color; }
|
||||
let mut lab = to_oklab(color);
|
||||
lab[1] *= factor;
|
||||
lab[2] *= factor;
|
||||
from_oklab(lab, color.a)
|
||||
}
|
||||
|
||||
/// perceptual interpolation between two colors in OKLab.
|
||||
pub fn mix(a: Color, b: Color, t: f32) -> Color {
|
||||
let la = to_oklab(a);
|
||||
let lb = to_oklab(b);
|
||||
|
|
|
|||
|
|
@ -60,25 +60,22 @@ pub static MOCHA: Palette = Palette {
|
|||
crust: Color::from_rgb(0.067, 0.067, 0.106),
|
||||
};
|
||||
|
||||
/// KiCad-inspired dark — near-black background, saturated accents, high
|
||||
/// contrast. The signature KiCad schematic-editor feel: vivid greens,
|
||||
/// bright cyans, punchy reds and yellows on a deep navy base.
|
||||
/// KiCad-inspired dark palette with saturated accents on a deep navy base.
|
||||
pub static KICAD: Palette = Palette {
|
||||
// From acord-palette-used.svg, 13 user-kept swatches (rounded to f32).
|
||||
rosewater: Color::from_rgb(0.976, 0.639, 0.984), // (249,163,251) light pink
|
||||
flamingo: Color::from_rgb(1.000, 0.718, 0.937), // (255,183,239) pink-light
|
||||
pink: Color::from_rgb(0.988, 0.545, 0.808), // (252,139,206)
|
||||
mauve: Color::from_rgb(0.741, 0.494, 0.984), // (189,126,251)
|
||||
red: Color::from_rgb(0.973, 0.545, 0.545), // (248,139,139)
|
||||
maroon: Color::from_rgb(0.933, 0.506, 0.639), // (238,129,163)
|
||||
peach: Color::from_rgb(1.000, 0.667, 0.396), // (255,170,101)
|
||||
yellow: Color::from_rgb(1.000, 0.886, 0.486), // (255,226,124)
|
||||
green: Color::from_rgb(0.592, 0.925, 0.671), // (151,236,171)
|
||||
teal: Color::from_rgb(0.310, 1.000, 0.882), // (79,255,225)
|
||||
sky: Color::from_rgb(0.404, 0.812, 0.973), // (103,207,248)
|
||||
sapphire: Color::from_rgb(0.384, 0.635, 0.949), // unchanged — unused slot
|
||||
blue: Color::from_rgb(0.310, 0.643, 0.992), // (79,164,253)
|
||||
lavender: Color::from_rgb(0.957, 0.737, 0.373), // (244,188,95) amber accent
|
||||
rosewater: Color::from_rgb(0.976, 0.639, 0.984),
|
||||
flamingo: Color::from_rgb(1.000, 0.718, 0.937),
|
||||
pink: Color::from_rgb(0.988, 0.545, 0.808),
|
||||
mauve: Color::from_rgb(0.741, 0.494, 0.984),
|
||||
red: Color::from_rgb(0.973, 0.545, 0.545),
|
||||
maroon: Color::from_rgb(0.933, 0.506, 0.639),
|
||||
peach: Color::from_rgb(1.000, 0.667, 0.396),
|
||||
yellow: Color::from_rgb(1.000, 0.886, 0.486),
|
||||
green: Color::from_rgb(0.592, 0.925, 0.671),
|
||||
teal: Color::from_rgb(0.310, 1.000, 0.882),
|
||||
sky: Color::from_rgb(0.404, 0.812, 0.973),
|
||||
sapphire: Color::from_rgb(0.384, 0.635, 0.949),
|
||||
blue: Color::from_rgb(0.310, 0.643, 0.992),
|
||||
lavender: Color::from_rgb(0.957, 0.737, 0.373),
|
||||
text: Color::from_rgb(0.965, 0.954, 0.969),
|
||||
subtext1: Color::from_rgb(0.824, 0.813, 0.852),
|
||||
subtext0: Color::from_rgb(0.679, 0.668, 0.725),
|
||||
|
|
@ -145,8 +142,7 @@ pub fn set_theme(name: &str) {
|
|||
IS_DARK.with(|d| *d.borrow_mut() = dark);
|
||||
}
|
||||
|
||||
/// Colors for bordered inline widgets (tables, trees). Shared so both
|
||||
/// widget types render identical frosted-card surfaces in both themes.
|
||||
/// shared surface colors for bordered inline widgets (tables, trees).
|
||||
pub struct WidgetSurface {
|
||||
pub fill: Color,
|
||||
pub border: Color,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,4 @@
|
|||
//! Black-and-white PDF print of the open document.
|
||||
//!
|
||||
//! No styling beyond what print needs: Helvetica body, Helvetica-Bold headings,
|
||||
//! Courier code/tables, simple grid borders, page numbers at the bottom.
|
||||
|
||||
use printpdf::{
|
||||
BuiltinFont, Color, Greyscale, Line, LinePoint, Mm, Op, PdfDocument, PdfFontHandle,
|
||||
|
|
@ -16,10 +13,9 @@ use crate::table_block::TableBlock;
|
|||
use crate::text_block::TextBlock;
|
||||
use crate::tree_block::TreeBlock;
|
||||
|
||||
/// US Letter with 1-inch margins.
|
||||
const PAGE_W_MM: f32 = 215.9;
|
||||
const PAGE_H_MM: f32 = 279.4;
|
||||
const MARGIN_MM: f32 = 19.05; // 0.75 inch — slightly tighter than 1" so notes fit
|
||||
const MARGIN_MM: f32 = 19.05;
|
||||
|
||||
const BODY_PT: f32 = 10.5;
|
||||
const CODE_PT: f32 = 9.5;
|
||||
|
|
@ -30,9 +26,7 @@ const BLOCK_GAP_PT: f32 = 8.0;
|
|||
const TABLE_PAD_PT: f32 = 4.0;
|
||||
const TABLE_LINE_PT: f32 = 0.5;
|
||||
|
||||
/// Approximate glyph widths in em-units. Built-in font metrics aren't exposed
|
||||
/// when `text_layout` is off; these are conservative averages so wrapping
|
||||
/// under-fills slightly rather than overflowing.
|
||||
/// approximate glyph widths in em-units per builtin font family.
|
||||
fn avg_em(font: BuiltinFont) -> f32 {
|
||||
match font {
|
||||
BuiltinFont::Courier
|
||||
|
|
@ -154,7 +148,6 @@ fn wrap_lines(text: &str, font: BuiltinFont, size_pt: f32, max_w_pt: f32) -> Vec
|
|||
if approx_text_width_pt(&candidate, font, size_pt) <= max_w_pt {
|
||||
current = candidate;
|
||||
} else if current.is_empty() {
|
||||
// single word too long for the line — let it overflow rather than truncate
|
||||
out.push(word.to_string());
|
||||
current = String::new();
|
||||
} else {
|
||||
|
|
@ -174,7 +167,6 @@ fn strip_inline_md(line: &str) -> String {
|
|||
while let Some(c) = chars.next() {
|
||||
match c {
|
||||
'*' | '_' => {
|
||||
// collapse runs of the same marker
|
||||
while chars.peek() == Some(&c) { chars.next(); }
|
||||
}
|
||||
'`' => {
|
||||
|
|
@ -192,7 +184,6 @@ struct Layout {
|
|||
margin_pt: f32,
|
||||
pages: Vec<Vec<Op>>,
|
||||
cur: Vec<Op>,
|
||||
/// y-cursor in PDF coordinates (origin at bottom-left, growing upward).
|
||||
y_pt: f32,
|
||||
page_count: usize,
|
||||
}
|
||||
|
|
@ -304,7 +295,6 @@ impl Layout {
|
|||
let font_head = BuiltinFont::HelveticaBold;
|
||||
let cell_inner_w = col_w - 2.0 * TABLE_PAD_PT;
|
||||
|
||||
// pre-wrap each cell into lines so we can compute row heights up front.
|
||||
let wrapped: Vec<Vec<Vec<String>>> = rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
|
|
|
|||
|
|
@ -1,12 +1,4 @@
|
|||
//! Central selection model.
|
||||
//!
|
||||
//! Every selectable element in the document — a cell, a row, a column, a line,
|
||||
//! a character range, or a whole block — is addressed by a `NodePath`. The
|
||||
//! `Selection` enum holds whatever the user has currently selected and is the
|
||||
//! single source of truth (no per-block selection state).
|
||||
//!
|
||||
//! Cursorline highlight, table cell selection, multi-line picks, and cross-block
|
||||
//! ranges are all the same primitive at different scopes.
|
||||
//! Central selection model addressing every selectable element via NodePath.
|
||||
|
||||
pub type BlockId = u64;
|
||||
|
||||
|
|
@ -20,24 +12,15 @@ impl TextPos {
|
|||
pub const ZERO: Self = TextPos { line: 0, col: 0 };
|
||||
}
|
||||
|
||||
/// Address of any selectable element inside a block. Which variants are valid
|
||||
/// depends on the block kind: a heading only meaningfully has `Whole`; a table
|
||||
/// has `Cell`/`CellRect`/`Row`/`Col`; a text block has `Line`/`LineRange`.
|
||||
/// address of a selectable element inside a block, variant set depends on block kind.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub enum InnerPath {
|
||||
/// The whole block (heading, HR, tree, or "this entire table/text block").
|
||||
Whole,
|
||||
/// A specific line in a text-bearing block (cursorline target).
|
||||
Line(usize),
|
||||
/// A character range within a text-bearing block.
|
||||
LineRange { start: TextPos, end: TextPos },
|
||||
/// A cell at (row, col) in a table.
|
||||
Cell { row: usize, col: usize },
|
||||
/// A rectangular range of cells.
|
||||
CellRect { r0: usize, c0: usize, r1: usize, c1: usize },
|
||||
/// An entire row of a table.
|
||||
Row(usize),
|
||||
/// An entire column of a table.
|
||||
Col(usize),
|
||||
}
|
||||
|
||||
|
|
@ -61,17 +44,13 @@ impl NodePath {
|
|||
}
|
||||
}
|
||||
|
||||
/// The single selection state for the entire document.
|
||||
/// single selection state for the entire document.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub enum Selection {
|
||||
/// Nothing selected.
|
||||
#[default]
|
||||
None,
|
||||
/// Cursor anchor at a single path. No range. Cursorline target.
|
||||
Caret(NodePath),
|
||||
/// A range from anchor to head. The two paths can live in different blocks.
|
||||
Range { anchor: NodePath, head: NodePath },
|
||||
/// An independent set (Cmd-click multi-cell, multi-line picks).
|
||||
Set(Vec<NodePath>),
|
||||
}
|
||||
|
||||
|
|
@ -80,10 +59,7 @@ impl Selection {
|
|||
matches!(self, Selection::None)
|
||||
}
|
||||
|
||||
/// True if the given path is a member of this selection. Iterative — no
|
||||
/// recursion. Range membership beyond exact endpoints (e.g. "is cell
|
||||
/// (2,3) inside the rect from (1,1) to (3,5)?") is the consumer block's
|
||||
/// responsibility; this only does point-equality.
|
||||
/// point-equality membership check against the selection endpoints.
|
||||
pub fn contains_path(&self, path: &NodePath) -> bool {
|
||||
match self {
|
||||
Selection::None => false,
|
||||
|
|
@ -93,7 +69,7 @@ impl Selection {
|
|||
}
|
||||
}
|
||||
|
||||
/// True if any path in the selection lives in the given block.
|
||||
/// true when any path in the selection belongs to the given block.
|
||||
pub fn touches_block(&self, block_id: BlockId) -> bool {
|
||||
match self {
|
||||
Selection::None => false,
|
||||
|
|
|
|||
|
|
@ -1,43 +1,4 @@
|
|||
//! Embedded sidecar archive.
|
||||
//!
|
||||
//! Markdown is the floor — `.md` files stay readable in vim and on GitHub —
|
||||
//! but Numbers-class table features (positional metadata, per-cell formatting,
|
||||
//! formulas, references) don't fit in markdown.
|
||||
//!
|
||||
//! Acord stores rich metadata in the SAME `.md` file as a base64-encoded zip
|
||||
//! wrapped in an HTML comment appended to the end of the document:
|
||||
//!
|
||||
//! ```text
|
||||
//! ...the user's markdown content...
|
||||
//!
|
||||
//! <!-- acord-archive
|
||||
//! UEsDBBQAAAAIA...base64...AAAA
|
||||
//! -->
|
||||
//! ```
|
||||
//!
|
||||
//! Why this shape:
|
||||
//! - HTML comments are valid markdown — every renderer (GitHub, Bear, Obsidian)
|
||||
//! treats them as invisible. Vim shows them as a single comment block, not
|
||||
//! as binary garbage.
|
||||
//! - Base64 stays text-clean — no `\0` bytes, vim won't flag the file as
|
||||
//! binary, `git diff` is still legible (modulo a wide line at the bottom).
|
||||
//! - The zip's central directory makes it trivial to add more entries later
|
||||
//! (per-block scratch state, formula caches, embedded images) without
|
||||
//! changing the framing.
|
||||
//!
|
||||
//! Per-table linking is positional: the Nth non-eval table in document layout
|
||||
//! order is sidecar key "N". No proprietary tags appear in the markdown body.
|
||||
//! Identity is runtime state derived from the document, never written to disk.
|
||||
//!
|
||||
//! The archive is structured like a Rust crate — each block is a submodule
|
||||
//! file under `src/`, and `config.toml` holds display-only metadata (col
|
||||
//! widths, row heights, cell styles). Save direction only: the markdown is
|
||||
//! always the source of truth; the archive is regenerated fresh on every save.
|
||||
//! On load, only `config.toml` is read for display metadata. If missing or
|
||||
//! malformed, start fresh — next save overwrites.
|
||||
//!
|
||||
//! Eval result tables are explicitly NOT persisted. Only the source `/= expr`
|
||||
//! line goes into markdown; the result table re-renders fresh on load.
|
||||
//! Embedded sidecar archive: rich table metadata stored as a zip inside the .md file.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::{Cursor, Read, Write};
|
||||
|
|
@ -48,26 +9,17 @@ use serde::{Deserialize, Serialize};
|
|||
use zip::write::SimpleFileOptions;
|
||||
use zip::{CompressionMethod, ZipArchive, ZipWriter};
|
||||
|
||||
/// Sentinel that opens the embedded archive comment. Anything from this string
|
||||
/// to the matching `-->` is the archive payload (base64-encoded zip).
|
||||
const ARCHIVE_OPEN: &str = "<!-- acord-archive";
|
||||
const ARCHIVE_CLOSE: &str = "-->";
|
||||
|
||||
/// Root-level display metadata file inside the zip. Holds col widths, row
|
||||
/// heights, cell styles, formulas — things that don't affect evaluation.
|
||||
const CONFIG_ENTRY: &str = "config.toml";
|
||||
/// Directory inside the zip holding one `.cord` file per block. Each file
|
||||
/// contains TOML front-matter + source, structured like a crate submodule.
|
||||
const SRC_DIR: &str = "src/";
|
||||
|
||||
/// Top-level schema of a `<file>.acord.toml` companion. Versioned so we can
|
||||
/// migrate later as the Numbers-class table feature set grows.
|
||||
/// top-level versioned schema of the sidecar config.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct Sidecar {
|
||||
/// Schema version. Bump on incompatible changes.
|
||||
#[serde(default = "default_version")]
|
||||
pub version: u32,
|
||||
/// Table metadata indexed by `[#id]` markers in the markdown.
|
||||
#[serde(default)]
|
||||
pub tables: HashMap<String, TableSidecar>,
|
||||
}
|
||||
|
|
@ -78,20 +30,12 @@ fn default_version() -> u32 {
|
|||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct TableSidecar {
|
||||
/// Per-column widths in pixels. Same length as the table's column count
|
||||
/// (or shorter; missing entries fall back to the editor's default width).
|
||||
#[serde(default)]
|
||||
pub col_widths: Vec<f32>,
|
||||
/// Sparse per-row explicit heights. Keys are row indices serialized as
|
||||
/// strings (TOML's native key type); convert with `parse::<usize>()` at
|
||||
/// the boundary. A table with a few resized rows doesn't carry the
|
||||
/// default for every other row.
|
||||
#[serde(default)]
|
||||
pub row_heights: HashMap<String, f32>,
|
||||
/// Per-cell metadata indexed by spreadsheet-style address ("A1", "D2", ...).
|
||||
#[serde(default)]
|
||||
pub cells: HashMap<String, CellSidecar>,
|
||||
/// Cell formulas indexed by spreadsheet address.
|
||||
#[serde(default)]
|
||||
pub formulas: HashMap<String, String>,
|
||||
}
|
||||
|
|
@ -108,9 +52,7 @@ pub struct CellSidecar {
|
|||
pub align: Option<String>,
|
||||
}
|
||||
|
||||
/// Reads sidecar TOML. Returns `Default` on parse error so a corrupt sidecar
|
||||
/// never blocks opening a markdown file — the user just loses the rich metadata
|
||||
/// until they re-save.
|
||||
/// reads sidecar TOML, defaulting on parse error.
|
||||
pub struct SidecarReader {
|
||||
inner: Sidecar,
|
||||
}
|
||||
|
|
@ -130,10 +72,7 @@ impl SidecarReader {
|
|||
}
|
||||
}
|
||||
|
||||
/// Accumulates sidecar entries during a save pass. Each block's `to_md` writes
|
||||
/// its side-channel state into the writer; after the pass, `flush` produces the
|
||||
/// TOML text to write to disk (or `None` if there's nothing to write — empty
|
||||
/// sidecars should be deleted from disk to avoid littering).
|
||||
/// accumulates sidecar entries during a save pass, flushing to TOML or None.
|
||||
pub struct SidecarWriter {
|
||||
inner: Sidecar,
|
||||
}
|
||||
|
|
@ -152,7 +91,7 @@ impl SidecarWriter {
|
|||
self.inner.tables.insert(id, data);
|
||||
}
|
||||
|
||||
/// Returns the serialized TOML, or `None` if the sidecar has no entries.
|
||||
/// serializes to TOML, or None when empty.
|
||||
pub fn flush(self) -> Option<String> {
|
||||
if self.inner.tables.is_empty() {
|
||||
return None;
|
||||
|
|
@ -171,19 +110,13 @@ impl Default for SidecarWriter {
|
|||
// Embedded archive: split markdown text into (body, optional sidecar)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/// Result of pulling an archive out of an `.md` file. `markdown` is the user
|
||||
/// content with the archive comment stripped; `sidecar` is the parsed config
|
||||
/// (or `None` if the file had no archive).
|
||||
/// markdown body with the archive comment stripped, plus parsed sidecar config.
|
||||
pub struct LoadedDoc {
|
||||
pub markdown: String,
|
||||
pub sidecar: Option<Sidecar>,
|
||||
}
|
||||
|
||||
/// Pull an embedded archive out of a markdown file. If the file has no
|
||||
/// `<!-- acord-archive ... -->` comment, returns the text unchanged with
|
||||
/// `sidecar = None`. Failure modes (truncated comment, bad base64, malformed
|
||||
/// zip, malformed TOML) all degrade gracefully to "no sidecar" — the user
|
||||
/// never loses access to their markdown content because of corrupted metadata.
|
||||
/// extracts an embedded archive comment, degrading gracefully on any failure.
|
||||
pub fn extract_archive(text: &str) -> LoadedDoc {
|
||||
let Some(open_idx) = text.rfind(ARCHIVE_OPEN) else {
|
||||
return LoadedDoc {
|
||||
|
|
@ -211,8 +144,7 @@ pub fn extract_archive(text: &str) -> LoadedDoc {
|
|||
}
|
||||
}
|
||||
|
||||
/// A single block's source file for the archive. Written to `src/<filename>`
|
||||
/// inside the zip. Content is TOML front-matter + `---` separator + raw source.
|
||||
/// single block source file written to src/ inside the archive zip.
|
||||
pub struct BlockFile {
|
||||
pub filename: String,
|
||||
pub content: String,
|
||||
|
|
@ -233,9 +165,7 @@ pub fn extract_archive_bytes(bytes: &[u8]) -> Option<Sidecar> {
|
|||
toml::from_str::<Sidecar>(&toml_text).ok()
|
||||
}
|
||||
|
||||
/// magic separating the markdown body from the appended raw zip; the surrounding NULs
|
||||
/// trip text editors into "binary mode" so the archive shows up as garbage, not as
|
||||
/// readable base64.
|
||||
/// binary sentinel separating markdown from appended raw zip bytes.
|
||||
pub const BINARY_SENTINEL: &[u8] = b"\n\x00ACORD-ARCHIVE\x00\n";
|
||||
|
||||
/// appends raw zip bytes after the markdown body, separated by BINARY_SENTINEL.
|
||||
|
|
@ -268,8 +198,7 @@ fn rfind_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
|
|||
haystack.windows(needle.len()).rposition(|w| w == needle)
|
||||
}
|
||||
|
||||
/// legacy embed format: base64-encoded zip inside an HTML comment. Kept for the
|
||||
/// round-trip tests and the load-side back-compat path; new saves go through embed_in_md.
|
||||
/// legacy embed format: base64-encoded zip inside an HTML comment.
|
||||
pub fn embed_archive(markdown: &str, sidecar: &Sidecar, block_files: &[BlockFile]) -> String {
|
||||
let Some(zip_bytes) = build_archive_bytes(sidecar, block_files) else {
|
||||
return markdown.to_string();
|
||||
|
|
@ -289,8 +218,6 @@ pub fn embed_archive(markdown: &str, sidecar: &Sidecar, block_files: &[BlockFile
|
|||
}
|
||||
|
||||
fn strip_trailing_blank_lines(s: &str) -> String {
|
||||
// Walk back over consecutive trailing newlines / whitespace lines so that
|
||||
// round-tripping a doc with an archive doesn't accumulate blank lines.
|
||||
let mut end = s.len();
|
||||
let bytes = s.as_bytes();
|
||||
while end > 0 {
|
||||
|
|
@ -310,8 +237,6 @@ fn strip_trailing_blank_lines(s: &str) -> String {
|
|||
}
|
||||
|
||||
fn decode_archive_payload(payload: &str) -> Option<Sidecar> {
|
||||
// Strip whitespace inside the comment so the wrapping is invisible to the
|
||||
// decoder.
|
||||
let cleaned: String = payload.chars().filter(|c| !c.is_whitespace()).collect();
|
||||
let zip_bytes = B64.decode(cleaned.as_bytes()).ok()?;
|
||||
let toml_text = read_zip(&zip_bytes)?;
|
||||
|
|
@ -430,8 +355,6 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn extract_with_corrupt_payload_recovers_markdown() {
|
||||
// Garbage in the comment body must NOT eat the user's markdown — they
|
||||
// get the body back, sidecar None.
|
||||
let doc = "# Body\n\nstuff\n\n<!-- acord-archive\nnot-actually-base64!!!\n-->\n";
|
||||
let loaded = extract_archive(doc);
|
||||
assert!(loaded.markdown.contains("# Body"));
|
||||
|
|
|
|||
|
|
@ -12,12 +12,10 @@ use crate::palette;
|
|||
pub const EVAL_RESULT_KIND: u8 = 24;
|
||||
pub const EVAL_ERROR_KIND: u8 = 25;
|
||||
|
||||
// --- Cordial (eval-line) tokens. Start at 50 to leave room above the
|
||||
// markdown range. A single hand-rolled scanner (`highlight_cordial`) dispatches
|
||||
// on these so every Cordial visual element — the `/=` sigil, the `@` ref
|
||||
// prefix, `::`, table/block names, cell addresses, keywords, builtins,
|
||||
// numbers, strings, comments — gets its own color.
|
||||
// cordial token IDs, starting at 50 above the markdown range.
|
||||
const COR_EVAL_SIGIL: u8 = 50;
|
||||
const COR_EVAL_TABLE_SUFFIX: u8 = 68;
|
||||
const COR_EVAL_TREE_SUFFIX: u8 = 69;
|
||||
const COR_AT_SIGIL: u8 = 51;
|
||||
const COR_COLON_COLON: u8 = 52;
|
||||
const COR_REF_COLON: u8 = 53;
|
||||
|
|
@ -33,18 +31,12 @@ const COR_OPERATOR: u8 = 62;
|
|||
const COR_BRACKET: u8 = 63;
|
||||
const COR_TYPE_ANN: u8 = 64;
|
||||
|
||||
// Per-identifier rainbow. Each user-introduced name (let, fn, params, for var,
|
||||
// math-form fn def) gets one of eight palette slots, picked with a stride
|
||||
// that avoids adjacent colors landing on consecutive identifiers. Subsequent
|
||||
// references resolve to the same slot so the name reads the same color
|
||||
// throughout the document.
|
||||
// per-identifier rainbow: 8 palette slots, hop-of-3 stride to separate adjacent names.
|
||||
const USER_IDENT_BASE: u8 = 70;
|
||||
pub const USER_IDENT_PALETTE_SIZE: u8 = 8;
|
||||
pub const USER_IDENT_HOP: u32 = 3;
|
||||
|
||||
/// The 8-slot rainbow shared by user-identifier highlighting and the gutter
|
||||
/// line-number rainbow. Same hop-of-3 walk through the same palette so the
|
||||
/// two systems read as one design.
|
||||
/// maps an index into the 8-slot rainbow palette via hop-of-3 stride.
|
||||
pub fn rainbow_color(idx: u32) -> Color {
|
||||
let slot = ((idx * USER_IDENT_HOP) % USER_IDENT_PALETTE_SIZE as u32) as u8;
|
||||
highlight_color(USER_IDENT_BASE + slot)
|
||||
|
|
@ -70,11 +62,7 @@ const MD_TASK_OPEN: u8 = 42;
|
|||
const MD_TASK_DONE: u8 = 43;
|
||||
const MD_BOLD_ITALIC: u8 = 44;
|
||||
|
||||
/// The monospace family used for the editor body and every inline highlight
|
||||
/// span. Naming the family explicitly (rather than `Family::Monospace`) forces
|
||||
/// cosmic-text / fontdb to resolve real Bold, Italic and BoldItalic faces,
|
||||
/// which the generic monospace fallback does not reliably do on macOS because
|
||||
/// cosmic-text hardcodes its default monospace family to "Noto Sans Mono".
|
||||
/// named monospace family, forcing fontdb to resolve real Bold/Italic faces per platform.
|
||||
#[cfg(any(target_os = "macos", target_os = "ios"))]
|
||||
pub const EDITOR_FONT: Font = Font::with_name("Menlo");
|
||||
#[cfg(target_os = "windows")]
|
||||
|
|
@ -86,6 +74,8 @@ pub const EDITOR_FONT: Font = Font::with_name("DejaVu Sans Mono");
|
|||
pub struct SyntaxSettings {
|
||||
pub lang: String,
|
||||
pub source: String,
|
||||
/// doc-wide user-ident to rainbow-slot map, computed across all text blocks.
|
||||
pub user_idents: HashMap<String, u8>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
|
|
@ -111,54 +101,62 @@ pub struct SyntaxHighlighter {
|
|||
current_line: usize,
|
||||
line_decors: Vec<LineDecor>,
|
||||
user_idents: HashMap<String, u8>,
|
||||
/// per-line tree-sitter spans for fenced code body lines, by absolute line index.
|
||||
/// per-line tree-sitter spans for fenced code body lines, keyed by absolute line index.
|
||||
code_block_spans: HashMap<usize, Vec<(Range<usize>, SyntaxHighlight)>>,
|
||||
/// line count at last full rebuild, drives incremental skip.
|
||||
prev_line_count: usize,
|
||||
}
|
||||
|
||||
impl SyntaxHighlighter {
|
||||
fn rebuild(&mut self, source: &str) {
|
||||
self.spans = highlight_source(source, &self.lang);
|
||||
let new_line_count = source.split('\n').count();
|
||||
let structure_changed = new_line_count != self.prev_line_count;
|
||||
|
||||
self.line_offsets.clear();
|
||||
let mut offset = 0;
|
||||
for line in source.split('\n') {
|
||||
self.line_offsets.push(offset);
|
||||
offset += line.len() + 1;
|
||||
}
|
||||
let classified = classify_document(source);
|
||||
self.line_kinds = classified.into_iter().map(|cl| cl.kind).collect();
|
||||
|
||||
self.line_decors.clear();
|
||||
let mut in_fence = false;
|
||||
for (i, raw_line) in source.split('\n').enumerate() {
|
||||
let is_md = i < self.line_kinds.len() && self.line_kinds[i] == LineKind::Markdown;
|
||||
if is_md {
|
||||
let trimmed = raw_line.trim_start();
|
||||
if trimmed.starts_with("```") {
|
||||
in_fence = !in_fence;
|
||||
self.line_decors.push(LineDecor::FenceMarker);
|
||||
} else if in_fence {
|
||||
self.line_decors.push(LineDecor::CodeBlock);
|
||||
} else if is_horizontal_rule(trimmed) {
|
||||
self.line_decors.push(LineDecor::HorizontalRule);
|
||||
} else if trimmed.starts_with("> ") || trimmed == ">" {
|
||||
self.line_decors.push(LineDecor::Blockquote);
|
||||
if structure_changed {
|
||||
self.spans = highlight_source(source, &self.lang);
|
||||
let classified = classify_document(source);
|
||||
self.line_kinds = classified.into_iter().map(|cl| cl.kind).collect();
|
||||
|
||||
self.line_decors.clear();
|
||||
let mut in_fence = false;
|
||||
for (i, raw_line) in source.split('\n').enumerate() {
|
||||
let is_md = i < self.line_kinds.len() && self.line_kinds[i] == LineKind::Markdown;
|
||||
if is_md {
|
||||
let trimmed = raw_line.trim_start();
|
||||
if trimmed.starts_with("```") {
|
||||
in_fence = !in_fence;
|
||||
self.line_decors.push(LineDecor::FenceMarker);
|
||||
} else if in_fence {
|
||||
self.line_decors.push(LineDecor::CodeBlock);
|
||||
} else if is_horizontal_rule(trimmed) {
|
||||
self.line_decors.push(LineDecor::HorizontalRule);
|
||||
} else if trimmed.starts_with("> ") || trimmed == ">" {
|
||||
self.line_decors.push(LineDecor::Blockquote);
|
||||
} else {
|
||||
self.line_decors.push(LineDecor::None);
|
||||
}
|
||||
} else {
|
||||
if in_fence { in_fence = false; }
|
||||
self.line_decors.push(LineDecor::None);
|
||||
}
|
||||
} else {
|
||||
if in_fence { in_fence = false; }
|
||||
self.line_decors.push(LineDecor::None);
|
||||
}
|
||||
|
||||
self.scan_fenced_code_blocks(source);
|
||||
self.prev_line_count = new_line_count;
|
||||
}
|
||||
|
||||
self.in_fenced_code = false;
|
||||
self.current_line = 0;
|
||||
|
||||
self.scan_user_idents(source);
|
||||
self.scan_fenced_code_blocks(source);
|
||||
}
|
||||
|
||||
/// runs each language-tagged fenced block through tree-sitter and stashes per-line spans.
|
||||
/// highlights language-tagged fenced blocks via tree-sitter, stashing per-line spans.
|
||||
fn scan_fenced_code_blocks(&mut self, source: &str) {
|
||||
self.code_block_spans.clear();
|
||||
let lines: Vec<&str> = source.split('\n').collect();
|
||||
|
|
@ -233,86 +231,8 @@ impl SyntaxHighlighter {
|
|||
}
|
||||
}
|
||||
|
||||
/// Walk the source, find every identifier introduction site (let, fn,
|
||||
/// for, math-form fn def, params), and assign each unique name a slot
|
||||
/// in the user-ident rainbow. Subsequent references in `highlight_cordial`
|
||||
/// look the name up here.
|
||||
fn scan_user_idents(&mut self, source: &str) {
|
||||
self.user_idents.clear();
|
||||
let mut next_slot: u32 = 0;
|
||||
|
||||
for line in source.split('\n') {
|
||||
let trimmed = line.trim_start();
|
||||
let bytes = trimmed.as_bytes();
|
||||
|
||||
// `let IDENT...`
|
||||
if let Some(rest) = trimmed.strip_prefix("let ") {
|
||||
let mut i = 0;
|
||||
let rb = rest.as_bytes();
|
||||
while i < rb.len() && rb[i] == b' ' { i += 1; }
|
||||
let name_start = i;
|
||||
while i < rb.len() && (rb[i].is_ascii_alphanumeric() || rb[i] == b'_') { i += 1; }
|
||||
if i > name_start {
|
||||
assign_user_ident(&mut self.user_idents, &mut next_slot, &rest[name_start..i]);
|
||||
}
|
||||
while i < rb.len() && rb[i] == b' ' { i += 1; }
|
||||
if i < rb.len() && rb[i] == b'(' {
|
||||
extract_paren_idents(&rest[i + 1..], &mut self.user_idents, &mut next_slot);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// `fn IDENT(...)`
|
||||
if let Some(rest) = trimmed.strip_prefix("fn ") {
|
||||
let mut i = 0;
|
||||
let rb = rest.as_bytes();
|
||||
while i < rb.len() && rb[i] == b' ' { i += 1; }
|
||||
let name_start = i;
|
||||
while i < rb.len() && (rb[i].is_ascii_alphanumeric() || rb[i] == b'_') { i += 1; }
|
||||
if i > name_start {
|
||||
assign_user_ident(&mut self.user_idents, &mut next_slot, &rest[name_start..i]);
|
||||
}
|
||||
while i < rb.len() && rb[i] == b' ' { i += 1; }
|
||||
if i < rb.len() && rb[i] == b'(' {
|
||||
extract_paren_idents(&rest[i + 1..], &mut self.user_idents, &mut next_slot);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// `for IDENT in ...`
|
||||
if let Some(rest) = trimmed.strip_prefix("for ") {
|
||||
let rb = rest.as_bytes();
|
||||
let mut i = 0;
|
||||
while i < rb.len() && rb[i] == b' ' { i += 1; }
|
||||
let name_start = i;
|
||||
while i < rb.len() && (rb[i].is_ascii_alphanumeric() || rb[i] == b'_') { i += 1; }
|
||||
if i > name_start {
|
||||
assign_user_ident(&mut self.user_idents, &mut next_slot, &rest[name_start..i]);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// `IDENT(...) = ...` math-form fn def, OR `IDENT = ...` assignment
|
||||
let mut i = 0;
|
||||
let name_start = i;
|
||||
while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') { i += 1; }
|
||||
if i > name_start {
|
||||
let name = &trimmed[name_start..i];
|
||||
let mut j = i;
|
||||
while j < bytes.len() && bytes[j] == b' ' { j += 1; }
|
||||
if j < bytes.len() {
|
||||
if bytes[j] == b'(' {
|
||||
assign_user_ident(&mut self.user_idents, &mut next_slot, name);
|
||||
extract_paren_idents(&trimmed[j + 1..], &mut self.user_idents, &mut next_slot);
|
||||
} else if bytes[j] == b'=' && (j + 1 >= bytes.len() || bytes[j + 1] != b'=') {
|
||||
assign_user_ident(&mut self.user_idents, &mut next_slot, name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// byte ranges of inline format markers on a non-fenced markdown line, empty otherwise.
|
||||
/// byte ranges of inline format markers on a non-fenced markdown line.
|
||||
pub fn line_marker_ranges(&self, line_idx: usize, line_text: &str) -> Vec<Range<usize>> {
|
||||
if line_idx >= self.line_kinds.len() {
|
||||
return Vec::new();
|
||||
|
|
@ -401,10 +321,80 @@ impl SyntaxHighlighter {
|
|||
}
|
||||
}
|
||||
|
||||
/// Scan a Cordial line (or an eval line) and emit per-token highlight
|
||||
/// spans. Idempotent, single-pass; each branch either consumes a whole
|
||||
/// token or advances one byte. Unknown bytes get no highlight (they fall
|
||||
/// through to the editor's default text color).
|
||||
/// assigns each introduced identifier (let, fn, for, params) a rainbow slot at doc scope.
|
||||
pub fn scan_user_idents_in(source: &str) -> HashMap<String, u8> {
|
||||
let mut map: HashMap<String, u8> = HashMap::new();
|
||||
let mut next_slot: u32 = 0;
|
||||
|
||||
for line in source.split('\n') {
|
||||
let trimmed = line.trim_start();
|
||||
let bytes = trimmed.as_bytes();
|
||||
|
||||
if let Some(rest) = trimmed.strip_prefix("let ") {
|
||||
let mut i = 0;
|
||||
let rb = rest.as_bytes();
|
||||
while i < rb.len() && rb[i] == b' ' { i += 1; }
|
||||
let name_start = i;
|
||||
while i < rb.len() && (rb[i].is_ascii_alphanumeric() || rb[i] == b'_') { i += 1; }
|
||||
if i > name_start {
|
||||
assign_user_ident(&mut map, &mut next_slot, &rest[name_start..i]);
|
||||
}
|
||||
while i < rb.len() && rb[i] == b' ' { i += 1; }
|
||||
if i < rb.len() && rb[i] == b'(' {
|
||||
extract_paren_idents(&rest[i + 1..], &mut map, &mut next_slot);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(rest) = trimmed.strip_prefix("fn ") {
|
||||
let mut i = 0;
|
||||
let rb = rest.as_bytes();
|
||||
while i < rb.len() && rb[i] == b' ' { i += 1; }
|
||||
let name_start = i;
|
||||
while i < rb.len() && (rb[i].is_ascii_alphanumeric() || rb[i] == b'_') { i += 1; }
|
||||
if i > name_start {
|
||||
assign_user_ident(&mut map, &mut next_slot, &rest[name_start..i]);
|
||||
}
|
||||
while i < rb.len() && rb[i] == b' ' { i += 1; }
|
||||
if i < rb.len() && rb[i] == b'(' {
|
||||
extract_paren_idents(&rest[i + 1..], &mut map, &mut next_slot);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(rest) = trimmed.strip_prefix("for ") {
|
||||
let rb = rest.as_bytes();
|
||||
let mut i = 0;
|
||||
while i < rb.len() && rb[i] == b' ' { i += 1; }
|
||||
let name_start = i;
|
||||
while i < rb.len() && (rb[i].is_ascii_alphanumeric() || rb[i] == b'_') { i += 1; }
|
||||
if i > name_start {
|
||||
assign_user_ident(&mut map, &mut next_slot, &rest[name_start..i]);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut i = 0;
|
||||
let name_start = i;
|
||||
while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') { i += 1; }
|
||||
if i > name_start {
|
||||
let name = &trimmed[name_start..i];
|
||||
let mut j = i;
|
||||
while j < bytes.len() && bytes[j] == b' ' { j += 1; }
|
||||
if j < bytes.len() {
|
||||
if bytes[j] == b'(' {
|
||||
assign_user_ident(&mut map, &mut next_slot, name);
|
||||
extract_paren_idents(&trimmed[j + 1..], &mut map, &mut next_slot);
|
||||
} else if bytes[j] == b'=' && (j + 1 >= bytes.len() || bytes[j + 1] != b'=') {
|
||||
assign_user_ident(&mut map, &mut next_slot, name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
map
|
||||
}
|
||||
|
||||
fn assign_user_ident(map: &mut HashMap<String, u8>, slot: &mut u32, name: &str) {
|
||||
if name.is_empty()
|
||||
|| is_cordial_keyword(name)
|
||||
|
|
@ -432,8 +422,7 @@ fn extract_paren_idents(s: &str, map: &mut HashMap<String, u8>, slot: &mut u32)
|
|||
b'(' => { depth += 1; i += 1; }
|
||||
b')' => { depth -= 1; i += 1; }
|
||||
b':' => {
|
||||
// Skip the type identifier that follows; type names belong
|
||||
// to the type-annotation color, not the user-ident rainbow.
|
||||
// skip type identifier following the colon.
|
||||
i += 1;
|
||||
while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') { i += 1; }
|
||||
while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') { i += 1; }
|
||||
|
|
@ -454,36 +443,40 @@ fn highlight_cordial(line: &str, user_idents: &HashMap<String, u8>) -> Vec<(Rang
|
|||
let mut spans: Vec<(Range<usize>, SyntaxHighlight)> = Vec::new();
|
||||
let mut i = 0;
|
||||
|
||||
// Opening `/=`, `/=|`, `/=\` sigil (with optional leading whitespace).
|
||||
// /= sigil green, | table suffix yellow, \ tree suffix red.
|
||||
let leading = line.len() - line.trim_start().len();
|
||||
if leading + 2 <= len && &bytes[leading..leading + 2] == b"/=" {
|
||||
let sigil_end = if leading + 3 <= len
|
||||
&& (bytes[leading + 2] == b'|' || bytes[leading + 2] == b'\\')
|
||||
{
|
||||
leading + 3
|
||||
} else {
|
||||
leading + 2
|
||||
};
|
||||
spans.push((leading..sigil_end, SyntaxHighlight { kind: COR_EVAL_SIGIL }));
|
||||
i = sigil_end;
|
||||
spans.push((leading..leading + 2, SyntaxHighlight { kind: COR_EVAL_SIGIL }));
|
||||
i = leading + 2;
|
||||
if i < len {
|
||||
let suffix_kind = match bytes[i] {
|
||||
b'|' => Some(COR_EVAL_TABLE_SUFFIX),
|
||||
b'\\' => Some(COR_EVAL_TREE_SUFFIX),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(kind) = suffix_kind {
|
||||
spans.push((i..i + 1, SyntaxHighlight { kind }));
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
while i < len {
|
||||
let c = bytes[i];
|
||||
|
||||
// Whitespace: skip.
|
||||
// whitespace
|
||||
if c == b' ' || c == b'\t' || c == b'\r' {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Line comment: `// …` — rest of line.
|
||||
// line comment
|
||||
if c == b'/' && i + 1 < len && bytes[i + 1] == b'/' {
|
||||
spans.push((i..len, SyntaxHighlight { kind: COR_COMMENT }));
|
||||
break;
|
||||
}
|
||||
|
||||
// String literal.
|
||||
// string literal
|
||||
if c == b'"' {
|
||||
let start = i;
|
||||
i += 1;
|
||||
|
|
@ -495,15 +488,13 @@ fn highlight_cordial(line: &str, user_idents: &HashMap<String, u8>) -> Vec<(Rang
|
|||
continue;
|
||||
}
|
||||
|
||||
// `@` cell reference: @[Block::]Table[:A1[:B4]] or @T[A1:B4].
|
||||
// @ cell reference
|
||||
if c == b'@' {
|
||||
spans.push((i..i + 1, SyntaxHighlight { kind: COR_AT_SIGIL }));
|
||||
i += 1;
|
||||
// First ident.
|
||||
let n1_start = i;
|
||||
while i < len && is_ident_byte(bytes[i]) { i += 1; }
|
||||
let n1_end = i;
|
||||
// Is it a block qualifier? Look for `::` after.
|
||||
if i + 1 < len && bytes[i] == b':' && bytes[i + 1] == b':' {
|
||||
if n1_end > n1_start {
|
||||
spans.push((n1_start..n1_end, SyntaxHighlight { kind: COR_BLOCK_NAME }));
|
||||
|
|
@ -518,7 +509,6 @@ fn highlight_cordial(line: &str, user_idents: &HashMap<String, u8>) -> Vec<(Rang
|
|||
} else if n1_end > n1_start {
|
||||
spans.push((n1_start..n1_end, SyntaxHighlight { kind: COR_TABLE_NAME }));
|
||||
}
|
||||
// Optional `:A1` or `:A1:B2` cell/range target.
|
||||
if i < len && bytes[i] == b':' {
|
||||
spans.push((i..i + 1, SyntaxHighlight { kind: COR_REF_COLON }));
|
||||
i += 1;
|
||||
|
|
@ -529,7 +519,6 @@ fn highlight_cordial(line: &str, user_idents: &HashMap<String, u8>) -> Vec<(Rang
|
|||
i = consume_cell_addr(bytes, i, &mut spans);
|
||||
}
|
||||
} else if i < len && bytes[i] == b'[' {
|
||||
// Bracket range: `[A1:B2]`.
|
||||
spans.push((i..i + 1, SyntaxHighlight { kind: COR_BRACKET }));
|
||||
i += 1;
|
||||
i = consume_cell_addr(bytes, i, &mut spans);
|
||||
|
|
@ -546,10 +535,7 @@ fn highlight_cordial(line: &str, user_idents: &HashMap<String, u8>) -> Vec<(Rang
|
|||
continue;
|
||||
}
|
||||
|
||||
// Numeric literal (integer or decimal, with optional leading `-`
|
||||
// in operator-valid position — keep it simple: only recognise as
|
||||
// a number when we're right after an operator or at the start of
|
||||
// whitespace, otherwise leave `-` to the operator scanner).
|
||||
// numeric literal
|
||||
if c.is_ascii_digit()
|
||||
|| (c == b'.' && i + 1 < len && bytes[i + 1].is_ascii_digit())
|
||||
{
|
||||
|
|
@ -561,7 +547,7 @@ fn highlight_cordial(line: &str, user_idents: &HashMap<String, u8>) -> Vec<(Rang
|
|||
continue;
|
||||
}
|
||||
|
||||
// Identifier → keyword / builtin / type-annotation / user-rainbow.
|
||||
// identifier: keyword / builtin / type-annotation / user-rainbow
|
||||
if is_ident_byte(c) && !c.is_ascii_digit() {
|
||||
let start = i;
|
||||
while i < len && is_ident_byte(bytes[i]) { i += 1; }
|
||||
|
|
@ -578,28 +564,28 @@ fn highlight_cordial(line: &str, user_idents: &HashMap<String, u8>) -> Vec<(Rang
|
|||
continue;
|
||||
}
|
||||
|
||||
// `::` as a namespace separator outside of a ref (e.g. `use mod::item`).
|
||||
// :: namespace separator
|
||||
if c == b':' && i + 1 < len && bytes[i + 1] == b':' {
|
||||
spans.push((i..i + 2, SyntaxHighlight { kind: COR_COLON_COLON }));
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Plain `:` — likely a type annotation colon in `let x: T = …`.
|
||||
// plain colon (type annotation)
|
||||
if c == b':' {
|
||||
spans.push((i..i + 1, SyntaxHighlight { kind: COR_REF_COLON }));
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Bracket / brace / paren — separate color from operators.
|
||||
// brackets / parens / commas
|
||||
if matches!(c, b'(' | b')' | b'{' | b'}' | b'[' | b']' | b',') {
|
||||
spans.push((i..i + 1, SyntaxHighlight { kind: COR_BRACKET }));
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Operator run: consume a contiguous block of operator bytes.
|
||||
// operator run
|
||||
if is_operator_byte(c) {
|
||||
let start = i;
|
||||
while i < len && is_operator_byte(bytes[i]) { i += 1; }
|
||||
|
|
@ -622,9 +608,6 @@ fn consume_cell_addr(
|
|||
while i < bytes.len() && bytes[i].is_ascii_alphabetic() { i += 1; }
|
||||
let letters_end = i;
|
||||
while i < bytes.len() && bytes[i].is_ascii_digit() { i += 1; }
|
||||
// Only tag as a cell address when we matched BOTH letters AND digits —
|
||||
// otherwise we're looking at a bare identifier or a digit run that
|
||||
// some other branch should have handled.
|
||||
if i > start && letters_end > start && i > letters_end {
|
||||
spans.push((start..i, SyntaxHighlight { kind: COR_CELL_ADDR }));
|
||||
}
|
||||
|
|
@ -643,9 +626,6 @@ fn is_operator_byte(b: u8) -> bool {
|
|||
fn is_cordial_keyword(w: &str) -> bool {
|
||||
matches!(w, "let" | "fn" | "if" | "else" | "while" | "for" | "in"
|
||||
| "return" | "use" | "is" | "true" | "false" | "and" | "or" | "not"
|
||||
// Function-inversion DSL — two forms:
|
||||
// programmer: let lfreq = solve!(l, f0) // or `solve!(l from f0)`
|
||||
// math: let lfreq(freq, c) = l where f0(l, c) = freq
|
||||
| "solve" | "where" | "from")
|
||||
}
|
||||
|
||||
|
|
@ -656,6 +636,15 @@ fn is_cordial_builtin(w: &str) -> bool {
|
|||
| "sqrt" | "abs" | "floor" | "ceil" | "round" | "ln" | "log"
|
||||
// collections
|
||||
| "len" | "range" | "push"
|
||||
// higher-order
|
||||
| "map" | "each" | "fold" | "reduce" | "all" | "any"
|
||||
| "filter" | "find" | "take_while" | "skip_while" | "inspect" | "tap"
|
||||
| "take" | "skip" | "drop" | "chunk" | "window"
|
||||
| "zip" | "flatten" | "flat_map"
|
||||
| "sort" | "sort_by" | "distinct" | "unique"
|
||||
| "delta" | "scan"
|
||||
// ring module (gated by `use ring`)
|
||||
| "ring" | "iter" | "peek" | "history"
|
||||
// aggregates
|
||||
| "sum" | "avg" | "min" | "max" | "count" | "std_devp" | "std_devs"
|
||||
// constants
|
||||
|
|
@ -667,9 +656,6 @@ fn is_cordial_type_annotation(w: &str) -> bool {
|
|||
matches!(w, "int" | "float" | "bool" | "str" | "number" | "array" | "vec")
|
||||
}
|
||||
|
||||
/// Did the scanner just emit a `:` span? Used so a type name following a
|
||||
/// `:` picks up the type-annotation color only in the `let x: T = …` shape,
|
||||
/// never when it happens to sit elsewhere on the line.
|
||||
fn last_token_is_colon(spans: &[(Range<usize>, SyntaxHighlight)]) -> bool {
|
||||
matches!(spans.last(), Some((_, h)) if h.kind == COR_REF_COLON)
|
||||
}
|
||||
|
|
@ -737,16 +723,8 @@ fn parse_inline(text: &str, base: usize) -> Vec<(Range<usize>, SyntaxHighlight)>
|
|||
continue;
|
||||
}
|
||||
|
||||
// cosmic-text's partial reshape (called by iced's text_editor after
|
||||
// add_span) drops the new run's attrs on the FIRST glyph of the new
|
||||
// attribute run, so `*hello*` would render with "h" plain and "ello"
|
||||
// italic. Workaround: emit the bold/italic span covering the opening
|
||||
// marker bytes too — the marker becomes the "lost first glyph" and
|
||||
// the first letter of the inner text gets the style. The marker span
|
||||
// pushed first is overridden by the bold/italic span that follows
|
||||
// because cosmic-text uses the LAST add_span to win on overlap.
|
||||
// Markers (`*`, `**`, `***`) end up italic/bold themselves, which is
|
||||
// imperceptible at typical font sizes.
|
||||
// workaround: extend bold/italic spans to cover opening markers,
|
||||
// counteracting cosmic-text's partial reshape glyph-attr drop.
|
||||
|
||||
if i + 2 < len && bytes[i] == b'*' && bytes[i + 1] == b'*' && bytes[i + 2] == b'*' {
|
||||
if let Some(end) = find_triple_star(bytes, i + 3) {
|
||||
|
|
@ -774,7 +752,6 @@ fn parse_inline(text: &str, base: usize) -> Vec<(Range<usize>, SyntaxHighlight)>
|
|||
let kind = if h.kind == MD_ITALIC { MD_BOLD_ITALIC } else { h.kind };
|
||||
spans.push((r, SyntaxHighlight { kind }));
|
||||
}
|
||||
// Extend bold over closing marker for visual consistency.
|
||||
spans.push((base + end..base + end + 2, SyntaxHighlight { kind: MD_BOLD }));
|
||||
}
|
||||
}
|
||||
|
|
@ -924,8 +901,9 @@ impl highlighter::Highlighter for SyntaxHighlighter {
|
|||
in_fenced_code: false,
|
||||
current_line: 0,
|
||||
line_decors: Vec::new(),
|
||||
user_idents: HashMap::new(),
|
||||
user_idents: settings.user_idents.clone(),
|
||||
code_block_spans: HashMap::new(),
|
||||
prev_line_count: 0,
|
||||
};
|
||||
h.rebuild(&settings.source);
|
||||
h
|
||||
|
|
@ -933,6 +911,7 @@ impl highlighter::Highlighter for SyntaxHighlighter {
|
|||
|
||||
fn update(&mut self, new_settings: &Self::Settings) {
|
||||
self.lang = new_settings.lang.clone();
|
||||
self.user_idents = new_settings.user_idents.clone();
|
||||
self.rebuild(&new_settings.source);
|
||||
}
|
||||
|
||||
|
|
@ -955,7 +934,19 @@ impl highlighter::Highlighter for SyntaxHighlighter {
|
|||
return vec![(0..line.len(), SyntaxHighlight { kind: EVAL_ERROR_KIND })].into_iter();
|
||||
}
|
||||
|
||||
let is_markdown = ln < self.line_kinds.len()
|
||||
// pure-code mode bypasses cordial and markdown classifiers.
|
||||
let is_pure_code = !self.lang.is_empty();
|
||||
|
||||
if !is_pure_code
|
||||
&& ln < self.line_kinds.len()
|
||||
&& matches!(self.line_kinds[ln], LineKind::Cordial | LineKind::Eval | LineKind::Comment)
|
||||
{
|
||||
if self.in_fenced_code { self.in_fenced_code = false; }
|
||||
return highlight_cordial(line, &self.user_idents).into_iter();
|
||||
}
|
||||
|
||||
let is_markdown = !is_pure_code
|
||||
&& ln < self.line_kinds.len()
|
||||
&& self.line_kinds[ln] == LineKind::Markdown;
|
||||
|
||||
if is_markdown {
|
||||
|
|
@ -971,25 +962,11 @@ impl highlighter::Highlighter for SyntaxHighlighter {
|
|||
return vec![(0..line.len(), SyntaxHighlight { kind: MD_CODE_BLOCK })].into_iter();
|
||||
}
|
||||
|
||||
// Markdown lines always return md_spans, even when empty —
|
||||
// falling through to the code path would let plain prose pick up
|
||||
// Rust keyword highlighting on words like "let", "type", "return".
|
||||
return self.highlight_markdown(line).into_iter();
|
||||
} else if self.in_fenced_code {
|
||||
self.in_fenced_code = false;
|
||||
}
|
||||
|
||||
// Non-markdown lines are Cordial / Eval / Comment — hand-rolled
|
||||
// Cordial scanner, not the generic tree-sitter path (which uses
|
||||
// the configured `lang`, wrong for Cordial). Each token gets its
|
||||
// own color: `/=`, `@`, `::`, table / block names, cell addresses,
|
||||
// keywords, builtins, numbers, strings, comments.
|
||||
if ln < self.line_kinds.len()
|
||||
&& matches!(self.line_kinds[ln], LineKind::Cordial | LineKind::Eval | LineKind::Comment)
|
||||
{
|
||||
return highlight_cordial(line, &self.user_idents).into_iter();
|
||||
}
|
||||
|
||||
if ln >= self.line_offsets.len() {
|
||||
return Vec::new().into_iter();
|
||||
}
|
||||
|
|
@ -1062,7 +1039,9 @@ pub fn highlight_color(kind: u8) -> Color {
|
|||
23 => p.text,
|
||||
24 => p.green,
|
||||
25 => p.maroon,
|
||||
COR_EVAL_SIGIL => p.teal,
|
||||
COR_EVAL_SIGIL => p.green,
|
||||
COR_EVAL_TABLE_SUFFIX => p.yellow,
|
||||
COR_EVAL_TREE_SUFFIX => p.red,
|
||||
COR_AT_SIGIL => p.mauve,
|
||||
COR_COLON_COLON => p.flamingo,
|
||||
COR_REF_COLON => p.flamingo,
|
||||
|
|
@ -1101,8 +1080,6 @@ pub fn highlight_color(kind: u8) -> Color {
|
|||
}
|
||||
|
||||
pub fn highlight_font(kind: u8) -> Option<Font> {
|
||||
// Spans inherit the named family from EDITOR_FONT so fontdb can pick up
|
||||
// the real Bold, Italic and BoldItalic faces of the system monospace.
|
||||
match kind {
|
||||
MD_HEADING_MARKER => Some(Font { weight: Weight::Bold, ..EDITOR_FONT }),
|
||||
MD_H1 => Some(Font { weight: Weight::Black, ..EDITOR_FONT }),
|
||||
|
|
@ -1121,7 +1098,7 @@ pub fn highlight_font(kind: u8) -> Option<Font> {
|
|||
}
|
||||
}
|
||||
|
||||
/// maps a fenced-code label to a tree-sitter language id, recursing on the trailing extension for dotted labels.
|
||||
/// maps a fenced-code label to a tree-sitter language id.
|
||||
fn canonical_code_lang(label: &str) -> Option<String> {
|
||||
let label = label.trim().to_ascii_lowercase();
|
||||
if label.is_empty() {
|
||||
|
|
|
|||
|
|
@ -18,11 +18,7 @@ use crate::syntax::EDITOR_FONT;
|
|||
|
||||
const MIN_COL_WIDTH: f32 = 60.0;
|
||||
const DEFAULT_COL_WIDTH: f32 = 120.0;
|
||||
/// Sanity cap for double-click auto-fit. Drag past it for explicit override.
|
||||
const AUTO_FIT_MAX: f32 = 600.0;
|
||||
/// Approximate monospace glyph advance at the editor's default font size.
|
||||
/// Used when the renderer's actual font_size isn't available (e.g. during
|
||||
/// table construction). Tracks `font_size * 0.6` for size 13.
|
||||
const APPROX_CHAR_W: f32 = 7.8;
|
||||
const CELL_PADDING: Padding = Padding {
|
||||
top: 2.0,
|
||||
|
|
@ -32,14 +28,9 @@ const CELL_PADDING: Padding = Padding {
|
|||
};
|
||||
const ROW_NUMBER_WIDTH: f32 = 26.0;
|
||||
const PLUS_BUTTON_THICKNESS: f32 = 14.0;
|
||||
/// Default per-row height. Calibrated to match the natural height of an iced
|
||||
/// text_input at size 13 with CELL_PADDING — 13pt font + ~1.3 line height +
|
||||
/// 4px vertical padding + 2px border ≈ 23.
|
||||
const ROW_HEIGHT_ESTIMATE: f32 = 23.0;
|
||||
const MIN_ROW_HEIGHT: f32 = 18.0;
|
||||
const ROW_RESIZE_HANDLE_HEIGHT: f32 = 3.0;
|
||||
/// Vertical gap between rows. Slightly tighter than RESIZE_HANDLE_WIDTH —
|
||||
/// the horizontal gap stays at 4 so the resize handle has enough hit area.
|
||||
const CELL_GAP_Y: f32 = 2.0;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
|
|
@ -48,23 +39,12 @@ pub enum ReorderDrag {
|
|||
Row { from: usize, target: usize, start_y: f32 },
|
||||
}
|
||||
|
||||
/// Modifier state at the moment of a selection click or drag. Computed by
|
||||
/// editor.rs from its tracked modifier state, then passed in via the
|
||||
/// table-state mutation methods. The modifier determines the OPERATION on
|
||||
/// the existing selection — single-cell click vs rectangular drag is
|
||||
/// orthogonal to the modifier and determined by gesture (click = 1 cell,
|
||||
/// drag = rectangle).
|
||||
/// modifier-derived operation applied to the selection on click or drag.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SelectionMode {
|
||||
/// No modifier — selection becomes only the clicked cell (or drag rect).
|
||||
Replace,
|
||||
/// Cmd — toggle (XOR): each touched cell is added if absent, removed if
|
||||
/// present.
|
||||
Toggle,
|
||||
/// Shift — add (union): each touched cell is added; nothing is removed.
|
||||
Extend,
|
||||
/// Cmd+Shift — remove (subtract): each touched cell is removed; nothing
|
||||
/// is added.
|
||||
Subtract,
|
||||
}
|
||||
|
||||
|
|
@ -72,32 +52,17 @@ pub enum SelectionMode {
|
|||
pub enum TableMessage {
|
||||
CellChanged(usize, usize, String),
|
||||
FocusCell(usize, usize),
|
||||
/// Single click on a cell — select it but stay out of edit mode. Cell
|
||||
/// renders as static text with a tinted background. The Numbers/Excel
|
||||
/// "I might do something to this cell" gesture.
|
||||
/// single-cell selection without entering edit mode.
|
||||
SelectCell(usize, usize),
|
||||
/// Double click on a cell — enter edit mode. Cell renders as a text_input
|
||||
/// and gets iced focus. Same target as `SelectCell`, but the editor's
|
||||
/// `editing` field is also set to this cell's `NodePath`.
|
||||
/// double-click enters edit mode on the cell.
|
||||
EditCell(usize, usize),
|
||||
/// Click on the corner-cell delete affordance. The editor's TableMsg arm
|
||||
/// promotes this to a top-level `DeleteCurrentTable` so undo + block
|
||||
/// removal go through the same path as the keyboard shortcut.
|
||||
DeleteTable,
|
||||
/// Click on the corner-cell drag handle. Selects the whole table —
|
||||
/// every cell renders highlighted, plain Backspace clears all cells,
|
||||
/// Cmd+Backspace deletes the table outright. Single-cell click clears
|
||||
/// this back to single-cell selection.
|
||||
/// selects the whole table (corner-cell click).
|
||||
SelectAll,
|
||||
/// Plain Backspace/Delete with `table_selected == true` — empty every
|
||||
/// cell's content but leave the row/column structure intact.
|
||||
/// empties every cell's content, preserving row/column structure.
|
||||
ClearAll,
|
||||
/// Right-click on a cell. Promoted in editor.rs to a top-level
|
||||
/// `ShowContextMenu` carrying the cursor anchor position.
|
||||
ContextMenu(usize, usize),
|
||||
/// Mouse entered a cell. Used by the drag-select system: when a drag
|
||||
/// is armed (post-click, pre-release), each `CellEnter` extends the
|
||||
/// selection rectangle to that cell. No-op when no drag is active.
|
||||
/// drag-select extension: expands the rectangle to the entered cell.
|
||||
CellEnter(usize, usize),
|
||||
AddRow,
|
||||
AddColumn,
|
||||
|
|
@ -113,100 +78,47 @@ pub enum TableMessage {
|
|||
BeginColReorder(usize),
|
||||
BeginRowReorder(usize),
|
||||
EndDrag,
|
||||
/// Double-click on the column resize handle: fit width to the widest
|
||||
/// cell content in the column. f32 carries the current font_size so the
|
||||
/// pixel width tracks zoom level.
|
||||
/// double-click auto-fits column width to content at given font_size.
|
||||
AutoFitCol(usize, f32),
|
||||
/// Toggle the per-table word-wrap mode. Wrap on (default): rows grow to
|
||||
/// fit; nothing clips. Wrap off: cells clip; spillover popup reveals
|
||||
/// content on click or 3s hover.
|
||||
ToggleWrap,
|
||||
/// Open the spillover popup for a cell. Replaces any existing spillover
|
||||
/// (only one open per table at a time). Click on a clipped cell when
|
||||
/// `wrap == false`.
|
||||
OpenSpillover(usize, usize),
|
||||
/// Close the active spillover popup. Click outside, ESC, or any cell
|
||||
/// selection change.
|
||||
CloseSpillover,
|
||||
/// Click on a column-header sort arrow: cycles that column through
|
||||
/// Neutral → Asc → Desc → Neutral and re-applies the composite sort.
|
||||
/// cycles column sort: Neutral -> Asc -> Desc -> Neutral.
|
||||
CycleSort(usize),
|
||||
/// mouse pressed on the corner select-all marker, arms a free-layer drag.
|
||||
PromoteCornerPress,
|
||||
/// mouse released on the corner select-all marker, ends or cancels the drag.
|
||||
PromoteCornerRelease,
|
||||
}
|
||||
|
||||
/// Trait-implementing block for tables. Owns all the per-table mutable state
|
||||
/// directly (rows, widths, focus, drags, selection) — no separate `TableState`
|
||||
/// HashMap. Lives in `EditorState::blocks` as a `Box<dyn Block>`.
|
||||
/// table block owning all per-table mutable state (rows, widths, focus, drags, selection).
|
||||
pub struct TableBlock {
|
||||
pub id: BlockId,
|
||||
pub start_line: usize,
|
||||
/// User-assigned name from a ### or #### heading directly above this table.
|
||||
/// H3 = global scope, H4 = block-scoped. None for unnamed tables.
|
||||
pub table_name: Option<String>,
|
||||
pub rows: Vec<Vec<String>>,
|
||||
pub col_widths: Vec<f32>,
|
||||
/// Per-row explicit height override. None means use ROW_HEIGHT_ESTIMATE.
|
||||
pub row_heights: Vec<Option<f32>>,
|
||||
/// Last cell that had focus. PRESERVED across blur so keyboard shortcuts
|
||||
/// (Cmd+Opt+Arrow, Cmd+Shift+T, Tab/Enter) keep targeting the right cell
|
||||
/// even when the user has clicked elsewhere in the document.
|
||||
/// preserved across blur for keyboard shortcut targeting.
|
||||
pub focused_cell: Option<(usize, usize)>,
|
||||
/// True only on frames where iced's focus is currently inside one of this
|
||||
/// table's cells. Used for "active editing chrome" (ABCD/123 headers) which
|
||||
/// must DISAPPEAR when the user clicks out, even though focused_cell is kept.
|
||||
pub is_active: bool,
|
||||
/// Whole-table selection mode. Set by clicking the corner select-all
|
||||
/// affordance. All cells render highlighted; plain Backspace/Delete
|
||||
/// clears every cell's content; Cmd+Backspace deletes the whole table.
|
||||
/// Cleared the moment a single cell is clicked.
|
||||
pub table_selected: bool,
|
||||
/// Eval-result tables set this so the widget disables cell editing while
|
||||
/// keeping selection and Cmd-C intact. Markdown tables keep it false.
|
||||
pub read_only: bool,
|
||||
/// True for eval-result tables (regenerated on every eval). Skipped during
|
||||
/// markdown serialization.
|
||||
pub is_eval_result: bool,
|
||||
/// Active column-resize drag: (col index, original width at drag start, drag-start x).
|
||||
pub resize_drag: Option<(usize, f32, f32)>,
|
||||
/// Active row-resize drag: (row index, original height at drag start, drag-start y).
|
||||
pub row_resize_drag: Option<(usize, f32, f32)>,
|
||||
pub reorder_drag: Option<ReorderDrag>,
|
||||
pub selection: std::collections::HashSet<(usize, usize)>,
|
||||
pub selection_anchor: Option<(usize, usize)>,
|
||||
/// Drag-rectangle origin. Set on cell click; cleared on mouse release.
|
||||
/// When `Some`, every cell-enter event extends the rectangle.
|
||||
pub drag_select_start: Option<(usize, usize)>,
|
||||
/// SelectionMode captured at the moment the drag started — keeps the
|
||||
/// modifier semantics constant for the duration of the drag, even if
|
||||
/// the user releases the modifier mid-drag.
|
||||
pub drag_select_mode: Option<SelectionMode>,
|
||||
/// Selection state at the moment the drag started. Each `cell_enter`
|
||||
/// during the drag recomputes the selection by re-applying the mode
|
||||
/// against the baseline + the current rectangle. Without a baseline,
|
||||
/// repeated rectangle redraws would compound (e.g. Toggle mode would
|
||||
/// flip cells multiple times as the rectangle grows and shrinks).
|
||||
/// selection snapshot at drag start, prevents compounding on rectangle redraws.
|
||||
pub drag_select_baseline: std::collections::HashSet<(usize, usize)>,
|
||||
pub last_cursor_x: f32,
|
||||
pub last_cursor_y: f32,
|
||||
/// Composite sort. Each entry is `(col_idx, dir)`. The first entry is
|
||||
/// the dominant sort key; later entries break ties within groups of
|
||||
/// equal dominant values. Empty = no sort active (visual neutral).
|
||||
/// composite sort keys, first entry dominant, later entries break ties.
|
||||
pub sort_priority: Vec<(usize, SortDir)>,
|
||||
/// When true (default), cell text word-wraps and each row grows to fit
|
||||
/// the tallest wrapped cell — no content ever clips. When false, content
|
||||
/// is hard-clipped at the cell bounds and the spillover popup reveals
|
||||
/// the full text on click or hover.
|
||||
pub wrap: bool,
|
||||
/// Currently spilled-over cell, if any. Only one popup at a time per
|
||||
/// table. Set by click or 3s hover when `wrap == false`.
|
||||
pub spillover: Option<(usize, usize)>,
|
||||
/// Cell currently being hovered with the dwell timer running. Captured
|
||||
/// on CellEnter; consumed by `tick_hover` after the 3s threshold to
|
||||
/// open the spillover popup. Cleared on any meaningful interaction
|
||||
/// (click, edit, drag, scroll) so a brief mouseover never triggers.
|
||||
/// dwell timer for hover-to-spillover, cleared on any interaction.
|
||||
pub hover_armed: Option<(usize, usize, std::time::Instant)>,
|
||||
}
|
||||
|
||||
|
|
@ -231,9 +143,6 @@ impl TableBlock {
|
|||
) -> Self {
|
||||
let col_count = rows.iter().map(|r| r.len()).max().unwrap_or(0);
|
||||
let col_widths = col_widths_override.unwrap_or_else(|| {
|
||||
// For eval result tables, size columns to fit content; for markdown
|
||||
// tables, fit each column to its header so the new wrap-on default
|
||||
// gives short headers a tight column and lets long body text wrap.
|
||||
if is_eval_result {
|
||||
(0..col_count)
|
||||
.map(|ci| {
|
||||
|
|
@ -287,8 +196,7 @@ impl TableBlock {
|
|||
}
|
||||
}
|
||||
|
||||
/// 3s dwell threshold for hover-to-spillover. Independent of the eval
|
||||
/// debounce so a slow-typing user doesn't accidentally trigger popups.
|
||||
/// triggers spillover popup after 3s hover dwell on a clipped cell.
|
||||
pub fn check_hover_spillover(&mut self) -> bool {
|
||||
if self.wrap { self.hover_armed = None; return false; }
|
||||
let Some((r, c, started)) = self.hover_armed else { return false; };
|
||||
|
|
@ -303,16 +211,12 @@ impl TableBlock {
|
|||
true
|
||||
}
|
||||
|
||||
/// Has a hover dwell timer running. Used by `has_pending_eval`-equivalent
|
||||
/// to keep the vsync loop ticking until the 3s threshold fires.
|
||||
/// true when a hover dwell timer needs vsync ticks.
|
||||
pub fn has_pending_hover(&self) -> bool {
|
||||
!self.wrap && self.hover_armed.is_some()
|
||||
}
|
||||
|
||||
/// Build the canonical clipboard payload for the current selection.
|
||||
/// Single cell: just the cell text. Multiple cells: TSV — tabs between
|
||||
/// columns, newlines between rows. Excel/Numbers/Sheets parse this
|
||||
/// natively when pasted back in. Returns None if nothing is selected.
|
||||
/// builds a TSV clipboard payload from the current cell selection.
|
||||
pub fn copy_selection_payload(&self) -> Option<String> {
|
||||
if self.selection.is_empty() {
|
||||
return None;
|
||||
|
|
@ -341,11 +245,7 @@ impl TableBlock {
|
|||
Some(lines.join("\n"))
|
||||
}
|
||||
|
||||
/// Resize `col` to fit its widest cell content (header + body) at
|
||||
/// `font_size`. Width = max char count × monospace char width + horizontal
|
||||
/// padding, clamped to [MIN_COL_WIDTH, AUTO_FIT_MAX]. The cap keeps a
|
||||
/// pathological cell from blowing the table off-screen — drag past it
|
||||
/// for explicit override.
|
||||
/// resizes column to fit widest cell content at the given font_size.
|
||||
pub fn auto_fit_col(&mut self, col: usize, font_size: f32) {
|
||||
if col >= self.col_widths.len() { return; }
|
||||
let max_chars = self.rows.iter()
|
||||
|
|
@ -359,10 +259,7 @@ impl TableBlock {
|
|||
self.col_widths[col] = raw.max(MIN_COL_WIDTH).min(AUTO_FIT_MAX);
|
||||
}
|
||||
|
||||
/// Cycle the sort state of `col`: Neutral → Asc → Desc → Neutral.
|
||||
/// First click on a previously-neutral column appends it to the
|
||||
/// END of the priority list (least dominant). Re-clicking advances
|
||||
/// its direction in place; the third click removes it.
|
||||
/// cycles sort state for a column: Neutral -> Asc -> Desc -> Neutral.
|
||||
pub fn cycle_sort(&mut self, col: usize) {
|
||||
if let Some(idx) = self.sort_priority.iter().position(|(c, _)| *c == col) {
|
||||
match self.sort_priority[idx].1 {
|
||||
|
|
@ -375,17 +272,14 @@ impl TableBlock {
|
|||
self.apply_sort();
|
||||
}
|
||||
|
||||
/// Sort state for a column, if any. Used by the header chrome to pick
|
||||
/// the arrow tint and the optional precedence badge.
|
||||
/// returns sort direction and priority index for a column, if sorted.
|
||||
pub fn sort_state_for(&self, col: usize) -> Option<(SortDir, usize)> {
|
||||
self.sort_priority.iter().enumerate().find_map(|(i, (c, d))| {
|
||||
if *c == col { Some((*d, i)) } else { None }
|
||||
})
|
||||
}
|
||||
|
||||
/// Apply the composite sort to the data rows (everything below row 0,
|
||||
/// which is the header). Stable across equal keys so existing intra-
|
||||
/// group order is preserved.
|
||||
/// stable-sorts data rows (below the header) by the composite sort keys.
|
||||
pub fn apply_sort(&mut self) {
|
||||
if self.sort_priority.is_empty() || self.rows.len() <= 2 { return; }
|
||||
let priority = self.sort_priority.clone();
|
||||
|
|
@ -410,19 +304,8 @@ impl TableBlock {
|
|||
self.rows.len()
|
||||
}
|
||||
|
||||
/// Apply a modifier-aware single-cell click to the multi-cell selection
|
||||
/// set. Called by `EditorState::update`'s TableMsg arm after it reads
|
||||
/// `self.mods` and derives the `SelectionMode`. Mutates `selection`,
|
||||
/// `selection_anchor`, and `focused_cell` in place.
|
||||
///
|
||||
/// Also arms a drag-select: snapshots the selection BEFORE this click as
|
||||
/// the baseline so that if the user drags to other cells, the rectangle
|
||||
/// can be re-applied against the baseline (without compounding repeated
|
||||
/// applications of the modifier op).
|
||||
/// applies a modifier-aware cell click, arming drag-select with a baseline snapshot.
|
||||
pub fn apply_click_selection(&mut self, row: usize, col: usize, mode: SelectionMode) {
|
||||
// Baseline = selection state before the click. The drag handler
|
||||
// recomputes from this so the rectangle can shrink/grow without
|
||||
// compounding (e.g. toggling the same cell twice).
|
||||
let baseline = self.selection.clone();
|
||||
|
||||
match mode {
|
||||
|
|
@ -432,7 +315,6 @@ impl TableBlock {
|
|||
self.selection_anchor = Some((row, col));
|
||||
}
|
||||
SelectionMode::Toggle => {
|
||||
// Cmd = invert this cell.
|
||||
if self.selection.contains(&(row, col)) {
|
||||
self.selection.remove(&(row, col));
|
||||
if self.selection.is_empty() {
|
||||
|
|
@ -446,16 +328,12 @@ impl TableBlock {
|
|||
}
|
||||
}
|
||||
SelectionMode::Extend => {
|
||||
// Shift = add this cell (no removal). Drag extends with a
|
||||
// rectangle (also additive).
|
||||
self.selection.insert((row, col));
|
||||
if self.selection_anchor.is_none() {
|
||||
self.selection_anchor = Some((row, col));
|
||||
}
|
||||
}
|
||||
SelectionMode::Subtract => {
|
||||
// Cmd+Shift = remove this cell (no addition). Drag removes
|
||||
// a rectangle.
|
||||
self.selection.remove(&(row, col));
|
||||
if self.selection.is_empty() {
|
||||
self.selection_anchor = None;
|
||||
|
|
@ -466,15 +344,12 @@ impl TableBlock {
|
|||
self.is_active = true;
|
||||
self.table_selected = false;
|
||||
|
||||
// Arm the drag. Subsequent CellEnter events extend the rectangle.
|
||||
self.drag_select_start = Some((row, col));
|
||||
self.drag_select_mode = Some(mode);
|
||||
self.drag_select_baseline = baseline;
|
||||
}
|
||||
|
||||
/// Extend the active drag-rectangle to (row, col). Recomputes the
|
||||
/// selection from the baseline + drag mode + current rectangle. No-op
|
||||
/// if no drag is active.
|
||||
/// extends the drag-rectangle to (row, col), recomputing selection from baseline.
|
||||
pub fn apply_drag_to(&mut self, row: usize, col: usize) {
|
||||
let Some(start) = self.drag_select_start else { return };
|
||||
let Some(mode) = self.drag_select_mode else { return };
|
||||
|
|
@ -524,7 +399,6 @@ impl TableBlock {
|
|||
self.focused_cell = Some((row, col));
|
||||
}
|
||||
|
||||
/// Clear the drag-select state. Called from EndDrag.
|
||||
pub fn end_drag_select(&mut self) {
|
||||
self.drag_select_start = None;
|
||||
self.drag_select_mode = None;
|
||||
|
|
@ -548,21 +422,10 @@ impl TableBlock {
|
|||
self.focused_cell = Some((row, col));
|
||||
}
|
||||
TableMessage::SelectCell(row, col) => {
|
||||
// Single click — selected, not editing. The editor's
|
||||
// `editing` field is cleared by the editor-level dispatch
|
||||
// in `EditorState::update`'s `TableMsg` arm. The actual
|
||||
// multi-cell selection update happens via `apply_click_selection`,
|
||||
// called from the editor's TableMsg arm AFTER it has read
|
||||
// `self.mods` to derive the SelectionMode. Here we only mark
|
||||
// the cell as the focus point and clear table-level selection.
|
||||
self.focused_cell = Some((row, col));
|
||||
self.is_active = true;
|
||||
self.table_selected = false;
|
||||
self.hover_armed = None;
|
||||
// Wrap-off mode: a click that lands on a different cell
|
||||
// re-targets the spillover popup. Clicking the same cell
|
||||
// again toggles it closed so the user can dismiss without
|
||||
// an explicit ESC.
|
||||
if !self.wrap {
|
||||
self.spillover = match self.spillover {
|
||||
Some(prev) if prev == (row, col) => None,
|
||||
|
|
@ -573,30 +436,17 @@ impl TableBlock {
|
|||
}
|
||||
}
|
||||
TableMessage::EditCell(row, col) => {
|
||||
// Double click — selected AND editing. The editor's
|
||||
// `editing` field is set, and `pending_focus` is queued so
|
||||
// iced moves keyboard focus to the cell's text_input on the
|
||||
// next frame.
|
||||
self.focused_cell = Some((row, col));
|
||||
self.is_active = true;
|
||||
self.hover_armed = None;
|
||||
self.spillover = None;
|
||||
}
|
||||
TableMessage::DeleteTable => {
|
||||
// Handled at the editor level — the TableMsg arm in
|
||||
// editor.rs promotes this to DeleteCurrentTable. The block
|
||||
// itself does nothing here, but we still need to ensure the
|
||||
// table is registered as focused so the editor's
|
||||
// focused_table_index() finds it.
|
||||
if self.focused_cell.is_none() {
|
||||
self.focused_cell = Some((0, 0));
|
||||
}
|
||||
}
|
||||
TableMessage::SelectAll => {
|
||||
// Whole-table selection. Mark every cell as selected via the
|
||||
// table_selected flag — cell rendering keys off this for the
|
||||
// highlighted look. focused_cell stays where it was so
|
||||
// arrow keys can drop back into single-cell mode naturally.
|
||||
self.table_selected = true;
|
||||
self.is_active = true;
|
||||
if self.focused_cell.is_none() {
|
||||
|
|
@ -614,24 +464,11 @@ impl TableBlock {
|
|||
}
|
||||
}
|
||||
TableMessage::ContextMenu(_row, _col) => {
|
||||
// Right-click is purely a menu trigger — it does NOT modify
|
||||
// selection state. The context menu operates on whatever was
|
||||
// already selected. The editor.rs TableMsg arm handles the
|
||||
// overlay anchor; this branch is intentionally a no-op.
|
||||
}
|
||||
TableMessage::CellEnter(row, col) => {
|
||||
// Drag-select extension: only acts when a drag is armed.
|
||||
// Without an active drag, hovering over cells is a no-op
|
||||
// (every cell still fires CellEnter on every mouse-over,
|
||||
// which is a tiny per-frame cost).
|
||||
if self.drag_select_start.is_some() {
|
||||
self.apply_drag_to(row, col);
|
||||
}
|
||||
// Hover-to-spillover dwell: only meaningful with wrap off
|
||||
// (clipped cells are the ones that benefit). Re-arming on a
|
||||
// different cell resets the timer; same-cell re-entry leaves
|
||||
// the existing timer alone so a tiny twitch doesn't restart
|
||||
// the dwell.
|
||||
if !self.wrap {
|
||||
let already_armed = matches!(
|
||||
self.hover_armed,
|
||||
|
|
@ -664,7 +501,6 @@ impl TableBlock {
|
|||
return;
|
||||
}
|
||||
let Some((fr, _)) = self.focused_cell else { return };
|
||||
// Never insert above the header row — treat as insert-below-header.
|
||||
let insert_at = fr.max(1).min(self.rows.len());
|
||||
let cols = self.col_count();
|
||||
self.rows.insert(insert_at, vec![String::new(); cols]);
|
||||
|
|
@ -815,8 +651,6 @@ impl TableBlock {
|
|||
TableMessage::ToggleWrap => {
|
||||
if self.read_only { return; }
|
||||
self.wrap = !self.wrap;
|
||||
// Switching to wrap-on auto-closes any open spillover —
|
||||
// wrapped content is no longer clipped, so the popup is moot.
|
||||
if self.wrap { self.spillover = None; }
|
||||
}
|
||||
TableMessage::OpenSpillover(row, col) => {
|
||||
|
|
@ -851,9 +685,6 @@ impl TableBlock {
|
|||
}
|
||||
}
|
||||
}
|
||||
// Also tear down the cell drag-select. The selection state
|
||||
// has already been committed by the last `apply_drag_to`,
|
||||
// so we just clear the bookkeeping.
|
||||
self.end_drag_select();
|
||||
}
|
||||
TableMessage::PromoteCornerPress | TableMessage::PromoteCornerRelease => {}
|
||||
|
|
@ -1019,11 +850,7 @@ impl TableBlock {
|
|||
self.focused_cell.map(|(r, c)| cell_id(self.id, r, c))
|
||||
}
|
||||
|
||||
/// True if this table carries metadata that markdown alone can't
|
||||
/// represent — non-default column widths, explicit row heights, or
|
||||
/// cell formulas (markdown would otherwise serialize the raw `/=...`
|
||||
/// text into the cell, but round-tripping via the sidecar keeps the
|
||||
/// formula label separate from the computed display).
|
||||
/// true when non-default widths, explicit row heights, or cell formulas need sidecar persistence.
|
||||
pub fn has_persistent_metadata(&self) -> bool {
|
||||
if self.col_widths.iter().any(|w| (*w - DEFAULT_COL_WIDTH).abs() > f32::EPSILON) {
|
||||
return true;
|
||||
|
|
@ -1231,17 +1058,11 @@ where
|
|||
let mut col_elements: Vec<Element<'a, Message, Theme, iced_wgpu::Renderer>> = Vec::new();
|
||||
let read_only = block.read_only;
|
||||
let reserve_chrome = !read_only;
|
||||
// Derived sizes that scale with the editor's zoom level.
|
||||
let chrome_font = font_size * 0.77;
|
||||
let corner_font = font_size * 0.69;
|
||||
let plus_font = font_size * 0.85;
|
||||
let row_h = font_size * 1.3 + CELL_PADDING.top + CELL_PADDING.bottom + 2.0;
|
||||
let header_h = chrome_font * 1.3;
|
||||
// Chrome (ABCD column letters, 123 row numbers) appears whenever the table
|
||||
// has a selected cell, not just when iced widget focus is in a cell. Without
|
||||
// the focused_cell branch the chrome would vanish the moment selection mode
|
||||
// takes over from edit mode, hiding the visual cue that the table is yours
|
||||
// to manipulate.
|
||||
let chrome_active = !read_only
|
||||
&& (block.focused_cell.is_some()
|
||||
|| block.is_active
|
||||
|
|
@ -1250,14 +1071,6 @@ where
|
|||
|
||||
if reserve_chrome {
|
||||
let mut header_row_cells: Vec<Element<'a, Message, Theme, iced_wgpu::Renderer>> = Vec::new();
|
||||
// Corner cell at (row-numbers ⨉ column-letters). The "select-all"
|
||||
// affordance — click to mark the whole table as selected. With the
|
||||
// table selected, plain Backspace clears every cell, Cmd+Backspace
|
||||
// deletes the table outright. Eventually this same handle will also
|
||||
// be the drag origin for moving the table around the document, in
|
||||
// line with the broader plan to make every chunk-level node draggable.
|
||||
// Visible whenever the chrome is active so the user always has a
|
||||
// reachable affordance once they've touched the table once.
|
||||
let corner: Element<'a, Message, Theme, iced_wgpu::Renderer> = if chrome_active {
|
||||
let inner = container(
|
||||
text("\u{25A0}")
|
||||
|
|
@ -1425,11 +1238,7 @@ where
|
|||
let c = ci;
|
||||
|
||||
let is_editing_this = editing_cell == Some((ri, ci));
|
||||
// A cell renders selected ONLY because it's in the selection set
|
||||
// (or the table-wide select-all mode is on). The HashSet is the
|
||||
// sole source of truth — `focused_cell` is preserved across blur
|
||||
// for keyboard targeting, so it's not a valid selection signal.
|
||||
let is_focused_this = block.selection.contains(&(ri, ci))
|
||||
let is_focused_this = block.selection.contains(&(ri, ci))
|
||||
|| block.table_selected;
|
||||
|
||||
let font = if is_header {
|
||||
|
|
@ -1441,9 +1250,7 @@ where
|
|||
let cell_element: Element<'a, Message, Theme, iced_wgpu::Renderer> = if is_editing_this
|
||||
|| read_only
|
||||
{
|
||||
// Edit mode (or eval-result table that the user can still
|
||||
// copy from) — use the real text_input.
|
||||
let is_eval = read_only;
|
||||
let is_eval = read_only;
|
||||
let style_fn = move |_theme: &Theme, _status: text_input::Status| -> text_input::Style {
|
||||
if is_header { header_cell_style_for(is_eval) } else { cell_input_style_for(is_eval) }
|
||||
};
|
||||
|
|
@ -1457,28 +1264,16 @@ where
|
|||
if !read_only {
|
||||
input = input.on_input(move |val| on_msg(TableMessage::CellChanged(r, c, val)));
|
||||
}
|
||||
// Pin the wrapper to row_h so a manually-resized row keeps its
|
||||
// height when the user double-clicks to enter edit mode —
|
||||
// text_input alone would snap back to its natural font-size height.
|
||||
container(input)
|
||||
.width(Length::Fixed(width))
|
||||
.height(Length::Fixed(row_h))
|
||||
.into()
|
||||
} else {
|
||||
// Selected-but-not-editing or fully unfocused cell. Renders
|
||||
// as a static text widget inside a container styled to match
|
||||
// the text_input's bounds — visually identical to the edit
|
||||
// form modulo a tinted background when this cell is the
|
||||
// current selection.
|
||||
let label_color = if is_header {
|
||||
palette::widget_surface().header_accent
|
||||
} else {
|
||||
palette::widget_surface().body_text
|
||||
};
|
||||
// Show the computed formula value when this cell is a
|
||||
// `/=...` formula and the eval loop produced a result.
|
||||
// Any cell without a computed entry (plain values, eval
|
||||
// errors during parse/topo pre-pass) falls back to raw.
|
||||
let display_text: String = if cell.trim_start().starts_with("/=") {
|
||||
match computed_cells.get(&(block_id, ci as u32, ri as u32)) {
|
||||
Some(v) => v.display(),
|
||||
|
|
@ -1554,9 +1349,6 @@ where
|
|||
iced_widget::row(row_cells).spacing(0.0).into();
|
||||
col_elements.push(row_el);
|
||||
|
||||
// Row resize band — 3px hit area below each row, drags row height.
|
||||
// Skipped for read_only tables (eval results aren't meant to be
|
||||
// structurally edited).
|
||||
if !read_only {
|
||||
let resize_row = ri;
|
||||
let band_w: f32 = (if reserve_chrome { ROW_NUMBER_WIDTH } else { 0.0 })
|
||||
|
|
@ -1653,9 +1445,7 @@ where
|
|||
outer
|
||||
}
|
||||
|
||||
/// Wrap-aware row height. Manual override wins. Then if wrap is on, fit to
|
||||
/// the tallest wrapped cell (chars × char_w / col_width gives an approximate
|
||||
/// line count). Otherwise fall back to the default single-line row height.
|
||||
/// computes wrap-aware row height, manual override taking precedence.
|
||||
fn compute_row_height(
|
||||
block: &TableBlock,
|
||||
ri: usize,
|
||||
|
|
@ -1701,8 +1491,7 @@ pub fn column_letter(mut idx: usize) -> String {
|
|||
s
|
||||
}
|
||||
|
||||
/// Natural alphanumeric comparison: contiguous digit runs compare as
|
||||
/// integers so `R10` sorts after `R2`. Letter runs compare case-insensitive.
|
||||
/// natural alphanumeric comparison, digit runs compared as integers.
|
||||
fn compare_alphanumeric(a: &str, b: &str) -> std::cmp::Ordering {
|
||||
use std::cmp::Ordering;
|
||||
let mut ai = a.chars().peekable();
|
||||
|
|
|
|||
|
|
@ -1,7 +1,4 @@
|
|||
//! `TextBlock` — the trait-implementing wrapper around `text_widget::Content`.
|
||||
//!
|
||||
//! Owns the editor content and language hint for syntax highlighting. Lives in
|
||||
//! `EditorState::blocks` as a `Box<dyn Block>`.
|
||||
//! TextBlock: trait-implementing wrapper around text_widget::Content.
|
||||
|
||||
use iced_wgpu::core::text::Wrapping;
|
||||
use iced_wgpu::core::text::highlighter::Format;
|
||||
|
|
@ -91,9 +88,11 @@ impl<Message: Clone + 'static> Block<Message> for TextBlock {
|
|||
}
|
||||
});
|
||||
|
||||
let source = self.content.text();
|
||||
let settings = SyntaxSettings {
|
||||
lang: self.lang.clone(),
|
||||
source: self.content.text(),
|
||||
user_idents: syntax::scan_user_idents_in(&source),
|
||||
source,
|
||||
};
|
||||
let editor_el: Element<'a, Message, Theme, iced_wgpu::Renderer> = editor
|
||||
.highlight_with::<SyntaxHighlighter>(
|
||||
|
|
@ -117,9 +116,6 @@ impl<Message: Clone + 'static> Block<Message> for TextBlock {
|
|||
}
|
||||
|
||||
fn apply(&mut self, _cmd: BlockCommand) {
|
||||
// Text mutations go through `text_editor::Action` routed via
|
||||
// `Message::BlockAction` in the editor's update loop. BlockCommand
|
||||
// on a text block is a no-op.
|
||||
}
|
||||
|
||||
fn selectable_paths(&self) -> Box<dyn Iterator<Item = InnerPath> + '_> {
|
||||
|
|
|
|||
|
|
@ -66,33 +66,25 @@ pub use text::editor::{
|
|||
Action, Cursor, Edit, Line, LineEnding, Motion, Position, Selection,
|
||||
};
|
||||
|
||||
/// An anchored child element rendered at a line boundary within the text widget.
|
||||
/// The caller builds these using existing rendering code; the widget just draws them in order.
|
||||
/// anchored child element rendered at a line boundary within the text widget.
|
||||
pub struct AnchoredItem<'a, Message, Theme = iced_wgpu::core::Theme> {
|
||||
pub after_line: usize,
|
||||
pub height: f32,
|
||||
pub element: Element<'a, Message, Theme, iced_wgpu::Renderer>,
|
||||
}
|
||||
|
||||
/// Per-logical-line metrics. Stored on State so layout publishes once
|
||||
/// and every consumer (draw, cursor, hit-test) reads the same data.
|
||||
/// per-logical-line metrics published once by layout, read by all consumers.
|
||||
#[derive(Clone, Default, Debug)]
|
||||
pub struct LineMetric {
|
||||
/// Widget-y of this line's first visual row (relative to text_bounds.y).
|
||||
/// widget-y of first visual row (relative to text_bounds.y).
|
||||
pub widget_y: f32,
|
||||
/// Cosmic-text's viewport-relative y of this line's first visual row —
|
||||
/// matches the y produced by `Selection::Caret(position).y` and what
|
||||
/// `Action::Click { y }` consumes (already scroll-adjusted, items
|
||||
/// invisible to it). Diverges from `widget_y` whenever an anchored
|
||||
/// item sits between this line and `scroll.line`.
|
||||
/// cosmic-text viewport-relative y, diverges from widget_y around anchored items.
|
||||
pub viewport_y: f32,
|
||||
/// Number of visual rows this logical line occupies after wrap.
|
||||
/// visual rows after wrap.
|
||||
pub visual_rows: usize,
|
||||
}
|
||||
|
||||
/// Translate a cosmic-reported y (`Selection::Caret`, `Selection::Range`)
|
||||
/// into our widget-y so cursor + selection rectangles draw on top of the
|
||||
/// text rows the compositor actually rendered.
|
||||
/// translates cosmic-reported y into widget-y for cursor/selection rendering.
|
||||
fn cosmic_y_to_widget_y(metrics: &[LineMetric], cosmic_y: f32, _line_h: f32) -> f32 {
|
||||
if metrics.is_empty() { return cosmic_y; }
|
||||
for i in (0..metrics.len() - 1).rev() {
|
||||
|
|
@ -103,8 +95,7 @@ fn cosmic_y_to_widget_y(metrics: &[LineMetric], cosmic_y: f32, _line_h: f32) ->
|
|||
metrics[0].widget_y + (cosmic_y - metrics[0].viewport_y)
|
||||
}
|
||||
|
||||
/// Translate a widget-y (mouse coords) back into the y cosmic-text expects
|
||||
/// for click/drag actions — the inverse of `cosmic_y_to_widget_y`.
|
||||
/// translates widget-y (mouse coords) back into cosmic-text y for click/drag actions.
|
||||
fn widget_y_to_cosmic_y(metrics: &[LineMetric], widget_y: f32, line_h: f32) -> f32 {
|
||||
if metrics.len() < 2 { return widget_y; }
|
||||
let line_count = metrics.len() - 1;
|
||||
|
|
@ -126,15 +117,13 @@ fn widget_y_to_cosmic_y(metrics: &[LineMetric], widget_y: f32, line_h: f32) -> f
|
|||
tail.viewport_y + (widget_y - tail.widget_y).max(0.0)
|
||||
}
|
||||
|
||||
/// Distance-driven fade ratio for the gutter rainbow. `0.0` at the cursor
|
||||
/// (full saturation), `1.0` at the far end of the fade window.
|
||||
const GUTTER_FADE_CYCLES: f32 = 2.5;
|
||||
fn gutter_fade_t(distance: usize) -> f32 {
|
||||
let max_d = GUTTER_FADE_CYCLES * crate::syntax::USER_IDENT_PALETTE_SIZE as f32;
|
||||
(distance as f32 / max_d).min(1.0)
|
||||
}
|
||||
|
||||
/// builds iced Spans from cosmic glyphs, grouping by (color, weight, style) and skipping marker ranges.
|
||||
/// builds iced Spans from cosmic glyphs, grouping by color/weight/style and skipping marker ranges.
|
||||
fn build_color_spans<'a>(
|
||||
text: &'a str,
|
||||
glyphs: &[cosmic_text::LayoutGlyph],
|
||||
|
|
@ -172,7 +161,6 @@ fn build_color_spans<'a>(
|
|||
}
|
||||
}
|
||||
|
||||
/// span grouping key — color repr plus iced weight/style.
|
||||
type StyleKey = (Option<u32>, IcedWeight, IcedStyle);
|
||||
|
||||
let style_at = |byte_idx: usize| -> (IcedWeight, IcedStyle) {
|
||||
|
|
@ -201,7 +189,6 @@ fn build_color_spans<'a>(
|
|||
spans.push(span);
|
||||
};
|
||||
|
||||
// subtract every marker range from the full line range.
|
||||
let visible_segments: Vec<std::ops::Range<usize>> = if marker_ranges.is_empty() {
|
||||
vec![0..text.len()]
|
||||
} else {
|
||||
|
|
@ -226,7 +213,6 @@ fn build_color_spans<'a>(
|
|||
};
|
||||
|
||||
if glyphs.is_empty() {
|
||||
// empty line — fall back to line-level attrs.
|
||||
let (w, s) = style_at(0);
|
||||
let key: StyleKey = (None, w, s);
|
||||
let mut spans = Vec::new();
|
||||
|
|
@ -547,33 +533,31 @@ where
|
|||
self
|
||||
}
|
||||
|
||||
/// Sets the anchored child elements to draw at line boundaries.
|
||||
/// Items must be sorted by after_line.
|
||||
/// sets the anchored child elements to draw at line boundaries, sorted by after_line.
|
||||
pub fn anchored(mut self, items: Vec<AnchoredItem<'a, Message, Theme>>) -> Self {
|
||||
self.anchored_children = items;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the global line offset for gutter numbering.
|
||||
/// sets the global line offset for gutter numbering.
|
||||
pub fn gutter_offset(mut self, offset: usize) -> Self {
|
||||
self.gutter_offset = offset;
|
||||
self
|
||||
}
|
||||
|
||||
/// Marks this widget as the focused editing block.
|
||||
/// marks the widget as the focused editing block.
|
||||
pub fn focused(mut self, focused: bool) -> Self {
|
||||
self.is_focused_block = focused;
|
||||
self
|
||||
}
|
||||
|
||||
/// Reserve a left strip for line numbers + decoration stripes.
|
||||
/// reserves a left strip for line numbers and decoration stripes.
|
||||
pub fn show_gutter(mut self, show: bool) -> Self {
|
||||
self.show_gutter = show;
|
||||
self
|
||||
}
|
||||
|
||||
/// Cursor's current line within this block. `None` when not focused.
|
||||
/// Drives both the cursorline tint and the gutter rainbow center.
|
||||
/// cursor's current line within the block, driving cursorline tint and gutter rainbow.
|
||||
pub fn cursor_line(mut self, line: Option<usize>) -> Self {
|
||||
self.cursor_line = line;
|
||||
self
|
||||
|
|
@ -594,10 +578,7 @@ where
|
|||
self
|
||||
}
|
||||
|
||||
/// Width of the gutter strip given a line count. Caller passes the
|
||||
/// count so this never touches `self.content` (which would deadlock
|
||||
/// when called from inside layout/draw — those already hold the
|
||||
/// content's RefCell).
|
||||
/// gutter strip width for the given line count.
|
||||
fn gutter_width_for(&self, line_count: usize) -> f32 {
|
||||
if !self.show_gutter { return 0.0; }
|
||||
let total = self.gutter_offset + line_count;
|
||||
|
|
@ -624,6 +605,11 @@ where
|
|||
use crate::syntax::LineDecor;
|
||||
use crate::editor::LineIndicator;
|
||||
|
||||
let widget_bottom = bounds.y + bounds.height - self.padding.bottom;
|
||||
if y + line_h > widget_bottom {
|
||||
return;
|
||||
}
|
||||
|
||||
let gutter_left = bounds.x + self.padding.left;
|
||||
let gutter_right = gutter_left + gw;
|
||||
|
||||
|
|
@ -887,7 +873,7 @@ impl Content {
|
|||
self.0.borrow().editor.buffer().scroll().line
|
||||
}
|
||||
|
||||
/// scrolls cosmic-text so the given logical line lands at viewport top.
|
||||
/// repositions cosmic-text's scroll to the given logical line.
|
||||
pub fn jump_to_line(&mut self, target: usize) {
|
||||
let current = self.scroll_line() as i32;
|
||||
let delta = target as i32 - current;
|
||||
|
|
@ -932,17 +918,10 @@ pub struct State<Highlighter: text::Highlighter> {
|
|||
highlighter: RefCell<Highlighter>,
|
||||
highlighter_settings: Highlighter::Settings,
|
||||
highlighter_format_address: usize,
|
||||
/// Paragraphs built during draw() — kept alive so the renderer's Weak refs
|
||||
/// survive until the prepare() phase processes them.
|
||||
/// paragraphs kept alive across draw->prepare for the renderer's Weak refs.
|
||||
retained_paragraphs: RefCell<Vec<iced_graphics::text::Paragraph>>,
|
||||
/// Per-logical-line metrics published by `layout()`. Every consumer
|
||||
/// (draw, cursor caret, click/drag hit-testing, IME) reads from this
|
||||
/// same Vec — there is no parallel computation. Length = line_count
|
||||
/// + 1, with the trailing sentinel marking widget/buffer y past the
|
||||
/// last line.
|
||||
/// per-logical-line metrics published by layout, read by all consumers.
|
||||
line_metrics: RefCell<Vec<LineMetric>>,
|
||||
/// Gutter strip width, also published by layout. Same single-source
|
||||
/// rule: events translate click x by reading this, never recomputing.
|
||||
gutter_width: std::cell::Cell<f32>,
|
||||
}
|
||||
|
||||
|
|
@ -1092,11 +1071,6 @@ where
|
|||
self.text_size.unwrap_or_else(|| renderer.default_size()),
|
||||
).into();
|
||||
|
||||
// Single source-of-truth: walk lines + anchored children once and
|
||||
// build per-line metrics. Each LineMetric records the widget-y +
|
||||
// buffer-y of that line's first visual row, plus the wrap count.
|
||||
// Draw, cursor positioning, click/drag — every consumer reads from
|
||||
// this same Vec so they cannot drift.
|
||||
let mut child_nodes = Vec::with_capacity(self.anchored_children.len());
|
||||
let child_limits = layout::Limits::new(
|
||||
Size::ZERO,
|
||||
|
|
@ -1104,32 +1078,14 @@ where
|
|||
);
|
||||
let buffer = internal.editor.buffer();
|
||||
let line_count = buffer.lines.len();
|
||||
// Seed widget_y from cosmic-text's internal scroll so the metrics
|
||||
// we publish reflect ACTUAL on-screen positions, not no-scroll
|
||||
// positions. Without this seeding, draw renders text at unscrolled
|
||||
// y while the cursor (computed via cosmic's scroll-aware selection)
|
||||
// appears to drift — the classic "two sources of truth" violation.
|
||||
// Anchor cosmic's viewport-y at scroll.line top: cosmic's
|
||||
// `Selection::Caret(position).y` for a cursor sitting on the very
|
||||
// first visible visual row equals `-scroll.vertical`, regardless
|
||||
// of how many logical lines came before. Pre-scroll lines are not
|
||||
// shaped (`layout_opt() == None`) and contribute 0 visual rows in
|
||||
// cosmic's own bookkeeping — mirror that so the two y-spaces
|
||||
// agree.
|
||||
let scroll = buffer.scroll();
|
||||
let mut metrics: Vec<LineMetric> = Vec::with_capacity(line_count + 1);
|
||||
let mut widget_y = -scroll.vertical;
|
||||
let mut viewport_y = -scroll.vertical;
|
||||
// Pre-scroll lines: cosmic treats them as 0 rows, but we still
|
||||
// need a widget-y so any bottom-anchored items render relative to
|
||||
// a stable bottom. For the cursor/selection mapping to work the
|
||||
// viewport_y must stay parked at -scroll.vertical for them
|
||||
// (cosmic has no addressable y above scroll.line).
|
||||
for _ in 0..scroll.line.min(line_count) {
|
||||
metrics.push(LineMetric { widget_y, viewport_y, visual_rows: 0 });
|
||||
}
|
||||
let mut next_child = 0;
|
||||
// Skip anchored children that sit above the scroll line.
|
||||
while next_child < self.anchored_children.len()
|
||||
&& self.anchored_children[next_child].after_line < scroll.line
|
||||
{
|
||||
|
|
@ -1164,7 +1120,6 @@ where
|
|||
next_child += 1;
|
||||
}
|
||||
}
|
||||
// Remaining children after last line — they sit below all text.
|
||||
while next_child < self.anchored_children.len() {
|
||||
let child = &mut self.anchored_children[next_child];
|
||||
let mut node = child.element.as_widget_mut().layout(
|
||||
|
|
@ -1181,17 +1136,12 @@ where
|
|||
child_nodes.push(node);
|
||||
next_child += 1;
|
||||
}
|
||||
// Push sentinel AFTER trailing children are placed, so the
|
||||
// sentinel widget_y reflects the true bottom of the stream.
|
||||
metrics.push(LineMetric { widget_y, viewport_y, visual_rows: 0 });
|
||||
let extra = widget_y - viewport_y;
|
||||
*state.line_metrics.borrow_mut() = metrics;
|
||||
|
||||
match self.height {
|
||||
Length::Fill | Length::FillPortion(_) | Length::Fixed(_) => {
|
||||
// Fixed/Fill: caller specified the height. Honor it as-is —
|
||||
// anchored items live within that height; trailing space
|
||||
// would otherwise create phantom gaps below the block.
|
||||
layout::Node::with_children(limits.max(), child_nodes)
|
||||
}
|
||||
Length::Shrink => {
|
||||
|
|
@ -1219,7 +1169,6 @@ where
|
|||
shell: &mut Shell<'_, Message>,
|
||||
_viewport: &Rectangle,
|
||||
) {
|
||||
// Forward events to anchored children first
|
||||
if !self.anchored_children.is_empty() {
|
||||
let children_layouts: Vec<_> = layout.children().collect();
|
||||
for (i, child) in self.anchored_children.iter_mut().enumerate() {
|
||||
|
|
@ -1562,8 +1511,6 @@ where
|
|||
let text_size = self.text_size.unwrap_or_else(|| renderer.default_size());
|
||||
let line_h: f32 = self.line_height.to_absolute(text_size).into();
|
||||
|
||||
// Gutter background — only the strip below top_pad so the title-bar
|
||||
// / traffic-light area doesn't get painted.
|
||||
if self.show_gutter && self.padding.top < bounds.height {
|
||||
let p = crate::palette::current();
|
||||
renderer.fill_quad(
|
||||
|
|
@ -1599,16 +1546,11 @@ where
|
|||
);
|
||||
}
|
||||
} else {
|
||||
// Sequential stream: text lines (layer 0) interleaved with
|
||||
// anchored children (layer 1) in one continuous pass. Cursorline
|
||||
// tint and gutter line numbers are drawn on the SAME y as the
|
||||
// line's paragraph — single source of truth.
|
||||
let buffer = internal.editor.buffer();
|
||||
let line_count = buffer.lines.len();
|
||||
let mut child_idx = 0;
|
||||
let children_layouts: Vec<_> = layout.children().collect();
|
||||
|
||||
// cursor line draws raw cosmic glyphs, other lines strip marker bytes for WYS.
|
||||
let active_cursor_line = if self.is_focused_block {
|
||||
self.cursor_line
|
||||
} else {
|
||||
|
|
@ -1624,8 +1566,6 @@ where
|
|||
let mut paras = state.retained_paragraphs.borrow_mut();
|
||||
paras.clear();
|
||||
|
||||
// viewport-y bounds in screen space — anything outside is clipped
|
||||
// away by the renderer, so skip the shape + paragraph build.
|
||||
let view_top = viewport.y;
|
||||
let view_bot = viewport.y + viewport.height;
|
||||
|
||||
|
|
@ -1634,9 +1574,6 @@ where
|
|||
Some(m) => m,
|
||||
None => continue,
|
||||
};
|
||||
// Pre-scroll lines carry visual_rows == 0 (cosmic hasn't
|
||||
// shaped them, layout_opt returns None) — skip so we don't
|
||||
// pile unshaped paragraphs at the same y.
|
||||
if m.visual_rows == 0 {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -1709,9 +1646,6 @@ where
|
|||
);
|
||||
}
|
||||
|
||||
// children are advanced regardless of visibility so they stay
|
||||
// associated with the correct line; the child's own draw will
|
||||
// be culled by the renderer when off-screen.
|
||||
while child_idx < self.anchored_children.len()
|
||||
&& self.anchored_children[child_idx].after_line == line_i
|
||||
{
|
||||
|
|
|
|||
|
|
@ -224,10 +224,7 @@ impl<Message: Clone> canvas::Program<Message, Theme, iced_wgpu::Renderer> for Tr
|
|||
}
|
||||
}
|
||||
|
||||
/// Builds the framed canvas Element for a tree block. Returns `'static`
|
||||
/// because `TreeProgram::from_json` clones the labels into an owned `Vec<TreeNode>` —
|
||||
/// nothing in the returned widget tree borrows from `data`.
|
||||
/// Total rendered height of a tree element including padding and border.
|
||||
/// total rendered height of a tree element including padding and border.
|
||||
pub fn element_height(data: &serde_json::Value, font_size: f32) -> f32 {
|
||||
let program = TreeProgram::from_json_scaled(data, font_size);
|
||||
program.height()
|
||||
|
|
@ -278,9 +275,7 @@ pub fn build<Message: Clone + 'static>(
|
|||
.into()
|
||||
}
|
||||
|
||||
/// Trait-implementing struct for a tree block. Owns the JSON value; the
|
||||
/// canvas program is rebuilt fresh on each `view` call (cheap — flatten_tree
|
||||
/// is O(nodes) and the JSON is already parsed).
|
||||
/// tree block backed by an owned JSON value.
|
||||
pub struct TreeBlock {
|
||||
pub id: BlockId,
|
||||
pub data: serde_json::Value,
|
||||
|
|
@ -327,8 +322,6 @@ impl<Message: Clone + 'static> Block<Message> for TreeBlock {
|
|||
}
|
||||
|
||||
fn to_md(&self) -> String {
|
||||
// Trees aren't currently round-tripped through markdown — they only
|
||||
// appear as eval results.
|
||||
String::new()
|
||||
}
|
||||
|
||||
|
|
@ -337,7 +330,6 @@ impl<Message: Clone + 'static> Block<Message> for TreeBlock {
|
|||
}
|
||||
|
||||
fn apply(&mut self, _cmd: BlockCommand) {
|
||||
// Trees are read-only.
|
||||
}
|
||||
|
||||
fn selectable_paths(&self) -> Box<dyn Iterator<Item = InnerPath> + '_> {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,4 @@
|
|||
//! Modal dialog scaffolding — matches Acord's settings panel.
|
||||
//!
|
||||
//! [`overlay`] dims the underlying view and centers a panel; [`segmented_row`]
|
||||
//! is the labeled multi-button row used for theme/line-indicator toggles.
|
||||
//! modal dialog scaffolding matching Acord's settings panel.
|
||||
|
||||
use iced_wgpu::Renderer;
|
||||
use iced_wgpu::core::{Background, Border, Color, Element, Length, Padding, Shadow, Theme};
|
||||
|
|
@ -10,10 +7,7 @@ use iced_widget::{button, container, row, text};
|
|||
use crate::palette;
|
||||
use crate::syntax;
|
||||
|
||||
/// Wraps `panel` content in a centered card over a translucent dim backdrop.
|
||||
/// `panel` is rendered as-is — the caller controls its content, padding, and
|
||||
/// width. Set `width` to a fixed value (e.g. `Length::Fixed(font_size * 28.0)`)
|
||||
/// for a stable layout.
|
||||
/// centers a panel card over a translucent dim backdrop.
|
||||
pub fn overlay<'a, Message>(
|
||||
panel: Element<'a, Message, Theme, Renderer>,
|
||||
width: Length,
|
||||
|
|
@ -56,9 +50,7 @@ where
|
|||
.into()
|
||||
}
|
||||
|
||||
/// A "label … [Option1] [Option2] [Option3]" row with the current option
|
||||
/// highlighted. `options` is `(display_label, value)`; `current` is the
|
||||
/// active value; `on_select(value)` fires when one is clicked.
|
||||
/// labeled row of toggle buttons with the active option highlighted.
|
||||
pub fn segmented_row<'a, Message>(
|
||||
label: &str,
|
||||
options: &[(&'a str, &'a str)],
|
||||
|
|
|
|||
|
|
@ -1,9 +1,4 @@
|
|||
//! Menu strip + dropdown panel, generic over the host's `Message` type.
|
||||
//!
|
||||
//! `strip()` builds the horizontal category bar; `dropdown()` builds an
|
||||
//! anchored panel of label+shortcut rows. Hosts handle "is this category
|
||||
//! open?" themselves and stack the dropdown over their content with
|
||||
//! `iced_widget::stack!` after picking the click position.
|
||||
//! menu strip and dropdown panel, generic over the host's message type.
|
||||
|
||||
use iced_wgpu::Renderer;
|
||||
use iced_wgpu::core::{Background, Border, Element, Length, Padding, Shadow, Theme};
|
||||
|
|
@ -13,7 +8,7 @@ use crate::palette;
|
|||
use crate::syntax;
|
||||
use crate::widgets::style;
|
||||
|
||||
/// One row in a dropdown — either a clickable item or a horizontal separator.
|
||||
/// one row in a dropdown: clickable item or horizontal separator.
|
||||
#[derive(Clone)]
|
||||
pub enum Row<Message: Clone + 'static> {
|
||||
Item {
|
||||
|
|
@ -47,16 +42,14 @@ impl<K: Clone> Category<K> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Approximate button width for a category label, useful when computing
|
||||
/// horizontal anchor offsets for dropdowns.
|
||||
/// approximate button width for a category label given a font size.
|
||||
pub fn category_button_width(label: &str, font_size: f32) -> f32 {
|
||||
let char_w = font_size * 0.6;
|
||||
let pad_x = font_size * 0.85;
|
||||
label.len() as f32 * char_w + pad_x * 2.0
|
||||
}
|
||||
|
||||
/// Renders the horizontal category bar. `is_active` tells whether the dropdown
|
||||
/// for that category is currently open (renders a highlighted background).
|
||||
/// renders the horizontal category bar with active-state highlighting.
|
||||
pub fn strip<'a, K, Message, F>(
|
||||
categories: &'a [Category<K>],
|
||||
is_active: impl Fn(&K) -> bool,
|
||||
|
|
@ -111,9 +104,7 @@ where
|
|||
.into()
|
||||
}
|
||||
|
||||
/// Renders a dropdown panel of rows. Caller positions the panel over the
|
||||
/// strip via `iced_widget::stack!` (typically with a `column!` of [empty
|
||||
/// padding, dropdown]).
|
||||
/// renders a dropdown panel of label+shortcut rows.
|
||||
pub fn dropdown<'a, Message>(
|
||||
rows: Vec<Row<Message>>,
|
||||
font_size: f32,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,4 @@
|
|||
//! Reusable iced widgets pulled out of the Acord editor for use in other apps.
|
||||
//!
|
||||
//! Each submodule is generic over the host's `Message` type and renders against
|
||||
//! the `iced_wgpu::Renderer`. The widgets read from the global Acord palette
|
||||
//! (see [`crate::palette`]) so callers can theme them by calling
|
||||
//! `palette::set_theme(...)` before building their UI.
|
||||
//! reusable iced widgets shared across Acord shells.
|
||||
|
||||
pub mod dialog;
|
||||
pub mod menu;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,4 @@
|
|||
//! iced style functions matching Acord's look.
|
||||
//!
|
||||
//! Each reads from the global Acord palette ([`crate::palette::current`]) at
|
||||
//! call time, so they automatically follow theme switches.
|
||||
//! iced style functions matching Acord's palette.
|
||||
|
||||
use iced_wgpu::core::{Background, Border, Color, Shadow, Theme};
|
||||
use iced_widget::{button, text_input};
|
||||
|
|
@ -25,7 +22,7 @@ pub fn menu_item(_theme: &Theme, status: button::Status) -> button::Style {
|
|||
}
|
||||
}
|
||||
|
||||
/// solid surface-1 button with a 1px outline; used by Acord's find bar.
|
||||
/// solid surface-1 button with a 1px outline.
|
||||
pub fn outlined_button(_theme: &Theme, _status: button::Status) -> button::Style {
|
||||
let p = palette::current();
|
||||
button::Style {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ fn main() {
|
|||
|
||||
println!("cargo:rerun-if-changed={svg}");
|
||||
|
||||
// Rasterize SVG → PNGs → ICO.
|
||||
// rasterize SVG to PNGs to ICO.
|
||||
let _ = std::fs::create_dir_all(tmp);
|
||||
let sizes = [16, 24, 32, 48, 64, 128, 256];
|
||||
let mut pngs = Vec::new();
|
||||
|
|
@ -39,7 +39,7 @@ fn main() {
|
|||
return;
|
||||
}
|
||||
|
||||
// Tell the linker to include the compiled resource.
|
||||
// link the compiled resource into the binary.
|
||||
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap();
|
||||
println!("cargo:rustc-link-arg-bins={manifest_dir}/{res}");
|
||||
println!("cargo:warning=icon embedded via llvm-windres");
|
||||
|
|
|
|||
|
|
@ -412,10 +412,7 @@ impl App {
|
|||
self.last_autosaved_hash = None;
|
||||
}
|
||||
|
||||
/// Hash-gated autosave. Mirrors the macOS Swift `persistViewportToNotesDir`:
|
||||
/// fires on a poll cadence, skips the disk write when the buffer hash
|
||||
/// matches the last saved value. Without the hash gate this would rewrite
|
||||
/// the note every poll tick (~MB/s on a busy doc).
|
||||
/// hash-gated autosave, skipping the disk write when the buffer hash matches.
|
||||
fn try_autosave(&mut self) {
|
||||
if self.handle.is_null() { return; }
|
||||
let text_ptr = viewport_get_text(self.handle);
|
||||
|
|
@ -428,7 +425,7 @@ impl App {
|
|||
let hash = text_hash(&text);
|
||||
if Some(hash) == self.last_autosaved_hash { return; }
|
||||
|
||||
// skip the launch stub so it can't overwrite last session's Untitled.md.
|
||||
// skip the launch stub when no meaningful content exists yet.
|
||||
if self.current_file.is_none() && is_effectively_blank(&text) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -574,8 +571,7 @@ impl ApplicationHandler for App {
|
|||
.with_title("Acord")
|
||||
.with_inner_size(LogicalSize::new(1100.0, 750.0));
|
||||
|
||||
// Load window icon from the bundled PNG (rasterized from SVG at build
|
||||
// time or shipped alongside the exe). Falls back to no icon silently.
|
||||
// load window icon from the bundled PNG, falling back to none.
|
||||
if let Some(icon) = load_window_icon() {
|
||||
attrs = attrs.with_window_icon(Some(icon));
|
||||
}
|
||||
|
|
@ -587,7 +583,7 @@ impl ApplicationHandler for App {
|
|||
let w = size.width as f32 / self.scale;
|
||||
let h = size.height as f32 / self.scale;
|
||||
|
||||
// Get raw HWND and pass to viewport.
|
||||
// extract raw HWND.
|
||||
use raw_window_handle::HasWindowHandle;
|
||||
let wh = window.window_handle().expect("window handle");
|
||||
let raw = wh.as_raw();
|
||||
|
|
@ -742,7 +738,7 @@ fn text_hash(s: &str) -> u64 {
|
|||
h.finish()
|
||||
}
|
||||
|
||||
/// true when the buffer is empty or just leading heading markers.
|
||||
/// true for empty buffers or buffers containing only heading markers.
|
||||
fn is_effectively_blank(text: &str) -> bool {
|
||||
let trimmed = text.trim();
|
||||
if trimmed.is_empty() {
|
||||
|
|
@ -751,7 +747,7 @@ fn is_effectively_blank(text: &str) -> bool {
|
|||
trimmed.trim_start_matches('#').trim().is_empty()
|
||||
}
|
||||
|
||||
/// Map winit logical keys to iced keyboard keys for direct iced event push.
|
||||
/// maps winit logical keys to iced keyboard keys.
|
||||
fn winit_key_to_iced(key: &Key) -> iced_wgpu::core::keyboard::Key {
|
||||
use iced_wgpu::core::keyboard::{key as ikey, Key as IKey};
|
||||
match key {
|
||||
|
|
@ -777,10 +773,7 @@ fn winit_key_to_iced(key: &Key) -> iced_wgpu::core::keyboard::Key {
|
|||
}
|
||||
}
|
||||
|
||||
/// Map winit logical keys to the macOS-style keycodes the bridge expects.
|
||||
/// For Named keys, return the matching keycode. For character keys, the
|
||||
/// bridge ignores the keycode and uses the text parameter directly, so
|
||||
/// we return 0 (unmapped).
|
||||
/// maps winit logical keys to macOS-style keycodes for the bridge.
|
||||
fn winit_key_to_code(key: &Key) -> u32 {
|
||||
match key {
|
||||
Key::Named(n) => match n {
|
||||
|
|
@ -821,9 +814,7 @@ fn encode_modifiers(state: ModifiersState) -> u32 {
|
|||
if state.control_key() { flags |= 1 << 18; }
|
||||
if state.alt_key() { flags |= 1 << 19; }
|
||||
if state.super_key() { flags |= 1 << 20; }
|
||||
// Mirror Ctrl→LOGO so the viewport's `modifiers.logo()` shortcut arms fire.
|
||||
// Matches `decode_winit_modifiers` below; without this, only menu-accelerated
|
||||
// shortcuts (B/I/T) reach the viewport on Windows.
|
||||
// mirror Ctrl to LOGO for viewport shortcut matching.
|
||||
if state.control_key() { flags |= 1 << 20; }
|
||||
flags
|
||||
}
|
||||
|
|
@ -833,31 +824,27 @@ fn decode_winit_modifiers(state: ModifiersState) -> iced_wgpu::core::keyboard::M
|
|||
if state.shift_key() { m |= iced_wgpu::core::keyboard::Modifiers::SHIFT; }
|
||||
if state.control_key() { m |= iced_wgpu::core::keyboard::Modifiers::CTRL; }
|
||||
if state.alt_key() { m |= iced_wgpu::core::keyboard::Modifiers::ALT; }
|
||||
// On Windows, Ctrl is the action modifier (not Logo/Super).
|
||||
// Map Ctrl to LOGO so iced's Cmd+C/V/X bindings work via Ctrl on Windows.
|
||||
// map Ctrl to LOGO as the primary action modifier.
|
||||
if state.control_key() { m |= iced_wgpu::core::keyboard::Modifiers::LOGO; }
|
||||
m
|
||||
}
|
||||
|
||||
/// Load the app icon from `assets/Acord.svg` (relative to exe) or a
|
||||
/// pre-rasterized PNG next to the exe. Returns None on any failure.
|
||||
/// loads the app icon from a PNG or SVG near the exe.
|
||||
fn load_window_icon() -> Option<winit::window::Icon> {
|
||||
// Try loading a PNG icon next to the exe first.
|
||||
let exe_dir = std::env::current_exe().ok()?.parent()?.to_path_buf();
|
||||
|
||||
// Try pre-rasterized icon.png next to exe.
|
||||
let png_path = exe_dir.join("icon.png");
|
||||
let bytes = if png_path.exists() {
|
||||
std::fs::read(&png_path).ok()?
|
||||
} else {
|
||||
// Fall back to the SVG in the assets dir (repo layout).
|
||||
// fall back to the SVG in the assets dir.
|
||||
let svg_path = exe_dir.join("../assets/Acord.svg")
|
||||
.canonicalize().ok()
|
||||
.or_else(|| {
|
||||
// Running from repo root via cargo run.
|
||||
std::path::PathBuf::from("assets/Acord.svg").canonicalize().ok()
|
||||
})?;
|
||||
// Use rsvg-convert at runtime as a fallback.
|
||||
// rasterize with rsvg-convert.
|
||||
let output = std::process::Command::new("rsvg-convert")
|
||||
.args(["--width", "256", "--height", "256"])
|
||||
.arg(&svg_path)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// Hide the console window on release builds.
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
mod app;
|
||||
|
|
|
|||
Loading…
Reference in New Issue