git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

commit8691f0e41f16a3928575f2b710f5260529d837cd
parentc1494e7a48
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-08 11:47
feat(text): right-to-left and proportional text is edited where it is drawn (accessibility RFC phase 4)

- backend::text::shaped_run / ShapingMeasure::shape: a line as an editor needs it --
  the caret at every char boundary (a cluster's LEADING edge, a right-to-left letter's
  right), the clusters in logical order with their boxes, the width, the base direction;
  index_at for a click, spans for a selection (two where it crosses a change of
  direction). offsets and shaped_cluster_offsets give leading edges.
- normalized_glyph_starts rebuilt glyph starts whenever they fell, to repair cosmic-text
  0.12's Basic shaping; they also fall in every right-to-left run, which it scrambled.
  Basic shaping is ASCII only, so the repair now is too.
- paragraph_rtl, bidi_levels, visual_order (unicode-bidi, already in the tree through
  cosmic-text): a paragraph's base direction and rule L2's reordering.
- DocEditor: runs are split where the bidi level changes and each row is placed in visual
  order; a plain or heading line of a right-to-left paragraph is set against the right;
  runs carry their cluster boxes and the shaped width, and a selection is the boxes it
  covers.
- TextBox: column offsets are the shaped stops; a right-to-left paragraph is set against
  the right (folded into the offsets, so caret, click and selection follow); a selection
  crossing a change of direction is drawn as its pieces; a multiline box wraps by summed
  shaped advances against the width, not a char count against one monospace advance, so a
  proportional face and CJK wrap where they are drawn. The shaping key carries the room,
  which also re-shapes a box whose width was set after its first shaping (cce-text-editor's
  highlight stopped short on a CJK line).

Checked in a shadow: cce-text-editor sets a Hebrew paragraph against the right with its
English and Arabic in bidi order, and its highlight covers a CJK line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md                        |   4 +-
 Cargo.toml                       |   3 +
 docs/rfc-accessibility-locale.md |  52 ++++
 src/backend/text.rs              | 236 ++++++++++++++++-
 src/widget/doc_editor/layout.rs  | 162 +++++++++++-
 src/widget/doc_editor/mod.rs     |  10 +-
 src/widget/input/text_box.rs     | 559 +++++++++++++++++++++++++--------------
 src/widget/shaping.rs            |  84 ++++--
 8 files changed, 866 insertions(+), 244 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index fda7876..fa430f2 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1586,7 +1586,9 @@ Accessibility reaches Linux screen readers only, and only for an app that opts i
 widget tree is published over AT-SPI through AccessKit (`backend::a11y_unix`, the `a11y`
 feature, `Application::publishes_accessibility` or `CCE_A11Y=1`; phase 2, proven on
 cce-data-editor). There is nothing yet on macOS or in the browser, no message catalogue,
-and the editors assume left-to-right text (`TextBox` assumes monospace too). The RFC has
+and right-to-left text is edited where it is drawn (carets, clicks and selections follow
+it, a right-to-left paragraph is set against the right, the `DocEditor` draws styled runs
+in bidi order, a multiline `TextBox` wraps by shaped width — phase 4). The RFC has
 the measured state and a phased plan. Until it lands, two rules keep the retrofit cheap:
 
 - **A new widget declares what it is**: its `focus_role`, and a label that names it to a
diff --git a/Cargo.toml b/Cargo.toml
index 9dd2fbf..5168224 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -43,6 +43,9 @@ naga = { version = "24", features = ["wgsl-in"] }
 # Pinned to the exact version glyphon 0.8 resolved to, so shaping does not change:
 # verified by diffing shaped glyph ids/advances both ways over 288 runs.
 cosmic-text = "=0.12.1"
+# A paragraph's base direction and the reordering of styled runs (accessibility RFC phase 4).
+# Already in the tree through cosmic-text, which shapes with it.
+unicode-bidi = { version = "0.3.13", default-features = false, features = ["hardcoded-data"] }
 bitflags = "2"
 # The cursor names an app returns. smithay-client-toolkit re-exports this same
 # crate's type, so the Wayland shell hands it over unconverted; depending on it
diff --git a/docs/rfc-accessibility-locale.md b/docs/rfc-accessibility-locale.md
index 75af33a..ab602dd 100644
--- a/docs/rfc-accessibility-locale.md
+++ b/docs/rfc-accessibility-locale.md
@@ -210,6 +210,58 @@ accessible description is not done: no tooltips yet, by decision.
 
 ### Phase 4 — text that is not left-to-right monospace
 
+**Progress (2026-10-08).** Carets, clicks and selections follow the text in either
+direction, and a multiline `TextBox` wraps by shaped width:
+- **`backend::text::shaped_run`** (`ShapingMeasure::shape`) is a line as an editor needs it:
+  the caret's x at every char boundary — a cluster's LEADING edge, a right-to-left
+  letter's right — the clusters in logical order with their boxes, the width, and the
+  paragraph's base direction, which cosmic-text already takes from the first strong
+  character. `index_at` is a click, `spans` a selection (two where it crosses a change of
+  direction). `ShapingMeasure::offsets` is its stops; `shaped_cluster_offsets`, what
+  hand-drawn fields (`LineEdit` hosts) read, gives leading edges too.
+- **A fix underneath:** `normalized_glyph_starts` rebuilt glyph starts whenever they fell,
+  to repair cosmic-text 0.12's Basic shaping (span-relative starts) — but they also fall in
+  every right-to-left run, which it scrambled. Basic shaping is ASCII only, so the repair
+  now is too.
+- **`DocEditor`**: runs carry their cluster boxes and the shaped width; a selection is the
+  boxes it covers (`LineLayout::selection_rects`), so it is drawn where its letters are.
+- **`TextBox`**: its column offsets are the shaped stops (a right-to-left word is clicked and
+  selected where it is drawn), and a multiline box wraps by the summed shaped advances of
+  its chars against the width (`wrap_text(max_width)`, `wrap_width`), not a char count
+  against one monospace advance, so a proportional face and CJK wrap where they are drawn.
+  The advances are measured with the paint's font system in `prepare_text` and cached;
+  an edit that outruns the frame measures with the shared geometry font system, or a
+  thread's own when that one is held.
+
+**And the rest (the same day):**
+- **A right-to-left paragraph is set against the right.** `backend::text::paragraph_rtl` is
+  a paragraph's base direction (`unicode-bidi`, already in the tree through cosmic-text).
+  In a `TextBox` the shift is folded into the offsets, so caret, click and selection
+  follow, and added to where the text is drawn — a one-line box's text when it fits, each
+  wrapped line of a multiline box by its paragraph's direction. The shaping key now
+  carries the room the text is aligned in, which also re-shapes a box whose width was set
+  after its first shaping: cce-text-editor's highlight had stopped short on a CJK line
+  because its offsets were never re-shaped once the box had its size.
+- **The `DocEditor` draws a line's styled runs in visual order.** The line's bidi levels
+  (`bidi_levels`, neutrals resolved) split every run where the level changes, so a plain
+  run holding an English word and a Hebrew one becomes two, and each row's runs are placed
+  in the order rule L2 gives (`visual_order`); a plain or heading line of a right-to-left
+  paragraph is set against the right edge (a list, quote or table keeps its markers at the
+  left and only reorders).
+- **A selection crossing a change of direction is drawn as its pieces** in a `TextBox` too,
+  one-line and multiline, from the cluster boxes of each line's shaped run.
+
+Tests: `carets_follow_the_text_in_either_direction`,
+`right_to_left_words_are_laid_out_and_selected_where_they_are`,
+`styled_runs_are_placed_in_visual_order`, `runs_are_reordered_as_the_bidi_algorithm_draws_them`,
+`a_right_to_left_word_is_edited_where_it_is_drawn`,
+`a_right_to_left_paragraph_is_set_against_the_right`, `a_multiline_box_wraps_where_its_text_is_wide`.
+Checked in a shadow: cce-text-editor sets a Hebrew paragraph against the right with its
+English and Arabic in bidi order. Still the shaping's own limit: a run's base direction is
+cosmic-text's guess from the run's text, which a style split mid-paragraph can get wrong
+for a run that starts with a neutral; arrow keys move the caret in logical order, as
+editors on most platforms do.
+
 - Carets and selection from shaped clusters in VISUAL order (cosmic-text's layout runs
   carry direction), not byte-sorted x positions: `ShapingMeasure::offsets` grows a
   direction-aware form, and `DocEditor` and `LineEdit` use it.
diff --git a/src/backend/text.rs b/src/backend/text.rs
index fbd7687..cc78cad 100644
--- a/src/backend/text.rs
+++ b/src/backend/text.rs
@@ -660,7 +660,11 @@ pub(crate) fn shared_text_buffer(
 /// Byte-offset → x mapping of single-line `text`, shaped exactly as the renderer draws it —
 /// same buffer cache as the draw, so this is a lookup when the text is already on screen.
 /// Returns ascending `(byte_idx, x)` pairs (one per cluster start, logical px, relative to
-/// the text origin), terminated by `(text.len(), total_advance)`. This is the correct
+/// the text origin), terminated by `(text.len(), total_advance)`. A cluster's x is its
+/// LEADING edge — its left in left-to-right text, its right in right-to-left — so the pairs
+/// are ascending in bytes but not in x where the text turns; the closing pair is the width,
+/// which is where the caret after the text stands only in left-to-right text. For carets and
+/// selections in text of either direction use [`shaped_run`]. This is the correct
 /// source for caret placement and click→cursor mapping in hand-rolled text fields:
 /// `measure_text_width` reports SVG-rasterized inked extent through fontdb's family
 /// resolution, which disagrees with cosmic-text's advance and can even resolve a
@@ -673,16 +677,202 @@ pub fn shaped_cluster_offsets(
 ) -> Vec<(usize, f32)> {
     let scale = crate::scale::scale_factor().max(1.0);
     let buffer = shared_text_buffer(fs, text, size, font, crate::scene::paint::TextAttrs::default());
-    let mut out: Vec<(usize, f32)> = Vec::new();
-    let mut total: f32 = 0.0;
-    for (start, x, w) in normalized_glyph_starts(&buffer, text) {
-        if out.last().map_or(true, |&(b, _)| b != start) {
-            out.push((start, x / scale));
+    let run = shaped_run(&buffer, text, scale);
+    let mut out: Vec<(usize, f32)> = run.clusters.iter().map(|c| (c.start, c.leading())).collect();
+    out.push((text.len(), run.width));
+    out
+}
+
+/// Whether `text` is a right-to-left paragraph: its first strong character is right to left
+/// (Hebrew, Arabic, …), as the Unicode bidirectional algorithm decides a paragraph's base
+/// direction. Text with no strong character at all is left to right.
+pub fn paragraph_rtl(text: &str) -> bool {
+    matches!(unicode_bidi::get_base_direction(text), unicode_bidi::Direction::Rtl)
+}
+
+/// The visual order of runs of one line, given each run's text, in a paragraph whose base
+/// direction is `rtl`: the bidirectional algorithm's reordering (rule L2) at the
+/// granularity of runs. A run is at the paragraph's level when it has no strong character,
+/// one level up when its first strong character goes against the paragraph, and the
+/// sequences at each level from the highest down are reversed. So in a left-to-right line
+/// two Hebrew runs side by side swap places, and in a right-to-left line every run is
+/// placed from the right while English runs keep their order among themselves. Returns
+/// indices into `runs`, left to right.
+pub fn visual_run_order(runs: &[&str], rtl: bool) -> Vec<usize> {
+    let base: u8 = if rtl { 1 } else { 0 };
+    let levels: Vec<u8> = runs
+        .iter()
+        .map(|t| match unicode_bidi::get_base_direction(*t) {
+            unicode_bidi::Direction::Rtl => if base % 2 == 1 { base } else { base + 1 },
+            unicode_bidi::Direction::Ltr => if base % 2 == 0 { base } else { base + 1 },
+            unicode_bidi::Direction::Mixed => base,
+        })
+        .collect();
+    visual_order(&levels)
+}
+
+/// The embedding level of every byte of `text` as one paragraph (`rtl` its base
+/// direction), neutrals resolved from their neighbours — the bidirectional algorithm's
+/// levels, rules W1–I2. Odd is right to left.
+pub fn bidi_levels(text: &str, rtl: bool) -> Vec<u8> {
+    let base = if rtl { unicode_bidi::Level::rtl() } else { unicode_bidi::Level::ltr() };
+    unicode_bidi::BidiInfo::new(text, Some(base)).levels.iter().map(|l| l.number()).collect()
+}
+
+/// Left-to-right order of items at `levels` (rule L2): from the highest level down to the
+/// lowest odd one, every maximal run of items at that level or above is reversed.
+pub fn visual_order(levels: &[u8]) -> Vec<usize> {
+    let mut order: Vec<usize> = (0..levels.len()).collect();
+    let max = levels.iter().copied().max().unwrap_or(0);
+    for level in (1..=max).rev() {
+        let mut i = 0;
+        while i < order.len() {
+            if levels[order[i]] >= level {
+                let start = i;
+                while i < order.len() && levels[order[i]] >= level {
+                    i += 1;
+                }
+                order[start..i].reverse();
+            } else {
+                i += 1;
+            }
         }
-        total = total.max((x + w) / scale);
     }
-    out.push((text.len(), total));
-    out
+    order
+}
+
+/// One cluster of a [`ShapedRun`]: the bytes `start..end` drawn as one unit (a glyph, a
+/// ligature, a base with its marks), spanning `x0..x1` (logical px, left to right whatever
+/// the direction), and whether it reads right to left.
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct ShapedCluster {
+    pub start: usize,
+    pub end: usize,
+    pub x0: f32,
+    pub x1: f32,
+    pub rtl: bool,
+}
+
+impl ShapedCluster {
+    /// Where the caret before this cluster stands: its left in left-to-right text, its
+    /// right in right-to-left.
+    pub fn leading(&self) -> f32 {
+        if self.rtl { self.x1 } else { self.x0 }
+    }
+
+    /// Where the caret after it stands.
+    pub fn trailing(&self) -> f32 {
+        if self.rtl { self.x0 } else { self.x1 }
+    }
+}
+
+/// A single line of text as shaped, in the terms an editor needs whatever its direction:
+/// where the caret stands at every char boundary, the clusters in logical order with their
+/// boxes, the width, and the paragraph's base direction (cosmic-text takes it from the first
+/// strong character, as the Unicode bidirectional algorithm does).
+#[derive(Debug, Clone, Default, PartialEq)]
+pub struct ShapedRun {
+    /// `(byte, x)` for every char boundary, ascending in bytes, ending at `text.len()`: the
+    /// caret before the char at `byte` stands at its cluster's leading edge (a boundary
+    /// inside a cluster takes the cluster's), and the caret after the text at the last
+    /// char's trailing edge — the RIGHT end of left-to-right text, the LEFT end of
+    /// right-to-left. In mixed text x is not monotonic.
+    pub stops: Vec<(usize, f32)>,
+    /// The clusters, in logical order (by `start`).
+    pub clusters: Vec<ShapedCluster>,
+    /// The glyphs' extent, trailing spaces included.
+    pub width: f32,
+    /// The paragraph's base direction.
+    pub rtl: bool,
+}
+
+impl ShapedRun {
+    /// The char boundary nearest x: a click's caret.
+    pub fn index_at(&self, x: f32) -> usize {
+        self.stops
+            .iter()
+            .min_by(|a, b| (a.1 - x).abs().total_cmp(&(b.1 - x).abs()))
+            .map_or(0, |s| s.0)
+    }
+
+    /// The caret x before byte `at` (the nearest boundary at or before it).
+    pub fn x_of(&self, at: usize) -> f32 {
+        self.stops.iter().rev().find(|s| s.0 <= at).map_or(0.0, |s| s.1)
+    }
+
+    /// The x spans the bytes `a..b` cover, left to right and merged where they touch: one
+    /// span in text of one direction, more where a selection crosses a change of direction
+    /// (the logical range is then visually apart).
+    pub fn spans(&self, a: usize, b: usize) -> Vec<(f32, f32)> {
+        let mut boxes: Vec<(f32, f32)> =
+            self.clusters.iter().filter(|c| c.start < b && c.end > a).map(|c| (c.x0, c.x1)).collect();
+        boxes.sort_by(|p, q| p.0.total_cmp(&q.0));
+        let mut out: Vec<(f32, f32)> = Vec::new();
+        for (x0, x1) in boxes {
+            match out.last_mut() {
+                Some(last) if x0 <= last.1 + 0.5 => last.1 = last.1.max(x1),
+                _ => out.push((x0, x1)),
+            }
+        }
+        out
+    }
+}
+
+/// Shape-derived positions of single-line `text` from its `buffer` (see [`ShapedRun`]),
+/// in logical px at `scale`.
+pub fn shaped_run(buffer: &Buffer, text: &str, scale: f32) -> ShapedRun {
+    let scale = scale.max(0.01);
+    let mut rtl = false;
+    let mut glyphs: Vec<ShapedCluster> = Vec::new();
+    let mut first = true;
+    for run in buffer.layout_runs() {
+        if first {
+            rtl = run.rtl;
+            first = false;
+        }
+        for g in run.glyphs {
+            glyphs.push(ShapedCluster { start: g.start, end: g.end, x0: g.x / scale, x1: (g.x + g.w) / scale, rtl: g.level.is_rtl() });
+        }
+    }
+    // The Basic shaping path's span-relative starts (see `normalized_glyph_starts`): ASCII
+    // only, one glyph per char in logical order.
+    if text.is_ascii() && glyphs.windows(2).any(|w| w[1].start < w[0].start) {
+        for (g, (i, _)) in glyphs.iter_mut().zip(text.char_indices()) {
+            g.start = i;
+            g.end = i + 1;
+        }
+    }
+    // One cluster per byte range: a base and its marks are several glyphs of one cluster.
+    glyphs.sort_by(|a, b| a.start.cmp(&b.start).then(a.x0.total_cmp(&b.x0)));
+    let mut clusters: Vec<ShapedCluster> = Vec::new();
+    for g in glyphs {
+        match clusters.last_mut() {
+            Some(c) if c.start == g.start && c.end == g.end => {
+                c.x0 = c.x0.min(g.x0);
+                c.x1 = c.x1.max(g.x1);
+            }
+            _ => clusters.push(g),
+        }
+    }
+    let width = clusters.iter().map(|c| c.x1).fold(0.0, f32::max);
+    let mut stops: Vec<(usize, f32)> = Vec::with_capacity(text.len() + 1);
+    let mut ci = 0;
+    let mut last_x = if rtl { width } else { 0.0 };
+    for (b, _) in text.char_indices() {
+        while ci < clusters.len() && clusters[ci].end <= b {
+            ci += 1;
+        }
+        if let Some(c) = clusters.get(ci).filter(|c| c.start <= b) {
+            last_x = c.leading();
+        }
+        stops.push((b, last_x));
+    }
+    let end_x = match clusters.last() {
+        Some(c) => c.trailing(),
+        None => 0.0,
+    };
+    stops.push((text.len(), end_x));
+    ShapedRun { stops, clusters, width, rtl }
 }
 
 /// Every glyph of `buffer`'s layout runs as `(start_byte, x, w)` (physical px),
@@ -694,10 +884,13 @@ pub fn shaped_cluster_offsets(
 /// picks Basic exactly for ASCII text in a monospace family (the DE's default
 /// control font), so any multi-word value hit the bug: offsets keyed by those
 /// starts collide on the low columns and the caret/selection walk off the
-/// glyphs. A reset can ONLY come from that path, which shapes strictly one
-/// glyph per char in logical order — so when one is seen, byte starts are
-/// rebuilt by walking the text's chars. `text` must be the single line the
-/// buffer was shaped from.
+/// glyphs. That path shapes strictly one glyph per char in logical order, and
+/// only ASCII text — so in ASCII text a reset means it, and byte starts are
+/// rebuilt by walking the text's chars. Anywhere else glyph starts fall
+/// because the text turns right to left (glyphs are in visual order), and are
+/// kept: until 2026-10-08 any fall was read as the reset, which scrambled
+/// every right-to-left run. `text` must be the single line the buffer was
+/// shaped from.
 pub(crate) fn normalized_glyph_starts(buffer: &Buffer, text: &str) -> Vec<(usize, f32, f32)> {
     let mut glyphs: Vec<(usize, f32, f32)> = Vec::new();
     let mut monotonic = true;
@@ -711,7 +904,7 @@ pub(crate) fn normalized_glyph_starts(buffer: &Buffer, text: &str) -> Vec<(usize
             glyphs.push((g.start, g.x, g.w));
         }
     }
-    if !monotonic {
+    if !monotonic && text.is_ascii() {
         let mut starts = text.char_indices().map(|(i, _)| i);
         for g in glyphs.iter_mut() {
             g.0 = starts.next().unwrap_or(text.len());
@@ -1197,3 +1390,18 @@ mod text_cache_tests {
         let _ = std::fs::remove_dir_all(&dir);
     }
 }
+
+#[cfg(test)]
+mod visual_order_tests {
+    use super::*;
+
+    #[test]
+    fn runs_are_reordered_as_the_bidi_algorithm_draws_them() {
+        assert_eq!(visual_run_order(&["say ", "שלום", " עולם", " now"], false), [0, 2, 1, 3]);
+        assert_eq!(visual_run_order(&["שלום ", "bold", " and", " עולם"], true), [3, 1, 2, 0], "English keeps its order inside");
+        assert_eq!(visual_run_order(&["a", "b", "c"], false), [0, 1, 2]);
+        assert_eq!(visual_run_order(&["א", "ב"], true), [1, 0]);
+        assert_eq!(visual_run_order(&[" ", "123"], true), [1, 0], "neutral runs sit at the paragraph's level");
+        assert!(paragraph_rtl("  «שלום» hello") && !paragraph_rtl("hello שלום") && !paragraph_rtl("123 ..."));
+    }
+}
diff --git a/src/widget/doc_editor/layout.rs b/src/widget/doc_editor/layout.rs
index fef69f2..16ec98b 100644
--- a/src/widget/doc_editor/layout.rs
+++ b/src/widget/doc_editor/layout.rs
@@ -99,8 +99,14 @@ pub struct Run {
     pub strike: bool,
     pub link: Option<usize>,
     pub look: Look,
-    /// (line byte, x relative to `x`) at every char boundary of `src`.
+    /// (line byte, x relative to `x`) at every char boundary of `src`: the caret's place
+    /// before that char (a right-to-left letter's right edge), so not ascending in x where
+    /// the text turns.
     pub xs: Vec<(usize, f32)>,
+    /// The run's clusters (line bytes, x relative to `x`), what a selection covers.
+    pub clusters: Vec<crate::widget::shaping::ShapedCluster>,
+    /// Whether the run reads right to left (its own first strong character).
+    pub rtl: bool,
 }
 
 #[derive(Clone, Debug)]
@@ -338,22 +344,87 @@ pub fn layout_line(text: &str, line: &preview::Line, active: bool, width: f32, t
                 link: seg.link,
                 look: seg.look,
                 xs: Vec::new(),
+                clusters: Vec::new(),
+                rtl: false,
             });
         }
     }
+    // Split every run where the bidirectional level changes, so each is one direction: a
+    // run of plain text can hold an English word and a Hebrew one, which the reordering
+    // below must be able to place apart. Levels are the whole line's, neutrals (spaces,
+    // markup) resolved from their neighbours.
+    let para_rtl = crate::backend::text::paragraph_rtl(text);
+    let levels = crate::backend::text::bidi_levels(text, para_rtl);
+    let level_at = |b: usize| levels.get(b).copied().unwrap_or(if para_rtl { 1 } else { 0 });
+    let mut split: Vec<Run> = Vec::with_capacity(runs.len());
+    for r in runs {
+        let mut from = r.src.start;
+        let mut cur = level_at(from);
+        for (i, _) in text[r.src.clone()].char_indices() {
+            let b = r.src.start + i;
+            if level_at(b) != cur {
+                split.push(Run { src: from..b, ..r.clone() });
+                from = b;
+                cur = level_at(b);
+            }
+        }
+        split.push(Run { src: from..r.src.end, ..r });
+    }
+    let mut runs = split;
     for r in &mut runs {
         while row_x.len() <= r.row {
             row_x.push(if row_x.is_empty() { start_x } else { content_x });
         }
         let shown = text[r.src.clone()].replace('\t', " ");
-        let offs = m.offsets(&shown, r.size, &r.font, r.attrs);
-        r.w = offs.last().map(|o| o.1).unwrap_or(0.0);
-        r.xs = offs.into_iter().map(|(b, x)| (r.src.start + b, x)).collect();
+        let shaped = m.shape(&shown, r.size, &r.font, r.attrs);
+        r.w = shaped.width;
+        r.rtl = shaped.rtl;
+        r.xs = shaped.stops.iter().map(|&(b, x)| (r.src.start + b, x)).collect();
+        r.clusters = shaped
+            .clusters
+            .iter()
+            .map(|c| crate::widget::shaping::ShapedCluster { start: r.src.start + c.start, end: r.src.start + c.end, ..*c })
+            .collect();
         r.text = shown;
         let pad = if r.look.pill { PILL_PAD } else { 0.0 };
         r.x = row_x[r.row] + pad;
         row_x[r.row] += r.w + 2.0 * pad + if r.look.pill { PILL_GAP } else { 0.0 };
     }
+    // Visual order. Runs were placed left to right in logical order; a row whose runs are
+    // not all of the paragraph's direction is re-placed in the order the bidirectional
+    // algorithm draws them (`visual_run_order`), and a right-to-left paragraph's rows are
+    // set against the right edge — a plain line or a heading's; a list, quote or table
+    // keeps its markers at the left and only reorders.
+    let align_right = para_rtl && matches!(line.kind, Kind::Plain | Kind::Heading(_));
+    let rows_placed = runs.last().map_or(0, |r| r.row + 1);
+    for row in 0..rows_placed {
+        let idx: Vec<usize> = (0..runs.len()).filter(|&i| runs[i].row == row).collect();
+        if idx.is_empty() {
+            continue;
+        }
+        let order = {
+            let run_levels: Vec<u8> = idx.iter().map(|&i| level_at(runs[i].src.start)).collect();
+            crate::backend::text::visual_order(&run_levels)
+        };
+        if !align_right && order.iter().enumerate().all(|(k, &o)| k == o) {
+            continue;
+        }
+        // A run's room: its pill padding either side, and the gap after a pill.
+        let room = |r: &Run| {
+            let pad = if r.look.pill { PILL_PAD } else { 0.0 };
+            (pad, r.w + 2.0 * pad + if r.look.pill { PILL_GAP } else { 0.0 })
+        };
+        let start = runs[idx[0]].x - room(&runs[idx[0]]).0;
+        let total: f32 = idx.iter().map(|&i| room(&runs[i]).1).sum();
+        let mut x = if align_right { (width - total).max(start) } else { start };
+        for &k in &order {
+            let i = idx[k];
+            let (pad, adv) = room(&runs[i]);
+            runs[i].x = x + pad;
+            x += adv;
+        }
+    }
+
     let rows = runs.last().map(|r| r.row + 1).unwrap_or(1);
     let mut height = rows as f32 * row_h;
 
@@ -502,7 +573,8 @@ impl LineLayout {
                     break;
                 }
             } else {
-                best = Some((r.x + r.w, r.row));
+                // Past the run's end: its trailing side, the left of a right-to-left run.
+                best = Some((if r.rtl { r.x } else { r.x + r.w }, r.row));
             }
         }
         best.unwrap_or_else(|| match self.runs.first() {
@@ -511,6 +583,38 @@ impl LineLayout {
         })
     }
 
+    /// What the bytes `a..b` of the line cover, as `(row, x0, x1)` rects left to right: the
+    /// boxes of the clusters in that range, merged where they touch (and across the gap
+    /// between two runs of a row). A range crossing a change of direction is visually apart,
+    /// so it can be several on one row. `to_end` stretches the last row's selection a little
+    /// past the line's end, for a selection that goes on to the next line.
+    pub fn selection_rects(&self, a: usize, b: usize, to_end: bool) -> Vec<(usize, f32, f32)> {
+        let mut boxes: Vec<(usize, f32, f32)> = Vec::new();
+        for r in &self.runs {
+            for c in r.clusters.iter().filter(|c| c.start < b && c.end > a) {
+                boxes.push((r.row, r.x + c.x0, r.x + c.x1));
+            }
+        }
+        boxes.sort_by(|p, q| p.0.cmp(&q.0).then(p.1.total_cmp(&q.1)));
+        let gap = 2.0 * PILL_PAD + PILL_GAP + 1.0;
+        let mut out: Vec<(usize, f32, f32)> = Vec::new();
+        for (row, x0, x1) in boxes {
+            match out.last_mut() {
+                Some(last) if last.0 == row && x0 <= last.2 + gap => last.2 = last.2.max(x1),
+                _ => out.push((row, x0, x1)),
+            }
+        }
+        if to_end {
+            let last_row = self.rows.saturating_sub(1);
+            let end = self.runs.iter().map(|r| r.x + r.w).fold(self.content_x, f32::max) + 6.0;
+            match out.iter_mut().rev().find(|s| s.0 == last_row) {
+                Some(s) => s.2 = s.2.max(end),
+                None => out.push((last_row, end - 6.0, end)),
+            }
+        }
+        out
+    }
+
     /// The byte nearest a point (x, row) of the line.
     pub fn col_at(&self, x: f32, row: usize) -> usize {
         let in_row: Vec<&Run> = self.runs.iter().filter(|r| r.row == row).collect();
@@ -578,6 +682,54 @@ mod tests {
         }
     }
 
+    /// A Hebrew word in a Latin line: its carets fall from its right end, a click on its
+    /// right edge is its first letter, and a selection from inside the Latin into it is two
+    /// rects, the Latin end and the Hebrew word's right side (its first letters).
+    #[test]
+    fn right_to_left_words_are_laid_out_and_selected_where_they_are() {
+        let t = "ab שלום cd";
+        let l = lay(t, false, 2000.0);
+        let w0 = t.find('ש').unwrap();
+        let w_end = w0 + "שלום".len();
+        let (first, _) = l.caret_xy(w0);
+        let (last, _) = l.caret_xy(w0 + 6);
+        assert!(first > last, "the word's first letter is right of its last: {first} vs {last}");
+        assert_eq!(l.col_at(first, 0), w0, "a click at its right edge is its first letter");
+        let rects = l.selection_rects(1, w0 + 2, false);
+        assert_eq!(rects.len(), 2, "the Latin end and the word's right side: {rects:?}");
+        let (x_b, _) = l.caret_xy(1);
+        assert!((rects[0].1 - x_b).abs() < 0.5, "the first starts at the b: {rects:?}");
+        assert!(rects[1].2 <= first + 0.5 && rects[1].1 > last, "the second is inside the word, at its right");
+        let whole = l.selection_rects(0, w_end, false);
+        assert_eq!(whole.len(), 1, "a range ending at the word's end is one strip: {whole:?}");
+    }
+
+    /// A Hebrew paragraph is set against the right edge with its runs placed from the
+    /// right: its first word rightmost, a bold word after it to its left. In an English line,
+    /// two Hebrew runs side by side swap places (the right-to-left sequence reads from the
+    /// right), the English around them staying where it was.
+    #[test]
+    fn styled_runs_are_placed_in_visual_order() {
+        let width = 600.0;
+        let rtl = "שלום **עולם** טוב";
+        let l = lay(rtl, false, width);
+        let run_of = |l: &LineLayout, needle: &str| l.runs.iter().find(|r| r.text.contains(needle)).map(|r| (r.x, r.x + r.w)).unwrap();
+        let (first_x0, first_x1) = run_of(&l, "שלום");
+        let (bold_x0, bold_x1) = run_of(&l, "עולם");
+        let (last_x0, _) = run_of(&l, "טוב");
+        assert!((first_x1 - width).abs() < 1.0, "set against the right edge: {first_x1}");
+        assert!(bold_x1 <= first_x0 + 0.5 && last_x0 < bold_x0, "placed from the right: {:?}", l.runs.iter().map(|r| (&r.text, r.x)).collect::<Vec<_>>());
+
+        let mixed = "say שלום **עולם** now";
+        let m = lay(mixed, false, width);
+        let (say_x0, _) = run_of(&m, "say");
+        let (shalom_x0, _) = run_of(&m, "שלום");
+        let (olam_x0, olam_x1) = run_of(&m, "עולם");
+        let (now_x0, _) = run_of(&m, "now");
+        assert!(say_x0 < olam_x0 && olam_x1 <= shalom_x0 + 0.5 && shalom_x0 < now_x0, "the Hebrew pair swapped, between the English: {:?}", m.runs.iter().map(|r| (&r.text, r.x)).collect::<Vec<_>>());
+        assert!(say_x0 < 50.0, "a left-to-right line still starts at the left");
+    }
+
     #[test]
     fn long_lines_wrap_with_a_hanging_list_indent() {
         let t = "- one two three four five six seven eight nine ten eleven twelve";
diff --git a/src/widget/doc_editor/mod.rs b/src/widget/doc_editor/mod.rs
index 3bb37af..30ee935 100644
--- a/src/widget/doc_editor/mod.rs
+++ b/src/widget/doc_editor/mod.rs
@@ -1020,13 +1020,13 @@ impl DocEditor {
                 };
                 let top = oy + self.tops[i];
                 // Selection, behind everything on the line.
+                // The boxes of the selected clusters, so a selection crossing a change of
+                // direction is drawn where its letters are.
                 if let Some((a, b)) = sel {
                     if i >= a.line && i <= b.line {
-                        let from = if i == a.line { l.caret_xy(a.col) } else { (l.runs.first().map_or(l.content_x, |r| r.x), 0) };
-                        let to = if i == b.line { l.caret_xy(b.col) } else { (l.runs.iter().map(|r| r.x + r.w).fold(l.content_x, f32::max) + 6.0, l.rows - 1) };
-                        for row in from.1..=to.1 {
-                            let x0 = if row == from.1 { from.0 } else { l.runs.iter().filter(|r| r.row == row).map(|r| r.x).fold(f32::INFINITY, f32::min).min(l.content_x) };
-                            let x1 = if row == to.1 { to.0 } else { l.runs.iter().filter(|r| r.row == row).map(|r| r.x + r.w).fold(x0, f32::max) };
+                        let from = if i == a.line { a.col } else { 0 };
+                        let to = if i == b.line { b.col } else { usize::MAX };
+                        for (row, x0, x1) in l.selection_rects(from, to, i < b.line) {
                             if x1 > x0 {
                                 pc.quad(Rect { x: ox + x0, y: top + row as f32 * l.row_h, width: x1 - x0, height: l.row_h }, th.selection);
                             }
diff --git a/src/widget/input/text_box.rs b/src/widget/input/text_box.rs
index 8d14d79..a61f1e9 100644
--- a/src/widget/input/text_box.rs
+++ b/src/widget/input/text_box.rs
@@ -53,46 +53,21 @@ struct PrepKey {
     attrs: crate::scene::paint::TextAttrs,
     scale_bits: u32,
     vertical: bool,
-    /// `Some(max chars per line)` for a multiline box.
-    wrap: Option<usize>,
+    /// `Some(the wrap width's bits)` for a multiline box ([`TextBox::wrap_width`]).
+    wrap: Option<u32>,
+    /// The width a right-to-left paragraph is set against the right of (the box less its
+    /// padding), as bits.
+    room_bits: u32,
 }
 
-/// Per-column x offsets of `text` as `buffer` shaped it: one entry per char plus
-/// its total advance. Byte offsets map to columns through one table, rather than
-/// recounting the prefix per glyph — which made long text quadratic.
-fn column_offsets(buffer: &cosmic_text::Buffer, text: &str, scale: f32) -> (Vec<f32>, f32) {
-    let mut col_of_byte = vec![0usize; text.len() + 1];
-    let mut n = 0;
-    for (col, (b, ch)) in text.char_indices().enumerate() {
-        col_of_byte[b..b + ch.len_utf8()].fill(col);
-        n = col + 1;
-    }
-    col_of_byte[text.len()] = n;
-
-    let mut offs = vec![0.0f32; n + 1];
-    let mut total: f32 = 0.0;
-    for (start, gx, gw) in crate::backend::text::normalized_glyph_starts(buffer, text) {
-        let c_idx = col_of_byte[start.min(text.len())];
-        if c_idx < offs.len() {
-            offs[c_idx] = gx / scale;
-        }
-        total = total.max((gx + gw) / scale);
-    }
-    (offs, total)
-}
-
-/// Carry the last recorded offset forward over columns no glyph started at
-/// (the trailing chars of a ligature or cluster). The single-line pass has
-/// always kept column 0's own value; the per-line pass fills it too.
-fn fill_gaps(offs: &mut [f32], include_first: bool) {
-    let mut current = 0.0;
-    for (i, o) in offs.iter_mut().enumerate() {
-        if *o == 0.0 && (include_first || i > 0) {
-            *o = current;
-        } else {
-            current = *o;
-        }
-    }
+/// What a [`TextBox`]'s cached advances were measured for.
+#[derive(Debug, Clone, PartialEq)]
+struct AdvanceKey {
+    text: String,
+    font_size_bits: u32,
+    font: Option<String>,
+    attrs: crate::scene::paint::TextAttrs,
+    scale_bits: u32,
 }
 
 #[derive(Debug, Clone)]
@@ -154,6 +129,22 @@ pub struct TextBox {
     /// families through fontdb, not cosmic-text) — a per-column error that made the multiline
     /// selection highlight drift off the glyphs. 0.0 until the first `prepare_text`.
     shaped_char_advance: f32,
+    /// Each char's shaped advance in the text being wrapped (logical px; a cluster's
+    /// whole width on its first char, 0 on the rest and on a newline), for the key it was
+    /// measured for. Wrapping sums these against the width, so a proportional face and
+    /// two-column CJK wrap where they are drawn. Filled from the paint's font system in
+    /// `prepare_text`, else from `geometry_font_system` when an edit outruns the frame.
+    advances: std::cell::RefCell<Option<(AdvanceKey, Vec<f32>)>>,
+    /// How far right a right-to-left paragraph is set so its right edge meets the box's
+    /// (0 for left-to-right text, and for a line too wide to fit): the single line's, and
+    /// each wrapped line's. Already in `glyph_positions` / `line_glyph_positions`, so
+    /// carets, clicks and selections follow; the drawing adds it to the text's x.
+    glyph_shift: f32,
+    line_shift: Vec<f32>,
+    /// The shaped runs those positions came from, for selections drawn as the boxes of
+    /// the clusters they cover (two pieces where one crosses a change of direction).
+    glyph_run: Option<crate::backend::text::ShapedRun>,
+    line_runs: Vec<crate::backend::text::ShapedRun>,
     /// Multiline counterpart of `glyph_positions`: per WRAPPED line, per-column x
     /// offsets of that line as drawn (`[line][col]`, one extra entry per line = its
     /// total advance), recorded by [`Paint::prepare_text`] over the same wrap the
@@ -235,6 +226,11 @@ impl TextBox {
             glyph_positions: Vec::new(),
             total_text_width: 0.0,
             shaped_char_advance: 0.0,
+            advances: std::cell::RefCell::new(None),
+            glyph_shift: 0.0,
+            line_shift: Vec::new(),
+            glyph_run: None,
+            line_runs: Vec::new(),
             line_glyph_positions: Vec::new(),
             prep_key: None,
             update_on_type: false,
@@ -342,15 +338,44 @@ impl TextBox {
         // A multiline box reads only the per-line offsets; shaping the whole
         // document as one buffer would be its most expensive and least used step.
         self.line_glyph_positions.clear();
-        if let Some(max_chars) = key.wrap {
-            let (lines, _) = self.wrap_text(max_chars);
-            for line in &lines {
+        if let Some(bits) = key.wrap {
+            // Measure the wrapped text's advances with the paint's font system first, so
+            // the wrap below never reaches for the shared one (which may be this one).
+            let src = if self.editing { self.edit_buffer.clone() } else { self.text.clone() };
+            self.cache_advances(fs, &src);
+            let (lines, map) = self.wrap_text(f32::from_bits(bits));
+            // Each wrapped line's paragraph, and whether that paragraph is right to left: a
+            // paragraph's direction is its first strong character's, for all its lines.
+            let para_rtl: Vec<bool> = src.split('\n').map(crate::backend::text::paragraph_rtl).collect();
+            let mut para_of_line = vec![0usize; lines.len()];
+            let mut para = 0usize;
+            let mut seen = vec![false; lines.len()];
+            for (ci, ch) in src.chars().enumerate() {
+                let line = map[ci].0.min(lines.len() - 1);
+                if !seen[line] {
+                    seen[line] = true;
+                    para_of_line[line] = para;
+                }
+                if ch == '\n' {
+                    para += 1;
+                }
+            }
+            for (line, s) in seen.iter().enumerate() {
+                if !s {
+                    para_of_line[line] = para;
+                }
+            }
+            let room = f32::from_bits(key.room_bits);
+            self.line_shift.clear();
+            self.line_runs.clear();
+            for (li, line) in lines.iter().enumerate() {
                 let line_buffer = shared(fs, line, self.font_size, font_fam, attrs);
-                let (mut offs, line_total) = column_offsets(&line_buffer, line, scale);
-                fill_gaps(&mut offs, true);
-                let n = offs.len() - 1;
-                offs[n] = line_total;
-                self.line_glyph_positions.push(offs);
+                let run = crate::backend::text::shaped_run(&line_buffer, line, scale);
+                let rtl = para_rtl.get(para_of_line[li]).copied().unwrap_or(false);
+                let shift = if rtl { (room - run.width).max(0.0) } else { 0.0 };
+                self.line_glyph_positions.push(run.stops.iter().map(|s| s.1 + shift).collect());
+                self.line_shift.push(shift);
+                self.line_runs.push(run);
             }
             self.glyph_positions = vec![0.0; render_text.chars().count() + 1];
             self.total_text_width = 0.0;
@@ -358,12 +383,15 @@ impl TextBox {
         }
 
         let buffer = shared(fs, &render_text, self.font_size, font_fam, attrs);
-        let (mut x_offsets, total_w) = column_offsets(&buffer, &render_text, scale);
-        fill_gaps(&mut x_offsets, false);
-        let last_idx = x_offsets.len() - 1;
-        x_offsets[last_idx] = total_w;
-
-        self.glyph_positions = x_offsets;
+        let run = crate::backend::text::shaped_run(&buffer, &render_text, scale);
+        let total_w = run.width;
+        // A right-to-left line that fits is set against the right; one that does not starts
+        // at the left and scrolls, as any overflowing line does.
+        let room = f32::from_bits(key.room_bits);
+        let shift = if crate::backend::text::paragraph_rtl(&render_text) && total_w < room { room - total_w } else { 0.0 };
+        self.glyph_positions = run.stops.iter().map(|s| s.1 + shift).collect();
+        self.glyph_shift = shift;
+        self.glyph_run = Some(run);
         self.total_text_width = total_w;
     }
 
@@ -379,14 +407,105 @@ impl TextBox {
         self.font_size * 1.333
     }
 
-    pub fn wrap_text(&self, max_chars_per_line: usize) -> (Vec<String>, Vec<(usize, usize)>) {
+    /// The width a multiline box wraps its lines at, for a box `outer_w` wide: its width
+    /// less the padding, or no limit with wrapping off.
+    pub fn wrap_width(&self, outer_w: f32) -> f32 {
+        if self.line_wrap_enabled() {
+            (outer_w - 2.0 * self.pad()).max(1.0)
+        } else {
+            f32::INFINITY
+        }
+    }
+
+    fn advance_key(&self, text: &str) -> AdvanceKey {
+        AdvanceKey {
+            text: text.to_string(),
+            font_size_bits: self.font_size.to_bits(),
+            font: self.value_font(),
+            attrs: self.font_attrs,
+            scale_bits: crate::scale::scale_factor().to_bits(),
+        }
+    }
+
+    /// Each char's shaped advance in `text` (see the `advances` field), shaped with `fs`.
+    fn measure_advances(&self, fs: &mut cosmic_text::FontSystem, text: &str) -> Vec<f32> {
+        let scale = crate::scale::scale_factor().max(0.01);
+        let font = self.value_font();
+        let mut out = Vec::with_capacity(text.chars().count());
+        for (pi, para) in text.split('\n').enumerate() {
+            if pi > 0 {
+                out.push(0.0); // the newline
+            }
+            let shown = if self.is_password { "•".repeat(para.chars().count()) } else { para.to_string() };
+            let buf = crate::backend::text::shared_text_buffer(fs, &shown, self.font_size, font.as_deref(), self.font_attrs);
+            let run = crate::backend::text::shaped_run(&buf, &shown, scale);
+            let mut adv = vec![0.0f32; shown.chars().count()];
+            let char_at: std::collections::HashMap<usize, usize> =
+                shown.char_indices().enumerate().map(|(ci, (b, _))| (b, ci)).collect();
+            for c in &run.clusters {
+                if let Some(&ci) = char_at.get(&c.start) {
+                    adv[ci] += c.x1 - c.x0;
+                }
+            }
+            out.extend(adv);
+        }
+        out
+    }
+
+    /// Measure `text`'s advances with `fs` into the cache, unless it already holds them.
+    fn cache_advances(&self, fs: &mut cosmic_text::FontSystem, text: &str) {
+        let key = self.advance_key(text);
+        if self.advances.borrow().as_ref().is_some_and(|(k, _)| *k == key) {
+            return;
+        }
+        let adv = self.measure_advances(fs, text);
+        *self.advances.borrow_mut() = Some((key, adv));
+    }
+
+    /// `text`'s char advances: the cache, else measured with the shared geometry font
+    /// system, else — that one held, by this thread further up the stack or by another —
+    /// with a font system of this thread's own, from the same font set. Always measured,
+    /// never a grid: two wraps of one text must agree.
+    fn char_advances(&self, text: &str) -> Vec<f32> {
+        thread_local! {
+            static OWN_FS: std::cell::RefCell<Option<cosmic_text::FontSystem>> = const { std::cell::RefCell::new(None) };
+        }
+        let key = self.advance_key(text);
+        if let Some((k, adv)) = self.advances.borrow().as_ref() {
+            if *k == key {
+                return adv.clone();
+            }
+        }
+        let adv = match crate::geometry_font_system().try_lock() {
+            Ok(mut fs) => self.measure_advances(&mut fs, text),
+            Err(_) => OWN_FS.with(|own| {
+                let mut own = own.borrow_mut();
+                let fs = own.get_or_insert_with(crate::create_font_system);
+                self.measure_advances(fs, text)
+            }),
+        };
+        *self.advances.borrow_mut() = Some((key, adv.clone()));
+        adv
+    }
+
+    /// The box's text wrapped at `max_width` px ([`TextBox::wrap_width`]): its lines, and
+    /// where each char lands as (line, column). Words move whole to the next line; a word
+    /// wider than the line breaks where it overflows. Widths are the shaped advances, so a
+    /// proportional face and two-column CJK wrap where they are drawn (until 2026-10-08 it
+    /// counted chars against one monospace advance).
+    pub fn wrap_text(&self, max_width: f32) -> (Vec<String>, Vec<(usize, usize)>) {
         let text_src = if self.editing { &self.edit_buffer } else { &self.text };
+        self.wrap_str(text_src, max_width)
+    }
+
+    /// [`TextBox::wrap_text`] for any text in the box's face (its placeholder).
+    pub fn wrap_str(&self, text_src: &str, max_width: f32) -> (Vec<String>, Vec<(usize, usize)>) {
         let chars: Vec<char> = text_src.chars().collect();
         let mut lines = Vec::new();
         let mut current_line = Vec::new();
         let mut index_map = vec![(0, 0); chars.len() + 1];
 
-        if !self.line_wrap_enabled() {
+        if !self.line_wrap_enabled() || !max_width.is_finite() {
             let mut i = 0;
             while i < chars.len() {
                 let ch = chars[i];
@@ -405,7 +524,10 @@ impl TextBox {
             return (lines, index_map);
         }
 
-        let max_chars = max_chars_per_line.max(1);
+        let adv = self.char_advances(text_src);
+        let max_width = max_width.max(1.0);
+        // The current line's width: the advances of the chars it holds.
+        let mut line_w = 0.0f32;
 
         let mut i = 0;
         while i < chars.len() {
@@ -415,46 +537,34 @@ impl TextBox {
                 index_map[i] = (lines.len(), current_line.len());
                 lines.push(current_line.iter().collect::<String>());
                 current_line.clear();
+                line_w = 0.0;
                 i += 1;
                 continue;
             }
 
             current_line.push(ch);
+            line_w += adv.get(i).copied().unwrap_or(0.0);
             index_map[i] = (lines.len(), current_line.len() - 1);
 
-            if current_line.len() > max_chars {
-                let mut space_idx = None;
-                for (s_idx, &c) in current_line.iter().enumerate().rev() {
-                    if c.is_whitespace() {
-                        space_idx = Some(s_idx);
-                        break;
-                    }
-                }
-
-                if let Some(s_idx) = space_idx {
-                    let line_to_push: Vec<char> = current_line[0..s_idx + 1].to_vec();
-                    let remaining: Vec<char> = current_line[s_idx + 1..].to_vec();
-
-                    let line_idx = lines.len();
-                    lines.push(line_to_push.iter().collect::<String>());
-
-                    current_line = remaining;
-                    let start_orig = i - current_line.len() + 1;
-                    for c_idx in 0..current_line.len() {
-                        index_map[start_orig + c_idx] = (line_idx + 1, c_idx);
-                    }
-                } else {
-                    let line_to_push: Vec<char> = current_line[0..max_chars].to_vec();
-                    let remaining: Vec<char> = current_line[max_chars..].to_vec();
-
-                    let line_idx = lines.len();
-                    lines.push(line_to_push.iter().collect::<String>());
-
-                    current_line = remaining;
-                    let start_orig = i - current_line.len() + 1;
-                    for c_idx in 0..current_line.len() {
-                        index_map[start_orig + c_idx] = (line_idx + 1, c_idx);
-                    }
+            // A space may hang past the edge, as it is drawn there; anything else that
+            // overflows breaks the line after its last space, else before itself.
+            if line_w > max_width + 0.5 && current_line.len() > 1 && !ch.is_whitespace() {
+                let split = match current_line.iter().rposition(|c| c.is_whitespace()) {
+                    Some(s_idx) => s_idx + 1,
+                    None => current_line.len() - 1,
+                };
+                let line_to_push: Vec<char> = current_line[..split].to_vec();
+                let remaining: Vec<char> = current_line[split..].to_vec();
+
+                let line_idx = lines.len();
+                lines.push(line_to_push.iter().collect::<String>());
+
+                current_line = remaining;
+                let start_orig = i + 1 - current_line.len();
+                line_w = 0.0;
+                for c_idx in 0..current_line.len() {
+                    index_map[start_orig + c_idx] = (line_idx + 1, c_idx);
+                    line_w += adv.get(start_orig + c_idx).copied().unwrap_or(0.0);
                 }
             }
             i += 1;
@@ -744,7 +854,9 @@ impl TextBox {
         let shaped = if self.multiline {
             self.line_glyph_positions
                 .iter()
-                .filter_map(|l| l.last().copied())
+                // A line's width is its widest stop: the last in left-to-right text, the
+                // first in right-to-left.
+                .map(|l| l.iter().copied().fold(0.0f32, f32::max))
                 .fold(0.0f32, f32::max)
         } else {
             self.total_text_width
@@ -759,15 +871,10 @@ impl TextBox {
 
     pub fn clamp_scroll(&mut self) {
         let pad = self.pad();
-        let char_width = self.char_width();
         let line_height = self.line_height();
-        let max_chars = if self.line_wrap_enabled() {
-            (((self.rect.width - 2.0 * pad) / char_width).floor() as usize).max(1)
-        } else {
-            999999
-        };
+        let max_w = self.wrap_width(self.rect.width);
         let (lines, _) = if self.multiline {
-            self.wrap_text(max_chars)
+            self.wrap_text(max_w)
         } else {
             let buffer = if self.editing { &self.edit_buffer } else { &self.text };
             (vec![buffer.clone()], vec![(0, 0); buffer.chars().count() + 1])
@@ -794,13 +901,9 @@ impl TextBox {
         let pad = self.pad();
         let char_width = self.char_width();
         let line_height = self.line_height();
-        let max_chars = if self.line_wrap_enabled() {
-            (((self.rect.width - 2.0 * pad) / char_width).floor() as usize).max(1)
-        } else {
-            999999
-        };
+        let max_w = self.wrap_width(self.rect.width);
         let (_lines, index_map) = if self.multiline {
-            self.wrap_text(max_chars)
+            self.wrap_text(max_w)
         } else {
             let buffer = if self.editing { &self.edit_buffer } else { &self.text };
             let mut m = Vec::new();
@@ -949,16 +1052,11 @@ impl TextBox {
     /// `mouse_input` press arm and `drag_update`.
     fn position_to_idx(&self, px: f32, py: f32) -> usize {
         let pad = self.pad();
-        let char_width = self.char_width();
         let top = self.label_top();
         if self.multiline {
             let line_height = self.line_height();
-            let max_chars = if self.line_wrap_enabled() {
-                (((self.rect.width - 2.0 * pad) / char_width).floor() as usize).max(1)
-            } else {
-                999999
-            };
-            let (lines, index_map) = self.wrap_text(max_chars);
+            let max_w = self.wrap_width(self.rect.width);
+            let (lines, index_map) = self.wrap_text(max_w);
             let click_line = (((py - (self.rect.y + top + pad) + self.scroll_y) / line_height).floor() as isize).max(0) as usize;
             let rel_x = px - (self.rect.x + pad) + self.scroll_x;
             let click_col = self.line_x_to_col(click_line.min(lines.len() - 1), rel_x);
@@ -987,7 +1085,6 @@ impl TextBox {
 
     /// Port of the legacy `keyboard_input` body.
     fn handle_key(&mut self, event: &KeyEvent) -> bool {
-        let pad = self.pad();
         if !self.editing || self.disabled { return false; }
         if event.state != ElementState::Pressed { return false; }
         // While an input method composes, its keys are its own: a shell does
@@ -1039,9 +1136,8 @@ impl TextBox {
                     state.clear_selection();
                 }
                 if self.multiline {
-                    let char_width = self.char_width();
-                    let max_chars = (((self.rect.width - 2.0 * pad) / char_width).floor() as usize).max(1);
-                    let (lines, index_map) = self.wrap_text(max_chars);
+                    let max_w = self.wrap_width(self.rect.width);
+                    let (lines, index_map) = self.wrap_text(max_w);
                     let (cursor_l, cursor_c) = index_map[state.cursor_idx.min(index_map.len() - 1)];
                     if cursor_l > 0 {
                         state.cursor_idx = self.map_2d_to_1d(&index_map, cursor_l - 1, cursor_c, lines.len() - 1);
@@ -1064,9 +1160,8 @@ impl TextBox {
                     state.clear_selection();
                 }
                 if self.multiline {
-                    let char_width = self.char_width();
-                    let max_chars = (((self.rect.width - 2.0 * pad) / char_width).floor() as usize).max(1);
-                    let (lines, index_map) = self.wrap_text(max_chars);
+                    let max_w = self.wrap_width(self.rect.width);
+                    let (lines, index_map) = self.wrap_text(max_w);
                     let (cursor_l, cursor_c) = index_map[state.cursor_idx.min(index_map.len() - 1)];
                     if cursor_l < lines.len() - 1 {
                         state.cursor_idx = self.map_2d_to_1d(&index_map, cursor_l + 1, cursor_c, lines.len() - 1);
@@ -1188,14 +1283,10 @@ impl TextBox {
         let char_width = self.char_width();
         let line_height = self.line_height();
 
-        let max_chars = if self.line_wrap_enabled() {
-            (((self.rect.width - 2.0 * pad) / char_width).floor() as usize).max(1)
-        } else {
-            999999
-        };
+        let max_w = self.wrap_width(self.rect.width);
 
         let (lines, _) = if self.multiline {
-            self.wrap_text(max_chars)
+            self.wrap_text(max_w)
         } else {
             let buffer = if self.editing { &self.edit_buffer } else { &self.text };
             (vec![buffer.clone()], vec![(0, 0); buffer.chars().count() + 1])
@@ -1284,12 +1375,8 @@ impl TextBox {
         let end = self.select_anchor.unwrap_or(self.cursor_idx).max(self.cursor_idx);
 
         if self.multiline {
-            let max_chars = if self.line_wrap_enabled() {
-                (((w - 2.0 * pad) / char_width).floor() as usize).max(1)
-            } else {
-                999999
-            };
-            let (_lines, index_map) = self.wrap_text(max_chars);
+            let max_w = self.wrap_width(w);
+            let (_lines, index_map) = self.wrap_text(max_w);
 
             let view_top = self.rect.y + top;
             let view_bottom = self.rect.y + self.rect.height;
@@ -1315,15 +1402,28 @@ impl TextBox {
                         }
                     }
                     if let (Some(sc), Some(ec)) = (line_start_col, line_end_col) {
-                        let highlight_x = x + pad + self.line_col_x(line_idx, sc) - self.scroll_x;
-                        let highlight_w = self.line_col_x(line_idx, ec + 1) - self.line_col_x(line_idx, sc);
                         let highlight_y = self.rect.y + top + pad + (line_idx as f32 * line_height) - self.scroll_y;
                         let clipped_y = highlight_y.max(view_top);
                         let clipped_bottom = (highlight_y + line_height).min(view_bottom);
-                        let h_left = highlight_x.max(x + pad);
-                        let h_right = (highlight_x + highlight_w).min(x + w - pad);
-                        if h_left < h_right && clipped_y < clipped_bottom {
-                            out.push((h_left, clipped_y, h_right - h_left, clipped_bottom - clipped_y, highlight_color));
+                        let shift = self.line_shift.get(line_idx).copied().unwrap_or(0.0);
+                        // The boxes of the selected clusters: two pieces where the selection
+                        // crosses a change of direction. Without a shaped run, the column span.
+                        let spans = match (self.line_runs.get(line_idx), _lines.get(line_idx)) {
+                            (Some(run), Some(line)) => {
+                                let byte = |col: usize| line.char_indices().nth(col).map_or(line.len(), |(b, _)| b);
+                                run.spans(byte(sc), byte(ec + 1)).into_iter().map(|(a, b)| (a + shift, b + shift)).collect()
+                            }
+                            _ => {
+                                let (a, b) = (self.line_col_x(line_idx, sc), self.line_col_x(line_idx, ec + 1));
+                                vec![(a.min(b), a.max(b))]
+                            }
+                        };
+                        for (a, b) in spans {
+                            let h_left = (x + pad + a - self.scroll_x).max(x + pad);
+                            let h_right = (x + pad + b - self.scroll_x).min(x + w - pad);
+                            if h_left < h_right && clipped_y < clipped_bottom {
+                                out.push((h_left, clipped_y, h_right - h_left, clipped_bottom - clipped_y, highlight_color));
+                            }
                         }
                     }
                 }
@@ -1339,8 +1439,9 @@ impl TextBox {
                         .filter_map(|i| index_map.get(i).filter(|p| p.0 == line_idx).map(|p| p.1))
                         .collect();
                     if let (Some(&a), Some(&b)) = (cols.iter().min(), cols.iter().max()) {
-                        let ux = x + pad + self.line_col_x(line_idx, a) - self.scroll_x;
-                        let uw = self.line_col_x(line_idx, b + 1) - self.line_col_x(line_idx, a);
+                        let (xa, xb) = (self.line_col_x(line_idx, a), self.line_col_x(line_idx, b + 1));
+                        let ux = x + pad + xa.min(xb) - self.scroll_x;
+                        let uw = (xb - xa).abs();
                         let uy = self.rect.y + top + pad + ((line_idx + 1) as f32 * line_height) - 2.0 - self.scroll_y;
                         let left = ux.max(x + pad);
                         let right = (ux + uw).min(x + w - pad);
@@ -1368,26 +1469,39 @@ impl TextBox {
         } else {
             let caret_h = self.font_size * 1.15;
             if start != end {
-                let h_left_offset = self.glyph_positions.get(start).copied().unwrap_or_else(|| start as f32 * char_width);
-                let h_right_offset = self.glyph_positions.get(end).copied().unwrap_or_else(|| end as f32 * char_width);
-                let highlight_x = x + pad + h_left_offset - self.scroll_x;
-                let h_left = highlight_x.max(x + pad);
-                let h_right = (x + pad + h_right_offset - self.scroll_x).min(x + w - pad);
-                if h_left < h_right {
-                    out.push((
-                        h_left,
-                        crate::layout::align_text_y(self.rect.y, self.rect.height, self.font_size, top),
-                        h_right - h_left,
-                        crate::layout::line_height(self.font_size),
-                        highlight_color,
-                    ));
+                // The boxes of the selected clusters: two pieces where the selection crosses
+                // a change of direction. Without a shaped run, the column span.
+                let shown = if self.is_password { "•".repeat(self.edit_buffer.chars().count()) } else { self.edit_buffer.clone() };
+                let spans: Vec<(f32, f32)> = match &self.glyph_run {
+                    Some(run) => {
+                        let byte = |col: usize| shown.char_indices().nth(col).map_or(shown.len(), |(b, _)| b);
+                        run.spans(byte(start), byte(end)).into_iter().map(|(a, b)| (a + self.glyph_shift, b + self.glyph_shift)).collect()
+                    }
+                    None => {
+                        let at = |i: usize| self.glyph_positions.get(i).copied().unwrap_or(i as f32 * char_width);
+                        vec![(at(start).min(at(end)), at(start).max(at(end)))]
+                    }
+                };
+                for (a, b) in spans {
+                    let h_left = (x + pad + a - self.scroll_x).max(x + pad);
+                    let h_right = (x + pad + b - self.scroll_x).min(x + w - pad);
+                    if h_left < h_right {
+                        out.push((
+                            h_left,
+                            crate::layout::align_text_y(self.rect.y, self.rect.height, self.font_size, top),
+                            h_right - h_left,
+                            crate::layout::line_height(self.font_size),
+                            highlight_color,
+                        ));
+                    }
                 }
             }
 
             if let Some((cs, cl)) = self.composing {
                 let at = |i: usize| self.glyph_positions.get(i).copied().unwrap_or(i as f32 * char_width);
-                let left = (x + pad + at(cs) - self.scroll_x).max(x + pad);
-                let right = (x + pad + at(cs + cl) - self.scroll_x).min(x + w - pad);
+                let (ca, cb) = (at(cs).min(at(cs + cl)), at(cs).max(at(cs + cl)));
+                let left = (x + pad + ca - self.scroll_x).max(x + pad);
+                let right = (x + pad + cb - self.scroll_x).min(x + w - pad);
                 if left < right {
                     let text_y = crate::layout::align_text_y(self.rect.y, self.rect.height, self.font_size, top);
                     out.push((left, text_y + self.font_size + 1.0, right - left, 1.5, cursor_color));
@@ -1452,40 +1566,19 @@ impl TextBox {
         let w = self.rect.width;
 
         if self.multiline {
-            let char_width = self.char_width();
             let line_height = self.line_height();
-            let max_chars = if self.line_wrap_enabled() {
-                (((w - 2.0 * pad) / char_width).floor() as usize).max(1)
-            } else {
-                999999
-            };
-            let (lines, _) = self.wrap_text(max_chars);
+            let max_w = self.wrap_width(w);
+            let (lines, _) = self.wrap_text(max_w);
             let lines_to_draw = if is_placeholder {
-                let placeholder_src = self.placeholder.as_ref().unwrap();
-                let chars: Vec<char> = placeholder_src.chars().collect();
-                let mut p_lines = Vec::new();
-                let mut current_line = Vec::new();
-                for ch in chars {
-                    if ch == '\n' {
-                        p_lines.push(current_line.iter().collect::<String>());
-                        current_line.clear();
-                    } else {
-                        current_line.push(ch);
-                        if self.line_wrap_enabled() && current_line.len() > max_chars {
-                            p_lines.push(current_line.iter().collect::<String>());
-                            current_line.clear();
-                        }
-                    }
-                }
-                p_lines.push(current_line.iter().collect::<String>());
-                p_lines
+                self.wrap_str(self.placeholder.as_deref().unwrap_or(""), max_w).0
             } else {
                 lines
             };
             for (line_idx, line_text) in lines_to_draw.iter().enumerate() {
+                let shift = if is_placeholder { 0.0 } else { self.line_shift.get(line_idx).copied().unwrap_or(0.0) };
                 labels.push(TextLabel {
                     text: line_text.clone(),
-                    x: x + pad - self.scroll_x,
+                    x: x + pad + shift - self.scroll_x,
                     y: self.rect.y + top + pad + (line_idx as f32 * line_height) + (line_height - self.font_size) / 2.0 - self.scroll_y,
                     font_size: self.font_size,
                     color: label_color,
@@ -1494,7 +1587,7 @@ impl TextBox {
         } else {
             labels.push(TextLabel {
                 text: display_text,
-                x: x + pad - self.scroll_x,
+                x: x + pad + self.glyph_shift - self.scroll_x,
                 y: crate::layout::align_text_y(self.rect.y, self.rect.height, self.font_size, top),
                 font_size: self.font_size,
                 color: label_color,
@@ -1693,7 +1786,6 @@ impl Paint for TextBox {
     /// The legacy `prepare_text`: sync font family/size with the live config defaults, then
     /// shape the display text and record per-glyph advances (`map_x_to_idx` reads them).
     fn prepare_text(&mut self, fs: &mut cosmic_text::FontSystem, _rect: Rect) {
-        let pad = self.pad();
         // The input method's composition, shown before the buffer is shaped.
         self.sync_preedit();
         let (style_family, style_size) = crate::layout::control_label_font_detached_parsed();
@@ -1737,13 +1829,7 @@ impl Paint for TextBox {
         // `char_width()` returns this frame's shaped advance from here on, so the
         // wrap below matches the one `selection_quads`/`value_labels` compute at
         // paint time.
-        let wrap = self.multiline.then(|| {
-            if self.line_wrap_enabled() {
-                (((self.rect.width - 2.0 * pad) / self.char_width()).floor() as usize).max(1)
-            } else {
-                999999
-            }
-        });
+        let wrap = self.multiline.then(|| self.wrap_width(self.rect.width).to_bits());
 
         let key = PrepKey {
             text: display_text.to_string(),
@@ -1755,6 +1841,7 @@ impl Paint for TextBox {
             scale_bits: scale.to_bits(),
             vertical: crate::backend::text::vertical_text().is_some(),
             wrap,
+            room_bits: (self.rect.width - 2.0 * self.pad()).max(0.0).to_bits(),
         };
         if self.prep_key.as_ref() != Some(&key) {
             self.shape_columns(fs, &key);
@@ -1863,12 +1950,7 @@ impl Paint for TextBox {
             // top and bottom), so the thumb tracks the scroll range exactly.
             if self.multiline {
                 let line_height = self.line_height();
-                let max_chars = if self.line_wrap_enabled() {
-                    (((self.rect.width - 2.0 * pad) / self.char_width()).floor() as usize).max(1)
-                } else {
-                    999999
-                };
-                let content_h = self.wrap_text(max_chars).0.len() as f32 * line_height;
+                let content_h = self.wrap_text(self.wrap_width(self.rect.width)).0.len() as f32 * line_height;
                 crate::widget::container::scroll_box::paint_relief_scrollbar(
                     ctx,
                     Rect { x, y: self.rect.y + top, width: w, height: visual_h },
@@ -2110,6 +2192,99 @@ mod tests {
 
     /// The multiline caret/click math reads shaped per-line offsets; a caret
     /// must land exactly where it is drawn, on every column of every line.
+    /// A right-to-left word in a one-line box is set against the box's right edge, and
+    /// edited where it is drawn: the caret before each letter stands at its right edge, so
+    /// the offsets fall from the right end to the word's left; a click at the right end is
+    /// the start, at the word's left the end; the drawn text starts where the offsets do.
+    /// A selection from inside English into Hebrew is two pieces. The bidi levels come from
+    /// the text, so this holds whatever face draws the letters.
+    #[test]
+    fn a_right_to_left_word_is_edited_where_it_is_drawn() {
+        let mut fs = crate::create_font_system();
+        let mut tb = TextBox::new("שלום".to_string());
+        tb.set_rect(10.0, 10.0, 300.0, 30.0);
+        tb.prepare_text(&mut fs);
+        let offs = tb.glyph_positions.clone();
+        assert_eq!(offs.len(), 5, "four letters and the end");
+        if tb.total_text_width == 0.0 {
+            return; // nothing shaped (no fonts at all): nothing to check
+        }
+        let pad = tb.inner().pad();
+        let (left, room) = (10.0 + pad, 300.0 - 2.0 * pad);
+        assert!(offs.windows(2).all(|w| w[1] < w[0]), "carets fall right to left: {offs:?}");
+        assert!((offs[0] - room).abs() < 0.01, "set against the right edge: {offs:?}");
+        assert!((offs[4] - (room - tb.total_text_width)).abs() < 0.01, "{offs:?}");
+        assert_eq!(tb.inner().map_x_to_idx(left + room), 0, "the right end is the start");
+        assert_eq!(tb.inner().map_x_to_idx(left + offs[4]), 4, "the word's left is the end");
+        let label = &tb.inner().value_labels()[0];
+        assert!((label.x - (left + offs[4])).abs() < 0.01, "drawn where the offsets are: {} vs {}", label.x, left + offs[4]);
+
+        // English then Hebrew: a left-to-right line, as it was; a selection from the "b" to
+        // the Hebrew word's first letter is the b and that letter at the word's far right.
+        let mut tb = TextBox::new("ab שלום".to_string());
+        tb.set_rect(10.0, 10.0, 300.0, 30.0);
+        tb.inner_mut().editing = true;
+        tb.inner_mut().edit_buffer = "ab שלום".to_string();
+        tb.prepare_text(&mut fs);
+        assert!(tb.glyph_positions[0].abs() < 0.01, "a left-to-right line starts at the left");
+        tb.inner_mut().select_anchor = Some(1);
+        tb.inner_mut().cursor_idx = 4;
+        let mut quads = Vec::new();
+        tb.inner().selection_quads(10.0, 300.0, &mut quads);
+        let sel: Vec<_> = quads.iter().filter(|q| q.2 > 2.0).collect();
+        assert_eq!(sel.len(), 2, "two pieces: {sel:?}");
+    }
+
+    /// In a multiline box a Hebrew paragraph's lines are set against the right edge and an
+    /// English paragraph's are not; the drawn lines carry the same shift as the offsets.
+    #[test]
+    fn a_right_to_left_paragraph_is_set_against_the_right() {
+        let mut fs = crate::create_font_system();
+        let mut tb = TextBox::new("hello there\nשלום עולם".to_string()).with_multiline(true).with_line_wrap(true);
+        tb.set_rect(10.0, 10.0, 300.0, 200.0);
+        tb.prepare_text(&mut fs);
+        let room = 300.0 - 2.0 * tb.inner().pad();
+        let lines = tb.line_glyph_positions.clone();
+        assert_eq!(lines.len(), 2);
+        if lines[1].iter().all(|x| *x == 0.0) {
+            return; // nothing shaped
+        }
+        assert!(lines[0][0].abs() < 0.01, "English starts at the left");
+        let widest = lines[1].iter().copied().fold(0.0f32, f32::max);
+        assert!((widest - room).abs() < 0.01, "Hebrew ends at the right: {:?}", lines[1]);
+        let labels = tb.inner().value_labels();
+        assert!(labels[1].x > labels[0].x + 50.0, "the Hebrew line is drawn shifted: {} vs {}", labels[1].x, labels[0].x);
+    }
+
+    /// A multiline box wraps by the shaped width of what it holds, not a count of chars:
+    /// every line fits the box (a hanging space aside) and none breaks early — the next
+    /// line's first word would not have fitted on it. Text of mixed widths (narrow, wide,
+    /// CJK) is where a column count and the drawn width disagree.
+    #[test]
+    fn a_multiline_box_wraps_where_its_text_is_wide() {
+        let text = "iiii WWWW lll MMM 漢字漢字 iii WW 漢字漢字漢字 il WM 漢字".repeat(3);
+        let mut tb = TextBox::new(text.clone()).with_multiline(true).with_line_wrap(true);
+        crate::widget::WidgetHost::set_rect(&mut tb, 0.0, 0.0, 160.0, 400.0);
+        let inner = tb.inner();
+        let max_w = inner.wrap_width(160.0);
+        let (lines, map) = inner.wrap_text(max_w);
+        assert!(lines.len() > 2, "{lines:?}");
+        let width = |s: &str| inner.char_advances(s).iter().sum::<f32>();
+        for (i, line) in lines.iter().enumerate() {
+            let shown = line.trim_end();
+            assert!(width(shown) <= max_w + 0.5 || shown.chars().count() == 1, "line {i} {shown:?} is {} wide, past {max_w}", width(shown));
+            if let Some(next) = lines.get(i + 1) {
+                let word: String = next.chars().take_while(|c| !c.is_whitespace()).collect();
+                if !word.is_empty() && line.ends_with(' ') {
+                    assert!(width(&format!("{line}{word}")) > max_w + 0.5, "line {i} {line:?} broke before {word:?}, which fitted");
+                }
+            }
+        }
+        let rejoined: String = lines.concat();
+        assert_eq!(rejoined, text, "the lines are the text, cut");
+        assert_eq!(map.len(), text.chars().count() + 1);
+    }
+
     #[test]
     fn multiline_shaped_offsets_round_trip() {
         let mut fs = cosmic_text::FontSystem::new();
diff --git a/src/widget/shaping.rs b/src/widget/shaping.rs
index 4210e68..fc96272 100644
--- a/src/widget/shaping.rs
+++ b/src/widget/shaping.rs
@@ -6,6 +6,8 @@ use std::collections::HashMap;
 
 use crate::scene::paint::TextAttrs;
 
+pub use crate::backend::text::{ShapedCluster, ShapedRun};
+
 /// Text widths in logical px, for one run in one style.
 pub trait Measure {
     fn width(&mut self, text: &str, size: f32, font: &str, attrs: TextAttrs) -> f32;
@@ -52,34 +54,62 @@ impl Measure for ShapingMeasure {
 }
 
 impl ShapingMeasure {
-    /// The x (logical px) of every char boundary in `text` shaped as one
-    /// run: `(byte offset, x)`, starting at `(0, 0.0)` and ending at
-    /// `(text.len(), width)`. A boundary inside a cluster (a ligature, a
-    /// combining mark) takes the cluster's start.
-    pub fn offsets(&mut self, text: &str, size: f32, font: &str, attrs: TextAttrs) -> Vec<(usize, f32)> {
+    /// `text` shaped as one run, in an editor's terms: the caret's x at every char
+    /// boundary, the clusters with their boxes, the width and the base direction — right
+    /// for text of either direction (see [`ShapedRun`]).
+    pub fn shape(&mut self, text: &str, size: f32, font: &str, attrs: TextAttrs) -> ShapedRun {
         let scale = crate::scale::scale_factor().max(0.01);
         let buf = crate::backend::text::shared_text_buffer(&mut self.fs, text, size, Some(font), attrs);
-        let glyphs = crate::backend::text::normalized_glyph_starts(&buf, text);
-        let mut starts: Vec<(usize, f32)> = Vec::with_capacity(glyphs.len() + 1);
-        let mut width = 0.0f32;
-        for (start, x, w) in glyphs {
-            if starts.last().map_or(true, |&(b, _)| b != start) {
-                starts.push((start, x / scale));
-            }
-            width = width.max((x + w) / scale);
-        }
-        starts.sort_by_key(|&(b, _)| b);
-        let mut out = Vec::with_capacity(text.len() + 1);
-        let mut gi = 0;
-        let mut last_x = 0.0;
-        for (b, _) in text.char_indices() {
-            while gi < starts.len() && starts[gi].0 <= b {
-                last_x = starts[gi].1;
-                gi += 1;
-            }
-            out.push((b, last_x));
-        }
-        out.push((text.len(), width));
-        out
+        crate::backend::text::shaped_run(&buf, text, scale)
+    }
+
+    /// The x (logical px) of every char boundary in `text` shaped as one
+    /// run: `(byte offset, x)`, from `(0, _)` to `(text.len(), _)` — the
+    /// caret's place before each char, [`ShapedRun::stops`]. In left-to-right
+    /// text they run from 0 to the width; in right-to-left text they fall. A
+    /// boundary inside a cluster (a ligature, a combining mark) takes the
+    /// cluster's place. The width is [`Measure::width`].
+    pub fn offsets(&mut self, text: &str, size: f32, font: &str, attrs: TextAttrs) -> Vec<(usize, f32)> {
+        self.shape(text, size, font, attrs).stops
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    /// Carets in right-to-left text stand at each letter's right edge and fall across the
+    /// run; the caret after it is its left end; a click finds the boundary nearest it; and a
+    /// selection crossing a change of direction is two spans. The bidi levels come from the
+    /// text, not the font, so this holds whatever face draws the letters.
+    #[test]
+    fn carets_follow_the_text_in_either_direction() {
+        let mut m = ShapingMeasure::new(false);
+        let font = crate::layout::control_label_font();
+        let attrs = TextAttrs::default();
+
+        let ltr = m.shape("abc", 14.0, &font, attrs);
+        assert!(!ltr.rtl);
+        let xs: Vec<f32> = ltr.stops.iter().map(|s| s.1).collect();
+        assert!(xs.windows(2).all(|w| w[1] > w[0]), "left to right, carets rise: {xs:?}");
+        assert!((xs[3] - ltr.width).abs() < 0.01, "the caret after it is its right end");
+
+        let heb = "שלום"; // four letters, two bytes each
+        let rtl = m.shape(heb, 14.0, &font, attrs);
+        assert!(rtl.rtl, "the first strong letter is Hebrew: the paragraph is right to left");
+        let xs: Vec<f32> = rtl.stops.iter().map(|s| s.1).collect();
+        assert_eq!(rtl.stops.iter().map(|s| s.0).collect::<Vec<_>>(), [0, 2, 4, 6, 8]);
+        assert!(xs.windows(2).all(|w| w[1] < w[0]), "right to left, carets fall: {xs:?}");
+        assert!((xs[0] - rtl.width).abs() < 0.01 && xs[4].abs() < 0.01, "from the right end to the left: {xs:?}");
+        assert_eq!(rtl.index_at(rtl.width + 5.0), 0, "a click past the right end is the start");
+        assert_eq!(rtl.index_at(-5.0), heb.len(), "past the left end, the end");
+
+        // "ab" then a Hebrew word: the logical range a..ש (bytes 1..4) is the "b" on the left
+        // and the Hebrew word's FIRST letter at its far right — two spans.
+        let mixed = m.shape("ab שלום", 14.0, &font, attrs);
+        assert!(!mixed.rtl, "the first strong letter is Latin");
+        let spans = mixed.spans(1, 5);
+        assert_eq!(spans.len(), 2, "visually apart: {spans:?} in {:?}", mixed.clusters);
+        assert!(spans[0].1 < spans[1].0);
     }
 }