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 <noreply@anthropic.com>
This commit is contained in:
BFlat 2026-10-10 06:26:26 +07:00 committed by GitHub
parent b8a217ebba
commit 0a6d06f1e2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 61 additions and 3 deletions

View File

@ -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 |

View File

@ -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 {

View File

@ -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<usize> {
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);

View File

@ -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());
}