mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-31 22:07:06 -06:00
* WIP for #1609 * Equations: a script after _ or ^ is the whole run of letters and digits (#1609) Word for Mac 16.113.4: V_dd and V_(dd) build up the same (dd is all subscript), likewise x^ab, and Linear format shows V_dd bare. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Equations: bare scripts end at a space, digits continue a script run, upright function names (#1609) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Equations: a bare script also ends before a following period (#1609 review) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * ROADMAP: drop three progress rows main already has Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
945 lines
41 KiB
Rust
945 lines
41 KiB
Rust
//! Equations: insert (empty, from the gallery, linear format or LaTeX), edit in place (type,
|
||
//! structures, symbols, caret movement, delete, build-up), convert between built-up and linear
|
||
//! form, inline/display, justification and equation numbers.
|
||
|
||
use serde_json::{Value, json};
|
||
use wordcraft_doc::math::{Arg, MNode, Math, MathJc, parse_linear, to_linear};
|
||
use wordcraft_doc::math_edit::{self as me, MathPos, TypeStyle};
|
||
use wordcraft_doc::math_latex::{parse_latex, to_latex};
|
||
use wordcraft_doc::para::OBJ;
|
||
use wordcraft_doc::{InlineObject, Pos};
|
||
|
||
use super::{delete_selection, sel_result, split_para};
|
||
use crate::{CmdError, CmdResult, CommandSpec, MathEdit, Selection, Session, p};
|
||
|
||
fn editing(s: &Session) -> Option<&'static str> {
|
||
if s.math.is_none() { Some("not editing an equation") } else { None }
|
||
}
|
||
|
||
fn near_equation(s: &Session) -> Option<&'static str> {
|
||
if s.math.is_some() || equation_at_caret(s).is_some() { None } else { Some("no equation at the caret") }
|
||
}
|
||
|
||
pub fn specs() -> Vec<CommandSpec> {
|
||
vec![
|
||
CommandSpec::new("insert.equation", "Equation", "Insert › Symbols", insert)
|
||
.key("Alt+=")
|
||
.params(r#"{"linear"?: string, "latex"?: string, "builtin"?: "pythagoras|area|quadratic|binomial|expansion|euler|derivative|taylor|gaussian|fourier|trig1|trig2|normal|bayes", "display"?: bool, "edit"?: bool (default true)}"#),
|
||
CommandSpec::new("equation.edit", "Edit Equation", "Equation", edit)
|
||
.pure()
|
||
.when(near_equation)
|
||
.params(r#"{"pos"?: {"path": [[node, child]…], "off": n}}"#),
|
||
CommandSpec::new("equation.exit", "Leave Equation", "Equation", exit).pure().when(editing).params(r#"{"before"?: bool}"#),
|
||
CommandSpec::new("equation.type", "Type in Equation", "Equation", type_text).when(editing).params(r#"{"text": string}"#),
|
||
CommandSpec::new("equation.insertStructure", "Insert Structure", "Equation › Structures", insert_structure)
|
||
.params(r#"{"id"?: string (see equation.gallery), "linear"?: string, "latex"?: string}"#),
|
||
CommandSpec::new("equation.insertSymbol", "Insert Symbol", "Equation › Symbols", insert_symbol).params(r#"{"char": string}"#),
|
||
CommandSpec::new("equation.move", "Move in Equation", "Equation", move_caret)
|
||
.pure()
|
||
.when(editing)
|
||
.params(r#"{"dir": "left|right|up|down|home|end|next|prev"}"#),
|
||
CommandSpec::new("equation.backspace", "Delete Back in Equation", "Equation", |s, _| delete(s, true)).when(editing),
|
||
CommandSpec::new("equation.delete", "Delete in Equation", "Equation", |s, _| delete(s, false)).when(editing),
|
||
CommandSpec::new("equation.enter", "Enter in Equation", "Equation", enter).when(editing),
|
||
CommandSpec::new("equation.convert", "Convert", "Equation › Conversions", convert)
|
||
.when(near_equation_or_all)
|
||
.params(r#"{"to": "professional|linear", "all"?: bool}"#),
|
||
CommandSpec::new("equation.inputFormat", "Input Format", "Equation › Conversions", |s, v| {
|
||
if let Some(f) = p::str(v, "format") {
|
||
s.math_latex = f.eq_ignore_ascii_case("latex");
|
||
}
|
||
Ok(json!({"format": if s.math_latex { "latex" } else { "unicode" }}))
|
||
})
|
||
.pure()
|
||
.params(r#"{"format": "unicode|latex"}"#),
|
||
CommandSpec::new("equation.normalText", "Normal Text", "Equation › Tools", |s, v| {
|
||
s.math_normal_text = p::bool(v, "value").unwrap_or(!s.math_normal_text);
|
||
Ok(json!({"normalText": s.math_normal_text}))
|
||
})
|
||
.pure()
|
||
.params(r#"{"value"?: bool}"#),
|
||
CommandSpec::new("equation.display", "Change to Display", "Equation", set_display).when(near_equation).params(r#"{"value"?: bool}"#),
|
||
CommandSpec::new("equation.justify", "Justification", "Equation", justify)
|
||
.when(near_equation)
|
||
.params(r#"{"jc": "centerGroup|center|left|right"}"#),
|
||
CommandSpec::new("equation.number", "Equation Number", "Equation", number).when(near_equation).params(r#"{"value"?: bool}"#),
|
||
CommandSpec::new("equation.set", "Set Equation", "Equation", set).when(near_equation).params(r#"{"linear"?: string, "latex"?: string}"#),
|
||
CommandSpec::new("equation.get", "Equation Info", "Equation", get).pure().when(near_equation),
|
||
CommandSpec::new("equation.gallery", "Equation Gallery", "Equation › Structures", gallery).pure(),
|
||
CommandSpec::new("equation.structureActions", "Structure Actions", "Equation", structure_actions).pure().when(editing),
|
||
CommandSpec::new("equation.structure", "Change Structure", "Equation", structure)
|
||
.when(editing)
|
||
.params(r#"{"action": string (see equation.structureActions), "level"?: n}"#),
|
||
]
|
||
}
|
||
|
||
fn near_equation_or_all(_: &Session) -> Option<&'static str> {
|
||
None
|
||
}
|
||
|
||
/// The equation the caret is at: on its U+FFFC or just after it (or the one being edited).
|
||
fn equation_at_caret(s: &Session) -> Option<Pos> {
|
||
if let Some(m) = &s.math {
|
||
return Some(m.at.clone());
|
||
}
|
||
let f = &s.sel.focus;
|
||
let para = s.doc.para_at(f)?;
|
||
let is_eq = |off: usize| matches!(para.object_at(off), Some(InlineObject::Equation { .. }));
|
||
if is_eq(f.off) {
|
||
return Some(f.clone());
|
||
}
|
||
let before = f.off.checked_sub(OBJ.len_utf8())?;
|
||
if is_eq(before) {
|
||
return Some(Pos { off: before, ..f.clone() });
|
||
}
|
||
// A selection holding exactly one equation.
|
||
let (a, b) = s.sel.ordered();
|
||
if a.path == b.path && b.off == a.off + OBJ.len_utf8() && is_eq(a.off) {
|
||
return Some(a);
|
||
}
|
||
None
|
||
}
|
||
|
||
/// Run `f` on the equation at `at`; keeps its linear text in step and drops the source OMML
|
||
/// (it no longer matches).
|
||
fn with_eq<R>(s: &mut Session, at: &Pos, f: impl FnOnce(&mut Math, &mut bool) -> R) -> Result<R, CmdError> {
|
||
let para = s.doc.para_mut(at.story, &at.path)?;
|
||
let Some(InlineObject::Equation { linear, display, math }) = para.object_at_mut(at.off) else {
|
||
return Err(CmdError::Failed("no equation there".into()));
|
||
};
|
||
if math.nodes.is_empty() && !linear.is_empty() && !math.linear {
|
||
math.nodes = parse_linear(linear);
|
||
}
|
||
let r = f(math, display);
|
||
*linear = if math.linear { plain(&math.nodes) } else { to_linear(&math.nodes) };
|
||
math.omml.clear();
|
||
para.touch();
|
||
Ok(r)
|
||
}
|
||
|
||
/// All the text of an equation's runs (its linear form while shown linear).
|
||
fn plain(nodes: &[MNode]) -> String {
|
||
let mut s = String::new();
|
||
for n in nodes {
|
||
if let MNode::Run(r) = n {
|
||
s.push_str(&r.text);
|
||
}
|
||
}
|
||
s
|
||
}
|
||
|
||
fn result(s: &Session) -> CmdResult {
|
||
let Some(m) = &s.math else { return sel_result(s) };
|
||
let info = s.doc.para_at(&m.at).and_then(|p| match p.object_at(m.at.off) {
|
||
Some(InlineObject::Equation { linear, display, .. }) => Some((linear.clone(), *display)),
|
||
_ => None,
|
||
});
|
||
let (linear, display) = info.unwrap_or_default();
|
||
Ok(json!({"editing": true, "linear": linear, "display": display, "pos": serde_json::to_value(&m.pos).unwrap_or(Value::Null)}))
|
||
}
|
||
|
||
/// Nodes from `linear`, `latex` or a gallery `id` / `builtin` parameter.
|
||
fn nodes_from(v: &Value) -> Option<Arg> {
|
||
if let Some(id) = p::str(v, "id") {
|
||
return crate::math_gallery::template(id).map(|t| t.nodes());
|
||
}
|
||
if let Some(b) = p::str(v, "builtin") {
|
||
return crate::math_gallery::BUILT_INS.iter().find(|(id, _, _)| *id == b).map(|(_, _, lin)| parse_linear(lin));
|
||
}
|
||
if let Some(l) = p::str(v, "latex") {
|
||
return Some(parse_latex(l));
|
||
}
|
||
p::str(v, "linear").map(parse_linear)
|
||
}
|
||
|
||
fn insert(s: &mut Session, v: &Value) -> CmdResult {
|
||
let selected = if s.sel.is_collapsed() { String::new() } else { s.selected_text() };
|
||
let nodes = match nodes_from(v) {
|
||
Some(n) => n,
|
||
None if p::str(v, "id").is_some() || p::str(v, "builtin").is_some() => return Err(CmdError::Params("unknown equation".into())),
|
||
// Selected text becomes the equation (Word converts it).
|
||
None => parse_linear(selected.trim()),
|
||
};
|
||
insert_nodes_as_equation(s, nodes, v)
|
||
}
|
||
|
||
fn insert_nodes_as_equation(s: &mut Session, nodes: Arg, v: &Value) -> CmdResult {
|
||
s.math = None;
|
||
let props = s.typing_props();
|
||
let at = delete_selection(s)?;
|
||
// On an empty line it's a display equation, in text an inline one.
|
||
let empty_line = s.doc.para_at(&at).is_none_or(|p| p.text.trim().is_empty());
|
||
let display = p::bool(v, "display").unwrap_or(empty_line);
|
||
let linear = to_linear(&nodes);
|
||
let caret = match me::next_placeholder(&nodes, &MathPos::default(), false) {
|
||
Some(p) if !nodes.is_empty() => p,
|
||
_ => MathPos::new(Vec::new(), me::units(&nodes)),
|
||
};
|
||
let obj = InlineObject::Equation { linear, display, math: Math { nodes, ..Default::default() } };
|
||
let end = s.doc.insert_object(&at, obj, &props)?;
|
||
if p::bool(v, "edit").unwrap_or(true) {
|
||
s.sel = Selection::caret(at.clone());
|
||
s.math = Some(MathEdit { at, pos: caret });
|
||
} else {
|
||
s.sel = Selection::caret(end);
|
||
}
|
||
result(s)
|
||
}
|
||
|
||
fn edit(s: &mut Session, v: &Value) -> CmdResult {
|
||
let at = equation_at_caret(s).ok_or_else(|| CmdError::Disabled("no equation at the caret".into()))?;
|
||
let nodes = match s.doc.para_at(&at).and_then(|p| p.object_at(at.off)) {
|
||
Some(InlineObject::Equation { math, linear, .. }) => {
|
||
if math.nodes.is_empty() && !math.linear {
|
||
parse_linear(linear)
|
||
} else {
|
||
math.nodes.clone()
|
||
}
|
||
}
|
||
_ => return Err(CmdError::Failed("no equation there".into())),
|
||
};
|
||
let pos =
|
||
v.get("pos").and_then(|p| serde_json::from_value::<MathPos>(p.clone()).ok()).unwrap_or_else(|| MathPos::new(Vec::new(), me::units(&nodes)));
|
||
s.sel = Selection::caret(at.clone());
|
||
s.math = Some(MathEdit { pos: me::clamp(&nodes, &pos), at });
|
||
result(s)
|
||
}
|
||
|
||
fn exit(s: &mut Session, v: &Value) -> CmdResult {
|
||
if let Some(m) = s.math.take() {
|
||
let before = p::bool(v, "before").unwrap_or(false);
|
||
let off = if before { m.at.off } else { m.at.off + OBJ.len_utf8() };
|
||
s.sel = Selection::caret(Pos { off, ..m.at });
|
||
}
|
||
sel_result(s)
|
||
}
|
||
|
||
fn current(s: &Session) -> Result<MathEdit, CmdError> {
|
||
s.math.clone().ok_or_else(|| CmdError::Disabled("not editing an equation".into()))
|
||
}
|
||
|
||
fn type_text(s: &mut Session, v: &Value) -> CmdResult {
|
||
let text = p::req_str(v, "text")?.to_string();
|
||
let m = current(s)?;
|
||
let latex = s.math_latex;
|
||
let style = TypeStyle { nor: s.math_normal_text };
|
||
let mut pos = m.pos.clone();
|
||
with_eq(s, &m.at, |math, _| {
|
||
if math.linear {
|
||
// Linear form is plain text: no build-up.
|
||
pos = me::insert_text(&mut math.nodes, &pos, &text, TypeStyle::default());
|
||
set_literal(&mut math.nodes);
|
||
return;
|
||
}
|
||
for c in text.chars().take(10_000) {
|
||
let rel = matches!(wordcraft_doc::math::math_class(c), wordcraft_doc::math::MClass::Rel);
|
||
if !style.nor && (c == ' ' || c == '\t' || rel) {
|
||
let mut changed = false;
|
||
if let Some(p) = me::autocorrect(&mut math.nodes, &pos) {
|
||
pos = p;
|
||
changed = true;
|
||
}
|
||
if let Some(p) = me::build_up(&mut math.nodes, &pos, latex) {
|
||
pos = p;
|
||
changed = true;
|
||
}
|
||
if c == ' ' && changed {
|
||
continue;
|
||
}
|
||
}
|
||
pos = me::insert_text(&mut math.nodes, &pos, &c.to_string(), style);
|
||
}
|
||
})?;
|
||
if let Some(m) = s.math.as_mut() {
|
||
m.pos = pos;
|
||
}
|
||
result(s)
|
||
}
|
||
|
||
/// Runs of a linear-form equation are literal text.
|
||
fn set_literal(nodes: &mut Arg) {
|
||
for n in nodes.iter_mut() {
|
||
if let MNode::Run(r) = n {
|
||
r.lit = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
fn insert_structure(s: &mut Session, v: &Value) -> CmdResult {
|
||
let nodes = nodes_from(v).ok_or_else(|| CmdError::Params("give an id, linear or latex".into()))?;
|
||
let Some(m) = s.math.clone() else {
|
||
// Outside an equation: a new equation holding the structure.
|
||
return insert_nodes_as_equation(s, nodes, &json!({}));
|
||
};
|
||
let mut pos = m.pos.clone();
|
||
with_eq(s, &m.at, |math, _| {
|
||
if math.linear {
|
||
pos = me::insert_text(&mut math.nodes, &pos, &to_linear(&nodes), TypeStyle::default());
|
||
set_literal(&mut math.nodes);
|
||
} else {
|
||
pos = me::insert_nodes(&mut math.nodes, &pos, nodes);
|
||
}
|
||
})?;
|
||
if let Some(m) = s.math.as_mut() {
|
||
m.pos = pos;
|
||
}
|
||
result(s)
|
||
}
|
||
|
||
fn insert_symbol(s: &mut Session, v: &Value) -> CmdResult {
|
||
let c = p::req_str(v, "char")?.to_string();
|
||
if c.is_empty() {
|
||
return Err(CmdError::Params("empty char".into()));
|
||
}
|
||
let Some(m) = s.math.clone() else {
|
||
return insert_nodes_as_equation(s, parse_linear(&c), &json!({}));
|
||
};
|
||
let mut pos = m.pos.clone();
|
||
let style = TypeStyle { nor: s.math_normal_text };
|
||
with_eq(s, &m.at, |math, _| {
|
||
pos = me::insert_text(&mut math.nodes, &pos, &c, style);
|
||
if math.linear {
|
||
set_literal(&mut math.nodes);
|
||
}
|
||
})?;
|
||
if let Some(m) = s.math.as_mut() {
|
||
m.pos = pos;
|
||
}
|
||
result(s)
|
||
}
|
||
|
||
fn nodes_of(s: &Session, at: &Pos) -> Option<Arg> {
|
||
match s.doc.para_at(at)?.object_at(at.off)? {
|
||
InlineObject::Equation { math, linear, .. } => {
|
||
Some(if math.nodes.is_empty() && !math.linear { parse_linear(linear) } else { math.nodes.clone() })
|
||
}
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
fn move_caret(s: &mut Session, v: &Value) -> CmdResult {
|
||
let m = current(s)?;
|
||
let dir = p::str(v, "dir").unwrap_or("right");
|
||
if !matches!(dir, "left" | "right" | "up" | "down" | "home" | "end" | "next" | "prev") {
|
||
return Err(CmdError::Params(format!("unknown direction {dir}")));
|
||
}
|
||
// Leaving a slot finishes what was typed there (AutoCorrect names, build-up). That is worked
|
||
// out on a copy: plain navigation must leave the equation (and its source OMML) untouched,
|
||
// and a real change is a document edit — undoable and marking the document dirty (#446, #447).
|
||
let shown_linear = matches!(
|
||
s.doc.para_at(&m.at).and_then(|p| p.object_at(m.at.off)),
|
||
Some(InlineObject::Equation { math, .. }) if math.linear
|
||
);
|
||
if !s.math_normal_text
|
||
&& !shown_linear
|
||
&& let Some(before) = nodes_of(s, &m.at)
|
||
{
|
||
let latex = s.math_latex;
|
||
let mut nodes = before.clone();
|
||
let mut pos = m.pos.clone();
|
||
let mut changed = false;
|
||
if let Some(p) = me::autocorrect(&mut nodes, &pos) {
|
||
pos = p;
|
||
changed = true;
|
||
}
|
||
if let Some(p) = me::build_up(&mut nodes, &pos, latex) {
|
||
pos = p;
|
||
changed = true;
|
||
}
|
||
if changed && nodes != before {
|
||
// `equation.move` is a pure command, so record the edit here.
|
||
s.checkpoint("Build Up");
|
||
with_eq(s, &m.at, |math, _| math.nodes = nodes)?;
|
||
s.touch();
|
||
if let Some(mm) = s.math.as_mut() {
|
||
mm.pos = pos;
|
||
}
|
||
}
|
||
}
|
||
let m = current(s)?;
|
||
let nodes = nodes_of(s, &m.at).ok_or_else(|| CmdError::Failed("no equation there".into()))?;
|
||
let next = match dir {
|
||
"left" => me::move_left(&nodes, &m.pos),
|
||
"right" => me::move_right(&nodes, &m.pos),
|
||
"up" => Some(me::move_vertical(&nodes, &m.pos, true).unwrap_or(m.pos.clone())),
|
||
"down" => Some(me::move_vertical(&nodes, &m.pos, false).unwrap_or(m.pos.clone())),
|
||
"home" => Some(me::home_end(&nodes, &m.pos, false)),
|
||
"end" => Some(me::home_end(&nodes, &m.pos, true)),
|
||
"next" | "prev" => Some(me::next_placeholder(&nodes, &m.pos, dir == "prev").unwrap_or(m.pos.clone())),
|
||
other => return Err(CmdError::Params(format!("unknown direction {other}"))),
|
||
};
|
||
match next {
|
||
Some(p) => {
|
||
if let Some(m) = s.math.as_mut() {
|
||
m.pos = p;
|
||
}
|
||
result(s)
|
||
}
|
||
// Past either end: the caret leaves the equation.
|
||
None => exit(s, &json!({"before": dir == "left"})),
|
||
}
|
||
}
|
||
|
||
fn delete(s: &mut Session, back: bool) -> CmdResult {
|
||
let m = current(s)?;
|
||
let nodes = nodes_of(s, &m.at).ok_or_else(|| CmdError::Failed("no equation there".into()))?;
|
||
// An empty equation goes away entirely.
|
||
if nodes.is_empty() {
|
||
s.math = None;
|
||
s.sel = Selection { anchor: m.at.clone(), focus: Pos { off: m.at.off + OBJ.len_utf8(), ..m.at.clone() } };
|
||
let at = delete_selection(s)?;
|
||
s.sel = Selection::caret(at);
|
||
return sel_result(s);
|
||
}
|
||
let mut pos = m.pos.clone();
|
||
with_eq(s, &m.at, |math, _| {
|
||
pos = if back { me::delete_back(&mut math.nodes, &pos) } else { me::delete_forward(&mut math.nodes, &pos) };
|
||
})?;
|
||
if let Some(m) = s.math.as_mut() {
|
||
m.pos = pos;
|
||
}
|
||
result(s)
|
||
}
|
||
|
||
fn enter(s: &mut Session, _: &Value) -> CmdResult {
|
||
let m = current(s)?;
|
||
let latex = s.math_latex;
|
||
let mut pos = m.pos.clone();
|
||
let mut changed = false;
|
||
with_eq(s, &m.at, |math, _| {
|
||
if math.linear {
|
||
return;
|
||
}
|
||
if let Some(p) = me::autocorrect(&mut math.nodes, &pos) {
|
||
pos = p;
|
||
changed = true;
|
||
}
|
||
if let Some(p) = me::build_up(&mut math.nodes, &pos, latex) {
|
||
pos = p;
|
||
changed = true;
|
||
}
|
||
if me::number_equation(&mut math.nodes) {
|
||
pos = MathPos::new(Vec::new(), me::units(&math.nodes));
|
||
changed = true;
|
||
}
|
||
})?;
|
||
if changed {
|
||
if let Some(m) = s.math.as_mut() {
|
||
m.pos = pos;
|
||
}
|
||
return result(s);
|
||
}
|
||
// Nothing to build: Enter ends the equation and starts a new paragraph.
|
||
s.math = None;
|
||
let after = Pos { off: m.at.off + OBJ.len_utf8(), ..m.at };
|
||
let new = split_para(s, &after)?;
|
||
s.sel = Selection::caret(new);
|
||
sel_result(s)
|
||
}
|
||
|
||
/// Built-up ↔ linear form for one equation's nodes.
|
||
fn convert_math(math: &mut Math, professional: bool, latex: bool) {
|
||
if professional {
|
||
if math.linear {
|
||
let text = plain(&math.nodes);
|
||
math.nodes = if latex { parse_latex(&text) } else { parse_linear(&text) };
|
||
math.linear = false;
|
||
}
|
||
} else if !math.linear {
|
||
let text = if latex { to_latex(&math.nodes) } else { to_linear(&math.nodes) };
|
||
math.nodes = vec![MNode::Run(wordcraft_doc::math::MRun { text, lit: true, ..Default::default() })];
|
||
math.linear = true;
|
||
}
|
||
}
|
||
|
||
fn convert(s: &mut Session, v: &Value) -> CmdResult {
|
||
let professional = p::str(v, "to").map(|t| !t.eq_ignore_ascii_case("linear")).unwrap_or(true);
|
||
let latex = s.math_latex;
|
||
if p::bool(v, "all").unwrap_or(false) {
|
||
let mut n = 0;
|
||
let stories: Vec<wordcraft_doc::StoryRef> =
|
||
std::iter::once(wordcraft_doc::StoryRef::Body).chain(s.doc.parts.keys().map(|k| wordcraft_doc::StoryRef::Part(*k))).collect();
|
||
for story in stories {
|
||
for path in s.doc.para_paths(story) {
|
||
let offs: Vec<usize> = s
|
||
.doc
|
||
.para(story, &path)
|
||
.map(|p| p.object_offsets().into_iter().filter(|o| matches!(p.object_at(*o), Some(InlineObject::Equation { .. }))).collect())
|
||
.unwrap_or_default();
|
||
for off in offs {
|
||
let at = Pos { story, path: path.clone(), off };
|
||
with_eq(s, &at, |math, _| convert_math(math, professional, latex))?;
|
||
n += 1;
|
||
}
|
||
}
|
||
}
|
||
if let Some(m) = s.math.as_mut() {
|
||
m.pos = MathPos::default();
|
||
}
|
||
s.clamp_selection();
|
||
return Ok(json!({"converted": n}));
|
||
}
|
||
let at = equation_at_caret(s).ok_or_else(|| CmdError::Disabled("no equation at the caret".into()))?;
|
||
let mut end = 0;
|
||
with_eq(s, &at, |math, _| {
|
||
convert_math(math, professional, latex);
|
||
end = me::units(&math.nodes);
|
||
})?;
|
||
if let Some(m) = s.math.as_mut() {
|
||
m.pos = MathPos::new(Vec::new(), end);
|
||
}
|
||
result(s)
|
||
}
|
||
|
||
fn set_display(s: &mut Session, v: &Value) -> CmdResult {
|
||
let at = equation_at_caret(s).ok_or_else(|| CmdError::Disabled("no equation at the caret".into()))?;
|
||
with_eq(s, &at, |_, display| *display = p::bool(v, "value").unwrap_or(!*display))?;
|
||
result(s)
|
||
}
|
||
|
||
fn justify(s: &mut Session, v: &Value) -> CmdResult {
|
||
let at = equation_at_caret(s).ok_or_else(|| CmdError::Disabled("no equation at the caret".into()))?;
|
||
let jc = match p::str(v, "jc").unwrap_or("centerGroup") {
|
||
"left" => MathJc::Left,
|
||
"right" => MathJc::Right,
|
||
"center" => MathJc::Center,
|
||
_ => MathJc::CenterGroup,
|
||
};
|
||
with_eq(s, &at, |math, display| {
|
||
math.jc = jc;
|
||
*display = true;
|
||
})?;
|
||
result(s)
|
||
}
|
||
|
||
/// Add (or remove) an automatic equation number: `#` with nothing after it.
|
||
fn number(s: &mut Session, v: &Value) -> CmdResult {
|
||
let at = equation_at_caret(s).ok_or_else(|| CmdError::Disabled("no equation at the caret".into()))?;
|
||
let mut pos_reset = false;
|
||
with_eq(s, &at, |math, display| {
|
||
let numbered = matches!(math.nodes.as_slice(), [MNode::EqArr { rows }] if rows.iter().any(|r| wordcraft_doc::math::has_number_mark(r)));
|
||
let want = p::bool(v, "value").unwrap_or(!numbered);
|
||
if want && !numbered {
|
||
let mut row = std::mem::take(&mut math.nodes);
|
||
row.push(MNode::Run(wordcraft_doc::math::MRun::new("#")));
|
||
math.nodes = vec![MNode::EqArr { rows: vec![row] }];
|
||
*display = true;
|
||
pos_reset = true;
|
||
} else if !want && numbered {
|
||
// Every row loses its number; a single row stands alone again, several stay an
|
||
// array (#444).
|
||
if let [MNode::EqArr { rows }] = math.nodes.as_mut_slice() {
|
||
let mut rows: Vec<Arg> = std::mem::take(rows).into_iter().map(without_number).collect();
|
||
math.nodes = if rows.len() == 1 { rows.pop().unwrap_or_default() } else { vec![MNode::EqArr { rows }] };
|
||
}
|
||
pos_reset = true;
|
||
}
|
||
})?;
|
||
if pos_reset && let Some(m) = s.math.as_mut() {
|
||
m.pos = MathPos::default();
|
||
}
|
||
s.clamp_selection();
|
||
result(s)
|
||
}
|
||
|
||
/// An equation-array row without its number: everything from the `#` on goes.
|
||
fn without_number(row: Arg) -> Arg {
|
||
let mut out = Vec::new();
|
||
for n in row {
|
||
match n {
|
||
MNode::Run(mut r) if !r.lit && r.text.contains('#') => {
|
||
if let Some(k) = r.text.find('#') {
|
||
r.text.truncate(k);
|
||
}
|
||
if !r.text.is_empty() {
|
||
out.push(MNode::Run(r));
|
||
}
|
||
break;
|
||
}
|
||
n => out.push(n),
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
fn set(s: &mut Session, v: &Value) -> CmdResult {
|
||
let at = equation_at_caret(s).ok_or_else(|| CmdError::Disabled("no equation at the caret".into()))?;
|
||
let nodes = nodes_from(v).ok_or_else(|| CmdError::Params("give linear or latex".into()))?;
|
||
let end = me::units(&nodes);
|
||
with_eq(s, &at, |math, _| {
|
||
math.nodes = nodes;
|
||
math.linear = false;
|
||
})?;
|
||
if let Some(m) = s.math.as_mut() {
|
||
m.pos = MathPos::new(Vec::new(), end);
|
||
}
|
||
result(s)
|
||
}
|
||
|
||
fn get(s: &mut Session, _: &Value) -> CmdResult {
|
||
let at = equation_at_caret(s).ok_or_else(|| CmdError::Disabled("no equation at the caret".into()))?;
|
||
let Some(InlineObject::Equation { linear, display, math }) = s.doc.para_at(&at).and_then(|p| p.object_at(at.off)) else {
|
||
return Err(CmdError::Failed("no equation there".into()));
|
||
};
|
||
let nodes = if math.nodes.is_empty() && !math.linear { parse_linear(linear) } else { math.nodes.clone() };
|
||
Ok(json!({
|
||
"linear": linear,
|
||
"latex": to_latex(&nodes),
|
||
"display": display,
|
||
"linearForm": math.linear,
|
||
"editing": s.math.is_some(),
|
||
"pos": s.math.as_ref().map(|m| serde_json::to_value(&m.pos).unwrap_or(Value::Null)),
|
||
"at": super::pos_json(&at),
|
||
}))
|
||
}
|
||
|
||
fn structure_actions(s: &mut Session, _: &Value) -> CmdResult {
|
||
let m = current(s)?;
|
||
let nodes = nodes_of(s, &m.at).ok_or_else(|| CmdError::Failed("no equation there".into()))?;
|
||
let acts: Vec<Value> =
|
||
me::structure_actions(&nodes, &m.pos).into_iter().map(|(l, a, label)| json!({"level": l, "action": a, "label": label})).collect();
|
||
Ok(json!({"actions": acts}))
|
||
}
|
||
|
||
fn structure(s: &mut Session, v: &Value) -> CmdResult {
|
||
let m = current(s)?;
|
||
let action = p::req_str(v, "action")?.to_string();
|
||
let nodes = nodes_of(s, &m.at).ok_or_else(|| CmdError::Failed("no equation there".into()))?;
|
||
let level = match p::u64(v, "level") {
|
||
Some(l) => l as usize,
|
||
None => me::structure_actions(&nodes, &m.pos)
|
||
.into_iter()
|
||
.find(|(_, a, _)| *a == action)
|
||
.map(|(l, _, _)| l)
|
||
.ok_or_else(|| CmdError::Params(format!("{action} doesn't apply here")))?,
|
||
};
|
||
let mut pos = None;
|
||
with_eq(s, &m.at, |math, _| pos = me::apply_structure(&mut math.nodes, &m.pos, level, &action))?;
|
||
let pos = pos.ok_or_else(|| CmdError::Params(format!("{action} doesn't apply here")))?;
|
||
if let Some(m) = s.math.as_mut() {
|
||
m.pos = pos;
|
||
}
|
||
result(s)
|
||
}
|
||
|
||
fn gallery(_: &mut Session, _: &Value) -> CmdResult {
|
||
let structures: Vec<Value> = crate::math_gallery::STRUCTURES
|
||
.iter()
|
||
.map(|g| {
|
||
json!({
|
||
"id": g.id,
|
||
"label": g.label.replace('\n', " "),
|
||
"sections": g.sections.iter().map(|(name, ts)| json!({
|
||
"name": name,
|
||
"templates": ts.iter().map(|t| json!({"id": t.id, "label": t.label, "linear": t.linear})).collect::<Vec<_>>(),
|
||
})).collect::<Vec<_>>(),
|
||
})
|
||
})
|
||
.collect();
|
||
let symbols: Vec<Value> = crate::math_gallery::SYMBOL_SETS.iter().map(|(n, chars)| json!({"name": n, "chars": chars})).collect();
|
||
let builtins: Vec<Value> = crate::math_gallery::BUILT_INS.iter().map(|(id, l, lin)| json!({"id": id, "label": l, "linear": lin})).collect();
|
||
Ok(json!({"structures": structures, "symbols": symbols, "builtins": builtins}))
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::Session;
|
||
use wordcraft_doc::Document;
|
||
|
||
fn session() -> Session {
|
||
let mut s = Session::new(Document::new());
|
||
s.run("document.setText", &json!({"text": ""})).unwrap();
|
||
s
|
||
}
|
||
|
||
fn linear(s: &Session) -> String {
|
||
s.math.as_ref().and_then(|m| s.doc.para_at(&m.at).and_then(|p| p.object_at(m.at.off))).map(|o| o.plain_text().to_string()).unwrap_or_default()
|
||
}
|
||
|
||
#[test]
|
||
fn type_an_equation_like_word() {
|
||
let mut s = session();
|
||
s.run("insert.equation", &json!({})).unwrap();
|
||
assert!(s.math.is_some(), "editing the new equation");
|
||
// An empty line gives a display equation.
|
||
assert!(matches!(s.doc.para_at(&s.math.as_ref().unwrap().at).unwrap().object_at(0), Some(InlineObject::Equation { display: true, .. })));
|
||
for t in ["x", "=", "(-b\\pm", " ", "\\sqrt", " ", "(b^2-4ac))/2a", " "] {
|
||
s.run("equation.type", &json!({"text": t})).unwrap();
|
||
}
|
||
assert_eq!(linear(&s), "x=(-b±√(b^2-4ac))/2a");
|
||
let nodes = nodes_of(&s, &s.math.as_ref().unwrap().at).unwrap();
|
||
assert!(matches!(nodes.get(1), Some(MNode::Frac { .. })), "{nodes:#?}");
|
||
// One undo step for the typing.
|
||
s.run("equation.exit", &json!({})).unwrap();
|
||
s.run("edit.undo", &json!({})).unwrap();
|
||
assert!(linear(&s).is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn structures_symbols_and_navigation() {
|
||
let mut s = session();
|
||
s.run("equation.insertStructure", &json!({"id": "frac.stacked"})).unwrap();
|
||
s.run("equation.type", &json!({"text": "1"})).unwrap();
|
||
s.run("equation.move", &json!({"dir": "down"})).unwrap();
|
||
s.run("equation.insertSymbol", &json!({"char": "π"})).unwrap();
|
||
assert_eq!(linear(&s), "1/π");
|
||
s.run("equation.move", &json!({"dir": "right"})).unwrap();
|
||
s.run("equation.insertStructure", &json!({"id": "nary.sumLimits"})).unwrap();
|
||
s.run("equation.type", &json!({"text": "k=1"})).unwrap();
|
||
s.run("equation.move", &json!({"dir": "next"})).unwrap();
|
||
s.run("equation.type", &json!({"text": "n"})).unwrap();
|
||
s.run("equation.move", &json!({"dir": "next"})).unwrap();
|
||
s.run("equation.type", &json!({"text": "k"})).unwrap();
|
||
assert_eq!(linear(&s), "1/π∑_(k=1)^n▒k");
|
||
let r = s.run("equation.get", &json!({})).unwrap();
|
||
assert!(r["latex"].as_str().unwrap().contains("\\sum"));
|
||
// Moving right past the end leaves the equation.
|
||
for _ in 0..20 {
|
||
if s.math.is_none() {
|
||
break;
|
||
}
|
||
let _ = s.run("equation.move", &json!({"dir": "right"}));
|
||
}
|
||
assert!(s.math.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn numbering_conversion_and_display() {
|
||
let mut s = session();
|
||
s.run("insert.equation", &json!({"linear": "E=mc^2"})).unwrap();
|
||
s.run("equation.type", &json!({"text": "#(1)"})).unwrap();
|
||
s.run("equation.enter", &json!({})).unwrap();
|
||
assert_eq!(linear(&s), "E=mc^2#(1)");
|
||
s.run("equation.number", &json!({"value": false})).unwrap();
|
||
assert_eq!(linear(&s), "E=mc^2");
|
||
s.run("equation.number", &json!({})).unwrap();
|
||
assert_eq!(linear(&s), "E=mc^2#");
|
||
s.run("equation.convert", &json!({"to": "linear"})).unwrap();
|
||
s.run("equation.convert", &json!({"to": "professional"})).unwrap();
|
||
assert_eq!(linear(&s), "E=mc^2#");
|
||
s.run("equation.display", &json!({"value": false})).unwrap();
|
||
let r = s.run("equation.get", &json!({})).unwrap();
|
||
assert_eq!(r["display"], false);
|
||
// LaTeX input.
|
||
s.run("equation.set", &json!({"latex": "\\frac{a}{b}"})).unwrap();
|
||
assert_eq!(linear(&s), "a/b");
|
||
// Backspacing an equation empty, then once more, removes it.
|
||
let mut n = 0;
|
||
while s.math.is_some() && n < 50 {
|
||
s.run("equation.backspace", &json!({})).unwrap();
|
||
n += 1;
|
||
}
|
||
assert!(s.math.is_none());
|
||
assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().objects.len(), 0);
|
||
}
|
||
|
||
#[test]
|
||
fn gallery_lists_everything() {
|
||
let mut s = session();
|
||
let g = s.run("equation.gallery", &json!({})).unwrap();
|
||
assert_eq!(g["structures"].as_array().unwrap().len(), 11);
|
||
assert!(g["symbols"].as_array().unwrap().len() >= 8);
|
||
for b in crate::math_gallery::BUILT_INS {
|
||
let mut s = session();
|
||
s.run("insert.equation", &json!({"builtin": b.0, "edit": false})).unwrap();
|
||
}
|
||
}
|
||
|
||
/// A session on a DOCX whose only paragraph is `<m:oMath>{math}</m:oMath>`.
|
||
fn imported(math: &str) -> Session {
|
||
use std::io::Write;
|
||
let w = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
|
||
let m = "http://schemas.openxmlformats.org/officeDocument/2006/math";
|
||
let parts = [
|
||
(
|
||
"[Content_Types].xml",
|
||
r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>"#.to_string(),
|
||
),
|
||
(
|
||
"_rels/.rels",
|
||
r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#.to_string(),
|
||
),
|
||
("word/document.xml", format!(r#"<w:document xmlns:w="{w}" xmlns:m="{m}"><w:body><w:p><m:oMath>{math}</m:oMath></w:p><w:sectPr/></w:body></w:document>"#)),
|
||
];
|
||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||
for (name, xml) in parts {
|
||
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||
zw.write_all(xml.as_bytes()).unwrap();
|
||
}
|
||
let bytes = zw.finish().unwrap().into_inner();
|
||
let mut s = Session::new(wordcraft_docx::read(&bytes).unwrap());
|
||
s.run("equation.edit", &json!({})).unwrap();
|
||
s
|
||
}
|
||
|
||
fn document_xml(s: &Session) -> String {
|
||
use std::io::Read;
|
||
let bytes = wordcraft_docx::write(&s.doc).unwrap();
|
||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||
let mut xml = String::new();
|
||
z.by_name("word/document.xml").unwrap().read_to_string(&mut xml).unwrap();
|
||
xml
|
||
}
|
||
|
||
#[test]
|
||
fn removing_numbers_keeps_every_row() {
|
||
// #444: a numbered two-row array lost its second row when its numbers were removed.
|
||
for numbered in ["█(a#(1)@b#(2))", "█(a@b#(2))"] {
|
||
let mut s = session();
|
||
s.run("insert.equation", &json!({"linear": numbered})).unwrap();
|
||
s.run("equation.number", &json!({"value": false})).unwrap();
|
||
assert_eq!(linear(&s), "█(a@b)", "{numbered}");
|
||
let mut back = Session::new(wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap());
|
||
assert_eq!(back.run("equation.get", &json!({})).unwrap()["linear"], "█(a@b)", "{numbered} after save and reopen");
|
||
s.run("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(linear(&s), numbered, "Undo restores the numbers");
|
||
}
|
||
// A single numbered row stands alone again.
|
||
let mut s = session();
|
||
s.run("insert.equation", &json!({"linear": "a#(1)"})).unwrap();
|
||
s.run("equation.number", &json!({"value": false})).unwrap();
|
||
assert_eq!(linear(&s), "a");
|
||
}
|
||
|
||
#[test]
|
||
fn colour_survives_save_and_reopen() {
|
||
// #1467: a coloured equation came back black after saving and reopening.
|
||
let mut s = session();
|
||
s.run("insert.equation", &json!({"linear": "(a+b)/c"})).unwrap();
|
||
s.run("equation.exit", &json!({})).unwrap();
|
||
s.run("select.all", &json!({})).unwrap();
|
||
s.run("format.color", &json!({"color": "C00000"})).unwrap();
|
||
assert!(document_xml(&s).contains(r#"<w:color w:val="C00000"/></w:rPr><m:t"#), "the math runs carry the colour");
|
||
let mut back = Session::new(wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap());
|
||
back.run("equation.edit", &json!({})).unwrap();
|
||
let at = back.math.as_ref().unwrap().at.clone();
|
||
let p = back.doc.para_at(&at).unwrap();
|
||
let (_, props) = p.run_ranges().find(|(r, _)| r.contains(&at.off)).unwrap();
|
||
assert_eq!(props.color, Some(wordcraft_doc::props::TextColor::Rgb(wordcraft_doc::props::Rgb(0xC0, 0, 0))), "the equation's colour is back");
|
||
let Some(InlineObject::Equation { math, .. }) = p.object_at(at.off) else { panic!() };
|
||
let mut nodes = math.nodes.clone();
|
||
let mut runs = 0;
|
||
wordcraft_doc::math_edit::each_run_mut(&mut nodes, &mut |r| {
|
||
runs += 1;
|
||
assert_eq!(r.color, None, "the colour is the equation's, not stuck on each run");
|
||
});
|
||
assert!(runs > 0);
|
||
// Automatic again: no colour written.
|
||
back.run("select.all", &json!({})).unwrap();
|
||
back.run("format.color", &json!({"color": "auto"})).unwrap();
|
||
assert!(!document_xml(&back).contains("C00000"));
|
||
}
|
||
|
||
#[test]
|
||
fn imported_colour_keeps_source_omml_until_recoloured() {
|
||
// #1467: lifting a Word equation's colour must not drop the OMML the model can't hold.
|
||
let r = r#"<w:rPr><w:color w:val="FF0000"/></w:rPr>"#;
|
||
let ctrl = r#"<m:ctrlPr><w:rPr><w:color w:val="00FF00"/></w:rPr></m:ctrlPr>"#;
|
||
let mut s =
|
||
imported(&format!("<m:f><m:fPr>{ctrl}</m:fPr><m:num><m:r>{r}<m:t>a</m:t></m:r></m:num><m:den><m:r>{r}<m:t>b</m:t></m:r></m:den></m:f>"));
|
||
assert!(document_xml(&s).contains("00FF00"), "untouched: source OMML written back");
|
||
s.run("select.all", &json!({})).unwrap();
|
||
s.run("format.color", &json!({"color": "0000FF"})).unwrap();
|
||
let xml = document_xml(&s);
|
||
assert!(xml.contains("0000FF") && !xml.contains("FF0000"), "recoloured: runs rewritten");
|
||
}
|
||
|
||
#[test]
|
||
fn navigation_keeps_imported_omml() {
|
||
// #446: a Left move without any change dropped the fraction's imported control colour.
|
||
let red = r#"<m:f><m:fPr><m:ctrlPr><w:rPr><w:color w:val="FF0000"/></w:rPr></m:ctrlPr></m:fPr><m:num><m:r><m:t>a</m:t></m:r></m:num><m:den><m:r><m:t>b</m:t></m:r></m:den></m:f>"#;
|
||
let mut s = imported(red);
|
||
s.run("equation.move", &json!({"dir": "left"})).unwrap();
|
||
assert!(document_xml(&s).contains(r#"w:val="FF0000""#), "the control colour survives navigation");
|
||
// A genuine edit still writes the new content.
|
||
s.run("equation.type", &json!({"text": "x"})).unwrap();
|
||
assert_eq!(linear(&s), "a/bx");
|
||
assert!(document_xml(&s).contains(">bx<"));
|
||
}
|
||
|
||
#[test]
|
||
fn navigation_build_up_is_an_undoable_edit() {
|
||
// #447: building `a/b` up on a Left move left the document clean and Undo did nothing.
|
||
let mut s = imported("<m:r><m:t>a/b</m:t></m:r>");
|
||
assert!(!s.dirty);
|
||
s.run("equation.move", &json!({"dir": "left"})).unwrap();
|
||
assert_eq!(s.run("equation.get", &json!({})).unwrap()["latex"], "\\frac{a}{b}");
|
||
assert!(s.dirty, "build-up marks the document dirty");
|
||
s.run("edit.undo", &json!({})).unwrap();
|
||
s.run("equation.edit", &json!({})).unwrap();
|
||
assert_eq!(s.run("equation.get", &json!({})).unwrap()["latex"], "a/b", "Undo restores the plain run");
|
||
// Plain navigation is no edit.
|
||
let mut s = imported("<m:r><m:t>abc</m:t></m:r>");
|
||
s.run("equation.move", &json!({"dir": "left"})).unwrap();
|
||
assert!(!s.dirty);
|
||
assert_eq!(s.undo_depth(), 0);
|
||
}
|
||
|
||
#[test]
|
||
fn latex_text_keeps_its_spaces() {
|
||
// #448: `\text{for all x}` came out as `forallx`.
|
||
for (tex, want) in [
|
||
("\\text{for all x}", "\\text{for all x}"),
|
||
("\\text{a b}", "\\text{a b}"),
|
||
("\\text{ab}", "\\text{ab}"),
|
||
("x + y", "x+y"),
|
||
("x+y", "x+y"),
|
||
] {
|
||
let mut s = session();
|
||
s.run("insert.equation", &json!({"latex": tex})).unwrap();
|
||
assert_eq!(s.run("equation.get", &json!({})).unwrap()["latex"], want, "{tex}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn typed_multi_letter_scripts_stay_whole() {
|
||
// #1609, Word for Mac 16.113.4: `V_dd` + Space leaves "dd" entirely in the subscript.
|
||
for typed in ["V_dd", "V_(dd)"] {
|
||
let mut s = session();
|
||
s.run("insert.equation", &json!({})).unwrap();
|
||
s.run("equation.type", &json!({"text": typed})).unwrap();
|
||
s.run("equation.type", &json!({"text": " "})).unwrap();
|
||
let nodes = nodes_of(&s, &s.math.as_ref().unwrap().at).unwrap();
|
||
assert_eq!(nodes.len(), 1, "{typed}: {nodes:#?}");
|
||
assert!(
|
||
matches!(&nodes[0], MNode::Script { sub, .. } if matches!(sub.as_slice(), [MNode::Run(r)] if r.text == "dd")),
|
||
"{typed}: {nodes:#?}"
|
||
);
|
||
}
|
||
let mut s = session();
|
||
s.run("insert.equation", &json!({})).unwrap();
|
||
s.run("equation.type", &json!({"text": "x^ab "})).unwrap();
|
||
let nodes = nodes_of(&s, &s.math.as_ref().unwrap().at).unwrap();
|
||
assert!(matches!(&nodes[0], MNode::Script { sup, .. } if matches!(sup.as_slice(), [MNode::Run(r)] if r.text == "ab")), "{nodes:#?}");
|
||
}
|
||
|
||
#[test]
|
||
fn a_built_script_keeps_the_letter_typed_after_it() {
|
||
// #1609 review: `x_i y ` built x_{iy}: the letter after a script is its own item.
|
||
let mut s = session();
|
||
s.run("insert.equation", &json!({})).unwrap();
|
||
s.run("equation.type", &json!({"text": "x_i y "})).unwrap();
|
||
let nodes = nodes_of(&s, &s.math.as_ref().unwrap().at).unwrap();
|
||
assert_eq!(nodes.len(), 2, "{nodes:#?}");
|
||
assert!(matches!(&nodes[0], MNode::Script { sub, .. } if matches!(sub.as_slice(), [MNode::Run(r)] if r.text == "i")), "{nodes:#?}");
|
||
assert!(matches!(&nodes[1], MNode::Run(r) if r.text.trim() == "y"), "{nodes:#?}");
|
||
let mut s = session();
|
||
s.run("insert.equation", &json!({})).unwrap();
|
||
s.run("equation.type", &json!({"text": "a_i b_j "})).unwrap();
|
||
let nodes = nodes_of(&s, &s.math.as_ref().unwrap().at).unwrap();
|
||
let subs: Vec<&str> = nodes
|
||
.iter()
|
||
.filter_map(|n| match n {
|
||
MNode::Script { sub, .. } => match sub.as_slice() {
|
||
[MNode::Run(r)] => Some(r.text.as_str()),
|
||
_ => None,
|
||
},
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
assert_eq!(subs, ["i", "j"], "{nodes:#?}");
|
||
assert!(nodes.iter().all(|n| matches!(n, MNode::Script { .. }) || matches!(n, MNode::Run(r) if r.text.trim().is_empty())), "{nodes:#?}");
|
||
}
|
||
}
|