From 62a17b841fcd083fc0c03ac5ce877541e2c3106e Mon Sep 17 00:00:00 2001 From: Hevar Hemin <66733462+Hevarh1@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:51:03 +0300 Subject: [PATCH] Bidi: auto-detect RTL paragraph direction when typing Kurdish, Arabic and Hebrew (#713) * 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 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/doc/src/bidi.rs | 26 ++++++++++++++++++++++ crates/engine/src/cmd/mod.rs | 22 +++++++++++++++++++ crates/engine/src/tests_bidi.rs | 20 +++++++++++++++++ crates/engine/src/tests_typing.rs | 36 +++++++++++++++++++++++++++++++ docs/typing-parity.md | 12 ++++++++++- 5 files changed, 115 insertions(+), 1 deletion(-) diff --git a/crates/doc/src/bidi.rs b/crates/doc/src/bidi.rs index 5da0dab..e4c9183 100644 --- a/crates/doc/src/bidi.rs +++ b/crates/doc/src/bidi.rs @@ -37,6 +37,26 @@ pub fn has_rtl(text: &str) -> bool { text.chars().any(is_rtl_script) } +/// Does the first strong directional character in `text` belong to a right-to-left script? +/// +/// Follows UAX #9 rules P2 and P3: characters with strong bidirectional class AL or R +/// (Arabic, Hebrew, Syriac, Thaana…) give `true`; strong L characters (Latin, Greek, +/// Cyrillic, CJK…) give `false`. Neutral, weak and whitespace characters are skipped. +pub fn first_strong_is_rtl(text: &str) -> bool { + for c in text.chars() { + if c == '\u{200F}' { + return true; + } + if c == '\u{200E}' { + return false; + } + if c.is_alphabetic() { + return is_rtl_script(c); + } + } + false +} + #[cfg(test)] mod tests { use super::*; @@ -56,5 +76,11 @@ mod tests { assert!("می‌خواهم".chars().all(is_word_char), "ZWNJ is inside the word"); assert!("کِتاب".chars().all(is_word_char), "so are harakat"); assert!(!is_word_char(' ') && !is_word_char('،')); + assert!(first_strong_is_rtl("سلام")); + assert!(first_strong_is_rtl("من ناوم هێڤاره")); + assert!(first_strong_is_rtl(" 123. سلام")); + assert!(!first_strong_is_rtl("Hello سلام")); + assert!(!first_strong_is_rtl("123 Hello")); + assert!(!first_strong_is_rtl("")); } } diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index ab23672..a656912 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -275,7 +275,18 @@ fn insert_text(s: &mut Session, text: &str, enter: bool) -> Result<(), CmdError> first = false; let part = part.replace('\u{000B}', "\n"); if !part.is_empty() { + let starts_para = direction_unset(s, &at); at = s.doc.insert_text(&at, &part, &props)?; + // Typing Arabic, Hebrew… as the first letters of a paragraph whose direction nothing + // sets makes it a right-to-left paragraph, so the caret and text start at the right + // margin. Paragraphs that already have letters, or a direction, keep theirs. + if starts_para + && let Ok(p) = s.doc.para_mut(at.story, &at.path) + && wordcraft_doc::bidi::first_strong_is_rtl(&p.text) + { + p.props.bidi = Some(true); + p.touch(); + } } } s.sel = crate::Selection::caret(at); @@ -283,6 +294,17 @@ fn insert_text(s: &mut Session, text: &str, enter: bool) -> Result<(), CmdError> Ok(()) } +/// Is the paragraph at `at` still without letters (empty, or only spaces, digits and +/// punctuation), with no direction set on it, its style chain or the document defaults? +fn direction_unset(s: &Session, at: &Pos) -> bool { + let Some(p) = s.doc.para_at(at) else { return false }; + if p.props.bidi.is_some() || p.text.chars().any(char::is_alphabetic) || s.doc.styles.default_para.bidi.is_some() { + return false; + } + let style = p.props.style.as_deref().unwrap_or("Normal"); + !s.doc.styles.chain(style).iter().any(|st| st.para.bidi.is_some()) +} + /// Take a paragraph out of its list like Word does when Enter or Backspace ends a list: a /// "List Paragraph" goes back to Normal; any other style keeps itself with numbering switched off. pub fn leave_list(para: &mut wordcraft_doc::Paragraph) { diff --git a/crates/engine/src/tests_bidi.rs b/crates/engine/src/tests_bidi.rs index 8f6224f..745eb5c 100644 --- a/crates/engine/src/tests_bidi.rs +++ b/crates/engine/src/tests_bidi.rs @@ -225,3 +225,23 @@ fn explicit_alignment_sets_without_toggling() { } } } + +#[test] +fn typing_kurdish_in_new_document_advances_caret_smoothly() { + let mut s = Session::new(wordcraft_doc::Document::new()); + let mut first_x = None; + let mut last_x = 0.0; + for ch in ["م", "ن", " ", "ن", "ا", "و", "م", " ", "ه", "ێ", "ڤ", "ا", "ر", "ه"] { + run(&mut s, "text.insert", json!({"text": ch})); + let l = s.layout(); + let c = l.caret(&s.sel.focus).unwrap(); + if first_x.is_none() { + first_x = Some(c.x); + } + last_x = c.x; + // The caret must not jump back to the left margin (72.0), but stay near the right margin (540.0). + assert!(c.x > 400.0, "caret should not be at left margin: {}", c.x); + } + assert!(last_x < first_x.unwrap(), "caret should advance leftwards overall"); + assert_eq!(props(&s, 0).bidi, Some(true), "paragraph auto-detected as right-to-left"); +} diff --git a/crates/engine/src/tests_typing.rs b/crates/engine/src/tests_typing.rs index 8a39a60..48a5d92 100644 --- a/crates/engine/src/tests_typing.rs +++ b/crates/engine/src/tests_typing.rs @@ -705,3 +705,39 @@ fn plain_text_paste_keeps_an_empty_paragraph_in_a_list() { 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}"); +} diff --git a/docs/typing-parity.md b/docs/typing-parity.md index 5d67de2..8e64485 100644 --- a/docs/typing-parity.md +++ b/docs/typing-parity.md @@ -1,6 +1,6 @@ # Typing parity with Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** trivial (grammar proofing: ellipses are not sentence boundaries; previously minor: pasted paragraph breaks tracked under Track Changes, kept as text in lists, #417, #422) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (right-to-left letters typed into an empty paragraph make it right-to-left, #713; previously minor: editing options: typing replaces selection, overtype, #485; previously minor: Move Paragraph Up/Down, Promote/Demote, Go Back and Alt+X keys, #343) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 What Word does when you type, observed black-box (same keystrokes typed into Word and WordCraft, results read back paragraph by paragraph) and pinned by tests in @@ -97,6 +97,15 @@ Word. | Typing over a selection | replaces it (Word's default); with "Typing replaces selected text" off, the text goes in front of the selection, which stays | `typing_over_a_selection_follows_the_replace_selection_option` | | Overtype on, typing | each character replaces the one after the caret, never the paragraph mark or an object; Insert switches overtype only with the Insert-key option on (off by default, as in Word) | `overtype_replaces_the_characters_after_the_caret` | +## Text direction + +- Typing Arabic, Hebrew or another right-to-left script as the first letters of a paragraph + whose direction nothing sets (not the paragraph, its style or the document defaults) makes it + a right-to-left paragraph, so the caret starts at the right margin. A paragraph that already + has letters keeps its direction, and so do opened documents: a paragraph without `w:bidi` stays + left-to-right whatever its text (`typing_rtl_letters_into_an_empty_paragraph_makes_it_rtl`). + Word takes the direction from the keyboard language instead; not yet compared side by side. + ## Layout - No line break right after a slash (`and/or`, URLs) unless the word can't fit a line @@ -135,6 +144,7 @@ Word. | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Text direction: right-to-left letters typed into an empty paragraph make it right-to-left; existing paragraphs keep theirs (#713) | | 2026-10-11 | trivial | Grammar proofing (not AutoCorrect) no longer suggests capitalising a sentence after `...` or `…` (`proof::tests::ellipses_do_not_start_sentences`) | | 2026-10-11 | minor | AutoFormat tables from `+---+`, arrow bullets, smileys inside words, undo after a border line (#546) | | 2026-10-11 | trivial | Outline view keys: expand/collapse, Show Level, moving and promoting with collapsed content (#493) |