mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-29 12:57:09 -06:00
refactor(engine): split cmd/text.rs into text/
This commit is contained in:
+12
-1133
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,448 @@
|
||||
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<String> {
|
||||
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<bool, CmdError> {
|
||||
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<bool, CmdError> {
|
||||
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<usize>),
|
||||
/// 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<Border> {
|
||||
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<Vec<usize>> {
|
||||
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<u8> {
|
||||
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<Pos, CmdError> {
|
||||
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::<usize>().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(())
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
use super::enter_tab::tab_indent_on;
|
||||
use super::*;
|
||||
|
||||
pub(super) fn backspace(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let r = backspace_inner(s, v)?;
|
||||
crate::cmd::controls::restore_placeholder(s)?;
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Deletes the selection or the character before the caret. At a paragraph start it removes list
|
||||
/// numbering while keeping the text position, takes a List Paragraph out of the list, outdents,
|
||||
/// joins the previous paragraph or moves into a preceding table; the command wrapper restores a
|
||||
/// content-control placeholder.
|
||||
/// The first Backspace at a numbered paragraph's start removes the number but keeps the item's
|
||||
/// indent (Word): the text stays where it was, so the indent becomes the paragraph's own.
|
||||
fn remove_number(s: &mut Session, f: &Pos, n: NumRef) -> CmdResult {
|
||||
let text_at = s.doc.numbering.level(n.num, n.level).map(|l| l.indent);
|
||||
let p = s.doc.para_mut(f.story, &f.path)?;
|
||||
if p.props.style.as_deref() == Some("ListParagraph") {
|
||||
if p.props.indent_left.is_none() && p.props.indent_left_chars.is_none_or(|c| c == 0.0) {
|
||||
p.props.indent_left = text_at;
|
||||
}
|
||||
if p.props.indent_first.is_none() && p.props.indent_first_chars.is_none_or(|c| c == 0.0) {
|
||||
p.props.indent_first = Some(0.0);
|
||||
}
|
||||
}
|
||||
p.props.numbering = Some(NumRef { num: 0, level: 0 });
|
||||
p.touch();
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
fn backspace_inner(s: &mut Session, _: &Value) -> CmdResult {
|
||||
s.goal_x = None;
|
||||
s.pending = None;
|
||||
if !s.sel.is_collapsed() {
|
||||
let effect = delete_selection_effect(s)?;
|
||||
s.set_ime_edit_outcome(crate::ImeEditOutcome {
|
||||
classified: true,
|
||||
content_changed: effect.content_changed,
|
||||
state_changed: effect.state_changed,
|
||||
transformed: false,
|
||||
selection_changed: false,
|
||||
});
|
||||
return sel_result(s);
|
||||
}
|
||||
let f = s.sel.focus.clone();
|
||||
let Some(para) = s.doc.para_at(&f) else { return sel_result(s) };
|
||||
if f.off > 0 {
|
||||
let a = Pos { off: para.backspace_from(f.off), ..f.clone() };
|
||||
if let Some(c) = s.autocap.as_mut() {
|
||||
c.note_backspace(&a, &f);
|
||||
}
|
||||
s.sel = Selection { anchor: a, focus: f };
|
||||
let effect = delete_selection_effect(s)?;
|
||||
s.set_ime_edit_outcome(crate::ImeEditOutcome {
|
||||
classified: true,
|
||||
content_changed: effect.content_changed,
|
||||
state_changed: effect.state_changed,
|
||||
transformed: false,
|
||||
selection_changed: false,
|
||||
});
|
||||
return sel_result(s);
|
||||
}
|
||||
// At the start of a paragraph.
|
||||
if let Some(n) = para.props.numbering.filter(|n| n.num != 0) {
|
||||
return remove_number(s, &f, n);
|
||||
}
|
||||
if para.props.style.as_deref() == Some("ListParagraph") {
|
||||
// The second one takes it out of the list's indent instead of joining paragraphs.
|
||||
crate::cmd::leave_list(s.doc.para_mut(f.story, &f.path)?);
|
||||
return sel_result(s);
|
||||
}
|
||||
if para.props.indent_first.is_some_and(|x| x > 0.0) || para.props.indent_first_chars.is_some_and(|x| x > 0.0) {
|
||||
let p = s.doc.para_mut(f.story, &f.path)?;
|
||||
p.props.indent_first = Some(0.0);
|
||||
p.props.cancel_char_indents(false, false, true);
|
||||
p.touch();
|
||||
return sel_result(s);
|
||||
}
|
||||
if tab_indent_on(s) && (para.props.indent_left.is_some_and(|x| x > 0.0) || para.props.indent_left_chars.is_some_and(|x| x > 0.0)) {
|
||||
// Set left- and first-indent with tabs and backspaces: the paragraph's own left indent
|
||||
// (in points or characters) goes back one tab stop at a time before the paragraphs join.
|
||||
return crate::cmd::para::outdent(s, &Value::Null);
|
||||
}
|
||||
let i = f.path.last();
|
||||
if i == 0 {
|
||||
return sel_result(s); // start of a story or cell
|
||||
}
|
||||
let prev_path = f.path.with_last(i - 1);
|
||||
match s.doc.block(f.story, &prev_path) {
|
||||
Some(Block::Para(prev)) => {
|
||||
let a = Pos { story: f.story, path: prev_path, off: prev.len() };
|
||||
// An empty paragraph before a non-empty one takes the latter's formatting (Word).
|
||||
if s.doc.settings.track_changes {
|
||||
// Tracked: the paragraph mark above is marked deleted (or removed, when this
|
||||
// author inserted it) and the caret moves before it, as in Word.
|
||||
s.sel = Selection { anchor: a, focus: f };
|
||||
delete_selection(s)?;
|
||||
return sel_result(s);
|
||||
}
|
||||
let at = s.doc.delete_range(&a, &f)?;
|
||||
s.sel = Selection::caret(at);
|
||||
}
|
||||
Some(Block::Table(_)) => {
|
||||
// Move into the table's last cell. The table is matched by its whole path: a table
|
||||
// nested in a cell shares only its first index with the table around it.
|
||||
let last = s.doc.para_paths(f.story).into_iter().rfind(|q| q.0.len() > prev_path.0.len() && q.0.starts_with(&prev_path.0));
|
||||
if let Some(q) = last {
|
||||
let off = s.doc.para(f.story, &q).map(|x| x.len()).unwrap_or(0);
|
||||
if s.doc.para_at(&f).is_some_and(|x| x.is_empty()) && s.doc.container(f.story, &f.path).is_some_and(|c| c.len() > i + 1) {
|
||||
s.doc.remove_block(f.story, &f.path)?;
|
||||
}
|
||||
s.sel = Selection::caret(Pos { story: f.story, path: q, off });
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
pub(super) fn delete(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let r = delete_inner(s, v)?;
|
||||
crate::cmd::controls::restore_placeholder(s)?;
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Deletes the selection or the character after the caret, joins the next paragraph, handles a
|
||||
/// following table and removes a trailing section break; tracked changes keep their marks.
|
||||
fn delete_inner(s: &mut Session, _: &Value) -> CmdResult {
|
||||
s.goal_x = None;
|
||||
s.pending = None;
|
||||
if !s.sel.is_collapsed() {
|
||||
let effect = delete_selection_effect(s)?;
|
||||
s.set_ime_edit_outcome(crate::ImeEditOutcome {
|
||||
classified: true,
|
||||
content_changed: effect.content_changed,
|
||||
state_changed: effect.state_changed,
|
||||
transformed: false,
|
||||
selection_changed: false,
|
||||
});
|
||||
return sel_result(s);
|
||||
}
|
||||
let f = s.sel.focus.clone();
|
||||
let Some(para) = s.doc.para_at(&f) else { return sel_result(s) };
|
||||
if f.off < para.len() {
|
||||
let b = Pos { off: para.next_boundary(f.off), ..f.clone() };
|
||||
s.sel = Selection { anchor: f, focus: b };
|
||||
let effect = delete_selection_effect(s)?;
|
||||
s.set_ime_edit_outcome(crate::ImeEditOutcome {
|
||||
classified: true,
|
||||
content_changed: effect.content_changed,
|
||||
state_changed: effect.state_changed,
|
||||
transformed: false,
|
||||
selection_changed: false,
|
||||
});
|
||||
return sel_result(s);
|
||||
}
|
||||
// At the end: join with the next paragraph in the same container.
|
||||
let next = f.path.with_last(f.path.last() + 1);
|
||||
if let Some(Block::Para(_)) = s.doc.block(f.story, &next) {
|
||||
let b = Pos { story: f.story, path: next, off: 0 };
|
||||
if s.doc.settings.track_changes {
|
||||
// Tracked: the paragraph mark is marked deleted (or removed, when this author
|
||||
// inserted it). A mark left in place as a deletion is stepped over, as in Word.
|
||||
let blocks = s.doc.container(f.story, &f.path).map(|c| c.len());
|
||||
s.sel = Selection { anchor: f.clone(), focus: b.clone() };
|
||||
delete_selection(s)?;
|
||||
if s.doc.container(f.story, &f.path).map(|c| c.len()) == blocks && s.doc.para_at(&f).is_some_and(|p| p.mark.del.is_some()) {
|
||||
s.sel = Selection::caret(b);
|
||||
}
|
||||
return sel_result(s);
|
||||
}
|
||||
let at = s.doc.delete_range(&f, &b)?;
|
||||
s.sel = Selection::caret(at);
|
||||
} else if let Some(Block::Table(_)) = s.doc.block(f.story, &next) {
|
||||
// An empty paragraph before a table is removed; else nothing.
|
||||
if para.is_empty() && f.path.last() > 0 {
|
||||
s.doc.remove_block(f.story, &f.path)?;
|
||||
let mut path = next.0.clone();
|
||||
if let Some(l) = path.last_mut() {
|
||||
*l -= 1;
|
||||
}
|
||||
path.extend([0, 0, 0]);
|
||||
s.sel = Selection::caret(Pos { story: f.story, path: wordcraft_doc::Path(path), off: 0 });
|
||||
}
|
||||
} else if f.story == StoryRef::Body && f.path.0.len() == 1 && !s.doc.settings.track_changes {
|
||||
// The last paragraph: no paragraph to join, but a section break on its mark (the last
|
||||
// section then holds no paragraph) still goes, as in Word (#2127).
|
||||
s.doc.remove_section_break(f.path.last());
|
||||
}
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
pub(super) fn delete_word_back(s: &mut Session, v: &Value) -> CmdResult {
|
||||
if !s.sel.is_collapsed() {
|
||||
return backspace(s, v);
|
||||
}
|
||||
let f = s.sel.focus.clone();
|
||||
let Some(para) = s.doc.para_at(&f) else { return sel_result(s) };
|
||||
if f.off == 0 {
|
||||
return backspace(s, v);
|
||||
}
|
||||
let a = Pos { off: para.word_start(f.off), ..f.clone() };
|
||||
s.sel = Selection { anchor: a, focus: f };
|
||||
delete_selection(s)?;
|
||||
crate::cmd::controls::restore_placeholder(s)?;
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
pub(super) fn delete_word_fwd(s: &mut Session, v: &Value) -> CmdResult {
|
||||
if !s.sel.is_collapsed() {
|
||||
return delete(s, v);
|
||||
}
|
||||
let f = s.sel.focus.clone();
|
||||
let Some(para) = s.doc.para_at(&f) else { return sel_result(s) };
|
||||
if f.off >= para.len() {
|
||||
return delete(s, v);
|
||||
}
|
||||
let b = Pos { off: para.word_end(f.off), ..f.clone() };
|
||||
s.sel = Selection { anchor: f, focus: b };
|
||||
delete_selection(s)?;
|
||||
crate::cmd::controls::restore_placeholder(s)?;
|
||||
sel_result(s)
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
use super::autoformat::{
|
||||
EnterAutoFormat, apply_border_line, apply_heading_line, apply_table_line, border_line, heading_line, table_line, typing_options,
|
||||
};
|
||||
use super::unicode::ins_char;
|
||||
use super::*;
|
||||
|
||||
/// Exact sample-text formulas; bounded counts keep typed input from allocating without limit.
|
||||
fn sample_text(s: &Session) -> Option<String> {
|
||||
let at = &s.sel.focus;
|
||||
let para = s.doc.para_at(at)?;
|
||||
if !s.sel.is_collapsed() || at.off != para.len() {
|
||||
return None;
|
||||
}
|
||||
let (args, sentences) = if let Some(args) = para.text.strip_prefix("=rand(") {
|
||||
(
|
||||
args,
|
||||
[
|
||||
"A blank page leaves room for a new idea.",
|
||||
"The morning light falls across a notebook on the desk.",
|
||||
"Small changes in spacing can give a story a different rhythm.",
|
||||
],
|
||||
)
|
||||
} else {
|
||||
(
|
||||
para.text.strip_prefix("=lorem(")?,
|
||||
[
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
|
||||
"Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
|
||||
"Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.",
|
||||
],
|
||||
)
|
||||
};
|
||||
let args = args.strip_suffix(')')?;
|
||||
if args.contains(['(', ')']) {
|
||||
return None;
|
||||
}
|
||||
let count = |arg: &str| arg.trim().parse::<usize>().ok().filter(|n| (1..=200).contains(n));
|
||||
let counts = if args.is_empty() {
|
||||
Some((5, 3))
|
||||
} else if let Some((p, s)) = args.split_once(',') {
|
||||
count(p).zip(count(s))
|
||||
} else {
|
||||
count(args).map(|p| (p, 3))
|
||||
};
|
||||
let (paragraphs, lines) = counts.unwrap_or((5, 3));
|
||||
let mut text = String::new();
|
||||
for _ in 0..paragraphs {
|
||||
for sentence in sentences.iter().cycle().take(lines) {
|
||||
if !text.is_empty() && !text.ends_with('\r') {
|
||||
text.push(' ');
|
||||
}
|
||||
text.push_str(sentence);
|
||||
}
|
||||
text.push('\r');
|
||||
}
|
||||
Some(text)
|
||||
}
|
||||
|
||||
/// `text.newParagraph`: Enter, unless the selection is in a control that takes no paragraph break.
|
||||
pub(super) fn new_paragraph(s: &mut Session, _: &Value) -> CmdResult {
|
||||
// Enter in a one-line plain text control (or a check box or list) does nothing, as in Word.
|
||||
if crate::cmd::controls::refuses_paragraph(s) {
|
||||
return sel_result(s);
|
||||
}
|
||||
enter(s, true)?;
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// Most lines `text.insert` with `paragraphs` types in one call, for every caller (control
|
||||
/// channel, CLI, MCP): each line is an Enter, with its AutoFormat and AutoCorrect, so the work
|
||||
/// grows with the line count. More is refused before anything is typed (#3692).
|
||||
pub const MAX_TYPED_PARAGRAPHS: usize = 10_000;
|
||||
|
||||
/// `text.insert` with `paragraphs`: each line typed as it is (`raw`), and between lines the
|
||||
/// paragraph break Enter makes ([`enter`]: its AutoFormat and AutoCorrect), all in one command, so
|
||||
/// one undo step instead of a command and a whole-document snapshot per line (#3692). Where
|
||||
/// Enter would do nothing (a one-line control, a check box, a list) the text is refused and,
|
||||
/// like any failed command, nothing of it stays. More than [`MAX_TYPED_PARAGRAPHS`] lines are
|
||||
/// refused before anything is typed.
|
||||
pub(super) fn insert_paragraphs(s: &mut Session, text: &str) -> CmdResult {
|
||||
let text = text.replace("\r\n", "\n");
|
||||
let lines = text.split(['\n', '\r', '\u{2029}']).count();
|
||||
if lines > MAX_TYPED_PARAGRAPHS {
|
||||
return Err(CmdError::Params(format!("text.insert: `text` has {lines} lines; type at most {MAX_TYPED_PARAGRAPHS} paragraphs per call")));
|
||||
}
|
||||
typing_options(s, &text);
|
||||
for (i, line) in text.split(['\n', '\r', '\u{2029}']).enumerate() {
|
||||
if i > 0 {
|
||||
if crate::cmd::controls::refuses_paragraph(s) {
|
||||
return Err(CmdError::Disabled("text.insert: this content control takes no paragraph breaks".into()));
|
||||
}
|
||||
enter(s, false)?;
|
||||
}
|
||||
if !line.is_empty() {
|
||||
type_text(s, line)?;
|
||||
}
|
||||
}
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// Enter's paragraph break once a control has allowed it: `=rand()` sample text, AutoFormat of a
|
||||
/// border or table line, AutoCorrect of the word it ends, else a plain split. With `undo_step`
|
||||
/// an AutoFormat is recorded as its own undo step after the plain Enter, as in Word; without it
|
||||
/// (typed paragraphs, one undo step for all) no extra snapshot is taken.
|
||||
fn enter(s: &mut Session, undo_step: bool) -> Result<(), CmdError> {
|
||||
crate::cmd::controls::prepare_edit(s)?;
|
||||
if let Some(text) = sample_text(s) {
|
||||
let end = s.sel.focus.clone();
|
||||
s.sel = Selection { anchor: Pos { off: 0, ..end.clone() }, focus: end };
|
||||
return type_text(s, &text);
|
||||
}
|
||||
let mut at = delete_selection(s)?;
|
||||
let auto = border_line(s, &at)
|
||||
.map(EnterAutoFormat::Border)
|
||||
.or_else(|| table_line(s, &at).map(EnterAutoFormat::Table))
|
||||
.or_else(|| heading_line(s, &at).map(EnterAutoFormat::Heading));
|
||||
if let Some(auto) = auto {
|
||||
// Word: Undo right after reverts only the AutoFormat, so the typed characters come back
|
||||
// and the Enter stays. Record that plain Enter as its own undo step.
|
||||
let plain = if undo_step {
|
||||
let (doc, sel) = (s.doc.clone(), s.sel.clone());
|
||||
let new = split_para(s, &at)?;
|
||||
s.sel = Selection::caret(new);
|
||||
Some((std::mem::replace(&mut s.doc, doc), std::mem::replace(&mut s.sel, sel)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
match auto {
|
||||
EnterAutoFormat::Border(line) => apply_border_line(s, &at, line)?,
|
||||
EnterAutoFormat::Table(cols) => apply_table_line(s, &at, &cols)?,
|
||||
EnterAutoFormat::Heading(level) => apply_heading_line(s, &at, level)?,
|
||||
}
|
||||
s.goal_x = None;
|
||||
if let Some((plain_doc, plain_sel)) = plain {
|
||||
s.push_undo("AutoFormat", plain_doc, plain_sel);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
// Enter finishes a word like a space does ("teh" → "the", sentence capitals, links).
|
||||
if s.sel.is_collapsed() && at.off > 0 {
|
||||
// A `*bold*` or `_italic_` word: like the border lines above, Undo right after brings
|
||||
// the marks back and the Enter stays.
|
||||
let plain = crate::cmd::tools::has_emphasis(s, true).then(|| (s.doc.clone(), s.sel.clone()));
|
||||
if let Some((doc, sel)) = plain {
|
||||
// Removes the marks, then AutoCorrects the word as without them.
|
||||
crate::cmd::tools::autocorrect_word(s, true)?;
|
||||
s.autocap.take_if(|c| c.depth == u64::MAX);
|
||||
let (done_doc, done_sel) = (std::mem::replace(&mut s.doc, doc), std::mem::replace(&mut s.sel, sel));
|
||||
let new = split_para(s, &at)?;
|
||||
let c = s.doc.para_at(&new).map(|p| p.mark.clone()).unwrap_or_default();
|
||||
s.sel = Selection::caret(new);
|
||||
let plain_doc = std::mem::replace(&mut s.doc, done_doc);
|
||||
let plain_sel = std::mem::replace(&mut s.sel, done_sel);
|
||||
let new = split_para(s, &s.sel.focus.clone())?;
|
||||
let p = s.doc.para_mut(new.story, &new.path)?;
|
||||
// The new paragraph is formatted as after a plain Enter (the closing mark's
|
||||
// formatting, or none when the next style starts afresh); its mark keeps its revisions.
|
||||
let (ins, del, fmt_change) = (p.mark.ins, p.mark.del, p.mark.fmt_change.take());
|
||||
p.mark = wordcraft_doc::props::CharProps { ins, del, fmt_change, ..c };
|
||||
p.touch();
|
||||
s.sel = Selection::caret(new);
|
||||
s.goal_x = None;
|
||||
s.push_undo("AutoFormat", plain_doc, plain_sel);
|
||||
return Ok(());
|
||||
}
|
||||
crate::cmd::tools::autocorrect_word(s, true)?;
|
||||
s.autocap.take_if(|c| c.depth == u64::MAX);
|
||||
at = s.sel.focus.clone();
|
||||
}
|
||||
let new = split_para(s, &at)?;
|
||||
s.sel = Selection::caret(new);
|
||||
s.goal_x = None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// In a table: next cell (adds a row at the end). At the start of a list item: demote. With "Set
|
||||
/// left- and first-indent with tabs and backspaces" on, at the start of a line of a paragraph with
|
||||
/// text: indent. Else a tab.
|
||||
pub(super) fn tab(s: &mut Session, _: &Value) -> CmdResult {
|
||||
if let Some((tpath, r, c)) = s.sel.focus.path.cell() {
|
||||
let story = s.sel.focus.story;
|
||||
let Some(t) = s.doc.table(story, &tpath) else { return ins_char(s, '\t') };
|
||||
let row_len = t.rows.get(r).map(|x| x.cells.len()).unwrap_or(0);
|
||||
let (nr, nc) = if c + 1 < row_len { (r, c + 1) } else { (r + 1, 0) };
|
||||
if nr >= t.rows.len() {
|
||||
let t = s.doc.table_mut(story, &tpath)?;
|
||||
t.insert_row(nr, r);
|
||||
}
|
||||
let mut path = tpath.0.clone();
|
||||
path.extend([nr as u32, nc as u32, 0]);
|
||||
let start = Pos { story, path: wordcraft_doc::Path(path), off: 0 };
|
||||
// Select the cell's content like Word.
|
||||
let end_path = s.doc.para_paths(story).into_iter().rfind(|q| q.0.starts_with(&start.path.0[..start.path.0.len() - 1]));
|
||||
let end = end_path.map(|q| {
|
||||
let off = s.doc.para(story, &q).map(|x| x.len()).unwrap_or(0);
|
||||
Pos { story, path: q, off }
|
||||
});
|
||||
s.sel = Selection { anchor: start.clone(), focus: end.unwrap_or(start) };
|
||||
return sel_result(s);
|
||||
}
|
||||
let f = s.sel.focus.clone();
|
||||
if s.sel.is_collapsed()
|
||||
&& f.off == 0
|
||||
&& let Some(n) = s.doc.para_at(&f).and_then(|x| x.props.numbering)
|
||||
&& n.num != 0
|
||||
{
|
||||
let para = s.doc.para_mut(f.story, &f.path)?;
|
||||
para.props.numbering = Some(NumRef { num: n.num, level: n.level.saturating_add(1).min(8) });
|
||||
para.touch();
|
||||
return sel_result(s);
|
||||
}
|
||||
if s.sel.is_collapsed() && tab_indent_on(s) && tab_indents(s, &f) {
|
||||
return indent_by_tab(s, &f);
|
||||
}
|
||||
let whole_para = !s.sel.is_collapsed() && s.sel.anchor.path == s.sel.focus.path && {
|
||||
let (a, b) = s.sel.ordered();
|
||||
a.off == 0 && s.doc.para_at(&a).is_some_and(|p| b.off == p.len())
|
||||
};
|
||||
if !s.sel.is_collapsed() && (s.sel.anchor.path != s.sel.focus.path || whole_para) {
|
||||
// Several paragraphs or a whole paragraph selected: increase indent.
|
||||
return crate::cmd::para::indent(s, &Value::Null);
|
||||
}
|
||||
ins_char(s, '\t')
|
||||
}
|
||||
|
||||
/// Is AutoFormat As You Type's "Set left- and first-indent with tabs and backspaces" on? Not
|
||||
/// while the document limits formatting to its styles: the indents are direct formatting.
|
||||
pub(super) fn tab_indent_on(s: &Session) -> bool {
|
||||
let ac = &s.prefs.autocorrect;
|
||||
ac.enabled && ac.tab_indent && !(crate::cmd::protect::enforced(&s.doc) && s.doc.settings.protect_formatting)
|
||||
}
|
||||
|
||||
/// Does Tab at `f` indent instead of typing a tab? Only at the start of a line of a paragraph
|
||||
/// with text. Not before the first line of a list item numbered by its style, where Tab types a
|
||||
/// tab character as with the option off.
|
||||
fn tab_indents(s: &mut Session, f: &Pos) -> bool {
|
||||
let Some(para) = s.doc.para_at(f).filter(|p| !p.is_empty()) else { return false };
|
||||
if f.off == 0 {
|
||||
return s.doc.styles.resolve_para(¶.props).numbering.is_none_or(|n| n.num == 0);
|
||||
}
|
||||
let l = s.layout();
|
||||
l.line_bounds(f, s.page_hint).is_some_and(|(a, _)| a == *f)
|
||||
}
|
||||
|
||||
/// Tab before the first line indents the first line by one default tab stop; before any other
|
||||
/// line, or when the first line is indented or hanging already, it indents the whole paragraph.
|
||||
/// Before a later line of a list item, numbered directly or by its style, it takes the item down
|
||||
/// a level.
|
||||
fn indent_by_tab(s: &mut Session, f: &Pos) -> CmdResult {
|
||||
let Some(para) = s.doc.para_at(f) else { return sel_result(s) };
|
||||
let r = s.doc.styles.resolve_para(¶.props);
|
||||
if f.off > 0
|
||||
&& para.props.numbering.is_none()
|
||||
&& let Some(n) = r.numbering.filter(|n| n.num != 0)
|
||||
{
|
||||
let p = s.doc.para_mut(f.story, &f.path)?;
|
||||
p.props.numbering = Some(NumRef { num: n.num, level: n.level.saturating_add(1).min(8) });
|
||||
p.touch();
|
||||
return sel_result(s);
|
||||
}
|
||||
if f.off == 0 && r.indent_first == 0.0 && r.indent_chars[2] == 0.0 {
|
||||
let tab = s.doc.settings.default_tab.max(1.0);
|
||||
let p = s.doc.para_mut(f.story, &f.path)?;
|
||||
p.props.indent_first = Some(tab);
|
||||
p.props.cancel_char_indents(false, false, true);
|
||||
p.touch();
|
||||
return sel_result(s);
|
||||
}
|
||||
crate::cmd::para::indent(s, &Value::Null)
|
||||
}
|
||||
|
||||
pub(super) fn back_tab(s: &mut Session, _: &Value) -> CmdResult {
|
||||
if let Some((tpath, r, c)) = s.sel.focus.path.cell() {
|
||||
let story = s.sel.focus.story;
|
||||
let (nr, nc) = if c > 0 {
|
||||
(r, c - 1)
|
||||
} else if r > 0 {
|
||||
(r - 1, s.doc.table(story, &tpath).and_then(|t| t.rows.get(r - 1)).map(|x| x.cells.len().saturating_sub(1)).unwrap_or(0))
|
||||
} else {
|
||||
return sel_result(s);
|
||||
};
|
||||
let mut path = tpath.0.clone();
|
||||
path.extend([nr as u32, nc as u32, 0]);
|
||||
s.sel = Selection::caret(Pos { story, path: wordcraft_doc::Path(path), off: 0 });
|
||||
return sel_result(s);
|
||||
}
|
||||
let f = s.sel.focus.clone();
|
||||
if let Some(n) = s.doc.para_at(&f).and_then(|x| x.props.numbering).filter(|n| n.num != 0) {
|
||||
let para = s.doc.para_mut(f.story, &f.path)?;
|
||||
para.props.numbering = Some(NumRef { num: n.num, level: n.level.saturating_sub(1) });
|
||||
para.touch();
|
||||
return sel_result(s);
|
||||
}
|
||||
crate::cmd::para::outdent(s, &Value::Null)
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
use super::autoformat::insert;
|
||||
use super::*;
|
||||
|
||||
/// Toggle Unicode Character (Alt+X): the hex code before the caret (up to six digits, `U+`
|
||||
/// optional) or the selected code becomes its character; otherwise the character before the
|
||||
/// caret (or the one selected) becomes its code (`é` ↔ `00E9`). Formatting is kept; one undo step.
|
||||
pub(super) fn toggle_unicode(s: &mut Session, _: &Value) -> CmdResult {
|
||||
let (a, b) = s.sel.ordered();
|
||||
if a.story != b.story || a.path != b.path {
|
||||
return Err(CmdError::Failed("select a character code in one paragraph".into()));
|
||||
}
|
||||
let Some(para) = s.doc.para_at(&b) else { return Err(CmdError::Failed("no paragraph at the caret".into())) };
|
||||
let bad = || CmdError::Failed("the caret isn't on a character boundary".into());
|
||||
let (start, replacement) = if a != b {
|
||||
let sel = para.text.get(a.off..b.off).ok_or_else(bad)?;
|
||||
let code = sel.strip_prefix("U+").or_else(|| sel.strip_prefix("u+")).unwrap_or(sel);
|
||||
if let Some(c) = hex_char(code) {
|
||||
(a.off, c.to_string())
|
||||
} else {
|
||||
let mut it = sel.chars();
|
||||
match (it.next(), it.next()) {
|
||||
(Some(c), None) if codeable(c) => (a.off, code_of(c)),
|
||||
_ => return Err(CmdError::Failed("select a character code (like 00E9) or one character".into())),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let before = para.text.get(..b.off).ok_or_else(bad)?;
|
||||
// The hex digits right before the caret (at most six, nearest first); the longest that
|
||||
// names a character wins.
|
||||
let digits: Vec<usize> = before.char_indices().rev().take_while(|(_, c)| c.is_ascii_hexdigit()).take(6).map(|(i, _)| i).collect();
|
||||
let found = digits.iter().rev().find_map(|at| {
|
||||
let c = hex_char(before.get(*at..)?)?;
|
||||
let prefixed = before.get(..*at).is_some_and(|x| x.ends_with("U+") || x.ends_with("u+"));
|
||||
Some((if prefixed { at - 2 } else { *at }, c.to_string()))
|
||||
});
|
||||
match (found, before.chars().next_back()) {
|
||||
(Some(f), _) => f,
|
||||
(None, Some(c)) if codeable(c) => (b.off - c.len_utf8(), code_of(c)),
|
||||
_ => return Err(CmdError::Failed("no character or code before the caret".into())),
|
||||
}
|
||||
};
|
||||
s.sel = Selection { anchor: Pos { off: start, ..b.clone() }, focus: b };
|
||||
// The replacement keeps the replaced text's formatting.
|
||||
s.pending = None;
|
||||
type_text(s, &replacement)?;
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// Alt+numpad entry: type the character a decimal code names at the caret, as typed text (one
|
||||
/// undo step, AutoFormat as for a typed character). `ansi` and `oem` are the system's code pages
|
||||
/// (Windows-1252 and the locale's OEM page by default). The result's `char` is the character.
|
||||
pub(super) fn insert_code(s: &mut Session, v: &Value) -> CmdResult {
|
||||
use wordcraft_doc::encoding::{OEM_CODE_PAGES, SINGLE_BYTE_CODE_PAGES};
|
||||
let code = p::req_str(v, "code")?;
|
||||
let page = |key: &str, known: &[u16], default: u16| match p::u64(v, key) {
|
||||
Some(cp) => u16::try_from(cp).ok().filter(|cp| known.contains(cp)).ok_or_else(|| CmdError::Params(format!("{key} must be one of {known:?}"))),
|
||||
None => Ok(default),
|
||||
};
|
||||
let ansi = page("ansi", &SINGLE_BYTE_CODE_PAGES, 1252)?;
|
||||
let oem = page("oem", &OEM_CODE_PAGES, system_oem_code_page())?;
|
||||
let c = alt_code_char(code, ansi, oem).ok_or_else(|| CmdError::Failed(format!("{code:?} isn't a character code")))?;
|
||||
let mut r = insert(s, &serde_json::json!({"text": c.to_string()}))?;
|
||||
if let Value::Object(m) = &mut r {
|
||||
m.insert("char".into(), Value::String(c.to_string()));
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// The character Word on Windows types for an Alt+numpad code (decimal digits, as typed):
|
||||
/// - with a leading 0 and a value up to 255, the ANSI code page's character (`ansi`, one of
|
||||
/// [`wordcraft_doc::encoding::SINGLE_BYTE_CODE_PAGES`]): `0233` → é and `0128` → € in
|
||||
/// Windows-1252, `0185` → ą in 1250;
|
||||
/// - without one, 1–255 is the OEM code page's character (`oem`, one of
|
||||
/// [`wordcraft_doc::encoding::OEM_CODE_PAGES`]): `233` → Θ in 437, Ú in 850 (é is `130` in
|
||||
/// both), `165` → ą in 852; 1–31 and 127 are the code page's glyphs (`1` → ☺, `7` → •);
|
||||
/// - above 255, with or without the 0, the Unicode character with that decimal value
|
||||
/// (`8364` → €), as Word inserts it.
|
||||
///
|
||||
/// `None` for an empty or non-numeric code, 0, a control character, a byte the code page leaves
|
||||
/// unassigned or a value past Unicode.
|
||||
pub fn alt_code_char(code: &str, ansi: u16, oem: u16) -> Option<char> {
|
||||
if code.is_empty() || !code.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
let digits = code.trim_start_matches('0');
|
||||
// More than seven significant digits is past U+10FFFF (1114111).
|
||||
if digits.len() > 7 {
|
||||
return None;
|
||||
}
|
||||
let value: u32 = if digits.is_empty() { 0 } else { digits.parse().ok()? };
|
||||
let c = match u8::try_from(value) {
|
||||
Ok(0) => return None,
|
||||
Ok(b) if code.starts_with('0') => wordcraft_doc::encoding::windows_byte(ansi, b),
|
||||
Ok(b) => wordcraft_doc::encoding::oem_byte(oem, b),
|
||||
Err(_) => char::from_u32(value)?,
|
||||
};
|
||||
Some(c).filter(|c| codeable(*c) && *c != '\u{FFFD}')
|
||||
}
|
||||
|
||||
/// The OEM code page for the system locale the shell reported (437 headless).
|
||||
pub fn system_oem_code_page() -> u16 {
|
||||
oem_code_page(crate::session::system_locale())
|
||||
}
|
||||
|
||||
/// The (ANSI, OEM) code pages of the system locale the shell reported, as Windows sets them
|
||||
/// ([`windows_code_pages`]).
|
||||
pub fn system_code_pages() -> (u16, u16) {
|
||||
windows_code_pages(crate::session::system_locale())
|
||||
}
|
||||
|
||||
/// The OEM code page `text.typeCode` assumes for a locale (BCP 47 tag) when the shell names
|
||||
/// none: 850 for Western European languages and English outside the US, 437 otherwise. The
|
||||
/// other pages come only from the shell (Windows passes [`windows_code_pages`]).
|
||||
pub fn oem_code_page(locale: Option<&str>) -> u16 {
|
||||
match windows_code_pages(locale) {
|
||||
(_, 850) => 850,
|
||||
_ => 437,
|
||||
}
|
||||
}
|
||||
|
||||
/// The (ANSI, OEM) code pages Windows uses for a locale (BCP 47 tag or POSIX name), limited to
|
||||
/// the pages `text.typeCode` decodes: Windows-1252 and OEM 437 for US English, unknown locales
|
||||
/// and those with other pages (Asian double-byte pages, OEM 775/720/862…).
|
||||
pub fn windows_code_pages(locale: Option<&str>) -> (u16, u16) {
|
||||
let Some(tag) = locale else { return (1252, 437) };
|
||||
let parts: Vec<&str> = tag.split(['-', '_', '.', '@']).collect();
|
||||
let lang = parts.first().copied().unwrap_or_default().to_ascii_lowercase();
|
||||
let rest = parts.get(1..).unwrap_or_default();
|
||||
let region = rest.iter().find(|p| p.len() == 2 && p.chars().all(|c| c.is_ascii_alphabetic())).map(|r| r.to_ascii_uppercase());
|
||||
let script = rest.iter().find(|p| p.len() == 4 && p.chars().all(|c| c.is_ascii_alphabetic())).map(|r| r.to_ascii_lowercase());
|
||||
const WESTERN: [&str; 18] = ["ca", "da", "de", "es", "eu", "fi", "fo", "fr", "ga", "gl", "is", "it", "nb", "nl", "nn", "no", "pt", "sv"];
|
||||
match lang.as_str() {
|
||||
"en" => match region.as_deref() {
|
||||
None | Some("US") => (1252, 437),
|
||||
Some(_) => (1252, 850),
|
||||
},
|
||||
l if WESTERN.contains(&l) => (1252, 850),
|
||||
"pl" | "cs" | "sk" | "hu" | "sl" | "hr" | "ro" | "sq" | "bs" => (1250, 852),
|
||||
"sr" if script.as_deref() == Some("latn") => (1250, 852),
|
||||
"ru" | "uk" | "be" | "bg" => (1251, 866),
|
||||
// Macedonian and Cyrillic Serbian use OEM 855, which isn't decoded.
|
||||
"sr" | "mk" => (1251, 437),
|
||||
"el" => (1253, 737),
|
||||
"az" if script.as_deref() == Some("cyrl") => (1251, 866),
|
||||
"tr" | "az" => (1254, 857),
|
||||
"he" => (1255, 437),
|
||||
"ar" | "fa" | "ur" => (1256, 437),
|
||||
"et" | "lv" | "lt" => (1257, 437),
|
||||
"vi" => (1258, 437),
|
||||
"th" => (874, 437),
|
||||
_ => (1252, 437),
|
||||
}
|
||||
}
|
||||
|
||||
/// The character a hex code (1–6 digits) names, if it is one Alt+X makes.
|
||||
fn hex_char(code: &str) -> Option<char> {
|
||||
if code.is_empty() || code.len() > 6 || !code.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
u32::from_str_radix(code, 16).ok().and_then(char::from_u32).filter(|c| codeable(*c))
|
||||
}
|
||||
|
||||
/// Characters Alt+X converts (not control characters, breaks or object placeholders).
|
||||
fn codeable(c: char) -> bool {
|
||||
!c.is_control() && c != wordcraft_doc::para::OBJ
|
||||
}
|
||||
|
||||
/// A character's code as Word writes it: at least four upper-case hex digits.
|
||||
fn code_of(c: char) -> String {
|
||||
format!("{:04X}", u32::from(c))
|
||||
}
|
||||
|
||||
pub(super) fn ins_char(s: &mut Session, c: char) -> CmdResult {
|
||||
let mut b = [0u8; 4];
|
||||
type_text(s, c.encode_utf8(&mut b))?;
|
||||
sel_result(s)
|
||||
}
|
||||
Reference in New Issue
Block a user