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:
jess 2026-05-26 10:46:40 -07:00
parent ba983e3776
commit 1a0508bc06
87 changed files with 3744 additions and 2301 deletions

3
.gitignore vendored
View File

@ -1,4 +1,7 @@
.DS_Store
target/
build/
*.xcodeproj
*.xcassets
*.pbxproj
dist/

View File

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

10
compile/Cargo.toml Normal file
View File

@ -0,0 +1,10 @@
[package]
name = "acord-compile"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["rlib"]
[dependencies]
acord-core = { path = "../core" }

563
compile/src/lib.rs Normal file
View File

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

View File

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

View File

@ -1,4 +1,4 @@
/* Generated by cbindgen do not edit */
/* Generated by cbindgen - do not edit */
#ifndef SWIFTLY_H
#define SWIFTLY_H

View File

@ -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 == '_' {

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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' {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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" : [

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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> + '_>;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 `![alt](src)` from a line
/// parses a markdown image reference ![alt](src) from a line
pub(super) fn parse_image_ref(line: &str) -> Option<(String, String)> {
let trimmed = line.trim_start();
if !trimmed.starts_with("![") { return None; }
@ -298,3 +326,19 @@ pub fn write_clipboard_image_to_cache(img: &arboard::ImageData) -> Option<String
}
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
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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> + '_> {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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> + '_> {

View File

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

View File

@ -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> + '_> {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,3 @@
// Hide the console window on release builds.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod app;