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 <noreply@anthropic.com> --------- Co-authored-by: Brandon Thomas <echelon@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
e22ce1d05e
commit
62a17b841f
|
|
@ -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(""));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
|
|
|||
Loading…
Reference in New Issue