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

src/widget/doc_editor/layout.rs (31.6K)

  1 //! One line of the document, styled ([`preview::Line`]), laid out at a
  2 //! width: wrapped rows of runs, decorations, and the x of every byte the
  3 //! runs show — what the caret is drawn at and what a click maps back to.
  4 //!
  5 //! Wrapping is decided on measured word widths (a word running across
  6 //! styles wraps whole); then each run — consecutive bytes of one look on
  7 //! one row — is shaped once and its char boundaries recorded, and runs are
  8 //! placed at their shaped widths, so drawn text, caret and clicks agree.
  9 //! Spaces belong to the run before them, so a caret between words maps
 10 //! inside a run. A tab is shaped as one space.
 11 
 12 use std::ops::Range;
 13 
 14 use super::preview::{self, Kind, Look, Marker, PropShow, Target};
 15 use crate::scene::layout::Rect;
 16 use crate::scene::paint::TextAttrs;
 17 use crate::widget::shaping::{Measure, ShapingMeasure};
 18 
 19 /// Colours and fonts; colours are linear (what prims take).
 20 #[derive(Clone, Debug, PartialEq)]
 21 pub struct EditorTheme {
 22     pub body_font: String,
 23     pub mono_font: String,
 24     pub size: f32,
 25     /// Line height as a multiple of the font size.
 26     pub spacing: f32,
 27     pub fg: [f32; 4],
 28     pub dim: [f32; 4],
 29     pub link: [f32; 4],
 30     /// A note link with no target (the host says which, at paint).
 31     pub link_unresolved: [f32; 4],
 32     pub code_bg: [f32; 4],
 33     pub highlight_bg: [f32; 4],
 34     pub tag_bg: [f32; 4],
 35     /// A list property's item.
 36     pub pill_bg: [f32; 4],
 37     pub accent: [f32; 4],
 38     pub rule: [f32; 4],
 39     pub caret: [f32; 4],
 40     pub selection: [f32; 4],
 41 }
 42 
 43 impl EditorTheme {
 44     /// Obsidian-ish dark defaults at `size`, in the DE's sans and mono.
 45     pub fn new(size: f32) -> EditorTheme {
 46         let lin = crate::colors::to_linear;
 47         EditorTheme {
 48             body_font: "sans-serif".into(),
 49             mono_font: "monospace".into(),
 50             size,
 51             spacing: 1.55,
 52             fg: crate::colors::TEXT_FG,
 53             dim: crate::colors::TEXT_DIM,
 54             link: lin([0.66, 0.55, 0.98, 1.0]),
 55             link_unresolved: lin([0.50, 0.44, 0.70, 1.0]),
 56             code_bg: [1.0, 1.0, 1.0, 0.06],
 57             highlight_bg: lin([1.0, 0.82, 0.0, 0.40]),
 58             tag_bg: lin([0.66, 0.55, 0.98, 0.15]),
 59             pill_bg: [1.0, 1.0, 1.0, 0.09],
 60             accent: lin([0.66, 0.55, 0.98, 1.0]),
 61             rule: [1.0, 1.0, 1.0, 0.14],
 62             caret: lin([0.85, 0.85, 0.92, 1.0]),
 63             selection: lin([0.40, 0.45, 0.75, 0.45]),
 64         }
 65     }
 66 }
 67 
 68 /// Indent per list level, and the gutter a list marker sits in.
 69 fn indent(th: &EditorTheme) -> f32 {
 70     (th.size * 1.6).round()
 71 }
 72 fn gutter(th: &EditorTheme) -> f32 {
 73     (th.size * 1.6).round()
 74 }
 75 const QUOTE_STEP: f32 = 18.0;
 76 /// Space either side of a pill's text, inside its background.
 77 pub const PILL_PAD: f32 = 6.0;
 78 /// Space between two pills.
 79 const PILL_GAP: f32 = 4.0;
 80 
 81 /// The Properties table's key column, as the reading view sizes it.
 82 pub fn prop_key_w(width: f32) -> f32 {
 83     140f32.min(width * 0.35).round()
 84 }
 85 
 86 #[derive(Clone, Debug)]
 87 pub struct Run {
 88     pub row: usize,
 89     pub x: f32,
 90     pub w: f32,
 91     /// Source bytes of the line this run shows.
 92     pub src: Range<usize>,
 93     pub text: String,
 94     pub size: f32,
 95     pub font: String,
 96     pub attrs: TextAttrs,
 97     pub color: [f32; 4],
 98     pub bg: Option<[f32; 4]>,
 99     pub strike: bool,
100     pub link: Option<usize>,
101     pub look: Look,
102     /// (line byte, x relative to `x`) at every char boundary of `src`: the caret's place
103     /// before that char (a right-to-left letter's right edge), so not ascending in x where
104     /// the text turns.
105     pub xs: Vec<(usize, f32)>,
106     /// The run's clusters (line bytes, x relative to `x`), what a selection covers.
107     pub clusters: Vec<crate::widget::shaping::ShapedCluster>,
108     /// Whether the run reads right to left (its own first strong character).
109     pub rtl: bool,
110 }
111 
112 #[derive(Clone, Debug)]
113 pub enum Deco {
114     /// A filled rect (code backgrounds, rules, quote bars).
115     Quad(Rect, [f32; 4]),
116     Dot { cx: f32, cy: f32, r: f32, color: [f32; 4] },
117     Check { cx: f32, cy: f32, r: f32, checked: bool },
118     /// A list number, drawn in the gutter.
119     Text { text: String, x: f32, y: f32, size: f32, color: [f32; 4], font: String },
120     /// An embedded image, by its link text; the host's id is asked for at
121     /// paint.
122     Image { target: String, rect: Rect },
123 }
124 
125 #[derive(Clone, Debug)]
126 pub struct LineLayout {
127     pub height: f32,
128     pub row_h: f32,
129     pub rows: usize,
130     pub runs: Vec<Run>,
131     pub decos: Vec<Deco>,
132     pub links: Vec<Target>,
133     /// A task's checkbox hit rect, and the byte of its status char.
134     pub task: Option<(Rect, usize)>,
135     /// A boolean property's checkbox hit rect, its `true`/`false` bytes,
136     /// and whether it is ticked.
137     pub toggle: Option<(Rect, Range<usize>, bool)>,
138     /// Where an empty line's (or a hidden prefix's) caret sits.
139     pub content_x: f32,
140     pub content_start: usize,
141     pub text_size: f32,
142 }
143 
144 fn size_for(kind: &Kind, base: f32) -> f32 {
145     match kind {
146         Kind::Heading(1) => (base * 1.6).round(),
147         Kind::Heading(2) => (base * 1.4).round(),
148         Kind::Heading(3) => (base * 1.25).round(),
149         Kind::Heading(4) => (base * 1.1).round(),
150         Kind::Code | Kind::Fence | Kind::Frontmatter | Kind::Table | Kind::Prop(_) => (base * 0.92).round(),
151         _ => base,
152     }
153 }
154 
155 struct Piece {
156     src: Range<usize>,
157     seg: usize,
158     row: usize,
159 }
160 
161 /// Lay out `text` (one line) styled as `line`, wrapping at `width`.
162 /// `active` is whether the caret is on it (its markers show).
163 pub fn layout_line(text: &str, line: &preview::Line, active: bool, width: f32, th: &EditorTheme, m: &mut ShapingMeasure) -> LineLayout {
164     let base = size_for(&line.kind, th.size);
165     let row_h = (base * th.spacing).ceil();
166     let heading = matches!(line.kind, Kind::Heading(_));
167     let look_font = |look: &Look| -> (String, f32, TextAttrs) {
168         let mono = look.mono || look.code;
169         let size = if look.code && !look.mono { (base * 0.92).round() } else { base };
170         let font = if mono { th.mono_font.clone() } else { th.body_font.clone() };
171         (font, size, TextAttrs { italic: look.italic, weight: (look.bold || heading).then_some(700), ..Default::default() })
172     };
173 
174     // Where content starts, and the decorations of a hidden prefix.
175     let mut decos: Vec<Deco> = Vec::new();
176     let mut task = None;
177     let mut content_x = 0.0f32;
178     match &line.kind {
179         Kind::List { level, marker } => {
180             let x0 = *level as f32 * indent(th);
181             // A task's gutter holds its raw `- [ ] ` too, so the text stays
182             // put when the caret arrives and the marker shows.
183             let g = match marker {
184                 Marker::Task(..) => gutter(th).max(m.width("- [ ] ", base, &th.body_font, TextAttrs::default()).ceil()),
185                 _ => gutter(th),
186             };
187             content_x = x0 + g;
188             if !active {
189                 let cy = row_h / 2.0;
190                 let cx = x0 + g / 2.0;
191                 match marker {
192                     Marker::Bullet => decos.push(Deco::Dot { cx, cy, r: (th.size * 0.17).max(2.0), color: th.dim }),
193                     Marker::Number(n) => {
194                         let w = m.width(n, base, &th.body_font, TextAttrs::default());
195                         decos.push(Deco::Text {
196                             text: n.clone(),
197                             x: content_x - w - th.size * 0.35,
198                             y: (row_h - base) / 2.0,
199                             size: base,
200                             color: th.dim,
201                             font: th.body_font.clone(),
202                         })
203                     }
204                     Marker::Task(c, at) => {
205                         let r = (th.size * 0.42).round();
206                         decos.push(Deco::Check { cx, cy, r, checked: *c != ' ' });
207                         task = Some((Rect { x: cx - r - 3.0, y: cy - r - 3.0, width: 2.0 * r + 6.0, height: 2.0 * r + 6.0 }, *at));
208                     }
209                 }
210             } else if let Marker::Task(_, at) = marker {
211                 // The raw `- [ ] ` shows, but the box stays clickable.
212                 task = Some((Rect { x: 0.0, y: 0.0, width: 0.0, height: 0.0 }, *at));
213             }
214         }
215         Kind::Quote(d) => content_x = *d as f32 * QUOTE_STEP,
216         Kind::Prop(PropShow::Row { .. }) => content_x = prop_key_w(width),
217         _ => {}
218     }
219 
220     // Pass 1: break into rows on measured widths.
221     let segs = &line.segs;
222     let looks: Vec<(String, f32, TextAttrs)> = segs.iter().map(|s| look_font(&s.look)).collect();
223     let mut pieces: Vec<Piece> = Vec::new();
224     // On the active line the hidden prefix shows; its markers end where
225     // the content begins, so text does not jump when the caret arrives.
226     let prefix_w: f32 = if active && line.content_start > 0 {
227         segs.iter()
228             .enumerate()
229             .filter(|(_, s)| s.range.end <= line.content_start && s.look.marker)
230             .map(|(i, s)| m.width(&text[s.range.clone()].replace('\t', " "), looks[i].1, &looks[i].0, looks[i].2))
231             .sum()
232     } else {
233         0.0
234     };
235     let start_x = (content_x - prefix_w).max(0.0);
236     let right = width.max(content_x + 40.0);
237     let mut cx = start_x;
238     let mut row = 0usize;
239     let mut row_start = true;
240     for (si, seg) in segs.iter().enumerate() {
241         let (font, size, attrs) = &looks[si];
242         let t = &text[seg.range.clone()];
243         let mut i = 0;
244         let bytes = t.as_bytes();
245         while i < bytes.len() {
246             let ws = bytes[i] == b' ' || bytes[i] == b'\t';
247             let mut j = i;
248             while j < bytes.len() && (bytes[j] == b' ' || bytes[j] == b'\t') == ws {
249                 j += 1;
250             }
251             while j < t.len() && !t.is_char_boundary(j) {
252                 j += 1;
253             }
254             let src = seg.range.start + i..seg.range.start + j;
255             if ws {
256                 // Spaces join the piece before them (or lead a row).
257                 if row_start && cx <= content_x && !active {
258                     // Leading whitespace of a row shows as nothing.
259                     pieces.push(Piece { src, seg: si, row });
260                 } else {
261                     let w = m.width(&t[i..j].replace('\t', " "), *size, font, *attrs);
262                     pieces.push(Piece { src, seg: si, row });
263                     cx += w;
264                 }
265             } else {
266                 let mut w = m.width(&t[i..j], *size, font, *attrs);
267                 if seg.look.pill && i == 0 {
268                     w += 2.0 * PILL_PAD + PILL_GAP;
269                 }
270                 // A word glued to the previous piece (no space between,
271                 // other style) moves with it.
272                 let glued = pieces.last().is_some_and(|p| p.src.end == src.start && !text[p.src.clone()].ends_with([' ', '\t']));
273                 if cx + w > right && !row_start {
274                     if glued {
275                         // Carry the glued run down with this word.
276                         let mut k = pieces.len();
277                         while k > 0 && pieces[k - 1].row == row && !text[pieces[k - 1].src.clone()].ends_with([' ', '\t']) && (k == pieces.len() || pieces[k - 1].src.end == pieces[k].src.start) {
278                             k -= 1;
279                         }
280                         if k > 0 && pieces[k - 1].row == row {
281                             row += 1;
282                             for p in &mut pieces[k..] {
283                                 p.row = row;
284                             }
285                             cx = content_x;
286                             for p in &pieces[k..] {
287                                 let (f, s, a) = &looks[p.seg];
288                                 cx += m.width(&text[p.src.clone()], *s, f, *a);
289                             }
290                         }
291                     } else {
292                         row += 1;
293                         cx = content_x;
294                     }
295                 }
296                 pieces.push(Piece { src, seg: si, row });
297                 cx += w;
298                 row_start = false;
299             }
300             i = j;
301         }
302     }
303 
304     // Obsidian dims a finished task's text (the reading view does too).
305     let done = matches!(&line.kind, Kind::List { marker: Marker::Task(c, _), .. } if *c != ' ');
306 
307     // Pass 2: merge pieces into runs, shape each, place left to right.
308     let mut runs: Vec<Run> = Vec::new();
309     let mut row_x: Vec<f32> = Vec::new();
310     for p in pieces {
311         let seg = &segs[p.seg];
312         let joins = runs.last().is_some_and(|r: &Run| {
313             r.row == p.row && r.src.end == p.src.start && r.look == seg.look && r.link == seg.link && !text[p.src.clone()].contains('\t') && !text[r.src.clone()].contains('\t')
314         });
315         if joins {
316             let r = runs.last_mut().unwrap();
317             r.src.end = p.src.end;
318         } else {
319             let (font, size, attrs) = looks[p.seg].clone();
320             let color = if done && seg.link.is_none() { th.dim } else { color_for(&seg.look, th) };
321             let bg = if seg.look.pill {
322                 Some(th.pill_bg)
323             } else if seg.look.code && !seg.look.mono {
324                 Some(th.code_bg)
325             } else if seg.look.highlight {
326                 Some(th.highlight_bg)
327             } else if seg.look.tag {
328                 Some(th.tag_bg)
329             } else {
330                 None
331             };
332             runs.push(Run {
333                 row: p.row,
334                 x: 0.0,
335                 w: 0.0,
336                 src: p.src,
337                 text: String::new(),
338                 size,
339                 font,
340                 attrs,
341                 color,
342                 bg,
343                 strike: seg.look.strike,
344                 link: seg.link,
345                 look: seg.look,
346                 xs: Vec::new(),
347                 clusters: Vec::new(),
348                 rtl: false,
349             });
350         }
351     }
352     // Split every run where the bidirectional level changes, so each is one direction: a
353     // run of plain text can hold an English word and a Hebrew one, which the reordering
354     // below must be able to place apart. Levels are the whole line's, neutrals (spaces,
355     // markup) resolved from their neighbours.
356     let para_rtl = crate::backend::text::paragraph_rtl(text);
357     let levels = crate::backend::text::bidi_levels(text, para_rtl);
358     let level_at = |b: usize| levels.get(b).copied().unwrap_or(if para_rtl { 1 } else { 0 });
359     let mut split: Vec<Run> = Vec::with_capacity(runs.len());
360     for r in runs {
361         let mut from = r.src.start;
362         let mut cur = level_at(from);
363         for (i, _) in text[r.src.clone()].char_indices() {
364             let b = r.src.start + i;
365             if level_at(b) != cur {
366                 split.push(Run { src: from..b, ..r.clone() });
367                 from = b;
368                 cur = level_at(b);
369             }
370         }
371         split.push(Run { src: from..r.src.end, ..r });
372     }
373     let mut runs = split;
374     for r in &mut runs {
375         while row_x.len() <= r.row {
376             row_x.push(if row_x.is_empty() { start_x } else { content_x });
377         }
378         let shown = text[r.src.clone()].replace('\t', " ");
379         let shaped = m.shape(&shown, r.size, &r.font, r.attrs);
380         r.w = shaped.width;
381         r.rtl = shaped.rtl;
382         r.xs = shaped.stops.iter().map(|&(b, x)| (r.src.start + b, x)).collect();
383         r.clusters = shaped
384             .clusters
385             .iter()
386             .map(|c| crate::widget::shaping::ShapedCluster { start: r.src.start + c.start, end: r.src.start + c.end, ..*c })
387             .collect();
388         r.text = shown;
389         let pad = if r.look.pill { PILL_PAD } else { 0.0 };
390         r.x = row_x[r.row] + pad;
391         row_x[r.row] += r.w + 2.0 * pad + if r.look.pill { PILL_GAP } else { 0.0 };
392     }
393     // Visual order. Runs were placed left to right in logical order; a row whose runs are
394     // not all of the paragraph's direction is re-placed in the order the bidirectional
395     // algorithm draws them (`visual_run_order`), and a right-to-left paragraph's rows are
396     // set against the right edge — a plain line or a heading's; a list, quote or table
397     // keeps its markers at the left and only reorders.
398     let align_right = para_rtl && matches!(line.kind, Kind::Plain | Kind::Heading(_));
399     let rows_placed = runs.last().map_or(0, |r| r.row + 1);
400     for row in 0..rows_placed {
401         let idx: Vec<usize> = (0..runs.len()).filter(|&i| runs[i].row == row).collect();
402         if idx.is_empty() {
403             continue;
404         }
405         let order = {
406             let run_levels: Vec<u8> = idx.iter().map(|&i| level_at(runs[i].src.start)).collect();
407             crate::backend::text::visual_order(&run_levels)
408         };
409         if !align_right && order.iter().enumerate().all(|(k, &o)| k == o) {
410             continue;
411         }
412         // A run's room: its pill padding either side, and the gap after a pill.
413         let room = |r: &Run| {
414             let pad = if r.look.pill { PILL_PAD } else { 0.0 };
415             (pad, r.w + 2.0 * pad + if r.look.pill { PILL_GAP } else { 0.0 })
416         };
417         let start = runs[idx[0]].x - room(&runs[idx[0]]).0;
418         let total: f32 = idx.iter().map(|&i| room(&runs[i]).1).sum();
419         let mut x = if align_right { (width - total).max(start) } else { start };
420         for &k in &order {
421             let i = idx[k];
422             let (pad, adv) = room(&runs[i]);
423             runs[i].x = x + pad;
424             x += adv;
425         }
426     }
427 
428     let rows = runs.last().map(|r| r.row + 1).unwrap_or(1);
429     let mut height = rows as f32 * row_h;
430 
431     // The Properties table's own parts.
432     let mut toggle = None;
433     if let Kind::Prop(show) = &line.kind {
434         let label_in = |text: &str, x: f32, color: [f32; 4]| Deco::Text {
435             text: text.to_string(),
436             x,
437             y: (row_h - base) / 2.0,
438             size: base,
439             color,
440             font: th.body_font.clone(),
441         };
442         let label = |text: &str, x: f32| label_in(text, x, th.dim);
443         match show {
444             PropShow::Header => decos.push(label(&crate::l10n::tr("doc-properties"), 0.0)),
445             PropShow::Close => {
446                 height = (row_h * 0.75).round();
447                 decos.push(Deco::Quad(Rect { x: 0.0, y: (height / 2.0).round(), width, height: 1.0 }, th.rule));
448             }
449             PropShow::Hidden => height = 0.0,
450             PropShow::Row { key, check, empty } => {
451                 if let Some(k) = key {
452                     decos.push(label(&fit(k, content_x - 10.0, base, &th.body_font, m), 0.0));
453                 }
454                 if let Some((bytes, on)) = check {
455                     let r = (th.size * 0.42).round();
456                     let (cx, cy) = (content_x + r + 1.0, row_h / 2.0);
457                     decos.push(Deco::Check { cx, cy, r, checked: *on });
458                     let hit = Rect { x: cx - r - 3.0, y: cy - r - 3.0, width: 2.0 * r + 6.0, height: 2.0 * r + 6.0 };
459                     toggle = Some((hit, bytes.clone(), *on));
460                 } else if *empty {
461                     // Fainter than any value: text colour has no alpha.
462                     let d = th.dim;
463                     decos.push(label_in(&crate::l10n::tr("doc-empty"), content_x, [d[0] * 0.45, d[1] * 0.45, d[2] * 0.5, d[3]]));
464                 }
465             }
466         }
467     }
468 
469     // Block decorations that span the line.
470     match &line.kind {
471         Kind::Quote(d) => {
472             for k in 0..*d {
473                 decos.push(Deco::Quad(Rect { x: k as f32 * QUOTE_STEP + 2.0, y: 0.0, width: 3.0, height }, th.accent));
474             }
475         }
476         Kind::Rule if !active => decos.push(Deco::Quad(Rect { x: 0.0, y: (height / 2.0).round(), width, height: 1.0 }, th.rule)),
477         Kind::Code | Kind::Fence => decos.insert(0, Deco::Quad(Rect { x: -8.0, y: 0.0, width: width + 16.0, height }, th.code_bg)),
478         _ => {}
479     }
480     LineLayout { height, row_h, rows, runs, decos, links: line.links.clone(), task, toggle, content_x, content_start: line.content_start, text_size: base }
481 }
482 
483 /// `text` cut to `w` with an ellipsis.
484 fn fit(text: &str, w: f32, size: f32, font: &str, m: &mut ShapingMeasure) -> String {
485     if m.width(text, size, font, TextAttrs::default()) <= w {
486         return text.to_string();
487     }
488     let mut chars: Vec<char> = text.chars().collect();
489     while !chars.is_empty() {
490         chars.pop();
491         let t: String = chars.iter().collect::<String>() + "…";
492         if m.width(&t, size, font, TextAttrs::default()) <= w {
493             return t;
494         }
495     }
496     "…".into()
497 }
498 
499 fn color_for(look: &Look, th: &EditorTheme) -> [f32; 4] {
500     if look.marker || look.comment || look.dim {
501         th.dim
502     } else if look.link || look.tag {
503         th.link
504     } else {
505         th.fg
506     }
507 }
508 
509 /// Space above and below an embedded image.
510 pub const IMAGE_PAD: f32 = 4.0;
511 
512 impl LineLayout {
513     /// A line shown as nothing but its embedded image (live preview, the
514     /// caret elsewhere): one row as tall as the image, so a click on it
515     /// lands on the line and reveals the raw text.
516     pub fn image(target: &str, w: f32, h: f32, text_size: f32) -> LineLayout {
517         let height = h + 2.0 * IMAGE_PAD;
518         LineLayout {
519             height,
520             row_h: height,
521             rows: 1,
522             runs: Vec::new(),
523             decos: vec![Deco::Image { target: target.to_string(), rect: Rect { x: 0.0, y: IMAGE_PAD, width: w, height: h } }],
524             links: Vec::new(),
525             task: None,
526             toggle: None,
527             content_x: 0.0,
528             content_start: 0,
529             text_size,
530         }
531     }
532 
533     /// The line with the images embedded inside it shown in a row below
534     /// it, left to right, wrapping at `width` (live preview cannot sit a
535     /// picture inside a text row: rows are one height).
536     pub fn with_images_below(mut self, images: &[(String, f32, f32)], width: f32) -> LineLayout {
537         let (mut x, mut y, mut row_h) = (0.0f32, self.height + IMAGE_PAD, 0.0f32);
538         for (target, w, h) in images {
539             if x > 0.0 && x + w > width {
540                 y += row_h + IMAGE_PAD;
541                 x = 0.0;
542                 row_h = 0.0;
543             }
544             self.decos.push(Deco::Image { target: target.clone(), rect: Rect { x, y, width: *w, height: *h } });
545             x += w + 2.0 * IMAGE_PAD;
546             row_h = row_h.max(*h);
547         }
548         if !images.is_empty() {
549             self.height = y + row_h + IMAGE_PAD;
550         }
551         self
552     }
553 
554     /// The raw line with its image shown below it (the caret on it).
555     pub fn with_image_below(mut self, target: &str, w: f32, h: f32) -> LineLayout {
556         let y = self.height + IMAGE_PAD;
557         self.decos.push(Deco::Image { target: target.to_string(), rect: Rect { x: 0.0, y, width: w, height: h } });
558         self.height = y + h + IMAGE_PAD;
559         self
560     }
561 
562     /// Where the caret before byte `col` is drawn: (x, row).
563     pub fn caret_xy(&self, col: usize) -> (f32, usize) {
564         let mut best: Option<(f32, usize)> = None;
565         for r in &self.runs {
566             if col < r.src.start {
567                 break;
568             }
569             if col <= r.src.end {
570                 let x = r.xs.iter().rev().find(|(b, _)| *b <= col).map(|(_, x)| *x).unwrap_or(0.0);
571                 best = Some((r.x + x, r.row));
572                 if col < r.src.end {
573                     break;
574                 }
575             } else {
576                 // Past the run's end: its trailing side, the left of a right-to-left run.
577                 best = Some((if r.rtl { r.x } else { r.x + r.w }, r.row));
578             }
579         }
580         best.unwrap_or_else(|| match self.runs.first() {
581             Some(r) => (r.x, r.row),
582             None => (self.content_x, 0),
583         })
584     }
585 
586     /// What the bytes `a..b` of the line cover, as `(row, x0, x1)` rects left to right: the
587     /// boxes of the clusters in that range, merged where they touch (and across the gap
588     /// between two runs of a row). A range crossing a change of direction is visually apart,
589     /// so it can be several on one row. `to_end` stretches the last row's selection a little
590     /// past the line's end, for a selection that goes on to the next line.
591     pub fn selection_rects(&self, a: usize, b: usize, to_end: bool) -> Vec<(usize, f32, f32)> {
592         let mut boxes: Vec<(usize, f32, f32)> = Vec::new();
593         for r in &self.runs {
594             for c in r.clusters.iter().filter(|c| c.start < b && c.end > a) {
595                 boxes.push((r.row, r.x + c.x0, r.x + c.x1));
596             }
597         }
598         boxes.sort_by(|p, q| p.0.cmp(&q.0).then(p.1.total_cmp(&q.1)));
599         let gap = 2.0 * PILL_PAD + PILL_GAP + 1.0;
600         let mut out: Vec<(usize, f32, f32)> = Vec::new();
601         for (row, x0, x1) in boxes {
602             match out.last_mut() {
603                 Some(last) if last.0 == row && x0 <= last.2 + gap => last.2 = last.2.max(x1),
604                 _ => out.push((row, x0, x1)),
605             }
606         }
607         if to_end {
608             let last_row = self.rows.saturating_sub(1);
609             let end = self.runs.iter().map(|r| r.x + r.w).fold(self.content_x, f32::max) + 6.0;
610             match out.iter_mut().rev().find(|s| s.0 == last_row) {
611                 Some(s) => s.2 = s.2.max(end),
612                 None => out.push((last_row, end - 6.0, end)),
613             }
614         }
615         out
616     }
617 
618     /// The byte nearest a point (x, row) of the line.
619     pub fn col_at(&self, x: f32, row: usize) -> usize {
620         let in_row: Vec<&Run> = self.runs.iter().filter(|r| r.row == row).collect();
621         if in_row.is_empty() {
622             return match self.runs.iter().rev().find(|r| r.row < row) {
623                 Some(r) => r.src.end,
624                 None => self.content_start,
625             };
626         }
627         let mut best = (f32::INFINITY, in_row[0].src.start);
628         for r in in_row {
629             for (b, bx) in &r.xs {
630                 let d = (r.x + bx - x).abs();
631                 if d < best.0 {
632                     best = (d, *b);
633                 }
634             }
635         }
636         best.1
637     }
638 
639     /// The link under a point of the line, by index into `links`.
640     pub fn link_at(&self, x: f32, y: f32) -> Option<usize> {
641         let row = (y / self.row_h).floor().max(0.0) as usize;
642         self.runs.iter().find(|r| r.row == row && r.link.is_some() && x >= r.x && x <= r.x + r.w).and_then(|r| r.link)
643     }
644 }
645 
646 #[cfg(test)]
647 mod tests {
648     use super::*;
649     use crate::widget::doc_editor::preview::{style_line, Context};
650 
651     fn lay(text: &str, active: bool, width: f32) -> LineLayout {
652         let th = EditorTheme::new(14.0);
653         let mut m = ShapingMeasure::new(false);
654         let line = style_line(text, Context::Normal, active);
655         layout_line(text, &line, active, width, &th, &mut m)
656     }
657 
658     #[test]
659     fn every_shown_byte_has_an_x_and_clicks_come_back() {
660         let t = "plain **bold** and [[Link|shown]] end";
661         for active in [false, true] {
662             let l = lay(t, active, 2000.0);
663             assert_eq!(l.rows, 1);
664             let mut last = -1.0;
665             for r in &l.runs {
666                 for (b, x) in &r.xs {
667                     assert!(t.is_char_boundary(*b));
668                     let ax = r.x + x;
669                     assert!(ax >= last - 0.01, "x runs backwards at {b}: {ax} < {last}");
670                     last = ax;
671                 }
672             }
673             // Every visible boundary maps to a caret x that maps back to it.
674             for r in &l.runs {
675                 for (b, _) in &r.xs {
676                     let (x, row) = l.caret_xy(*b);
677                     let back = l.col_at(x, row);
678                     let (x2, _) = l.caret_xy(back);
679                     assert!((x - x2).abs() < 0.5, "{b} -> {x} -> {back}");
680                 }
681             }
682         }
683     }
684 
685     /// A Hebrew word in a Latin line: its carets fall from its right end, a click on its
686     /// right edge is its first letter, and a selection from inside the Latin into it is two
687     /// rects, the Latin end and the Hebrew word's right side (its first letters).
688     #[test]
689     fn right_to_left_words_are_laid_out_and_selected_where_they_are() {
690         let t = "ab שלום cd";
691         let l = lay(t, false, 2000.0);
692         let w0 = t.find('ש').unwrap();
693         let w_end = w0 + "שלום".len();
694         let (first, _) = l.caret_xy(w0);
695         let (last, _) = l.caret_xy(w0 + 6);
696         assert!(first > last, "the word's first letter is right of its last: {first} vs {last}");
697         assert_eq!(l.col_at(first, 0), w0, "a click at its right edge is its first letter");
698         let rects = l.selection_rects(1, w0 + 2, false);
699         assert_eq!(rects.len(), 2, "the Latin end and the word's right side: {rects:?}");
700         let (x_b, _) = l.caret_xy(1);
701         assert!((rects[0].1 - x_b).abs() < 0.5, "the first starts at the b: {rects:?}");
702         assert!(rects[1].2 <= first + 0.5 && rects[1].1 > last, "the second is inside the word, at its right");
703         let whole = l.selection_rects(0, w_end, false);
704         assert_eq!(whole.len(), 1, "a range ending at the word's end is one strip: {whole:?}");
705     }
706 
707     /// A Hebrew paragraph is set against the right edge with its runs placed from the
708     /// right: its first word rightmost, a bold word after it to its left. In an English line,
709     /// two Hebrew runs side by side swap places (the right-to-left sequence reads from the
710     /// right), the English around them staying where it was.
711     #[test]
712     fn styled_runs_are_placed_in_visual_order() {
713         let width = 600.0;
714         let rtl = "שלום **עולם** טוב";
715         let l = lay(rtl, false, width);
716         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();
717         let (first_x0, first_x1) = run_of(&l, "שלום");
718         let (bold_x0, bold_x1) = run_of(&l, "עולם");
719         let (last_x0, _) = run_of(&l, "טוב");
720         assert!((first_x1 - width).abs() < 1.0, "set against the right edge: {first_x1}");
721         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<_>>());
722 
723         let mixed = "say שלום **עולם** now";
724         let m = lay(mixed, false, width);
725         let (say_x0, _) = run_of(&m, "say");
726         let (shalom_x0, _) = run_of(&m, "שלום");
727         let (olam_x0, olam_x1) = run_of(&m, "עולם");
728         let (now_x0, _) = run_of(&m, "now");
729         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<_>>());
730         assert!(say_x0 < 50.0, "a left-to-right line still starts at the left");
731     }
732 
733     #[test]
734     fn long_lines_wrap_with_a_hanging_list_indent() {
735         let t = "- one two three four five six seven eight nine ten eleven twelve";
736         let l = lay(t, false, 160.0);
737         assert!(l.rows > 2, "rows {}", l.rows);
738         let first_x: Vec<f32> = (0..l.rows).map(|row| l.runs.iter().find(|r| r.row == row).unwrap().x).collect();
739         assert!(first_x.iter().all(|x| (*x - l.content_x).abs() < 0.5), "{first_x:?} vs {}", l.content_x);
740         assert!(matches!(l.decos[0], Deco::Dot { .. }));
741     }
742 
743     #[test]
744     fn the_active_lines_marker_ends_where_the_content_starts() {
745         let t = "- [ ] task text";
746         let off = lay(t, false, 1000.0);
747         let on = lay(t, true, 1000.0);
748         let text_x = |l: &LineLayout| l.caret_xy(6).0;
749         assert!((text_x(&off) - text_x(&on)).abs() < 1.5, "{} vs {}", text_x(&off), text_x(&on));
750         assert!(off.task.is_some() && matches!(off.decos[0], Deco::Check { checked: false, .. }));
751         // Hidden prefix: a click left of the text lands at its start.
752         assert_eq!(off.col_at(0.0, 0), 6);
753     }
754 
755     #[test]
756     fn empty_and_link_lines() {
757         let l = lay("", false, 500.0);
758         assert_eq!((l.rows, l.caret_xy(0)), (1, (0.0, 0)));
759         let t = "see [[Target]]";
760         let l = lay(t, false, 500.0);
761         let r = l.runs.iter().find(|r| r.link.is_some()).unwrap();
762         assert_eq!(l.link_at(r.x + 2.0, 2.0), Some(0));
763         assert_eq!(l.link_at(1.0, 2.0), None);
764     }
765 }