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 }