use super::enter_tab::insert_paragraphs; use super::*; /// Smart quotes and AutoFormat-as-you-type (lists from "* " / "1. ", dashes). fn autoformat(s: &mut Session, text: &str) -> Option { if text.chars().count() != 1 { return None; } let c = text.chars().next()?; if !s.prefs.autocorrect.enabled || !s.prefs.autocorrect.smart_quotes { return None; } let f = &s.sel.focus; let para = s.doc.para_at(f)?; let before = para.text.get(..f.off)?; let prev = before.chars().next_back(); let opening = prev.is_none_or(|p| { p.is_whitespace() || "([{<\u{2014}\u{2013}\u{00AB}\u{2039}\u{201E}\u{201A}".contains(p) || (c == '"' && p == '\u{2018}') || (c == '\'' && p == '\u{201C}') }); match c { '"' => Some(if opening { "\u{201C}" } else { "\u{201D}" }.into()), '\'' => Some(if opening { "\u{2018}" } else { "\u{2019}" }.into()), _ => None, } } /// File › Options › Advanced before typed text goes in: with "Typing replaces selected text" /// off the text goes in front of the selection and leaves it; in overtype the text replaces as /// many characters after the caret as it has, up to the paragraph's end or an object (picture, /// field), which stay. pub(super) fn typing_options(s: &mut Session, text: &str) { if !s.sel.is_collapsed() { if !s.prefs.typing_replaces_selection { let (start, _) = s.sel.ordered(); s.sel = Selection::caret(start); } return; } if !s.prefs.overtype || text.contains(['\r', '\n', '\u{2029}', '\u{000B}']) { return; } let f = s.sel.focus.clone(); let Some(rest) = s.doc.para_at(&f).and_then(|p| p.text.get(f.off..)) else { return }; let n = text.chars().count(); let len: usize = rest.chars().take(n).take_while(|c| *c != OBJ).map(char::len_utf8).sum(); if len > 0 { // The caret (focus) stays where typing happens; the characters after it are selected. s.sel = Selection { anchor: Pos { off: f.off + len, ..f.clone() }, focus: f }; } } /// `text.insert`: type `text` at the selection as the keyboard does (AutoCorrect and /// AutoFormat unless `raw`), or, with `paragraphs`, line by line with Enter between. A single /// trigger character records the IME outcome, and list, AutoCorrect, dash and double-space /// AutoFormat run as a separate undo step when they rewrite the paragraph. pub(super) fn insert(s: &mut Session, v: &Value) -> CmdResult { let text = p::req_str(v, "text")?; let raw = p::bool(v, "raw").unwrap_or(false); if p::bool(v, "paragraphs").unwrap_or(false) { return insert_paragraphs(s, text); } typing_options(s, text); let smart_quote = if raw { None } else { autoformat(s, text) }; let was_collapsed = s.sel.is_collapsed(); let effect = type_text_effect(s, smart_quote.as_deref().unwrap_or(text))?; let mut outcome = crate::ImeEditOutcome { classified: true, content_changed: effect.content_changed, state_changed: effect.state_changed, transformed: smart_quote.is_some(), selection_changed: false, }; let trigger = text.chars().count() == 1 && text.chars().all(|c| c == ' ' || ",.;:!?".contains(c)); if raw || !trigger { s.set_ime_edit_outcome(outcome); return sel_result(s); } // The paragraph as typed, so an automatic change can be undone on its own. let f = s.sel.focus.clone(); let typed = s.doc.para_at(&f).cloned().map(|p| (p, s.sel.clone())); // A list label ("a. ") becomes a list before AutoCorrect could capitalise it. let listed = text == " " && s.prefs.autocorrect.enabled && list_autoformat(s)?; if !listed { crate::cmd::tools::autocorrect(s).inspect_err(|_| s.autocap = None)?; } let dashed = text == " " && !listed && s.prefs.autocorrect.enabled && s.prefs.autocorrect.dashes && dash_autoformat(s).inspect_err(|_| s.autocap = None)?; if text == " " && !listed && was_collapsed && s.prefs.autocorrect.enabled && s.prefs.autocorrect.double_space_period { double_space_period(s)?; } // Compare the paragraph with the one exactly as typed. This is the actual effect of // AutoCorrect (including formatting-only superscripts and links), not an undo label that // may have been left by an earlier step. It also covers the double-space period. let rewritten = typed.as_ref().is_some_and(|(para, _)| s.doc.para_at(&f) != Some(para)); if listed || dashed || rewritten { outcome.content_changed = true; outcome.state_changed = true; outcome.transformed = true; } if let Some((para, sel)) = typed && s.doc.para_at(&f) != Some(¶) { let mut doc = s.doc.clone(); if let Ok(p) = doc.para_mut(f.story, &f.path) { *p = para; p.touch(); s.push_undo("AutoFormat", doc, sel); // Undo right after it makes the capitalised word an exception. let depth = s.undo_steps_so_far(); if let Some(c) = s.autocap.as_mut().filter(|c| c.depth == u64::MAX) { c.depth = depth; } } } s.autocap.take_if(|c| c.depth == u64::MAX); s.set_ime_edit_outcome(outcome); sel_result(s) } /// "* " / "- " / "> " → bullets, "1. " / "a) " → numbering, typed at the start of a paragraph /// (Word triggers on the paragraph's first characters, whatever text already follows the caret, /// not only when the trigger is the paragraph's entire content — #3352). fn list_autoformat(s: &mut Session) -> Result { let f = s.sel.focus.clone(); let Some(para) = s.doc.para_at(&f) else { return Ok(false) }; // A style-linked list (a heading-linked Multilevel List preset, #1793) numbers the paragraph // without `props.numbering` itself being set: resolve it, so autoformat doesn't paper over an // already-numbered heading with a fresh bullet. if s.doc.styles.resolve_para(¶.props).numbering.is_some_and(|n| n.num != 0) { return Ok(false); } let Some(prefix) = para.text.get(..f.off) else { return Ok(false) }; let kind = match prefix { "* " => ListKind::Bullet, "- " => ListKind::BulletChar('-'), // Arrow bullets (Word draws these from a symbol font; we use the Unicode arrows). "> " => ListKind::BulletChar('\u{27A2}'), "-> " => ListKind::BulletChar('\u{2794}'), "=> " => ListKind::BulletChar('\u{21E8}'), "1. " => ListKind::Numbered, "1) " => ListKind::NumberedParen, "a. " => ListKind::LowerLetterDot, "a) " => ListKind::LowerLetter, "A. " => ListKind::UpperLetter, "i. " => ListKind::LowerRoman, "I. " => ListKind::Outline, _ => return Ok(false), }; let ac = &s.prefs.autocorrect; if !(if kind.is_bullet() { ac.bullets } else { ac.numbering }) { return Ok(false); } // Bullets join an existing bullet list; typing "1. " always starts numbering again at 1, // as in Word. let found = if kind.is_bullet() { s.doc.numbering.find_kind(kind) } else { None }; let num = match found { Some(n) => n, None => s.doc.numbering.try_add_list(kind)?, }; let para = s.doc.para_mut(f.story, &f.path)?; para.delete(0, f.off)?; para.props.numbering = Some(NumRef { num, level: 0 }); // A heading stays a heading (Word: "1. " in a Heading 1 gives a numbered Heading 1). if para.props.style.as_deref().is_none_or(|st| st == "Normal") { para.props.style = Some("ListParagraph".into()); } s.sel = Selection::caret(Pos { off: 0, ..f }); Ok(true) } /// "word" + two spaces → "word. ": the second Space turns the first into a period, after a /// letter, a digit or a closing bracket or quote; not after other punctuation, at the start of a /// paragraph, after a tab, or for a third space (the text before them ends in a period by then). fn double_space_period(s: &mut Session) -> Result<(), CmdError> { let f = s.sel.focus.clone(); let Some(para) = s.doc.para_at(&f) else { return Ok(()) }; let Some(before) = para.text.get(..f.off) else { return Ok(()) }; let Some(word) = before.strip_suffix(" ") else { return Ok(()) }; if !word.chars().next_back().is_some_and(|c| c.is_alphanumeric() || ")]}\"'\u{201D}\u{2019}\u{00BB}\u{203A}".contains(c)) { return Ok(()); } // The period takes the formatting of the space it replaces. let at = word.len(); let p = s.doc.para_mut(f.story, &f.path)?; let props = p.props_at(at + 1).clone().without_symbol(); p.delete(at, at + 1)?; p.insert_text(at, ".", &props)?; s.sel = Selection::caret(f); Ok(()) } /// "word -- word" → en dash, "word--word" → em dash (on the space after). fn dash_autoformat(s: &mut Session) -> Result { let f = s.sel.focus.clone(); let Some(para) = s.doc.para_at(&f) else { return Ok(false) }; let Some(before) = para.text.get(..f.off) else { return Ok(false) }; if before.ends_with(' ') && let Some(i) = before.trim_end().rfind(" -- ") && before.trim_end().get(i + 4..).is_some_and(|w| !w.is_empty() && !w.contains(' ')) { // "word -- word" → "word – word" once the next word is typed. let p = s.doc.para_mut(f.story, &f.path)?; let props = p.props_at(i + 1).clone().without_symbol(); p.delete(i + 1, i + 3)?; p.insert_text(i + 1, "\u{2013}", &props)?; s.sel = Selection::caret(Pos { off: f.off + 1, ..f }); return Ok(true); } else if before.ends_with(' ') && let Some(i) = before.trim_end().rfind(" - ") && !before.trim_end()[i + 3..].contains(' ') { let p = s.doc.para_mut(f.story, &f.path)?; let props = p.props_at(i + 1).clone().without_symbol(); p.delete(i + 1, i + 2)?; p.insert_text(i + 1, "\u{2013}", &props)?; s.sel = Selection::caret(Pos { off: f.off + 2, ..f }); return Ok(true); } else if before.ends_with(' ') && let Some(i) = before.trim_end().rfind("--") && i > 0 && before.trim_end()[..i].chars().next_back().is_some_and(|c| c != ' ') { let p = s.doc.para_mut(f.story, &f.path)?; let props = p.props_at(i).clone().without_symbol(); p.delete(i, i + 2)?; p.insert_text(i, "\u{2014}", &props)?; s.sel = Selection::caret(Pos { off: f.off + 1, ..f }); return Ok(true); } Ok(false) } /// An AutoFormat that Enter applies to the paragraph just finished. pub(super) enum EnterAutoFormat { /// "---" and friends: a bottom border. Border(Border), /// "+---+-----+": a one-row table; the dash count of each column. Table(Vec), /// A short line before an empty paragraph: Heading 1, 2 or 3. Heading(u8), } /// AutoFormat border lines: Enter after "---", "___", "===", "***", "~~~" or "###". A line /// holding a tracked deletion stays as it is. pub(super) fn border_line(s: &Session, at: &Pos) -> Option { let para = s.doc.para_at(at)?; let text = para.text.as_str(); let c = text.chars().next()?; if !s.prefs.autocorrect.enabled || !s.prefs.autocorrect.border_lines || at.off != text.len() || text.len() < 3 || !text.chars().all(|x| x == c) || s.doc.styles.resolve_para(¶.props).numbering.is_some_and(|n| n.num != 0) || para.run_ranges().any(|(_, r)| r.del.is_some()) { return None; } let (style, width) = match c { '-' => (BorderStyle::Single, 0.75), '_' => (BorderStyle::Thick, 1.5), '=' => (BorderStyle::Double, 0.75), '*' => (BorderStyle::Dotted, 2.25), '~' => (BorderStyle::Wave, 0.75), '#' => (BorderStyle::Triple, 0.75), _ => return None, }; Some(Border { style, width, color: None, space: 1.0 }) } /// AutoFormat tables: a body paragraph that is exactly "+---+-----+" (plus signs around runs /// of hyphens). Returns the hyphen count of each column; more columns than a table can hold, /// or a line holding a tracked deletion, leave the text alone. pub(super) fn table_line(s: &Session, at: &Pos) -> Option> { let para = s.doc.para_at(at)?; let text = para.text.as_str(); if !s.prefs.autocorrect.enabled || at.story != StoryRef::Body || at.path.0.len() != 1 || at.off != text.len() || s.doc.styles.resolve_para(¶.props).numbering.is_some_and(|n| n.num != 0) || para.run_ranges().any(|(_, c)| c.del.is_some()) { return None; } let inner = text.strip_prefix('+')?.strip_suffix('+')?; let mut cols = Vec::new(); for run in inner.split('+') { if run.is_empty() || !run.bytes().all(|b| b == b'-') || cols.len() >= wordcraft_doc::table::MAX_COLS { return None; } cols.push(run.len()); } Some(cols) } /// AutoFormat Built-in Heading styles: Enter in an empty body paragraph whose previous paragraph /// is a line of Normal text with one to five words that ends in a letter or digit and holds no /// tracked deletion. The line becomes Heading 1, or Heading 2 or 3 when it starts with one or /// two tabs. pub(super) fn heading_line(s: &Session, at: &Pos) -> Option { let ac = &s.prefs.autocorrect; let cur = s.doc.para_at(at)?; let i = at.path.last(); if !ac.enabled || !ac.heading_styles || at.story != StoryRef::Body || at.path.0.len() != 1 || i == 0 || !cur.is_empty() { return None; } let Some(Block::Para(prev)) = s.doc.block(at.story, &at.path.with_last(i - 1)) else { return None }; // Normal and outside a list, also one the style sheet gives (a numbered Normal style). let plain = |p: &wordcraft_doc::Paragraph| { let r = s.doc.styles.resolve_para(&p.props); p.props.style.as_deref().is_none_or(|st| st == "Normal") && r.numbering.is_none_or(|n| n.num == 0) && r.outline_level.is_none_or(|l| l >= 9) }; let text = prev.text.trim_start_matches('\t'); let tabs = prev.text.len() - text.len(); if !plain(cur) || !plain(prev) || !prev.objects.is_empty() || prev.section.is_some() || prev.run_ranges().any(|(_, c)| c.del.is_some()) || tabs > 2 || !(1..=5).contains(&text.split_whitespace().count()) || text.starts_with(char::is_whitespace) || text.contains(char::is_control) || !text.ends_with(char::is_alphanumeric) { return None; } let level = tabs as u8 + 1; // Formatting limited to the document's styles: a locked heading style isn't applied. let id = format!("Heading{level}"); if crate::cmd::protect::enforced(&s.doc) && s.doc.settings.protect_formatting && s.doc.styles.get(&id).is_some_and(|st| st.locked) { return None; } Some(level) } /// Give the paragraph before the empty one at `at` Heading `level` without its leading tabs; /// the caret stays in the empty paragraph. With Track Changes on, the tabs are marked deleted /// (or removed, when this author typed them) and the style change is a formatting revision. pub(super) fn apply_heading_line(s: &mut Session, at: &Pos, level: u8) -> Result<(), CmdError> { let prev = at.path.with_last(at.path.last().saturating_sub(1)); let Some(before) = s.doc.para(at.story, &prev).cloned() else { return Ok(()) }; let tabs = before.text.len() - before.text.trim_start_matches('\t').len(); let start = Pos { story: at.story, path: prev.clone(), off: 0 }; s.sel = Selection { anchor: start.clone(), focus: Pos { off: tabs, ..start } }; delete_selection(s)?; crate::cmd::para::set_heading(s, at.story, &prev, Some(level))?; // Removed tabs change the text, and the formatting comparison after the command only looks // at paragraphs that keep theirs, so the style change is recorded here. if s.doc.settings.track_changes && s.prefs.markup.track_formatting && s.doc.para(at.story, &prev).is_some_and(|p| p.text != before.text && p.props.fmt_change.is_none()) { let rev = crate::cmd::new_revision(s, wordcraft_doc::RevisionKind::Format); let p = s.doc.para_mut(at.story, &prev)?; p.props.fmt_change = wordcraft_doc::props::PropChange::boxed(rev, before.props.formatting()); p.touch(); } s.sel = Selection::caret(at.clone()); Ok(()) } /// Turn the border-line characters into a bottom border on the paragraph above (or this one /// at the top of a story). With Track Changes on, the characters are marked deleted (or /// removed, when this author typed them) and the border is a formatting revision. pub(super) fn apply_border_line(s: &mut Session, at: &Pos, line: Border) -> Result<(), CmdError> { let set_bottom = |p: &mut wordcraft_doc::Paragraph| { let mut b = p.props.borders.unwrap_or_default(); b.bottom = Some(line); p.props.borders = Some(b); p.touch(); }; let i = at.path.last(); let prev = if i > 0 { Some(at.path.with_last(i - 1)) } else { None }; let end = delete_typed_line(s, at)?; match prev.filter(|q| matches!(s.doc.block(at.story, q), Some(Block::Para(_)))) { Some(q) => { // The characters' paragraph becomes the one typing continues in. set_bottom(s.doc.para_mut(at.story, &q)?); s.sel = Selection::caret(end); } None => { let before = s.doc.para(at.story, &at.path).map(|p| p.props.clone()); set_bottom(s.doc.para_mut(at.story, &at.path)?); let new = split_para(s, &end)?; let p = s.doc.para_mut(new.story, &new.path)?; p.props.borders = None; p.touch(); // The split changes the block count, and the formatting comparison after the command // skips a container whose block count changed, so the border is recorded here. if s.doc.settings.track_changes && s.prefs.markup.track_formatting && let Some(before) = before.filter(|b| b.fmt_change.is_none()) { let rev = crate::cmd::new_revision(s, wordcraft_doc::RevisionKind::Format); let p = s.doc.para_mut(at.story, &at.path)?; p.props.fmt_change = wordcraft_doc::props::PropChange::boxed(rev, before.formatting()); p.touch(); } s.sel = Selection::caret(new); } } Ok(()) } /// Delete the AutoFormat characters of the paragraph at `at`, tracked when Track Changes is on. /// Returns the end of what is left: the paragraph's end, after any characters marked deleted. fn delete_typed_line(s: &mut Session, at: &Pos) -> Result { let len = s.doc.para(at.story, &at.path).map_or(0, |p| p.len()); s.sel = Selection { anchor: Pos { off: 0, ..at.clone() }, focus: Pos { off: len, ..at.clone() } }; delete_selection(s)?; let left = s.doc.para(at.story, &at.path).map_or(0, |p| p.len()); Ok(Pos { off: left, ..at.clone() }) } /// Replace the "+---+" characters with a one-row table across the text width, each column as /// wide as its share of the hyphens; the emptied paragraph stays after the table and the caret /// goes into the first cell. With Track Changes on, the characters are marked deleted (or /// removed, when this author typed them); the table is inserted untracked, as Insert › Table /// inserts one. pub(super) fn apply_table_line(s: &mut Session, at: &Pos, cols: &[usize]) -> Result<(), CmdError> { let total = cols.iter().sum::().max(1) as f32; let block = at.path.0.first().copied().unwrap_or(0) as usize; let width = s.doc.sections().get(s.doc.section_index_of(block)).map(|(_, sp)| sp.text_width()).unwrap_or(468.0).max(36.0); let mut t = Table::new(1, cols.len(), width); for (i, d) in cols.iter().enumerate() { let w = (width * *d as f32 / total).max(6.0); if let Some(g) = t.grid.get_mut(i) { *g = w; } if let Some(c) = t.rows.get_mut(0).and_then(|r| r.cells.get_mut(i)) { c.props.width = Some(w); } } delete_typed_line(s, at)?; s.doc.insert_block(at.story, &at.path, Block::Table(t))?; let mut first = at.path.0.clone(); first.extend([0, 0, 0]); s.sel = Selection::caret(Pos { story: at.story, path: Path(first), off: 0 }); Ok(()) }