diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 6c503e5..3d7a948 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -339,7 +339,7 @@ fn page_title(ui: &mut Ui, ed: &mut Editor, tokens: &Tokens, title_id: egui::Id, out.response } else { let pieces = crate::bidi::plain(&ed.title_buf, 26.0 * zoom, tokens); - let (drawn, size) = crate::bidi::runs(ui, &ed.title_buf, &pieces, 26.0 * zoom, width); + let (drawn, size) = crate::bidi::runs(ui, &ed.title_buf, &pieces, 26.0 * zoom, width, ed.page_dir); let (rect, resp) = ui.allocate_exact_size(vec2(width, size.y.max(34.0)), egui::Sense::click()); crate::bidi::paint(ui, rect.min, &drawn); if resp.clicked() { diff --git a/crates/ui-egui/src/bidi.rs b/crates/ui-egui/src/bidi.rs index 465f78c..d99352b 100644 --- a/crates/ui-egui/src/bidi.rs +++ b/crates/ui-egui/src/bidi.rs @@ -18,6 +18,7 @@ use crate::theme::Tokens; use egui::text::{LayoutJob, TextFormat}; use egui::{Align, Align2, Color32, Pos2, Rect, Ui, Vec2, pos2, vec2}; use notecraft_model::justify::Strength; +use std::borrow::Cow; use std::cell::RefCell; thread_local! { @@ -85,8 +86,11 @@ pub struct Drawn { /// and draws each run; its galley can be about a pixel wider than parley's run (egui rounds /// each advance), which leans into the gap after it and never accumulates. The caret and /// the selection below are mapped through the same places. -pub fn runs(ui: &Ui, text: &str, pieces: &[Piece], size: f32, max_width: f32) -> (Vec, Vec2) { - let Some(split) = split(text, size, max_width) else { return (Vec::new(), Vec2::ZERO) }; +/// +/// `page` is the page's direction, which decides the line's own when it has no mark (see +/// [`based`]). +pub fn runs(ui: &Ui, text: &str, pieces: &[Piece], size: f32, max_width: f32, page: Option) -> (Vec, Vec2) { + let Some(split) = split(text, size, max_width, page) else { return (Vec::new(), Vec2::ZERO) }; let space = space_width(ui, size); let mut out: Vec = Vec::new(); let mut size_used = Vec2::ZERO; @@ -192,11 +196,43 @@ struct Split { lines: Vec, } -fn split(text: &str, size: f32, max_width: f32) -> Option { +/// The line as parley is given it, and how many bytes were put in front of it for that. +/// +/// parley takes a paragraph's base direction from its first strong letter and cannot be +/// told otherwise: 0.11 hands its own bidi resolver no base level. That is right for a line +/// that answers for itself and wrong for one whose direction comes from the page. A list +/// item `TODO: راجع هذه الصفحة` on a page set right to left was hung from the right edge +/// and still laid out left to right inside it, so `TODO:` landed at the far end of the line +/// instead of at its start. A direction mark in front settles it the way +/// `notecraft_model::direction_in` says, which is what a paragraph's own mark already does. +/// Every byte parley hands back is moved back past the mark, so no caller ever sees it. +fn based(text: &str, page: Option) -> (Cow<'_, str>, usize) { + let want = notecraft_model::direction_in(text, page); + if want == notecraft_model::direction(text) { + return (Cow::Borrowed(text), 0); + } + let mark = if want.is_rtl() { notecraft_model::text::RLM } else { notecraft_model::text::LRM }; + (Cow::Owned(format!("{mark}{text}")), mark.len_utf8()) +} + +/// A range of the marked line, as a range of the line. +fn unmarked(range: std::ops::Range, shift: usize) -> std::ops::Range { + range.start.saturating_sub(shift)..range.end.saturating_sub(shift) +} + +fn split(text: &str, size: f32, max_width: f32, page: Option) -> Option { + let (laid, shift) = based(text, page); TEXT.with(|t| { let mut engine = t.try_borrow_mut().ok()?; - let layout = engine.layout(text, size, Some(max_width)); - Some(Split { lines: notecraft_text::lines(&layout) }) + let layout = engine.layout(&laid, size, Some(max_width)); + let mut lines = notecraft_text::lines(&layout); + for line in &mut lines { + line.text = unmarked(line.text.clone(), shift); + for run in &mut line.runs { + run.text = unmarked(run.text.clone(), shift); + } + } + Some(Split { lines }) }) } @@ -241,7 +277,7 @@ pub fn line_at(ui: &Ui, painter: &egui::Painter, at: Pos2, align: Align2, text: } let pieces = [(0..text.len(), TextFormat { font_id: font.clone(), color, ..TextFormat::default() })]; // Nothing to wrap against: a label is one line, however long it is. - let (drawn, _) = runs(ui, text, &pieces, size, f32::INFINITY); + let (drawn, _) = runs(ui, text, &pieces, size, f32::INFINITY, page); let left = drawn.iter().map(|d| d.at.x).fold(f32::INFINITY, f32::min); let right = drawn.iter().map(|d| d.at.x + d.galley.rect.width()).fold(f32::NEG_INFINITY, f32::max); let tall = drawn.iter().map(|d| d.galley.rect.height()).fold(0.0_f32, f32::max); @@ -296,11 +332,11 @@ pub fn show( let (long, drawn, used) = match justify { Some(strength) => { let (long, moved) = stretched(ui, text, pieces, size, max_width, strength); - let (drawn, used) = runs(ui, &long, &moved, size, max_width); + let (drawn, used) = runs(ui, &long, &moved, size, max_width, page); (long, drawn, used) } None => { - let (drawn, used) = runs(ui, text, pieces, size, max_width); + let (drawn, used) = runs(ui, text, pieces, size, max_width, page); (text.to_string(), drawn, used) } }; @@ -403,7 +439,8 @@ fn remembered(ui: &Ui, text: &str, size: f32, max_width: f32, strength: Strength /// Stretches every line of the paragraph but the last to `max_width`. fn stretch(ui: &Ui, text: &str, size: f32, max_width: f32, strength: Strength) -> String { - let Some(split) = split(text, size, max_width) else { return text.to_string() }; + // Where the lines break does not depend on which way they read, so the page is not asked. + let Some(split) = split(text, size, max_width, None) else { return text.to_string() }; let lines = split.lines; // One line is all last line, and the last line of a paragraph is never justified: a // closing half-sentence pulled out to the margin is the mark of a bad typesetter. @@ -442,10 +479,10 @@ fn stretch_line(ui: &Ui, core: &str, size: f32, max_width: f32, strength: Streng let mut width = |s: &str| -> f32 { let pieces = [(0..s.len(), format.clone())]; // A stretch that wraps the line is no width at all, which is how the search stops. - if split(s, size, max_width).is_none_or(|l| l.lines.len() > 1) { + if split(s, size, max_width, None).is_none_or(|l| l.lines.len() > 1) { return f32::INFINITY; } - runs(ui, s, &pieces, size, f32::INFINITY).1.x + runs(ui, s, &pieces, size, f32::INFINITY, None).1.x }; notecraft_text::kashida::justify(core, max_width, strength, &mut width) } @@ -505,8 +542,8 @@ fn run_of(drawn: &[Drawn], at: usize) -> Option<&Drawn> { } /// Where the caret belongs on a line, in the drawn runs' own space. -pub fn caret(ui: &Ui, text: &str, pieces: &[Piece], at: usize, size: f32, max_width: f32) -> Option { - let (drawn, _) = runs(ui, text, pieces, size, max_width); +pub fn caret(ui: &Ui, text: &str, pieces: &[Piece], at: usize, size: f32, max_width: f32, page: Option) -> Option { + let (drawn, _) = runs(ui, text, pieces, size, max_width, page); caret_in(text, &drawn, at, size) } @@ -518,8 +555,17 @@ fn caret_in(text: &str, drawn: &[Drawn], at: usize, size: f32) -> Option { /// The rectangles to paint behind a selection between two bytes. A selection that covers /// both directions is more than one rectangle, one per run it touches. -pub fn selection(ui: &Ui, text: &str, pieces: &[Piece], from: usize, to: usize, size: f32, max_width: f32) -> Vec { - let (drawn, _) = runs(ui, text, pieces, size, max_width); +pub fn selection( + ui: &Ui, + text: &str, + pieces: &[Piece], + from: usize, + to: usize, + size: f32, + max_width: f32, + page: Option, +) -> Vec { + let (drawn, _) = runs(ui, text, pieces, size, max_width, page); selection_in(text, &drawn, from, to, size, space_width(ui, size)) } @@ -618,12 +664,13 @@ fn inside_a_letter(text: &str, at: usize) -> bool { /// /// A stop is a boundary between letters: the insides of a letter are left out, so a step /// never lands between a letter and the mark that hangs on it. -pub fn step(text: &str, at: usize, right: bool, size: f32, max_width: f32) -> usize { +pub fn step(text: &str, at: usize, right: bool, size: f32, max_width: f32, page: Option) -> usize { + let (laid, shift) = based(text, page); TEXT.with(|t| { let Ok(mut engine) = t.try_borrow_mut() else { return at }; - let layout = engine.layout(text, size, Some(max_width)); + let layout = engine.layout(&laid, size, Some(max_width)); let place = |b: usize| { - let r = notecraft_text::caret_rect(&layout, notecraft_text::Caret::at(b), 1.0); + let r = notecraft_text::caret_rect(&layout, notecraft_text::Caret::at(b + shift), 1.0); (r.x0, r.y0) }; let (here_x, here_y) = place(at); @@ -657,9 +704,9 @@ pub fn step(text: &str, at: usize, right: bool, size: f32, max_width: f32) -> us // where the next line begins. The marks are stepped past the same way as before. let mut now = at; for _ in 0..8 { - let caret = notecraft_text::Caret::at(now); + let caret = notecraft_text::Caret::at(now + shift); let moved = if right { notecraft_text::step_right(&layout, caret) } else { notecraft_text::step_left(&layout, caret) }; - let next = moved.index(); + let next = moved.index().saturating_sub(shift); if next == now { return now; } @@ -681,20 +728,22 @@ fn stops(text: &str) -> Vec { } /// The word under a point: what a double click takes. -pub fn word_at(text: &str, at: Vec2, size: f32, max_width: f32) -> std::ops::Range { +pub fn word_at(text: &str, at: Vec2, size: f32, max_width: f32, page: Option) -> std::ops::Range { + let (laid, shift) = based(text, page); TEXT.with(|t| { let Ok(mut engine) = t.try_borrow_mut() else { return 0..0 }; - let layout = engine.layout(text, size, Some(max_width)); - notecraft_text::word_at(&layout, at.x, at.y) + let layout = engine.layout(&laid, size, Some(max_width)); + unmarked(notecraft_text::word_at(&layout, at.x, at.y), shift) }) } /// The byte a click at `x`, `y` (from the top left of the line) lands on. -pub fn hit(text: &str, at: Vec2, size: f32, max_width: f32) -> usize { +pub fn hit(text: &str, at: Vec2, size: f32, max_width: f32, page: Option) -> usize { + let (laid, shift) = based(text, page); TEXT.with(|t| { let Ok(mut engine) = t.try_borrow_mut() else { return 0 }; - let layout = engine.layout(text, size, Some(max_width)); - let index = notecraft_text::caret_at(&layout, at.x, at.y).index(); + let layout = engine.layout(&laid, size, Some(max_width)); + let index = notecraft_text::caret_at(&layout, at.x, at.y).index().saturating_sub(shift); // A click near a letter that carries a haraka can land between the two; the caret // belongs before the letter, not inside it. let mut now = index; @@ -738,7 +787,7 @@ mod tests { let line = "ما الذي يمكنك فعله هنا"; let mut frame = ctx.run_ui(egui::RawInput::default(), |ui| { crate::fonts::install_once(ui.ctx()); - let (drawn, _) = runs(ui, line, &plain(line, 16.0, &t), 16.0, 400.0); + let (drawn, _) = runs(ui, line, &plain(line, 16.0, &t), 16.0, 400.0, None); assert_eq!(drawn.len(), 5, "one for each word: {:?}", drawn.iter().map(|d| d.galley.text().to_string()).collect::>()); // The first word of the line is the rightmost thing on it, and each word after // it is further left, with a gap between them. @@ -765,7 +814,7 @@ mod tests { let ctx = ctx_with_fonts(); let mut frame = ctx.run_ui(egui::RawInput::default(), |ui| { crate::fonts::install_once(ui.ctx()); - let (drawn, size) = runs(ui, PRICE, &plain(PRICE, 16.0, &t), 16.0, 400.0); + let (drawn, size) = runs(ui, PRICE, &plain(PRICE, 16.0, &t), 16.0, 400.0, None); assert!(drawn.len() >= 3, "{} runs", drawn.len()); let space = space_width(ui, 16.0); assert!(space > 1.0, "a space has a width: {space}"); @@ -801,7 +850,7 @@ mod tests { let ctx = ctx_with_fonts(); let mut frame = ctx.run_ui(egui::RawInput::default(), |ui| { crate::fonts::install_once(ui.ctx()); - let (drawn, used) = runs(ui, LONG, &plain(LONG, 16.0, &t), 16.0, 300.0); + let (drawn, used) = runs(ui, LONG, &plain(LONG, 16.0, &t), 16.0, 300.0, None); let mut lines: std::collections::BTreeMap = std::collections::BTreeMap::new(); for d in &drawn { let e = lines.entry(d.at.y.round() as i32).or_insert((f32::INFINITY, 0.0)); @@ -821,7 +870,7 @@ mod tests { assert!(lefts.last().is_some_and(|l| *l > 10.0), "the last line hangs from the right, not the left: {lefts:?}"); assert!((used.x - 300.0).abs() < 4.0, "the block is as wide as the column: {}", used.x); - let (ltr, _) = runs(ui, "Hello مرحبا world", &plain("Hello مرحبا world", 16.0, &t), 16.0, 300.0); + let (ltr, _) = runs(ui, "Hello مرحبا world", &plain("Hello مرحبا world", 16.0, &t), 16.0, 300.0, None); assert!(ltr.first().is_some_and(|d| d.at.x.abs() < 1.0), "a left-to-right line starts at the left"); }); frame.textures_delta.clear(); @@ -855,7 +904,7 @@ mod tests { let ctx = ctx_with_fonts(); let mut frame = ctx.run_ui(egui::RawInput::default(), |ui| { crate::fonts::install_once(ui.ctx()); - let (drawn, size) = runs(ui, PRICE, &plain(PRICE, 16.0, &t), 16.0, 400.0); + let (drawn, size) = runs(ui, PRICE, &plain(PRICE, 16.0, &t), 16.0, 400.0, None); assert!(drawn.len() >= 3, "Arabic, digits, Arabic: {} runs", drawn.len()); assert!(size.x > 0.0 && size.y > 0.0); let xs: Vec = drawn.iter().map(|d| d.at.x).collect(); @@ -883,11 +932,11 @@ mod tests { crate::fonts::install_once(ui.ctx()); let pieces = plain(PRICE, 16.0, &t); // Nothing selected, nothing painted. - assert!(selection(ui, PRICE, &pieces, 4, 4, 16.0, 400.0).is_empty()); + assert!(selection(ui, PRICE, &pieces, 4, 4, 16.0, 400.0, None).is_empty()); - let (drawn, size) = runs(ui, PRICE, &pieces, 16.0, 400.0); + let (drawn, size) = runs(ui, PRICE, &pieces, 16.0, 400.0, None); assert!(drawn.len() > 2, "the line is drawn as several runs: {}", drawn.len()); - let all = selection(ui, PRICE, &pieces, 0, PRICE.len(), 16.0, 400.0); + let all = selection(ui, PRICE, &pieces, 0, PRICE.len(), 16.0, 400.0, None); assert_eq!(all.len(), 1, "the whole of one line is one band: {all:?}"); // The band runs the length of the drawn line, spaces and all, where the @@ -902,7 +951,7 @@ mod tests { // A selection of the digits alone is still only the digits: joining a line's // rectangles must not reach past what was asked for. let start = PRICE.find("123").unwrap_or(0); - let digits = selection(ui, PRICE, &pieces, start, start + 3, 16.0, 400.0); + let digits = selection(ui, PRICE, &pieces, start, start + 3, 16.0, 400.0, None); assert_eq!(digits.len(), 1, "the digits are one run: {digits:?}"); let run = drawn.iter().find(|d| d.galley.text().contains("123")).map(|d| (d.at.x, d.galley.rect.width())); if let (Some(rect), Some((x, w))) = (digits.first(), run) { @@ -932,7 +981,7 @@ mod tests { #[test] fn a_double_click_takes_the_word_under_the_pointer() { // The middle of the line is the number; the far right is the first word. - let word = word_at(PRICE, vec2(500.0, 8.0), 16.0, 400.0); + let word = word_at(PRICE, vec2(500.0, 8.0), 16.0, 400.0, None); assert_eq!(PRICE.get(word.clone()).map(str::trim), Some("السعر"), "{word:?}"); assert!(!word.is_empty()); } @@ -944,15 +993,15 @@ mod tests { let mut frame = ctx.run_ui(egui::RawInput::default(), |ui| { crate::fonts::install_once(ui.ctx()); let pieces = plain(PRICE, 16.0, &t); - let start = caret(ui, PRICE, &pieces, 0, 16.0, 400.0).unwrap_or(Rect::ZERO); - let end = caret(ui, PRICE, &pieces, PRICE.len(), 16.0, 400.0).unwrap_or(Rect::ZERO); + let start = caret(ui, PRICE, &pieces, 0, 16.0, 400.0, None).unwrap_or(Rect::ZERO); + let end = caret(ui, PRICE, &pieces, PRICE.len(), 16.0, 400.0, None).unwrap_or(Rect::ZERO); assert!(start.height() > 4.0, "a caret bar: {start:?}"); // The line reads right to left, so the start of the text is at the right. assert!(start.min.x > end.min.x, "start on the right, end on the left: {start:?} {end:?}"); // And the caret never lands outside the drawn line. - let (_, size) = runs(ui, PRICE, &pieces, 16.0, 400.0); + let (_, size) = runs(ui, PRICE, &pieces, 16.0, 400.0, None); for at in [0, 5, 9, PRICE.len()] { - let r = caret(ui, PRICE, &pieces, at, 16.0, 400.0).unwrap_or(Rect::ZERO); + let r = caret(ui, PRICE, &pieces, at, 16.0, 400.0, None).unwrap_or(Rect::ZERO); assert!(r.min.x >= -1.0 && r.min.x <= size.x + 1.0, "{at}: {r:?} inside {size:?}"); } }); @@ -960,12 +1009,12 @@ mod tests { // The line reads right to left, so the first character is at the right edge and // there is nowhere further right to go; the way forward through the text is left. - assert_eq!(step(PRICE, 0, true, 16.0, 400.0), 0, "the start is already the right end"); - let left = step(PRICE, 0, false, 16.0, 400.0); + assert_eq!(step(PRICE, 0, true, 16.0, 400.0, None), 0, "the start is already the right end"); + let left = step(PRICE, 0, false, 16.0, 400.0, None); assert_ne!(left, 0, "a step left moves forward through the text"); - assert_eq!(step(PRICE, left, true, 16.0, 400.0), 0, "and back again"); + assert_eq!(step(PRICE, left, true, 16.0, 400.0, None), 0, "and back again"); // A click near the right edge lands at the beginning of the text. - let at = hit(PRICE, vec2(500.0, 8.0), 16.0, 400.0); + let at = hit(PRICE, vec2(500.0, 8.0), 16.0, 400.0, None); assert!(at <= 4, "the right edge is the start of the line: {at}"); } /// A justified paragraph is drawn from stretched words, so the formats of its pieces diff --git a/crates/ui-egui/src/pageedit.rs b/crates/ui-egui/src/pageedit.rs index 74c6bdb..43b4f2d 100644 --- a/crates/ui-egui/src/pageedit.rs +++ b/crates/ui-egui/src/pageedit.rs @@ -1165,7 +1165,7 @@ pub fn show_blocks( if let Some(done) = lead(ui, &block, t) { ticked = Some(done); } - let w = write_rtl(ui, c, size, t, id); + let w = write_rtl(ui, c, size, t, id, page); let resp = w.response.clone(); wrote = Some(w); resp @@ -1766,14 +1766,15 @@ pub struct Wrote { /// (`crates/ui-egui/tests/arabic_layout.rs`). So the line is drawn by the bidi painter, /// which puts the runs where the Unicode algorithm says, and the caret is NoteCraft's own: /// a click asks parley which character is under the pointer, and the arrow keys step the -/// way the eye moves, not the way the bytes run. -pub fn write_rtl(ui: &mut Ui, c: &mut Caret, size: f32, t: &Tokens, id: egui::Id) -> Wrote { +/// way the eye moves, not the way the bytes run. `page` is the page's direction, which the +/// line reads in when it has no mark of its own, the same as when it is not being written in. +pub fn write_rtl(ui: &mut Ui, c: &mut Caret, size: f32, t: &Tokens, id: egui::Id, page: Option) -> Wrote { let width = ui.available_width().max(32.0); let pieces = vec![( 0..c.text.len(), egui::text::TextFormat { font_id: egui::FontId::proportional(size), color: t.text, ..egui::text::TextFormat::default() }, )]; - let (drawn, used) = crate::bidi::runs(ui, &c.text, &pieces, size, width); + let (drawn, used) = crate::bidi::runs(ui, &c.text, &pieces, size, width, page); // The block's own id, the same one the egui text box uses for a line without // right-to-left text in it, so the focus carries over when a line becomes one or stops // being one under the caret. @@ -1822,7 +1823,7 @@ pub fn write_rtl(ui: &mut Ui, c: &mut Caret, size: f32, t: &Tokens, id: egui::Id if let Some(p) = resp.interact_pointer_pos().or(carried) { let point = p - rect.min; if resp.double_clicked() { - let word = crate::bidi::word_at(&c.text, point, size, width); + let word = crate::bidi::word_at(&c.text, point, size, width, page); anchor = word.start; at = word.end; resp.request_focus(); @@ -1833,15 +1834,15 @@ pub fn write_rtl(ui: &mut Ui, c: &mut Caret, size: f32, t: &Tokens, id: egui::Id // recognised and nothing is ever selected inside a block the caret already sat // in. That is what the owner saw. let began = ui.input(|i| i.pointer.press_origin()).unwrap_or(p); - anchor = crate::bidi::hit(&c.text, began - rect.min, size, width); - at = crate::bidi::hit(&c.text, point, size, width); + anchor = crate::bidi::hit(&c.text, began - rect.min, size, width, page); + at = crate::bidi::hit(&c.text, point, size, width, page); resp.request_focus(); } else if resp.clicked() && !resp.dragged() { - at = crate::bidi::hit(&c.text, point, size, width); + at = crate::bidi::hit(&c.text, point, size, width, page); anchor = at; resp.request_focus(); } else if resp.dragged() || carried.is_some() { - at = crate::bidi::hit(&c.text, point, size, width); + at = crate::bidi::hit(&c.text, point, size, width, page); resp.request_focus(); } } @@ -1862,10 +1863,10 @@ pub fn write_rtl(ui: &mut Ui, c: &mut Caret, size: f32, t: &Tokens, id: egui::Id let delete = pressed(Key::Delete).is_some(); let moved = left || right || home || end; if left { - at = crate::bidi::step(&c.text, at, false, size, width); + at = crate::bidi::step(&c.text, at, false, size, width, page); } if right { - at = crate::bidi::step(&c.text, at, true, size, width); + at = crate::bidi::step(&c.text, at, true, size, width, page); } if home { at = 0; @@ -1926,7 +1927,7 @@ pub fn write_rtl(ui: &mut Ui, c: &mut Caret, size: f32, t: &Tokens, id: egui::Id // What is selected, painted behind the words, one rectangle per run it covers. if resp.has_focus() && anchor != at { - let shapes: Vec = crate::bidi::selection(ui, &c.text, &pieces, anchor, at, size, width) + let shapes: Vec = crate::bidi::selection(ui, &c.text, &pieces, anchor, at, size, width, page) .into_iter() .map(|r| { let r = egui::Rect::from_min_max(rect.min + r.min.to_vec2(), rect.min + r.max.to_vec2()); @@ -1937,7 +1938,7 @@ pub fn write_rtl(ui: &mut Ui, c: &mut Caret, size: f32, t: &Tokens, id: egui::Id } // The caret itself, drawn where parley says it belongs on the line. if resp.has_focus() - && let Some(bar) = crate::bidi::caret(ui, &c.text, &pieces, at, size, width) + && let Some(bar) = crate::bidi::caret(ui, &c.text, &pieces, at, size, width, page) { let bar = egui::Rect::from_min_max(rect.min + bar.min.to_vec2(), rect.min + bar.max.to_vec2()); let bar = egui::Rect::from_min_size(bar.min, vec2(1.5, bar.height().max(size))); @@ -2193,7 +2194,7 @@ fn char_under_drawn(ui: &Ui, block: &Block, resp: &egui::Response, pos: egui::Po let column = (resp.rect.width() - lead).max(32.0); let (size, strong) = size_of(block); let at = if crate::bidi::paints(&drawn, page) { - let byte = crate::bidi::hit(&drawn, vec2(x, rel.y), size, column); + let byte = crate::bidi::hit(&drawn, vec2(x, rel.y), size, column, page); drawn.get(..byte).unwrap_or(drawn.as_str()).chars().count() } else { char_at(ui, &drawn, size, strong, t, column, vec2(x, rel.y), page) diff --git a/crates/ui-egui/src/preview.rs b/crates/ui-egui/src/preview.rs index 7de699e..e29be20 100644 --- a/crates/ui-egui/src/preview.rs +++ b/crates/ui-egui/src/preview.rs @@ -317,7 +317,7 @@ pub fn link_at_point(block: &Block, at: egui::Vec2, width: f32, page: Option 0.0); let digits = drawn.iter().position(|d| d.galley.text().contains("17")); let at = digits.unwrap_or(usize::MAX); diff --git a/crates/ui-egui/tests/rtl_quality.rs b/crates/ui-egui/tests/rtl_quality.rs index 01f2f0e..40a652d 100644 --- a/crates/ui-egui/tests/rtl_quality.rs +++ b/crates/ui-egui/tests/rtl_quality.rs @@ -58,8 +58,8 @@ fn home_and_end_go_to_the_visual_ends_of_a_right_to_left_line() { let t = Theme::DEFAULT.tokens(); let (home, end) = in_frame(|ui| { let pieces = bidi::plain(LINE, 16.0, &t); - let home = bidi::caret(ui, LINE, &pieces, 0, 16.0, 400.0).map(|r| r.min.x).unwrap_or(0.0); - let end = bidi::caret(ui, LINE, &pieces, LINE.len(), 16.0, 400.0).map(|r| r.min.x).unwrap_or(0.0); + let home = bidi::caret(ui, LINE, &pieces, 0, 16.0, 400.0, None).map(|r| r.min.x).unwrap_or(0.0); + let end = bidi::caret(ui, LINE, &pieces, LINE.len(), 16.0, 400.0, None).map(|r| r.min.x).unwrap_or(0.0); (home, end) }); assert!(home > end + 50.0, "Home is at the right edge and End at the left: home {home}, end {end}"); @@ -79,11 +79,11 @@ fn a_step_never_lands_between_a_letter_and_its_diacritic() { // Every stop the caret makes walking the word. In a right-to-left word the left // arrow is the one that goes forward through the letters, which is itself worth // saying: at the start of the word the right arrow has nowhere to go. - assert_eq!(bidi::step(VOWELLED, 0, true, 16.0, 400.0), 0, "the right arrow is already at the start of a right-to-left word"); + assert_eq!(bidi::step(VOWELLED, 0, true, 16.0, 400.0, None), 0, "the right arrow is already at the start of a right-to-left word"); let mut stops = vec![0usize]; let mut at = 0usize; for _ in 0..12 { - let next = bidi::step(VOWELLED, at, false, 16.0, 400.0); + let next = bidi::step(VOWELLED, at, false, 16.0, 400.0, None); if next == at { break; } @@ -107,7 +107,7 @@ fn a_click_inside_a_vowelled_letter_lands_beside_it() { // Points across the word, from its left edge to its right. for step in 0..40 { let x = step as f32 * 3.0; - let at = bidi::hit(VOWELLED, egui::vec2(x, 8.0), 16.0, 400.0); + let at = bidi::hit(VOWELLED, egui::vec2(x, 8.0), 16.0, 400.0, None); assert!(!marks.contains(&at), "a click at x={x} landed on the mark at {at}"); } } @@ -141,7 +141,7 @@ fn a_bracket_in_a_right_to_left_line_reaches_egui_as_it_was_typed() { let t = Theme::DEFAULT.tokens(); let drawn: Vec = in_frame(|ui| { let pieces = bidi::plain(BRACKETED, 16.0, &t); - let (runs, _) = bidi::runs(ui, BRACKETED, &pieces, 16.0, 400.0); + let (runs, _) = bidi::runs(ui, BRACKETED, &pieces, 16.0, 400.0, None); runs.iter().flat_map(|d| d.galley.text().chars().collect::>()).collect() }); assert!(drawn.contains(&'('), "the opening bracket is handed over as itself: {drawn:?}"); @@ -175,17 +175,17 @@ fn a_line_is_justified_by_stretching_the_letters_not_the_spaces() { // wraps the line is no width at all, which is how the search knows to stop. let line_height = { let pieces = bidi::plain(SENTENCE, 18.0, &t); - bidi::runs(ui, SENTENCE, &pieces, 18.0, COLUMN).1.y + bidi::runs(ui, SENTENCE, &pieces, 18.0, COLUMN, None).1.y }; let mut width = |s: &str| { let pieces = bidi::plain(s, 18.0, &t); // Laid out in the column, the painter reports the column's own width, so the // height is what says whether the line still fits; the ink width comes from // laying the same line out with nothing to wrap against. - if bidi::runs(ui, s, &pieces, 18.0, COLUMN).1.y > line_height + 1.0 { + if bidi::runs(ui, s, &pieces, 18.0, COLUMN, None).1.y > line_height + 1.0 { return f32::INFINITY; } - bidi::runs(ui, s, &pieces, 18.0, f32::INFINITY).1.x + bidi::runs(ui, s, &pieces, 18.0, f32::INFINITY, None).1.x }; let natural = width(SENTENCE); // The strongest of Word's three: this test is about whether kashida can fill a @@ -230,7 +230,7 @@ fn a_whole_paragraph_is_justified_except_its_last_line() { let pieces = bidi::plain(PARAGRAPH, SIZE, &t); let (out, moved) = bidi::stretched(ui, PARAGRAPH, &pieces, SIZE, COLUMN, notecraft_model::justify::Strength::High); // Every line as it is drawn: its left edge and its right edge in the column. - let (drawn, _) = bidi::runs(ui, &out, &moved, SIZE, COLUMN); + let (drawn, _) = bidi::runs(ui, &out, &moved, SIZE, COLUMN, None); let mut rows: Vec<(f32, f32, f32)> = Vec::new(); for d in &drawn { let (left, right) = (d.at.x, d.at.x + d.galley.rect.width()); @@ -281,7 +281,7 @@ fn paragraph_picture(plain: &str, justified: &str, column: f32, size: f32) { ui.add_space(16.0); for text in [plain.as_str(), justified.as_str()] { let pieces = bidi::plain(text, size, &t); - let (drawn, used) = bidi::runs(ui, text, &pieces, size, column); + let (drawn, used) = bidi::runs(ui, text, &pieces, size, column, None); let (rect, _) = ui.allocate_exact_size(egui::vec2(column, used.y.max(24.0)), egui::Sense::hover()); bidi::paint(ui, rect.min, &drawn); ui.painter().rect_stroke( @@ -313,7 +313,7 @@ fn picture(plain: &str, justified: &str, column: f32) { ui.add_space(20.0); for line in [plain.as_str(), justified.as_str()] { let pieces = bidi::plain(line, 18.0, &t); - let (drawn, size) = bidi::runs(ui, line, &pieces, 18.0, column); + let (drawn, size) = bidi::runs(ui, line, &pieces, 18.0, column, None); let (rect, _) = ui.allocate_exact_size(egui::vec2(column, size.y.max(24.0)), egui::Sense::hover()); bidi::paint(ui, rect.min, &drawn); // The column the line is being justified into, so the eye can see which @@ -529,7 +529,7 @@ fn the_three_justify_strengths_differ_on_the_page() { fn drawn_lines(text: &str, size: f32, column: f32, t: ¬ecraft_ui_egui::theme::Tokens) -> Vec<(f32, f32, f32)> { in_frame(|ui| { let pieces = bidi::plain(text, size, t); - let (drawn, _) = bidi::runs(ui, text, &pieces, size, column); + let (drawn, _) = bidi::runs(ui, text, &pieces, size, column, None); let mut rows: Vec<(f32, f32, f32)> = Vec::new(); for d in &drawn { let (left, right) = (d.at.x, d.at.x + d.galley.rect.width()); @@ -560,7 +560,7 @@ fn strengths_picture(plain: &str, stretched: &[String; 3], column: f32, size: f3 ui.add_space(14.0); for (label, text) in labels.iter().zip(&all) { let pieces = bidi::plain(text, size, &t); - let (drawn, used) = bidi::runs(ui, text, &pieces, size, column); + let (drawn, used) = bidi::runs(ui, text, &pieces, size, column, None); let (rect, _) = ui.allocate_exact_size(egui::vec2(column + 70.0, used.y.max(24.0)), egui::Sense::hover()); bidi::paint(ui, rect.min, &drawn); ui.painter().text( @@ -626,7 +626,7 @@ fn an_arrow_key_walks_a_mixed_line_the_way_it_points() { let x_at = |at: usize| -> f32 { in_frame(|ui| { let pieces = bidi::plain(line, 16.0, &t); - bidi::caret(ui, line, &pieces, at, 16.0, W).map(|r| r.min.x).unwrap_or(f32::NAN) + bidi::caret(ui, line, &pieces, at, 16.0, W, None).map(|r| r.min.x).unwrap_or(f32::NAN) }) }; for right in [false, true] { @@ -638,7 +638,7 @@ fn an_arrow_key_walks_a_mixed_line_the_way_it_points() { let mut was = x_at(at); let mut stops = 1; for _ in 0..64 { - let next = bidi::step(line, at, right, 16.0, W); + let next = bidi::step(line, at, right, 16.0, W, None); if next == at { break; } @@ -662,10 +662,72 @@ fn an_arrow_key_walks_a_mixed_line_the_way_it_points() { for line in ["السعر 123 ريال", "مرحبا hello بك"] { let mut at = line.len(); for _ in 0..6 { - let forward = bidi::step(line, at, true, 16.0, W); - let back = bidi::step(line, forward, false, 16.0, W); + let forward = bidi::step(line, at, true, 16.0, W, None); + let back = bidi::step(line, forward, false, 16.0, W, None); assert_eq!(back, at, "{line:?}: right then left came back to {at}, not {back}"); at = forward; } } } + +/// 1.4 of the bar. A line whose direction comes from the page reads that way inside, and +/// not only from that edge. +/// +/// Measured: **the bar**, from 2026-10-29, and a plain fault before that. parley takes a +/// paragraph's base direction from its first strong letter and cannot be told otherwise, so +/// a list item `TODO: راجع هذه الصفحة` on a page set right to left was hung from the right +/// edge and laid out left to right inside it: `TODO:` was drawn at the far left end of the +/// line, after the Arabic it comes before, which is what `docs/gaps.md` called the line +/// that "flips". A browser with `dir="rtl"` on the page puts `TODO:` at the right edge, +/// where the line starts. The line is laid out with the page's direction mark in front now. +#[test] +fn a_line_that_starts_in_latin_reads_the_way_the_page_does() { + const TODO: &str = "TODO: راجع هذه الصفحة"; + let t = Theme::DEFAULT.tokens(); + // Where `TODO:` and the first Arabic word are drawn, by the page painter. + let places = |page: Option| -> (f32, f32) { + let h = harness(); + let blocks = notecraft_ui_egui::preview::blocks_for(notecraft_model::PageFormat::Cmgfm, TODO); + let mut frame = h.ctx.run_ui(egui::RawInput::default(), |ui| { + fonts::install_once(ui.ctx()); + ui.set_width(560.0); + notecraft_ui_egui::preview::show(ui, &blocks, std::path::Path::new("."), &t, page, &Default::default()); + }); + let at = |word: &str| { + frame + .shapes + .iter() + .find_map(|c| match &c.shape { + // The colon after `TODO` is neutral, so on a right-to-left page it goes + // with the Arabic and `TODO` is a run of its own; on a left-to-right one + // the two are drawn together. + egui::Shape::Text(text) if text.galley.text().trim_end_matches(':') == word => Some(text.pos.x), + _ => None, + }) + .unwrap_or(f32::NAN) + }; + let out = (at("TODO"), at("راجع")); + frame.textures_delta.clear(); + out + }; + + let (latin, arabic) = places(Some(notecraft_model::Dir::Rtl)); + assert!(latin.is_finite() && arabic.is_finite(), "both words were drawn: {latin} and {arabic}"); + assert!(latin > arabic, "on a right-to-left page the line starts at the right, with TODO there: {latin} and {arabic}"); + // A page that says nothing leaves the line to its first letter, which is Latin. + let (latin, arabic) = places(None); + assert!(latin < arabic, "with no page direction the line reads left to right: {latin} and {arabic}"); + + // A click and the caret go through the same layout as the drawing, or a click on + // `TODO:` would land in the Arabic beside it. + let rtl = Some(notecraft_model::Dir::Rtl); + let near_right = bidi::hit(TODO, egui::vec2(395.0, 8.0), 16.0, 400.0, rtl); + assert!(near_right <= "TODO:".len(), "a click at the right edge lands in TODO: (byte {near_right})"); + let near_left = bidi::hit(TODO, egui::vec2(5.0, 8.0), 16.0, 400.0, rtl); + assert!(near_left > "TODO:".len(), "a click at the left edge lands in the Arabic (byte {near_left})"); + let start = in_frame(|ui| { + let pieces = bidi::plain(TODO, 16.0, &t); + bidi::caret(ui, TODO, &pieces, 0, 16.0, 400.0, rtl).map(|r| r.min.x).unwrap_or(f32::NAN) + }); + assert!(start > 300.0, "the start of the line is at the right edge, and so is a caret there: {start}"); +} diff --git a/docs/gaps.md b/docs/gaps.md index 48d164a..30698ae 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -135,9 +135,17 @@ it is fixed and tested, not when it is promised. is being typed in, which needs the page's own caret in the title box. - **The demo has a page with right-to-left in every element** - كل عنصر في العربية, under the Arabic How to - which is the corpus to look at after any change to text drawing. - **Known from it, and not fixed:** a numbered item puts its number on the left of a - right-to-left line (a bullet and a task box go to the right, as they should), and a line - that starts with a Latin tag word such as `TODO:` is read as left to right and flips. + **Known from it, and not fixed:** a numbered item is written `1.` with the full stop on + the right of the number, as in a left-to-right line; a browser writes it on the left, + `.1`, in a right-to-left list. The number itself does sit on the right of the line, beside + the words, in both formats: measured through the page painter on 2026-10-29. + **Fixed on 2026-10-29:** a line on a right-to-left page that starts with a Latin word, + such as `TODO: راجع هذه الصفحة`, hung from the right edge but read left to right inside + it, so `TODO` was drawn at the far left end of the line. parley takes a paragraph's base + direction from its first strong letter and cannot be told otherwise, so the bidi painter + now lays such a line out with the page's direction mark in front; the drawing, a click, + the caret and the arrow keys all use that layout. `crates/ui-egui/tests/rtl_quality.rs` + measures it. **Found there and fixed:** a picture low on a long page was drawn nothing at all tall, because egui fits a picture into the height the layout has left and a page that scrolls has none once the blocks above have filled the view. It was not a right-to-left fault -