GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
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);
}
}