mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-01 06:16:58 -06:00
* Bidi: auto-detect RTL paragraph direction and keep typing caret advancing smoothly When typing in Kurdish, Arabic or Hebrew in a new document, default paragraph direction (bidi: None) was resolving to LTR. In an LTR paragraph, visual reordering pinned newly typed characters and the caret to the left margin (x = 72.0), resetting to the start of the line with every keystroke. - Add first_strong_is_rtl() following UAX #9 rules P2/P3. - Auto-resolve unassigned paragraph direction to RTL in layout when the paragraph starts with an RTL script. - Auto-promote paragraph to RTL (bidi = Some(true)) when typing text starting with an RTL character. - Add regression test in wordcraft-engine. * Review fixes for #713: RTL only when typing into an empty paragraph - Drop the layout fallback that drew any paragraph without w:bidi as RTL when its first strong character is Arabic/Hebrew: opened DOCX paragraphs keep their direction, and a style that sets bidi=false is respected. - type_text promotes a paragraph to RTL only when it had no letters before the insert and nothing (paragraph, style chain, document defaults) sets its direction; typing into a paragraph that already has text no longer flips it. - docs/typing-parity.md: Text direction section; test in tests_typing.rs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Brandon Thomas <echelon@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
744 lines
32 KiB
Rust
744 lines
32 KiB
Rust
//! Typing behaviour checked side by side against Word: keystrokes go through the same commands
|
||
//! as the keyboard (one `text.insert` per character, Enter, Tab, Backspace), and the expected
|
||
//! results are what Word produces for the same keys.
|
||
|
||
use serde_json::json;
|
||
use wordcraft_doc::props::{BorderStyle, VertAlign};
|
||
use wordcraft_doc::{Pos, StoryRef};
|
||
|
||
use crate::Session;
|
||
|
||
fn s() -> Session {
|
||
Session::new(wordcraft_doc::Document::new())
|
||
}
|
||
|
||
fn run(s: &mut Session, id: &str, v: serde_json::Value) {
|
||
s.run(id, &v).unwrap_or_else(|e| panic!("{id}: {e}"));
|
||
}
|
||
|
||
/// Type like a person: `\n` is Enter, `\t` is Tab, `\u{8}` is Backspace, everything else one
|
||
/// character at a time.
|
||
fn typ(s: &mut Session, keys: &str) {
|
||
for c in keys.chars() {
|
||
match c {
|
||
'\n' => run(s, "text.newParagraph", json!({})),
|
||
'\t' => run(s, "text.tab", json!({})),
|
||
'\u{8}' => run(s, "text.backspace", json!({})),
|
||
c => run(s, "text.insert", json!({"text": c.to_string()})),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Body paragraphs as (text, list level if in a list, in "List Paragraph").
|
||
fn paras(s: &Session) -> Vec<(String, Option<u8>, bool)> {
|
||
s.doc
|
||
.body
|
||
.iter()
|
||
.filter_map(|b| b.as_para())
|
||
.map(|p| (p.text.clone(), p.props.numbering.filter(|n| n.num != 0).map(|n| n.level), p.props.style.as_deref() == Some("ListParagraph")))
|
||
.collect()
|
||
}
|
||
|
||
fn para(text: &str, level: Option<u8>) -> (String, Option<u8>, bool) {
|
||
(text.to_string(), level, level.is_some())
|
||
}
|
||
|
||
fn typed(keys: &str) -> Session {
|
||
let mut s = s();
|
||
typ(&mut s, keys);
|
||
s
|
||
}
|
||
|
||
#[test]
|
||
fn enter_twice_after_a_list_item_ends_the_list() {
|
||
let s = typed("* item\n\ntext");
|
||
assert_eq!(paras(&s), vec![para("Item", Some(0)), para("text", None)]);
|
||
}
|
||
|
||
#[test]
|
||
fn more_enters_after_a_list_make_blank_paragraphs() {
|
||
// The "stuck Enter" bug: after the list ended, further Enters did nothing.
|
||
let s = typed("* item\n\n\ntext");
|
||
assert_eq!(paras(&s), vec![para("Item", Some(0)), para("", None), para("text", None)]);
|
||
let s = typed("1. item\n\n\n\nafter");
|
||
assert_eq!(paras(&s), vec![para("Item", Some(0)), para("", None), para("", None), para("after", None)]);
|
||
}
|
||
|
||
#[test]
|
||
fn the_paragraph_after_a_list_is_normal_text() {
|
||
let mut s = typed("* Apple\nBanana\n\nAfter list");
|
||
let p = s.doc.para_at(&s.sel.focus).unwrap();
|
||
assert_eq!(p.props.style, None, "back to Normal, not an indented List Paragraph");
|
||
assert_eq!(p.props.numbering, None);
|
||
// And the next paragraph too.
|
||
typ(&mut s, "\nMore");
|
||
assert_eq!(paras(&s).last(), Some(¶("More", None)));
|
||
}
|
||
|
||
#[test]
|
||
fn enter_on_an_empty_nested_item_moves_it_up_a_level_first() {
|
||
let s = typed("* a\n\tb\n\ntext");
|
||
assert_eq!(paras(&s), vec![para("A", Some(0)), para("B", Some(1)), para("text", Some(0))]);
|
||
let s = typed("* a\n\tb\n\n\ntext");
|
||
assert_eq!(paras(&s), vec![para("A", Some(0)), para("B", Some(1)), para("text", None)]);
|
||
let s = typed("* a\n\tb\n\n\n\ntext");
|
||
assert_eq!(paras(&s), vec![para("A", Some(0)), para("B", Some(1)), para("", None), para("text", None)]);
|
||
let s = typed("* a\n\tb\n\tc\n\n\n\ntext");
|
||
assert_eq!(paras(&s), vec![para("A", Some(0)), para("B", Some(1)), para("C", Some(2)), para("text", None)]);
|
||
let s = typed("1. a\n\tb\n\n\ntext");
|
||
assert_eq!(paras(&s), vec![para("A", Some(0)), para("B", Some(1)), para("text", None)]);
|
||
}
|
||
|
||
#[test]
|
||
fn shift_tab_and_tab_change_list_levels() {
|
||
let mut s = typed("1. One\nTwo\n\tSub a\nSub b\n");
|
||
run(&mut s, "text.backTab", json!({}));
|
||
typ(&mut s, "Three\n\nDone");
|
||
assert_eq!(
|
||
paras(&s),
|
||
vec![para("One", Some(0)), para("Two", Some(0)), para("Sub a", Some(1)), para("Sub b", Some(1)), para("Three", Some(0)), para("Done", None)]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn backspace_at_the_start_of_an_item_removes_the_number_then_the_indent() {
|
||
let mut s = typed("* One\nTwo");
|
||
run(&mut s, "caret.home", json!({}));
|
||
typ(&mut s, "\u{8}");
|
||
// Number gone, still indented with the list.
|
||
assert_eq!(paras(&s)[1], ("Two".to_string(), None, true));
|
||
typ(&mut s, "\u{8}|");
|
||
// Second Backspace: Normal paragraph, not joined to the one above.
|
||
assert_eq!(paras(&s), vec![para("One", Some(0)), para("|Two", None)]);
|
||
}
|
||
|
||
#[test]
|
||
fn backspace_on_an_empty_item_keeps_the_paragraph() {
|
||
let s = typed("* One\n\u{8}Z");
|
||
assert_eq!(paras(&s), vec![para("One", Some(0)), ("Z".to_string(), None, true)]);
|
||
}
|
||
|
||
#[test]
|
||
fn enter_in_the_middle_or_at_the_start_of_an_item() {
|
||
let mut s = typed("* Hello world");
|
||
for _ in 0..5 {
|
||
run(&mut s, "caret.left", json!({}));
|
||
}
|
||
typ(&mut s, "\nX");
|
||
assert_eq!(paras(&s), vec![para("Hello ", Some(0)), para("Xworld", Some(0))]);
|
||
let mut s = typed("* first\nsecond");
|
||
run(&mut s, "caret.home", json!({}));
|
||
typ(&mut s, "\nY");
|
||
assert_eq!(paras(&s), vec![para("First", Some(0)), para("", Some(0)), para("Ysecond", Some(0))]);
|
||
}
|
||
|
||
fn label_at(s: &Session, i: usize) -> String {
|
||
let n = s.doc.para_at(&Pos::body(i, 0)).and_then(|p| p.props.numbering).unwrap();
|
||
s.doc.numbering.level(n.num, n.level).map(|l| l.text.clone()).unwrap_or_default()
|
||
}
|
||
|
||
#[test]
|
||
fn list_autoformat_triggers() {
|
||
let s = typed("- Item\nNext");
|
||
assert_eq!(label_at(&s, 0), "-");
|
||
assert_eq!(paras(&s), vec![para("Item", Some(0)), para("Next", Some(0))]);
|
||
let s = typed("1) a\nb");
|
||
assert_eq!(label_at(&s, 0), "%1)");
|
||
let s = typed("a. alpha\nbeta");
|
||
assert_eq!(paras(&s), vec![para("Alpha", Some(0)), para("beta", Some(0))]);
|
||
assert_eq!(label_at(&s, 0), "%1.");
|
||
let n = s.doc.para_at(&Pos::body(0, 0)).and_then(|p| p.props.numbering).unwrap();
|
||
assert_eq!(s.doc.numbering.level(n.num, 0).map(|l| l.format), Some(wordcraft_doc::section::NumFormat::LowerLetter));
|
||
}
|
||
|
||
#[test]
|
||
fn typing_1_dot_starts_numbering_again() {
|
||
let s = typed("1. a\nb\n\nmid\n1. c\nd");
|
||
let num = |i: usize| s.doc.para_at(&Pos::body(i, 0)).and_then(|p| p.props.numbering).map(|n| n.num);
|
||
assert_eq!(num(0), num(1));
|
||
assert_eq!(num(3), num(4));
|
||
assert_ne!(num(0), num(3), "a new list restarts at 1");
|
||
}
|
||
|
||
#[test]
|
||
fn undo_right_after_autoformat_undoes_only_the_autoformat() {
|
||
let mut s = typed("* ");
|
||
assert!(paras(&s)[0].1.is_some());
|
||
run(&mut s, "edit.undo", json!({}));
|
||
typ(&mut s, "x");
|
||
assert_eq!(paras(&s), vec![("* x".to_string(), None, false)]);
|
||
// Same for AutoCorrect.
|
||
let mut s = typed("teh ");
|
||
run(&mut s, "edit.undo", json!({}));
|
||
assert_eq!(s.doc.plain_text(StoryRef::Body), "teh ");
|
||
}
|
||
|
||
#[test]
|
||
fn autocorrect_as_you_type() {
|
||
let s = typed("teh cat said hello. world (c) \"quoted\" don't -- yes wait... i think so. e.g. this. ");
|
||
assert_eq!(s.doc.plain_text(StoryRef::Body), "The cat said hello. World © “quoted” don’t – yes wait… I think so. E.g. this. ");
|
||
// Abbreviations and initials don't end sentences.
|
||
let s = typed("see e.g. the map. Ask J. smith or Dr. jones ");
|
||
assert_eq!(s.doc.plain_text(StoryRef::Body), "See e.g. the map. Ask J. smith or Dr. jones ");
|
||
// Fractions; "iPhone" and file names stay as typed.
|
||
let s = typed("1/2 cup. iPhone. see readme.txt ");
|
||
assert_eq!(s.doc.plain_text(StoryRef::Body), "½ cup. iPhone. See readme.txt ");
|
||
}
|
||
|
||
#[test]
|
||
fn autocorrect_options_change_what_typing_does() {
|
||
let mut s = s();
|
||
// A user entry, a deleted built-in entry, an added exception; names of days.
|
||
run(&mut s, "tools.autocorrect", json!({"add": {"from": "wc", "to": "WordCraft"}}));
|
||
run(&mut s, "tools.autocorrect", json!({"delete": "teh"}));
|
||
run(&mut s, "tools.autocorrect", json!({"addException": "approx."}));
|
||
typ(&mut s, "wc ships on monday. teh end is approx. three weeks ");
|
||
assert_eq!(s.doc.plain_text(StoryRef::Body), "WordCraft ships on Monday. Teh end is approx. three weeks ");
|
||
// AutoFormat options off: straight quotes, no dashes, no list; sentences not capitalised.
|
||
let mut s = self::s();
|
||
run(&mut s, "tools.autocorrect", json!({"smartQuotes": false, "dashes": false, "bullets": false, "capSentences": false, "capDays": false}));
|
||
typ(&mut s, "* \"hi\" a -- b sunday ");
|
||
assert_eq!(s.doc.plain_text(StoryRef::Body), "* \"hi\" a -- b sunday ");
|
||
assert_eq!(paras(&s)[0].1, None);
|
||
}
|
||
|
||
#[test]
|
||
fn enter_finishes_a_word_for_autocorrect() {
|
||
let s = typed("hello\nteh end\n");
|
||
assert_eq!(s.doc.plain_text(StoryRef::Body), "Hello\nThe end\n");
|
||
}
|
||
|
||
#[test]
|
||
fn ordinals_become_superscript() {
|
||
let s = typed("the 1st and 22nd and 13th but not 1th ");
|
||
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
|
||
let sup = |t: &str| {
|
||
let i = p.text.find(t).unwrap() + t.len() - 2;
|
||
p.props_of_char(i).vert_align == Some(VertAlign::Superscript)
|
||
};
|
||
assert!(sup("1st") && sup("22nd") && sup("13th"));
|
||
assert!(!sup("1th"));
|
||
}
|
||
|
||
#[test]
|
||
fn web_addresses_become_links() {
|
||
let mut s = typed("see https://example.org/log, then ");
|
||
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
|
||
let at = p.text.find("https").unwrap();
|
||
assert_eq!(p.props_of_char(at).link.as_deref(), Some("https://example.org/log"));
|
||
assert_eq!(p.props_of_char(p.text.find(',').unwrap()).link, None, "trailing comma stays outside");
|
||
typ(&mut s, "more");
|
||
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
|
||
assert_eq!(p.props_of_char(p.text.find("more").unwrap()).link, None);
|
||
}
|
||
|
||
#[test]
|
||
fn dashes() {
|
||
let s = typed("a -- b and c - d and e--f ");
|
||
assert_eq!(s.doc.plain_text(StoryRef::Body), "A – b and c – d and e—f ");
|
||
}
|
||
|
||
#[test]
|
||
fn border_line_autoformat() {
|
||
let s = typed("Above\n---\nBelow");
|
||
assert_eq!(s.doc.plain_text(StoryRef::Body), "Above\nBelow");
|
||
let b = s.doc.para_at(&Pos::body(0, 0)).and_then(|p| p.props.borders).and_then(|b| b.bottom).unwrap();
|
||
assert_eq!(b.style, BorderStyle::Single);
|
||
assert!(s.doc.para_at(&Pos::body(1, 0)).unwrap().props.borders.is_none());
|
||
for (keys, style) in [("===", BorderStyle::Double), ("***", BorderStyle::Dotted), ("___", BorderStyle::Thick)] {
|
||
let s = typed(&format!("x\n{keys}\n"));
|
||
let b = s.doc.para_at(&Pos::body(0, 0)).and_then(|p| p.props.borders).and_then(|b| b.bottom);
|
||
assert_eq!(b.map(|b| b.style), Some(style), "{keys}");
|
||
}
|
||
// At the top of the document the line goes under that paragraph and typing continues below.
|
||
let s = typed("---\nText");
|
||
assert_eq!(s.doc.plain_text(StoryRef::Body), "\nText");
|
||
assert!(s.doc.para_at(&Pos::body(0, 0)).and_then(|p| p.props.borders).is_some());
|
||
assert!(s.doc.para_at(&Pos::body(1, 0)).unwrap().props.borders.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn undo_right_after_a_border_line_brings_the_characters_back() {
|
||
let mut s = typed("Above\n---\n");
|
||
assert_eq!(s.doc.plain_text(StoryRef::Body), "Above\n");
|
||
run(&mut s, "edit.undo", json!({}));
|
||
// The AutoFormat is undone on its own: the characters are back, the Enter stays.
|
||
assert_eq!(s.doc.plain_text(StoryRef::Body), "Above\n---\n");
|
||
assert!(s.doc.body.iter().filter_map(|b| b.as_para()).all(|p| p.props.borders.is_none()));
|
||
assert_eq!(s.sel.focus, Pos::body(2, 0));
|
||
run(&mut s, "edit.undo", json!({}));
|
||
assert_eq!(s.doc.plain_text(StoryRef::Body), "Above\n---");
|
||
}
|
||
|
||
#[test]
|
||
fn plus_and_hyphen_line_becomes_a_table() {
|
||
let mut s = typed("+--+------+\nA");
|
||
let Some(wordcraft_doc::Block::Table(t)) = s.doc.body.first().map(|b| &**b) else { panic!("no table: {:?}", paras(&s)) };
|
||
assert_eq!(t.rows.len(), 1);
|
||
assert_eq!(t.grid.len(), 2);
|
||
assert!((t.grid[1] / t.grid[0] - 3.0).abs() < 0.01, "widths follow the hyphens: {:?}", t.grid);
|
||
assert_eq!(t.rows[0].cells[0].blocks[0].as_para().map(|p| p.text.as_str()), Some("A"), "the caret is in the first cell");
|
||
assert!(s.doc.body.get(1).and_then(|b| b.as_para()).is_some_and(|p| p.is_empty()), "a paragraph follows the table");
|
||
// Undo right after brings the typed characters back, the Enter stays.
|
||
run(&mut s, "edit.undo", json!({}));
|
||
run(&mut s, "edit.undo", json!({}));
|
||
assert_eq!(s.doc.plain_text(StoryRef::Body), "+--+------+\n");
|
||
assert!(s.doc.body.iter().all(|b| b.as_para().is_some()));
|
||
// Not a table: text around it, or more columns than a table can hold.
|
||
let s = typed("x +--+--+\n");
|
||
assert_eq!(s.doc.plain_text(StoryRef::Body), "X +--+--+\n");
|
||
let s = typed(&format!("+{}\n", "-+".repeat(64)));
|
||
assert!(s.doc.body.iter().all(|b| b.as_para().is_some()));
|
||
}
|
||
|
||
#[test]
|
||
fn arrow_bullets() {
|
||
for (keys, bullet) in [("> ", "\u{27A2}"), ("-> ", "\u{2794}"), ("=> ", "\u{21E8}")] {
|
||
let s = typed(&format!("{keys}item\nnext"));
|
||
assert_eq!(paras(&s), vec![para("Item", Some(0)), para("next", Some(0))], "{keys}");
|
||
assert_eq!(label_at(&s, 0), bullet, "{keys}");
|
||
}
|
||
// An arrow list doesn't join a plain bullet list.
|
||
let s = typed("* a\n\n> b");
|
||
assert_eq!(label_at(&s, 0), "\u{2022}");
|
||
assert_eq!(label_at(&s, 1), "\u{27A2}");
|
||
}
|
||
|
||
#[test]
|
||
fn smileys_are_replaced_inside_words_too() {
|
||
let s = typed("so ok:) sad:( meh:| :) fine:-) ");
|
||
assert_eq!(s.doc.plain_text(StoryRef::Body), "So ok\u{263A} sad\u{2639} meh\u{1F610} \u{263A} fine\u{263A} ");
|
||
}
|
||
|
||
#[test]
|
||
fn a_large_mixed_document_types_like_word() {
|
||
// The corpus typed into Word and WordCraft side by side; the paragraph structure must match.
|
||
let mut s = s();
|
||
let prose = "The keeper's log runs to four hundred pages, and almost every one begins with the weather. ";
|
||
typ(&mut s, &format!("Notes\n{}\n", prose.repeat(20)));
|
||
typ(&mut s, "The supplies were:\n* Lamp oil, 1/2 barrel\nWicks of the 1st quality\n\tSpare chimneys\nSpare springs\n");
|
||
run(&mut s, "text.backTab", json!({}));
|
||
typ(&mut s, "Salt beef\n\nThe schedule:\n1. Signal the boat\nLower the stores\n\tCheck the net\n");
|
||
run(&mut s, "text.backTab", json!({}));
|
||
typ(&mut s, "Sign the manifest\n\n---\nIn March the storm broke -- and he slept.\n");
|
||
typ(&mut s, "a. Inspect the glass\nReplace the seals\n\n");
|
||
typ(&mut s, &format!("Afterword. teh keeper retired. {}\n* one more\nsecond\n\n\nFinal.", prose.repeat(10)));
|
||
let got: Vec<(String, Option<u8>)> = paras(&s).into_iter().map(|(t, l, _)| (t.chars().take(24).collect(), l)).collect();
|
||
let want: Vec<(&str, Option<u8>)> = vec![
|
||
("Notes", None),
|
||
("The keeper’s log runs to", None),
|
||
("The supplies were:", None),
|
||
("Lamp oil, ½ barrel", Some(0)),
|
||
("Wicks of the 1st quality", Some(0)),
|
||
("Spare chimneys", Some(1)),
|
||
("Spare springs", Some(1)),
|
||
("Salt beef", Some(0)),
|
||
("The schedule:", None),
|
||
("Signal the boat", Some(0)),
|
||
("Lower the stores", Some(0)),
|
||
("Check the net", Some(1)),
|
||
("Sign the manifest", Some(0)),
|
||
("In March the storm broke", None),
|
||
("Inspect the glass", Some(0)),
|
||
("Replace the seals", Some(0)),
|
||
("Afterword. The keeper re", None),
|
||
("One more", Some(0)),
|
||
("Second", Some(0)),
|
||
("", None),
|
||
("Final.", None),
|
||
];
|
||
let want: Vec<(String, Option<u8>)> = want.into_iter().map(|(t, l)| (t.to_string(), l)).collect();
|
||
assert_eq!(got, want);
|
||
// Every non-list paragraph is Normal (no list indent left behind).
|
||
for (t, l, lp) in paras(&s) {
|
||
assert!(l.is_some() || !lp, "{t:?} still a List Paragraph");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn enter_right_after_starting_a_list_cancels_it() {
|
||
let s = typed("* \nPlain");
|
||
assert_eq!(paras(&s), vec![para("Plain", None)]);
|
||
}
|
||
|
||
#[test]
|
||
fn shift_enter_stays_inside_the_list_item() {
|
||
let mut s = typed("* line one");
|
||
run(&mut s, "text.lineBreak", json!({}));
|
||
typ(&mut s, "cont\ntwo");
|
||
assert_eq!(paras(&s), vec![para("Line one\ncont", Some(0)), para("two", Some(0))]);
|
||
}
|
||
|
||
/// Body paragraph texts, lower-cased (AutoCorrect may capitalise a sentence start).
|
||
fn texts(s: &Session) -> Vec<String> {
|
||
paras(s).into_iter().map(|p| p.0.to_lowercase()).collect()
|
||
}
|
||
|
||
#[test]
|
||
fn tracked_enter_and_backspace_track_the_paragraph_mark() {
|
||
// Issue #229: with Track Changes on, Enter inserts a tracked paragraph mark that Reject All
|
||
// takes out again; Backspace over your own Enter removes it outright.
|
||
let mut s = typed("one");
|
||
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||
typ(&mut s, "\ntwo");
|
||
assert_eq!(texts(&s), ["one", "two"]);
|
||
typ(&mut s, "\u{8}\u{8}\u{8}\u{8}");
|
||
assert_eq!(texts(&s), ["one"]);
|
||
typ(&mut s, "\n");
|
||
assert_eq!(texts(&s), ["one", ""]);
|
||
run(&mut s, "review.rejectAll", json!({}));
|
||
assert_eq!(texts(&s), ["one"]);
|
||
// A break that was already there isn't removed: Backspace marks it deleted and moves before it.
|
||
let mut s = typed("one\ntwo");
|
||
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||
run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 0}}));
|
||
typ(&mut s, "\u{8}");
|
||
assert_eq!(texts(&s), ["one", "two"]);
|
||
assert_eq!(s.sel.focus, Pos::body(0, 3));
|
||
assert!(s.doc.para_at(&Pos::body(0, 0)).is_some_and(|p| p.mark.del.is_some()));
|
||
}
|
||
|
||
/// Linked text in each body paragraph, and whether its paragraph mark carries a link.
|
||
fn links(s: &Session) -> Vec<(String, bool)> {
|
||
s.doc
|
||
.body
|
||
.iter()
|
||
.filter_map(|b| b.as_para())
|
||
.map(|p| {
|
||
let linked = p.run_ranges().filter(|(_, c)| c.link.is_some()).filter_map(|(r, _)| p.text.get(r)).collect();
|
||
(linked, p.mark.link.is_some())
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
#[test]
|
||
fn a_link_covers_only_its_own_text() {
|
||
// Issue #413: linking a whole paragraph also linked its mark, so Enter carried the link into
|
||
// the next paragraph and everything typed there (or hovered below it) became the link.
|
||
let mut s = typed("hi\nhello");
|
||
run(&mut s, "select.text", json!({"text": "hello"}));
|
||
run(&mut s, "insert.link", json!({"url": "https://example.org/"}));
|
||
run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 5}}));
|
||
typ(&mut s, "\nnext");
|
||
run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 0}}));
|
||
typ(&mut s, "\n");
|
||
run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 0}}));
|
||
typ(&mut s, "above");
|
||
assert_eq!(texts(&s), ["hi", "above", "hello", "next"]);
|
||
let unlinked = (String::new(), false);
|
||
assert_eq!(links(&s), [unlinked.clone(), unlinked.clone(), ("Hello".into(), false), unlinked]);
|
||
}
|
||
|
||
#[test]
|
||
fn typing_at_either_end_of_a_link_is_not_linked() {
|
||
// As in Word: a link typed in with Ctrl+K, then text typed after it, at its end later on,
|
||
// at its start, or after Enter, stays outside the link; typing inside it extends it.
|
||
let mut s = typed("see ");
|
||
run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "site"}));
|
||
typ(&mut s, " now");
|
||
run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 8}}));
|
||
typ(&mut s, "!");
|
||
run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 4}}));
|
||
typ(&mut s, "a ");
|
||
run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 8}}));
|
||
typ(&mut s, "x");
|
||
run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 11}}));
|
||
typ(&mut s, "\nmore");
|
||
assert_eq!(texts(&s), ["see a sixte", "more! now"]);
|
||
assert_eq!(links(&s), [("sixte".into(), false), (String::new(), false)]);
|
||
}
|
||
|
||
#[test]
|
||
fn typing_then_selecting_then_typing_are_two_undo_steps() {
|
||
// #419: a selection that moves the insertion point ends the typing group, like a caret move.
|
||
let mut s = typed("alpha");
|
||
run(&mut s, "select.text", json!({"text": "ph"}));
|
||
run(&mut s, "text.insert", json!({"text": "X"}));
|
||
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().text, "alXa");
|
||
run(&mut s, "edit.undo", json!({}));
|
||
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().text, "alpha");
|
||
assert_eq!(s.selected_text(), "ph");
|
||
assert!(s.can_undo());
|
||
run(&mut s, "edit.undo", json!({}));
|
||
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().text, "");
|
||
// Uninterrupted typing (a read-only command in between included) is still one step.
|
||
let mut s = typed("alpha");
|
||
run(&mut s, "document.inspect", json!({}));
|
||
typ(&mut s, "X");
|
||
run(&mut s, "edit.undo", json!({}));
|
||
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().text, "");
|
||
}
|
||
|
||
#[test]
|
||
fn undoing_bold_at_the_caret_turns_bold_typing_off_again() {
|
||
// #424: Bold at a caret in an empty paragraph, Undo, type: the text isn't bold.
|
||
let mut s = s();
|
||
run(&mut s, "format.bold", json!({"value": true}));
|
||
run(&mut s, "edit.undo", json!({}));
|
||
assert_ne!(s.typing_props().bold, Some(true));
|
||
typ(&mut s, "abc");
|
||
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
|
||
assert_eq!(p.text, "abc");
|
||
assert_ne!(p.props_of_char(0).bold, Some(true));
|
||
// Redo brings bold typing back.
|
||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||
run(&mut s, "format.bold", json!({"value": true}));
|
||
run(&mut s, "edit.undo", json!({}));
|
||
run(&mut s, "edit.redo", json!({}));
|
||
typ(&mut s, "abc");
|
||
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props_of_char(0).bold, Some(true));
|
||
}
|
||
|
||
/// Myanmar (#604): typed in memory order (consonant, medial, then the prevowel ေ that is drawn
|
||
/// before it), the caret steps over whole syllables and Backspace removes one sign at a time.
|
||
#[test]
|
||
fn myanmar_typing_steps_by_syllable_and_backspaces_by_sign() {
|
||
// ကြေး ("money"): က + ြ + ေ + း, then မြန်မာ.
|
||
let word = "\u{1000}\u{103C}\u{1031}\u{1038}";
|
||
let mut s = typed(word);
|
||
assert_eq!(paras(&s), vec![para(word, None)]);
|
||
// Backspace takes the visarga, then the vowel sign: the consonant and medial stay.
|
||
typ(&mut s, "\u{8}");
|
||
assert_eq!(paras(&s)[0].0, "\u{1000}\u{103C}\u{1031}");
|
||
typ(&mut s, "\u{8}\u{1031}\u{1038}");
|
||
assert_eq!(paras(&s)[0].0, word, "retyped");
|
||
// Left from the end skips the whole syllable (the visarga and aa signs aren't grapheme
|
||
// extenders, so a grapheme step would stop inside it).
|
||
let mut s = typed("\u{1019}\u{102C}\u{1005}\u{102C}"); // မာစာ
|
||
run(&mut s, "caret.left", json!({}));
|
||
assert_eq!(s.sel.focus.off, 6);
|
||
run(&mut s, "caret.left", json!({}));
|
||
assert_eq!(s.sel.focus.off, 0);
|
||
run(&mut s, "caret.right", json!({}));
|
||
assert_eq!(s.sel.focus.off, 6);
|
||
// Delete removes the whole syllable after the caret.
|
||
run(&mut s, "text.delete", json!({}));
|
||
assert_eq!(paras(&s)[0].0, "\u{1019}\u{102C}");
|
||
}
|
||
|
||
/// Devanagari and Khmer: Backspace removes the last character (a matra), not the conjunct.
|
||
#[test]
|
||
fn indic_and_khmer_backspace_removes_one_character() {
|
||
let mut s = typed("\u{0915}\u{094D}\u{0937}\u{093F}\u{8}"); // क्षि ⌫
|
||
assert_eq!(paras(&s)[0].0, "\u{0915}\u{094D}\u{0937}");
|
||
typ(&mut s, "\u{8}");
|
||
assert_eq!(paras(&s)[0].0, "\u{0915}\u{094D}");
|
||
let s = typed("\u{1781}\u{17D2}\u{1798}\u{17C2}\u{8}"); // ខ្មែ ⌫
|
||
assert_eq!(paras(&s)[0].0, "\u{1781}\u{17D2}\u{1798}");
|
||
// Latin keeps grapheme Backspace: e + combining acute goes as one.
|
||
let s = typed("ae\u{301}\u{8}");
|
||
assert_eq!(paras(&s)[0].0, "a");
|
||
}
|
||
|
||
/// Press a shortcut the way the keyboard does: run the command bound to it.
|
||
fn press(s: &mut Session, combo: &str) -> Result<serde_json::Value, crate::CmdError> {
|
||
let id = s.registry.by_shortcut(combo).map(|c| c.id).unwrap_or_else(|| panic!("nothing bound to {combo}"));
|
||
s.run(id, &json!({}))
|
||
}
|
||
|
||
/// Word's outline keys: Alt+Shift+arrows, ⌃⇧+arrows on macOS.
|
||
fn outline_key(arrow: &str) -> String {
|
||
if cfg!(target_os = "macos") { format!("Ctrl+Shift+{arrow}") } else { format!("Alt+Shift+{arrow}") }
|
||
}
|
||
|
||
#[test]
|
||
fn alt_shift_down_then_up_moves_a_paragraph_and_back_and_undoes() {
|
||
let mut s = typed("One\nTwo\nThree");
|
||
run(&mut s, "caret.docStart", json!({}));
|
||
press(&mut s, &outline_key("Down")).unwrap();
|
||
assert_eq!(texts(&s), ["two", "one", "three"]);
|
||
assert_eq!(s.sel.focus, Pos::body(1, 0), "the caret moves with the paragraph");
|
||
press(&mut s, &outline_key("Up")).unwrap();
|
||
assert_eq!(texts(&s), ["one", "two", "three"]);
|
||
assert!(press(&mut s, &outline_key("Up")).is_err(), "nothing above the first paragraph");
|
||
run(&mut s, "edit.undo", json!({}));
|
||
assert_eq!(texts(&s), ["two", "one", "three"], "each move is one undo step");
|
||
run(&mut s, "edit.undo", json!({}));
|
||
assert_eq!(texts(&s), ["one", "two", "three"]);
|
||
// A selected paragraph (with its mark) moves alone; list items keep their list.
|
||
let mut s = typed("1. First\nSecond\n\nAfter");
|
||
run(&mut s, "select.paragraph", json!({}));
|
||
press(&mut s, &outline_key("Up")).unwrap();
|
||
assert_eq!(paras(&s), vec![para("First", Some(0)), para("After", None), para("Second", Some(0))]);
|
||
assert!(press(&mut s, &outline_key("Down")).is_ok());
|
||
assert!(press(&mut s, &outline_key("Down")).is_err(), "nothing below the last paragraph");
|
||
}
|
||
|
||
fn style_of(s: &Session, i: usize) -> Option<String> {
|
||
s.doc.body.get(i).and_then(|b| b.as_para()).and_then(|p| p.props.style.clone())
|
||
}
|
||
|
||
#[test]
|
||
fn alt_shift_left_and_right_promote_and_demote_headings_and_list_items() {
|
||
let mut s = typed("Intro\nBody");
|
||
run(&mut s, "caret.docStart", json!({}));
|
||
run(&mut s, "para.heading2", json!({}));
|
||
run(&mut s, "caret.docEnd", json!({}));
|
||
// Promoted body text takes the level of the heading before it; then one level at a time.
|
||
press(&mut s, &outline_key("Left")).unwrap();
|
||
assert_eq!(style_of(&s, 1).as_deref(), Some("Heading2"));
|
||
press(&mut s, &outline_key("Right")).unwrap();
|
||
assert_eq!(style_of(&s, 1).as_deref(), Some("Heading3"));
|
||
press(&mut s, &outline_key("Left")).unwrap();
|
||
press(&mut s, &outline_key("Left")).unwrap();
|
||
assert_eq!(style_of(&s, 1).as_deref(), Some("Heading1"));
|
||
assert!(press(&mut s, &outline_key("Left")).is_err(), "Heading 1 is the top level");
|
||
for _ in 0..8 {
|
||
press(&mut s, &outline_key("Right")).unwrap();
|
||
}
|
||
assert_eq!(style_of(&s, 1).as_deref(), Some("Heading9"));
|
||
assert!(press(&mut s, &outline_key("Right")).is_err(), "Heading 9 is the lowest heading");
|
||
// In a list they change the item's level, like Tab and Shift+Tab.
|
||
let mut s = typed("* One\nTwo");
|
||
press(&mut s, &outline_key("Right")).unwrap();
|
||
assert_eq!(paras(&s), vec![para("One", Some(0)), para("Two", Some(1))]);
|
||
press(&mut s, &outline_key("Left")).unwrap();
|
||
assert_eq!(paras(&s), vec![para("One", Some(0)), para("Two", Some(0))]);
|
||
}
|
||
|
||
#[test]
|
||
fn alt_x_toggles_between_a_character_and_its_code() {
|
||
if !cfg!(target_os = "macos") {
|
||
assert_eq!(s().registry.by_shortcut("Alt+X").map(|c| c.id), Some("text.toggleUnicode"));
|
||
}
|
||
let mut s = s();
|
||
run(&mut s, "text.insert", json!({"text": "x 00e9"}));
|
||
run(&mut s, "text.toggleUnicode", json!({}));
|
||
assert_eq!(texts(&s), ["x é"]);
|
||
run(&mut s, "text.toggleUnicode", json!({}));
|
||
assert_eq!(texts(&s), ["x 00e9"]);
|
||
run(&mut s, "edit.undo", json!({}));
|
||
assert_eq!(texts(&s), ["x é"], "one undo step");
|
||
run(&mut s, "text.insert", json!({"text": " U+1F600"}));
|
||
run(&mut s, "text.toggleUnicode", json!({}));
|
||
assert_eq!(texts(&s), ["x é 😀"]);
|
||
// A selected code converts too.
|
||
run(&mut s, "text.insert", json!({"text": " 2014"}));
|
||
run(&mut s, "select.text", json!({"text": "2014"}));
|
||
run(&mut s, "text.toggleUnicode", json!({}));
|
||
assert_eq!(texts(&s), ["x é 😀 —"]);
|
||
}
|
||
|
||
#[test]
|
||
fn shift_f5_goes_back_through_the_last_edits() {
|
||
let mut s = typed("One\nTwo\nThree");
|
||
run(&mut s, "caret.docStart", json!({}));
|
||
let mut back = || {
|
||
press(&mut s, "Shift+F5").unwrap();
|
||
s.sel.focus.clone()
|
||
};
|
||
assert_eq!(back(), Pos::body(2, 5), "the last edit first");
|
||
assert_eq!(back(), Pos::body(1, 3));
|
||
assert_eq!(back(), Pos::body(0, 3));
|
||
assert_eq!(back(), Pos::body(2, 5), "then round again");
|
||
assert!(Session::new(wordcraft_doc::Document::new()).run("edit.goBack", &json!({})).is_err(), "nothing edited yet");
|
||
}
|
||
|
||
/// File › Options › Advanced, "Typing replaces selected text" (#485): on (Word's default) the
|
||
/// typed text replaces the selection; off, it goes in front of the selection and keeps it.
|
||
#[test]
|
||
fn typing_over_a_selection_follows_the_replace_selection_option() {
|
||
let mut s = typed("One two");
|
||
run(&mut s, "select.text", json!({"text": "two"}));
|
||
typ(&mut s, "X");
|
||
assert_eq!(s.doc.plain_text(StoryRef::Body), "One X");
|
||
let mut s = typed("One two");
|
||
run(&mut s, "file.options", json!({"typingReplacesSelection": false}));
|
||
run(&mut s, "select.text", json!({"text": "two"}));
|
||
typ(&mut s, "XY");
|
||
assert_eq!(s.doc.plain_text(StoryRef::Body), "One XYtwo");
|
||
}
|
||
|
||
/// Overtype (#485): typed characters replace the ones after the caret, never past the end of the
|
||
/// paragraph; the Insert key toggles it only with "Use the Insert key to control overtype" on
|
||
/// (the UI checks that option; `text.overtype` is the switch).
|
||
#[test]
|
||
fn overtype_replaces_the_characters_after_the_caret() {
|
||
let mut s = typed("Abcdef\nXy");
|
||
run(&mut s, "caret.set", json!({"pos": Pos::body(0, 1)}));
|
||
run(&mut s, "text.overtype", json!({"value": true}));
|
||
typ(&mut s, "XY");
|
||
assert_eq!(s.doc.plain_text(StoryRef::Body), "AXYdef\nXy");
|
||
run(&mut s, "caret.set", json!({"pos": Pos::body(0, 5)}));
|
||
typ(&mut s, "123");
|
||
assert_eq!(s.doc.plain_text(StoryRef::Body), "AXYde123\nXy", "the paragraph mark isn't typed over");
|
||
run(&mut s, "text.overtype", json!({}));
|
||
run(&mut s, "caret.set", json!({"pos": Pos::body(0, 0)}));
|
||
typ(&mut s, "Z");
|
||
assert_eq!(s.doc.plain_text(StoryRef::Body), "ZAXYde123\nXy", "insert mode again");
|
||
}
|
||
|
||
#[test]
|
||
fn reject_all_takes_out_the_paragraph_breaks_a_tracked_paste_inserted() {
|
||
// Issue #417: Paste's paragraph marks weren't tracked, so Reject All left the split.
|
||
for text in ["X\nY", "\n"] {
|
||
let mut s = s();
|
||
run(&mut s, "text.insert", json!({"text": "AB", "raw": true}));
|
||
run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 1}}));
|
||
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||
run(&mut s, "edit.paste", json!({"text": text}));
|
||
let (head, tail) = text.split_once('\n').unwrap_or_default();
|
||
assert_eq!(texts(&s), [format!("a{}", head.to_lowercase()), format!("{}b", tail.to_lowercase())], "{text:?}");
|
||
let pasted = texts(&s);
|
||
run(&mut s, "review.rejectAll", json!({}));
|
||
assert_eq!(texts(&s), ["ab"], "{text:?}");
|
||
assert!(s.doc.revisions.is_empty(), "{text:?}");
|
||
run(&mut s, "edit.undo", json!({}));
|
||
run(&mut s, "review.acceptAll", json!({}));
|
||
assert_eq!(texts(&s), pasted, "{text:?}: accepting keeps the break");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn plain_text_paste_keeps_an_empty_paragraph_in_a_list() {
|
||
// Issue #422: the pasted paragraph separators went through Enter, which ends the list on an
|
||
// empty item, so `A\n\nB` lost its empty paragraph.
|
||
for (id, v) in [("edit.pasteText", json!({"text": "A\n\nB"})), ("edit.pasteSpecial", json!({"as": "text", "text": "A\n\nB"}))] {
|
||
let mut s = s();
|
||
run(&mut s, "para.bullets", json!({}));
|
||
run(&mut s, id, v);
|
||
assert_eq!(texts(&s), ["a", "", "b"], "{id}");
|
||
}
|
||
// Enter twice still ends the list.
|
||
let mut s = s();
|
||
run(&mut s, "para.bullets", json!({}));
|
||
typ(&mut s, "A\n\nB");
|
||
assert_eq!(paras(&s), vec![para("A", Some(0)), para("B", None)]);
|
||
}
|
||
|
||
/// #713: Arabic or Hebrew typed into an empty paragraph makes it right-to-left; a paragraph that
|
||
/// already has letters, or whose style sets a direction, keeps its own.
|
||
#[test]
|
||
fn typing_rtl_letters_into_an_empty_paragraph_makes_it_rtl() {
|
||
let bidi = |s: &Session, i: usize| s.doc.body.iter().filter_map(|b| b.as_para()).nth(i).and_then(|p| p.props.bidi);
|
||
// Empty paragraph: the first Arabic letter flips it, the rest keep it.
|
||
let s = typed("سلام");
|
||
assert_eq!(bidi(&s, 0), Some(true));
|
||
// Digits first, then Arabic: still no letters before, so it flips.
|
||
let s = typed("1. سلام");
|
||
assert_eq!(bidi(&s, 0), Some(true));
|
||
// Latin first: Arabic typed later doesn't flip the paragraph.
|
||
let s = typed("Hello سلام");
|
||
assert_eq!(bidi(&s, 0), None);
|
||
// A new paragraph after an RTL one starts its own detection.
|
||
let s = typed("سلام\nHello");
|
||
assert_eq!(bidi(&s, 1), Some(true), "Enter carries the paragraph's direction on, as in Word");
|
||
// A style that sets left-to-right wins.
|
||
let mut doc = Session::new(wordcraft_doc::Document::new());
|
||
if let Some(st) = doc.doc.styles.get_mut("Normal") {
|
||
st.para.bidi = Some(false);
|
||
}
|
||
typ(&mut doc, "سلام");
|
||
assert_eq!(bidi(&doc, 0), None);
|
||
// An opened paragraph without w:bidi lays out left-to-right whatever its text: the caret
|
||
// after the Arabic word sits near the left margin, not the right one.
|
||
let mut doc = typed("سلام");
|
||
let path = doc.sel.focus.path.clone();
|
||
if let Ok(p) = doc.doc.para_mut(StoryRef::Body, &path) {
|
||
p.props.bidi = None;
|
||
p.touch();
|
||
}
|
||
let x = doc.layout().caret(&doc.sel.focus).map(|c| c.x).unwrap_or(f32::MAX);
|
||
assert!(x < 300.0, "left-to-right paragraph: caret at {x}");
|
||
}
|