From 0a6d06f1e22eef1375b11a4ddb6cd0504c2eb068 Mon Sep 17 00:00:00 2001 From: BFlat Date: Sat, 10 Oct 2026 06:26:26 +0700 Subject: [PATCH] M4: arrow keys move visually through right-to-left text (#44) On lines with right-to-left runs, Left and Right now move the caret one position left or right on screen (TextLayout::visual_step) instead of one char in logical order, so the caret no longer jumps backwards inside Arabic or Hebrew. At a line's edge it continues to the neighbouring line in reading order. Plain left-to-right lines are unchanged. Co-authored-by: Claude Opus 5.5 --- ROADMAP.md | 2 +- crates/engine/src/textedit.rs | 8 ++++++-- crates/text/src/layout.rs | 22 ++++++++++++++++++++++ crates/text/src/tests.rs | 32 ++++++++++++++++++++++++++++++++ 4 files changed, 61 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 0a0b59c..c67e0a7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -159,7 +159,7 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent). | Effects | 4 | 70% | glass/texture fidelity, shadow spread on non-rects exactness | 4–7 | | Frames, groups, sections, masks | 5 | 75% | hiding section contents UI, section-level prototype/dev affordances | 2–4 | | Auto layout & constraints | 9 | 65% | nested hug/fill edge cases, drag-reorder across nested auto layout frames | 4–7 | -| Text | 8 | 65% | visual caret movement in right-to-left runs, right-to-left layer names in the panels, text style picker depth, axis hover preview | 6–9 | +| Text | 8 | 67% | right-to-left layer names in the panels, text style picker depth, axis hover preview | 5–8 | | Components & variants | 9 | 65% | slots, nested override edge cases, library variables | 7–11 | | Styles & variables | 5 | 72% | binding effects, line height and letter spacing, variable groups in the editor sidebar | 3–5 | | Prototyping | 6 | 70% | scrolling inside device frames, prototype preview pane in the editor | 3–5 | diff --git a/crates/engine/src/textedit.rs b/crates/engine/src/textedit.rs index 29b7e3f..7ab7262 100644 --- a/crates/engine/src/textedit.rs +++ b/crates/engine/src/textedit.rs @@ -186,8 +186,12 @@ pub fn key(s: &mut Session, key: &str, shift: bool, word: bool, line: bool) -> R "Right" if line => line_end(&chars, cur), "Left" if word => word_left(&chars, cur), "Right" if word => word_right(&chars, cur), - "Left" => cur.saturating_sub(1), - "Right" => (cur + 1).min(n), + // Plain arrows move on screen through right-to-left runs, by char elsewhere. + "Left" | "Right" => match layout.as_ref().and_then(|l| l.visual_step(cur, key == "Right")) { + Some(i) => i.min(n), + None if key == "Left" => cur.saturating_sub(1), + None => (cur + 1).min(n), + }, "Home" => line_start(&chars, cur), "End" => line_end(&chars, cur), "Up" | "Down" => match &layout { diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 7555111..7dd6696 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -767,6 +767,28 @@ impl TextLayout { (self.lines.last().map(|l| self.line_end_x(l)).unwrap_or(0.0), li) } + /// The caret one step left or right of `index` on screen, for lines with right-to-left runs + /// (None on plain left-to-right lines: there a step is one char). At the line's edge it moves + /// on logically, to the neighbouring line. + pub fn visual_step(&self, index: usize, right: bool) -> Option { + let (x, li) = self.caret(index); + let line = self.lines.get(li)?; + let gl = self.glyphs.get(line.glyph_start..line.glyph_end)?; + if !line.rtl && !gl.iter().any(|g| g.rtl) { + return None; + } + // The caret can't sit after a paragraph's newline on its own line. + let end = if line.para_end && li + 1 < self.lines.len() { line.char_end.saturating_sub(1) } else { line.char_end }; + let next = (line.char_start..=end.max(line.char_start)) + .filter(|i| *i != index) + .map(|i| (i, self.caret(i).0)) + .filter(|(_, cx)| if right { *cx > x + 0.01 } else { *cx < x - 0.01 }) + .min_by(|a, b| (a.1 - x).abs().total_cmp(&(b.1 - x).abs()).then(a.0.cmp(&b.0))) + .map(|(i, _)| i); + // At the edge: on to the next line in reading order. + Some(next.unwrap_or(if right != line.rtl { index + 1 } else { index.saturating_sub(1) })) + } + /// Where a line's text ends: its right edge, or its left edge for a right-to-left paragraph. fn line_end_x(&self, line: &Line) -> f64 { let glyphs = self.glyphs.get(line.glyph_start..line.glyph_end).unwrap_or(&[]).iter().filter(|g| g.char_end > g.char_start); diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 883b1c9..526ae0b 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -384,3 +384,35 @@ fn bidi_runs_lay_out_right_to_left() { assert_eq!(r.len(), 1); assert!(r[0].x0 >= glyph_x(&l, 2) - 1e-6 && r[0].x1 <= right + 1e-6 && r[0].x0 > glyph_x(&l, 6)); } + +#[test] +fn arrows_move_visually_through_right_to_left_runs() { + if !have_fonts() { + eprintln!("no fonts; skipping"); + return; + } + for text in ["abc \u{5D0}\u{5D1}\u{5D2} def", "\u{5D0}\u{5D1}\u{5D2} abc \u{5D3}\u{5D4}"] { + let l = layout(&TextData::new(text), None, None); + let n = text.chars().count(); + // Start at the visual left edge and step right until the caret stops moving right. + let mut i = (0..=n).min_by(|a, b| l.caret(*a).0.total_cmp(&l.caret(*b).0)).unwrap(); + let mut xs = vec![l.caret(i).0]; + let mut seen = vec![i]; + for _ in 0..(2 * n) { + let Some(j) = l.visual_step(i, true) else { panic!("bidi line steps visually") }; + if j > n || l.caret(j.min(n)).0 <= *xs.last().unwrap() { + break; + } + i = j; + xs.push(l.caret(i).0); + seen.push(i); + } + assert!(xs.windows(2).all(|w| w[0] < w[1]), "{text}: {xs:?}"); + assert!(seen.len() >= n - 1, "{text}: visited {seen:?}"); + // And back. + let back = l.visual_step(i, false).unwrap(); + assert!(l.caret(back).0 < l.caret(i).0); + } + // Plain left-to-right lines step by char (the caller's default). + assert!(layout(&TextData::new("plain"), None, None).visual_step(1, true).is_none()); +}