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

src/widget/markdown.rs (40.9K)

  1 //! `MarkdownView`: a note's blocks (`cce_vault::markdown`) laid out at a
  2 //! width into draw items and click targets — the reading view of
  3 //! cce-notes, the note cards on cce-grid's desktop, and any other surface
  4 //! that shows a vault note. Behind the `markdown` feature, which brings in
  5 //! cce-vault; clients that draw no Markdown do not build it.
  6 //!
  7 //! cce-ui's text prim draws one run in one style, so a paragraph is laid
  8 //! out word by word: each word is measured through the same shaping entry
  9 //! the renderer draws with ([`ShapingMeasure`]), wrapped greedily (a word
 10 //! running across styles — `` `code`, `` — wraps as one), and consecutive
 11 //! words of one style on one line merge back into a single prim. Lay out
 12 //! once per note and width; [`Layout::paint`] only translates and culls,
 13 //! [`Layout::paint_scaled`] also scales.
 14 //!
 15 //! Colours for links, tags, highlights and callouts are written in sRGB
 16 //! and converted where used; bullets are discs (`PaintCtx::circle`), since
 17 //! the squircle corner shape draws a few-px rounded rect as a square.
 18 //! Moved here from cce-notes (its `reading.rs`) on 2026-10-01.
 19 
 20 use crate::scene::layout::Rect;
 21 use crate::scene::paint::{Cap, PaintCtx, TextAttrs};
 22 pub use cce_vault::markdown::{blocks, Block, Callout, EmbedSize, ListItem, Span, SpanLink, Style};
 23 pub use crate::widget::EmbedImage;
 24 
 25 pub use crate::widget::shaping::{Measure, ShapingMeasure};
 26 
 27 /// Fonts and the body size the layout works from.
 28 #[derive(Clone, Debug, PartialEq)]
 29 pub struct Theme {
 30     pub body_font: String,
 31     pub mono_font: String,
 32     pub size: f32,
 33 }
 34 
 35 impl Theme {
 36     fn line_h(&self, size: f32) -> f32 {
 37         (size * 1.5).ceil()
 38     }
 39 }
 40 
 41 // Colours. TODO(style): the toolkit has no link or highlight colour yet;
 42 // these follow Obsidian's dark theme until it does. FG, DIM and the white
 43 // washes are already linear; the rest are written in sRGB (as a theme
 44 // states them) and go through `lin` where they are used.
 45 const FG: [f32; 4] = crate::colors::TEXT_FG;
 46 const DIM: [f32; 4] = crate::colors::TEXT_DIM;
 47 const LINK: [f32; 4] = [0.66, 0.55, 0.98, 1.0];
 48 const LINK_UNRESOLVED: [f32; 4] = [0.50, 0.44, 0.70, 1.0];
 49 const TAG: [f32; 4] = [0.66, 0.55, 0.98, 1.0];
 50 const TAG_BG: [f32; 4] = [0.66, 0.55, 0.98, 0.15];
 51 const CODE_BG: [f32; 4] = [1.0, 1.0, 1.0, 0.06];
 52 const HIGHLIGHT_BG: [f32; 4] = [1.0, 0.82, 0.0, 0.40];
 53 const RULE: [f32; 4] = [1.0, 1.0, 1.0, 0.12];
 54 const QUOTE_BAR: [f32; 4] = [0.66, 0.55, 0.98, 1.0];
 55 
 56 /// What an inline image's link text is swapped for while a paragraph is
 57 /// tokenized: one non-space char, so the image is one word.
 58 const IMAGE_MARK: &str = "\u{FFFC}";
 59 /// Space an inline image keeps above and below it on its line.
 60 const INLINE_IMAGE_PAD: f32 = 4.0;
 61 
 62 /// Indent of a list level, and of a quote's body past its bar.
 63 const INDENT: f32 = 24.0;
 64 const BLOCK_GAP: f32 = 10.0;
 65 const CODE_PAD: f32 = 10.0;
 66 
 67 #[derive(Clone, Debug, PartialEq)]
 68 pub enum Hit {
 69     Link(SpanLink),
 70     /// A task's checkbox: its source line and current status.
 71     Task { line: usize, status: char },
 72 }
 73 
 74 #[derive(Clone, Debug)]
 75 pub enum Draw {
 76     Text { text: String, x: f32, y: f32, size: f32, color: [f32; 4], font: String, attrs: TextAttrs },
 77     Quad { rect: Rect, color: [f32; 4] },
 78     Round { rect: Rect, radius: f32, color: [f32; 4] },
 79     Line { x1: f32, y1: f32, x2: f32, y2: f32, width: f32, color: [f32; 4] },
 80     Check { cx: f32, cy: f32, r: f32, checked: bool },
 81     /// A filled disc: a list bullet. Not a `Round` — at a few px the
 82     /// squircle corner shape draws a small radius as a square.
 83     Dot { cx: f32, cy: f32, r: f32, color: [f32; 4] },
 84     /// An embedded image, by its link text; its id is asked for at paint.
 85     Image { target: String, rect: Rect },
 86     /// A bundled cce-icons glyph, tinted `color` (sRGB, as a text colour).
 87     Icon { name: &'static str, rect: Rect, color: [f32; 4] },
 88 }
 89 
 90 impl Draw {
 91     fn top_bottom(&self) -> (f32, f32) {
 92         match self {
 93             Draw::Text { y, size, .. } => (*y, y + size * 1.3),
 94             Draw::Quad { rect, .. } | Draw::Round { rect, .. } | Draw::Image { rect, .. } | Draw::Icon { rect, .. } => (rect.y, rect.y + rect.height),
 95             Draw::Line { y1, y2, width, .. } => (y1.min(*y2) - width, y1.max(*y2) + width),
 96             Draw::Check { cy, r, .. } | Draw::Dot { cy, r, .. } => (cy - r, cy + r),
 97         }
 98     }
 99 }
100 
101 #[derive(Default, Debug)]
102 pub struct Layout {
103     pub draws: Vec<Draw>,
104     pub hits: Vec<(Rect, Hit)>,
105     /// Each block's (and list item's) first source line and the y it
106     /// starts at, in source order.
107     pub lines: Vec<(usize, f32)>,
108     pub height: f32,
109 }
110 
111 impl Layout {
112     /// The y of the last block starting at or before source `line`.
113     pub fn y_of_line(&self, line: usize) -> f32 {
114         self.lines.iter().take_while(|(l, _)| *l <= line).last().map(|(_, y)| *y).unwrap_or(0.0)
115     }
116 
117     pub fn hit(&self, x: f32, y: f32) -> Option<&Hit> {
118         self.hits
119             .iter()
120             .find(|(r, _)| x >= r.x && x <= r.x + r.width && y >= r.y && y <= r.y + r.height)
121             .map(|(_, h)| h)
122     }
123 
124     /// Paint at `origin` scrolled by `scroll`, culled to `viewport`.
125     pub fn paint(&self, pc: &mut PaintCtx, origin: (f32, f32), scroll: f32, viewport: Rect) {
126         self.paint_scaled(pc, (origin.0, origin.1 - scroll), 1.0, viewport);
127     }
128 
129     /// Paint with every length multiplied by `k`, the layout's top-left at
130     /// `origin` — for a host that lays out in its own units (cce-grid's
131     /// virtual desktop) and draws at another resolution. Culled to
132     /// `viewport`, in the painted space.
133     pub fn paint_scaled(&self, pc: &mut PaintCtx, origin: (f32, f32), k: f32, viewport: Rect) {
134         self.paint_scaled_with(pc, origin, k, viewport, &|_| None);
135     }
136 
137     /// [`Layout::paint`], drawing embedded images through `image` — the
138     /// same lookup the layout was made with ([`layout_with`]).
139     pub fn paint_with(&self, pc: &mut PaintCtx, origin: (f32, f32), scroll: f32, viewport: Rect, image: &dyn Fn(&str) -> Option<EmbedImage>) {
140         self.paint_scaled_with(pc, (origin.0, origin.1 - scroll), 1.0, viewport, image);
141     }
142 
143     /// [`Layout::paint_scaled`] with embedded images. An image the host no
144     /// longer has (gone since layout) leaves a faint placeholder.
145     pub fn paint_scaled_with(&self, pc: &mut PaintCtx, origin: (f32, f32), k: f32, viewport: Rect, image: &dyn Fn(&str) -> Option<EmbedImage>) {
146         let (ox, oy) = origin;
147         let (top, bottom) = ((viewport.y - oy) / k, (viewport.y + viewport.height - oy) / k);
148         let at = |x: f32, y: f32| (ox + x * k, oy + y * k);
149         let rect = |r: &Rect| {
150             let (x, y) = at(r.x, r.y);
151             Rect { x, y, width: r.width * k, height: r.height * k }
152         };
153         for d in &self.draws {
154             let (t, b) = d.top_bottom();
155             if b < top || t > bottom {
156                 continue;
157             }
158             match d {
159                 Draw::Text { text, x, y, size, color, font, attrs } => {
160                     let (x, y) = at(*x, *y);
161                     pc.text_attrs(text.clone(), x, y, size * k, srgb_u8(*color), Some(font.clone()), None, *attrs)
162                 }
163                 Draw::Quad { rect: r, color } => pc.quad(rect(r), *color),
164                 Draw::Round { rect: r, radius, color } => {
165                     pc.rounded_rect(rect(r), radius * k, (true, true, true, true), *color)
166                 }
167                 Draw::Line { x1, y1, x2, y2, width, color } => {
168                     let ((ax, ay), (bx, by)) = (at(*x1, *y1), at(*x2, *y2));
169                     pc.vector(ax, ay, bx, by, (width * k).max(0.5), *color, Cap::Flat)
170                 }
171                 Draw::Check { cx, cy, r, checked } => {
172                     let (x, y) = at(*cx, *cy);
173                     crate::widget::Checkbox::paint_inline(pc, x, y, r * k, *checked)
174                 }
175                 Draw::Dot { cx, cy, r, color } => {
176                     let (x, y) = at(*cx, *cy);
177                     pc.circle(x, y, r * k, *color)
178                 }
179                 Draw::Image { target, rect: r } => match image(target) {
180                     Some(img) => pc.image(img.id, rect(r), 1.0),
181                     None => pc.rounded_rect(rect(r), 4.0 * k, (true, true, true, true), CODE_BG),
182                 },
183                 Draw::Icon { name, rect: r, color } => {
184                     pc.icon(name, rect(r), *color);
185                 }
186             }
187         }
188     }
189 }
190 
191 /// An sRGB colour (alpha untouched) in the linear space prims take.
192 fn lin(srgb: [f32; 4]) -> [f32; 4] {
193     crate::colors::to_linear(srgb)
194 }
195 
196 pub fn srgb_u8(linear: [f32; 4]) -> [u8; 3] {
197     let s = crate::colors::to_srgb(linear);
198     [(s[0] * 255.0) as u8, (s[1] * 255.0) as u8, (s[2] * 255.0) as u8]
199 }
200 
201 /// Lay out `blocks` at `width`. `resolved` says whether a note link has a
202 /// target, so unresolved ones draw faded as in Obsidian.
203 pub fn layout(
204     blocks: &[Block],
205     width: f32,
206     theme: &Theme,
207     m: &mut dyn Measure,
208     resolved: &dyn Fn(&SpanLink) -> bool,
209 ) -> Layout {
210     layout_with(blocks, width, theme, m, resolved, &|_| None)
211 }
212 
213 /// [`layout`], with `image` answering for an embed's link text: an image
214 /// it has draws in place of the embed's link, sized by [`EmbedImage::fit`]
215 /// (Obsidian's `|300` / `|300x200` honoured). One it does not have — not
216 /// an image, or still loading — keeps the link; lay out again when it
217 /// arrives.
218 pub fn layout_with(
219     blocks: &[Block],
220     width: f32,
221     theme: &Theme,
222     m: &mut dyn Measure,
223     resolved: &dyn Fn(&SpanLink) -> bool,
224     image: &dyn Fn(&str) -> Option<EmbedImage>,
225 ) -> Layout {
226     let mut l = Layouter { theme, m, resolved, image, out: Layout::default(), pending: Vec::new(), pending_images: Vec::new() };
227     let y = l.blocks(blocks, 0.0, width.max(80.0), 0.0, true);
228     l.out.height = y;
229     l.out
230 }
231 
232 struct Layouter<'a> {
233     theme: &'a Theme,
234     m: &'a mut dyn Measure,
235     resolved: &'a dyn Fn(&SpanLink) -> bool,
236     image: &'a dyn Fn(&str) -> Option<EmbedImage>,
237     out: Layout,
238     /// Runs finished on the current line, emitted when the line ends.
239     pending: Vec<Run>,
240     /// Inline images placed on the current line: (link text, x, w, h).
241     pending_images: Vec<(String, f32, f32, f32)>,
242 }
243 
244 /// One inline run's look, before it is placed.
245 #[derive(Clone, Copy)]
246 struct RunStyle {
247     size: f32,
248     bold: bool,
249     color: Option<[f32; 4]>,
250 }
251 
252 impl<'a> Layouter<'a> {
253     fn blocks(&mut self, blocks: &[Block], x: f32, w: f32, mut y: f32, record: bool) -> f32 {
254         for (i, b) in blocks.iter().enumerate() {
255             if i > 0 {
256                 y += BLOCK_GAP;
257             }
258             if record {
259                 if let Some(line) = block_line(b) {
260                     self.out.lines.push((line, y));
261                 }
262             }
263             y = self.block(b, x, w, y);
264         }
265         y
266     }
267 
268     fn block(&mut self, b: &Block, x: f32, w: f32, y: f32) -> f32 {
269         let size = self.theme.size;
270         match b {
271             Block::Properties(props) => self.properties(props, x, w, y),
272             Block::Heading { level, spans, .. } => {
273                 let scale = match level {
274                     1 => 1.6,
275                     2 => 1.4,
276                     3 => 1.25,
277                     4 => 1.1,
278                     _ => 1.0,
279                 };
280                 let y = y + if *level <= 2 { size * 0.4 } else { 0.0 };
281                 let style = RunStyle { size: (size * scale).round(), bold: true, color: None };
282                 let end = self.inline(spans, x, w, y, style);
283                 if *level <= 2 {
284                     let ry = end + 4.0;
285                     self.out.draws.push(Draw::Quad { rect: Rect { x, y: ry, width: w, height: 1.0 }, color: RULE });
286                     return ry + 1.0;
287                 }
288                 end
289             }
290             Block::Paragraph { spans, .. } => self.inline(spans, x, w, y, self.plain_style()),
291             Block::Embed { target, size: want, .. } if (self.image)(target).is_some() => {
292                 let img = (self.image)(target).expect("checked by the guard");
293                 let (iw, ih) = img.fit(want.map(|s| s.width), want.and_then(|s| s.height), w);
294                 self.out.draws.push(Draw::Image { target: target.clone(), rect: Rect { x, y, width: iw, height: ih } });
295                 y + ih
296             }
297             Block::Embed { target, subpath, .. } => {
298                 // An embed that cannot be shown inline (a note, a missing
299                 // image) is a link to it, led by the `link` glyph in the
300                 // link colour (it was "↳ " until 2026-10-05).
301                 let shown = match subpath {
302                     Some(s) => format!("{target}#{s}"),
303                     None => target.clone(),
304                 };
305                 let size = self.theme.size;
306                 let side = (size * 0.85).round();
307                 let gap = (size * 0.4).round();
308                 self.out.draws.push(Draw::Icon {
309                     name: "link",
310                     rect: Rect { x, y: y + 0.5 * (size * 1.3 - side), width: side, height: side },
311                     color: LINK,
312                 });
313                 let span = Span {
314                     text: shown,
315                     style: Style::default(),
316                     link: Some(SpanLink::Note { target: target.clone(), subpath: subpath.clone() }),
317                 };
318                 self.inline(std::slice::from_ref(&span), x + side + gap, (w - side - gap).max(0.0), y, self.plain_style())
319             }
320             Block::List { start, items, .. } => self.list(*start, items, x, w, y),
321             Block::Quote { callout, blocks, .. } => self.quote(callout.as_ref(), blocks, x, w, y),
322             Block::Code { lang, text, .. } => self.code(lang.as_deref(), text, x, w, y),
323             Block::Table { header, rows, .. } => self.table(header, rows, x, w, y),
324             Block::Rule { .. } => {
325                 let ry = y + size * 0.5;
326                 self.out.draws.push(Draw::Quad { rect: Rect { x, y: ry, width: w, height: 1.0 }, color: RULE });
327                 ry + size * 0.5
328             }
329         }
330     }
331 
332     fn plain_style(&self) -> RunStyle {
333         RunStyle { size: self.theme.size, bold: false, color: None }
334     }
335 
336     fn properties(&mut self, props: &[(String, String)], x: f32, w: f32, mut y: f32) -> f32 {
337         let size = (self.theme.size * 0.9).round();
338         let key_w = 140.0f32.min(w * 0.35);
339         for (k, v) in props {
340             let lh = self.theme.line_h(size);
341             self.text(k, x, y + (lh - size) / 2.0, size, DIM, false, false);
342             let span = Span { text: v.clone(), style: Style::default(), link: None };
343             let style = RunStyle { size, bold: false, color: Some(FG) };
344             y = self.inline(std::slice::from_ref(&span), x + key_w, w - key_w, y, style);
345         }
346         y += 6.0;
347         self.out.draws.push(Draw::Quad { rect: Rect { x, y, width: w, height: 1.0 }, color: RULE });
348         y + 1.0
349     }
350 
351     fn list(&mut self, start: Option<u64>, items: &[ListItem], x: f32, w: f32, mut y: f32) -> f32 {
352         let size = self.theme.size;
353         let lh = self.theme.line_h(size);
354         for (i, item) in items.iter().enumerate() {
355             if i > 0 {
356                 y += 2.0;
357             }
358             // Items are where a search hit or a task usually points.
359             if self.out.lines.last().is_none_or(|(l, _)| *l < item.line) {
360                 self.out.lines.push((item.line, y));
361             }
362             let body_x = x + INDENT;
363             let mid = y + lh / 2.0;
364             match (item.task, start) {
365                 (Some(status), _) => {
366                     let r = (size * 0.45).round();
367                     let cx = x + INDENT - r - 6.0;
368                     self.out.draws.push(Draw::Check { cx, cy: mid, r, checked: status != ' ' });
369                     self.out.hits.push((
370                         Rect { x: cx - r - 3.0, y: mid - r - 3.0, width: 2.0 * r + 6.0, height: 2.0 * r + 6.0 },
371                         Hit::Task { line: item.line, status },
372                     ));
373                 }
374                 (None, Some(n)) => {
375                     let label = format!("{}.", n + i as u64);
376                     let lw = self.m.width(&label, size, &self.theme.body_font, TextAttrs::default());
377                     self.text(&label, body_x - 6.0 - lw, y + (lh - size) / 2.0, size, DIM, false, false);
378                 }
379                 (None, None) => {
380                     let r = (size * 0.16).max(2.0);
381                     self.out.draws.push(Draw::Dot { cx: body_x - 12.0, cy: mid, r, color: DIM });
382                 }
383             }
384             let done = item.task.is_some_and(|s| s == 'x' || s == 'X');
385             let before = self.out.draws.len();
386             y = self.item_blocks(&item.blocks, body_x, w - INDENT, y);
387             if done {
388                 // Obsidian dims a finished task's text rather than striking it.
389                 for d in &mut self.out.draws[before..] {
390                     if let Draw::Text { color, .. } = d {
391                         *color = DIM;
392                     }
393                 }
394             }
395         }
396         y
397     }
398 
399     /// An item's blocks, without the block gap before a nested list.
400     fn item_blocks(&mut self, blocks: &[Block], x: f32, w: f32, mut y: f32) -> f32 {
401         let lh = self.theme.line_h(self.theme.size);
402         if blocks.is_empty() {
403             return y + lh;
404         }
405         for (i, b) in blocks.iter().enumerate() {
406             if i > 0 && !matches!(b, Block::List { .. }) {
407                 y += BLOCK_GAP / 2.0;
408             }
409             y = self.block(b, x, w, y);
410         }
411         y
412     }
413 
414     fn quote(&mut self, callout: Option<&Callout>, blocks: &[Block], x: f32, w: f32, y: f32) -> f32 {
415         let size = self.theme.size;
416         let start = self.out.draws.len();
417         let pad = if callout.is_some() { 10.0 } else { 0.0 };
418         let mut cy = y + pad;
419         let body_x = x + if callout.is_some() { 14.0 } else { INDENT * 0.6 };
420         let body_w = w - (body_x - x) - pad;
421         if let Some(c) = callout {
422             let style = RunStyle { size, bold: true, color: Some(lin(callout_color(&c.kind))) };
423             let title: Vec<Span> = if c.title.is_empty() {
424                 vec![Span { text: title_case(&c.kind), style: Style::default(), link: None }]
425             } else {
426                 c.title.clone()
427             };
428             cy = self.inline(&title, body_x, body_w, cy, style);
429             if !blocks.is_empty() && c.folded != Some(true) {
430                 cy += BLOCK_GAP / 2.0;
431             }
432         }
433         if callout.is_none_or(|c| c.folded != Some(true)) {
434             cy = self.blocks(blocks, body_x, body_w, cy, false);
435         }
436         let end = cy + pad;
437         let rect = Rect { x, y, width: w, height: end - y };
438         // Backgrounds go under the text laid out above them.
439         match callout {
440             Some(c) => {
441                 let mut bg = lin(callout_color(&c.kind));
442                 bg[3] = 0.10;
443                 self.out.draws.insert(start, Draw::Round { rect, radius: 6.0, color: bg });
444             }
445             None => {
446                 self.out.draws.insert(
447                     start,
448                     Draw::Quad { rect: Rect { x, y, width: 2.0, height: end - y }, color: lin(QUOTE_BAR) },
449                 );
450             }
451         }
452         end
453     }
454 
455     fn code(&mut self, _lang: Option<&str>, text: &str, x: f32, w: f32, y: f32) -> f32 {
456         let size = (self.theme.size * 0.9).round();
457         let lh = self.theme.line_h(size);
458         let n = text.split('\n').count().max(1);
459         let h = n as f32 * lh + 2.0 * CODE_PAD;
460         self.out.draws.push(Draw::Round { rect: Rect { x, y, width: w, height: h }, radius: 6.0, color: CODE_BG });
461         // Code does not wrap; overlong lines are cut at the block's edge
462         // by the paint clip, as a scrolled source view would show them.
463         for (i, line) in text.split('\n').enumerate() {
464             if line.is_empty() {
465                 continue;
466             }
467             let ty = y + CODE_PAD + i as f32 * lh + (lh - size) / 2.0;
468             let font = self.theme.mono_font.clone();
469             self.out.draws.push(Draw::Text {
470                 text: line.replace('\t', "    "),
471                 x: x + CODE_PAD,
472                 y: ty,
473                 size,
474                 color: FG,
475                 font,
476                 attrs: TextAttrs::default(),
477             });
478         }
479         y + h
480     }
481 
482     fn table(&mut self, header: &[Vec<Span>], rows: &[Vec<Vec<Span>>], x: f32, w: f32, mut y: f32) -> f32 {
483         let cols = header.len().max(rows.iter().map(Vec::len).max().unwrap_or(0)).max(1);
484         let col_w = w / cols as f32;
485         let pad = 6.0;
486         let top = y;
487         let all = std::iter::once((header, true)).chain(rows.iter().map(|r| (r.as_slice(), false)));
488         for (ri, (row, is_header)) in all.enumerate() {
489             let style = RunStyle { size: self.theme.size, bold: is_header, color: None };
490             let mut bottom = y;
491             for (ci, cell) in row.iter().enumerate().take(cols) {
492                 let cx = x + ci as f32 * col_w + pad;
493                 bottom = bottom.max(self.inline(cell, cx, col_w - 2.0 * pad, y + pad, style));
494             }
495             y = bottom.max(y + self.theme.line_h(self.theme.size)) + pad;
496             if ri == 0 || ri < rows.len() {
497                 self.out.draws.push(Draw::Quad { rect: Rect { x, y, width: w, height: 1.0 }, color: RULE });
498             }
499         }
500         for c in 1..cols {
501             let lx = x + c as f32 * col_w;
502             self.out.draws.push(Draw::Quad { rect: Rect { x: lx, y: top, width: 1.0, height: y - top }, color: RULE });
503         }
504         y
505     }
506 
507     #[allow(clippy::too_many_arguments)]
508     fn text(&mut self, text: &str, x: f32, y: f32, size: f32, color: [f32; 4], bold: bool, italic: bool) {
509         let attrs = TextAttrs { italic, weight: bold.then_some(700), ..Default::default() };
510         let font = self.theme.body_font.clone();
511         self.out.draws.push(Draw::Text { text: text.to_string(), x, y, size, color, font, attrs });
512     }
513 
514     /// Lay out inline spans from `(x, y)` wrapping at `x + w`; returns the
515     /// y below the last line.
516     fn inline(&mut self, spans: &[Span], x: f32, w: f32, y: f32, base: RunStyle) -> f32 {
517         let lh = self.theme.line_h(base.size);
518         let right = x + w;
519         // An image embed inside the text is one box in the flow, the size
520         // its image fits at: its link text becomes a single placeholder
521         // word so the tokenizer cannot split it.
522         let boxes: Vec<Option<(f32, f32)>> = spans.iter().map(|s| self.inline_image(s, w)).collect();
523         // One with no image yet (loading, missing) shows as its link — by
524         // file name when its alias is only a size (`|300`), not "300".
525         let sized_link = |s: &Span| matches!(&s.link, Some(SpanLink::Embed { .. })) && cce_vault::markdown::embed_size(&s.text).is_some();
526         let owned: Vec<Span>;
527         let spans: &[Span] = if boxes.iter().any(Option::is_some) || spans.iter().any(sized_link) {
528             owned = spans
529                 .iter()
530                 .zip(&boxes)
531                 .map(|(s, b)| match (&s.link, b) {
532                     (_, Some(_)) => Span { text: IMAGE_MARK.to_string(), ..s.clone() },
533                     (Some(SpanLink::Embed { target, .. }), None) if sized_link(s) => Span { text: target.clone(), ..s.clone() },
534                     _ => s.clone(),
535                 })
536                 .collect();
537             &owned
538         } else {
539             spans
540         };
541         let looks: Vec<Look> = spans.iter().map(|s| self.look(s, base)).collect();
542         let spaces: Vec<f32> = looks.iter().map(|l| self.space_width(l)).collect();
543         let mut cx = x;
544         let mut line_y = y;
545         // A space seen since the last placed word, in the style it was in.
546         let mut gap: Option<f32> = None;
547         let mut run: Option<Run> = None;
548         let mut any = false;
549 
550         for tok in tokens(spans) {
551             match tok {
552                 Tok::Break => {
553                     line_y += self.flush(&mut run, line_y, lh);
554                     cx = x;
555                     gap = None;
556                 }
557                 Tok::Space(si) => {
558                     if cx > x {
559                         gap = Some(spaces[si]);
560                     }
561                 }
562                 Tok::Word(segs) => {
563                     any = true;
564                     let widths: Vec<f32> = segs
565                         .iter()
566                         .map(|(t, si)| match boxes[*si] {
567                             Some((bw, _)) => bw,
568                             None => {
569                                 let l = &looks[*si];
570                                 self.m.width(t, l.size, &l.font, l.attrs)
571                             }
572                         })
573                         .collect();
574                     let total: f32 = widths.iter().sum();
575                     let mut lead = gap.take().unwrap_or(0.0);
576                     if cx > x && cx + lead + total > right {
577                         line_y += self.flush(&mut run, line_y, lh);
578                         cx = x;
579                         lead = 0.0;
580                     }
581                     for ((text, si), mut ww) in segs.into_iter().zip(widths) {
582                         let (look, span) = (&looks[si], &spans[si]);
583                         if let Some((bw, bh)) = boxes[si] {
584                             // The text run before it ends here.
585                             if let Some(r) = run.take() {
586                                 self.pending.push(r);
587                             }
588                             let target = match &span.link {
589                                 Some(SpanLink::Embed { target, .. }) => target.clone(),
590                                 _ => unreachable!("only embeds get boxes"),
591                             };
592                             self.pending_images.push((target, cx + lead, bw, bh));
593                             cx += lead + bw;
594                             lead = 0.0;
595                             continue;
596                         }
597                         // A word wider than the whole line (a URL) breaks
598                         // by characters rather than overflowing.
599                         let mut rest = text;
600                         while cx + ww > right && rest.chars().count() > 1 {
601                             let cut = self.fit(rest, right - cx, look);
602                             let (head, tail) = rest.split_at(cut);
603                             let hw = self.m.width(head, look.size, &look.font, look.attrs);
604                             self.place(&mut run, head, cx + lead, hw, lead, look, span);
605                             line_y += self.flush(&mut run, line_y, lh);
606                             cx = x;
607                             lead = 0.0;
608                             rest = tail;
609                             ww = self.m.width(rest, look.size, &look.font, look.attrs);
610                         }
611                         self.place(&mut run, rest, cx + lead, ww, lead, look, span);
612                         cx += lead + ww;
613                         lead = 0.0;
614                     }
615                 }
616             }
617         }
618         let last = self.flush(&mut run, line_y, lh);
619         if !any && line_y == y {
620             return y + lh;
621         }
622         line_y + last
623     }
624 
625     /// The box an inline image embed takes in a column `w` wide, when the
626     /// host has its image (sized by its `|300` alias, as a block embed).
627     fn inline_image(&self, span: &Span, w: f32) -> Option<(f32, f32)> {
628         let Some(SpanLink::Embed { target, .. }) = &span.link else { return None };
629         let img = (self.image)(target)?;
630         let want = cce_vault::markdown::embed_size(&span.text);
631         Some(img.fit(want.map(|s| s.width), want.and_then(|s| s.height), w))
632     }
633 
634     /// The longest char prefix of `word` fitting `w` (at least one char).
635     fn fit(&mut self, word: &str, w: f32, look: &Look) -> usize {
636         let mut best = word.chars().next().map(char::len_utf8).unwrap_or(word.len());
637         for (i, c) in word.char_indices().skip(1) {
638             let end = i + c.len_utf8();
639             if self.m.width(&word[..end], look.size, &look.font, look.attrs) > w {
640                 break;
641             }
642             best = end;
643         }
644         best
645     }
646 
647     fn space_width(&mut self, look: &Look) -> f32 {
648         let two = self.m.width("x x", look.size, &look.font, look.attrs);
649         let one = self.m.width("xx", look.size, &look.font, look.attrs);
650         (two - one).max(look.size * 0.2)
651     }
652 
653     fn look(&self, span: &Span, base: RunStyle) -> Look {
654         let s = span.style;
655         let code = s.code || s.math;
656         let size = if code { (base.size * 0.92).round() } else { base.size };
657         let color = match &span.link {
658             Some(SpanLink::Tag(_)) => lin(TAG),
659             Some(l @ (SpanLink::Note { .. } | SpanLink::Embed { .. })) => {
660                 if (self.resolved)(l) {
661                     lin(LINK)
662                 } else {
663                     lin(LINK_UNRESOLVED)
664                 }
665             }
666             Some(SpanLink::Url(_)) => lin(LINK),
667             None => base.color.unwrap_or(FG),
668         };
669         Look {
670             size,
671             font: if code { self.theme.mono_font.clone() } else { self.theme.body_font.clone() },
672             attrs: TextAttrs { italic: s.italic, weight: (s.bold || base.bold).then_some(700), ..Default::default() },
673             color,
674             bg: if code {
675                 Some(CODE_BG)
676             } else if s.highlight {
677                 Some(lin(HIGHLIGHT_BG))
678             } else if matches!(span.link, Some(SpanLink::Tag(_))) {
679                 Some(lin(TAG_BG))
680             } else {
681                 None
682             },
683             strike: s.strike,
684         }
685     }
686 
687     /// Add a word to the current run, or start a new one if the look or
688     /// link changed.
689     #[allow(clippy::too_many_arguments)]
690     fn place(&mut self, run: &mut Option<Run>, word: &str, x: f32, w: f32, lead: f32, look: &Look, span: &Span) {
691         if let Some(r) = run {
692             if r.look == *look && r.link == span.link && (r.x + r.w + lead - x).abs() < 0.5 {
693                 if lead > 0.0 {
694                     r.text.push(' ');
695                 }
696                 r.text.push_str(word);
697                 r.w += lead + w;
698                 return;
699             }
700         }
701         // The line is the same until flushed; only a look change ends it.
702         if let Some(r) = run.take() {
703             self.pending.push(r);
704         }
705         *run = Some(Run { text: word.to_string(), x, w, look: look.clone(), link: span.link.clone() });
706     }
707 
708     /// Emit the current line at `line_y`; returns its height — `lh`, or
709     /// taller when an inline image is. Text sits on the line's bottom `lh`
710     /// band and images stand on the same bottom, as inline images do.
711     fn flush(&mut self, run: &mut Option<Run>, line_y: f32, lh: f32) -> f32 {
712         if let Some(r) = run.take() {
713             self.pending.push(r);
714         }
715         let images = std::mem::take(&mut self.pending_images);
716         let height = images.iter().fold(lh, |h, (_, _, _, ih)| h.max(*ih + INLINE_IMAGE_PAD));
717         for (target, ix, iw, ih) in images {
718             let rect = Rect { x: ix, y: line_y + height - ih - INLINE_IMAGE_PAD / 2.0, width: iw, height: ih };
719             self.out.draws.push(Draw::Image { target, rect });
720         }
721         // Text runs below sit in the bottom band.
722         let line_y = line_y + height - lh;
723         for r in std::mem::take(&mut self.pending) {
724             let ty = line_y + (lh - r.look.size) / 2.0;
725             let box_ = Rect { x: r.x - 2.0, y: line_y + 2.0, width: r.w + 4.0, height: lh - 4.0 };
726             if let Some(bg) = r.look.bg {
727                 self.out.draws.push(Draw::Round { rect: box_, radius: 3.0, color: bg });
728             }
729             self.out.draws.push(Draw::Text {
730                 text: r.text,
731                 x: r.x,
732                 y: ty,
733                 size: r.look.size,
734                 color: r.look.color,
735                 font: r.look.font.clone(),
736                 attrs: r.look.attrs,
737             });
738             if r.look.strike {
739                 let sy = ty + r.look.size * 0.55;
740                 self.out.draws.push(Draw::Line { x1: r.x, y1: sy, x2: r.x + r.w, y2: sy, width: 1.0, color: r.look.color });
741             }
742             if let Some(link) = r.link {
743                 self.out.hits.push((Rect { x: r.x, y: line_y, width: r.w, height: lh }, Hit::Link(link)));
744             }
745         }
746         height
747     }
748 }
749 
750 #[derive(Clone, Debug, PartialEq)]
751 struct Look {
752     size: f32,
753     font: String,
754     attrs: TextAttrs,
755     color: [f32; 4],
756     bg: Option<[f32; 4]>,
757     strike: bool,
758 }
759 
760 struct Run {
761     text: String,
762     x: f32,
763     w: f32,
764     look: Look,
765     link: Option<SpanLink>,
766 }
767 
768 enum Piece<'a> {
769     Word(&'a str),
770     Space,
771     Break,
772 }
773 
774 /// Split text into words, whitespace runs and hard line breaks.
775 fn pieces(text: &str) -> Vec<Piece<'_>> {
776     let mut out = Vec::new();
777     let mut start: Option<usize> = None;
778     for (i, c) in text.char_indices() {
779         if c.is_whitespace() {
780             if let Some(s) = start.take() {
781                 out.push(Piece::Word(&text[s..i]));
782             }
783             if c == '\n' {
784                 out.push(Piece::Break);
785             } else if !matches!(out.last(), Some(Piece::Space)) {
786                 out.push(Piece::Space);
787             }
788         } else if start.is_none() {
789             start = Some(i);
790         }
791     }
792     if let Some(s) = start {
793         out.push(Piece::Word(&text[s..]));
794     }
795     out
796 }
797 
798 /// What wraps as a unit: a word may run across spans (`**bold**,` is one
799 /// word in two styles), and never breaks between them.
800 enum Tok<'a> {
801     /// Segments of one word, each with the index of its span.
802     Word(Vec<(&'a str, usize)>),
803     /// A space, in the style of the span it sits in.
804     Space(usize),
805     Break,
806 }
807 
808 fn tokens(spans: &[Span]) -> Vec<Tok<'_>> {
809     let mut out: Vec<Tok> = Vec::new();
810     for (si, span) in spans.iter().enumerate() {
811         for (pi, piece) in pieces(&span.text).into_iter().enumerate() {
812             match piece {
813                 Piece::Word(w) => match out.last_mut() {
814                     // Glued: the previous span ended mid-word.
815                     Some(Tok::Word(segs)) if pi == 0 => segs.push((w, si)),
816                     _ => out.push(Tok::Word(vec![(w, si)])),
817                 },
818                 Piece::Space => out.push(Tok::Space(si)),
819                 Piece::Break => out.push(Tok::Break),
820             }
821         }
822     }
823     out
824 }
825 
826 fn block_line(b: &Block) -> Option<usize> {
827     match b {
828         Block::Properties(_) => Some(0),
829         Block::Heading { line, .. }
830         | Block::Paragraph { line, .. }
831         | Block::Embed { line, .. }
832         | Block::List { line, .. }
833         | Block::Quote { line, .. }
834         | Block::Code { line, .. }
835         | Block::Table { line, .. }
836         | Block::Rule { line } => Some(*line),
837     }
838 }
839 
840 fn callout_color(kind: &str) -> [f32; 4] {
841     match kind {
842         "warning" | "caution" | "attention" => [0.93, 0.60, 0.25, 1.0],
843         "danger" | "error" | "bug" | "failure" | "fail" | "missing" => [0.93, 0.35, 0.35, 1.0],
844         "tip" | "hint" | "important" | "success" | "check" | "done" => [0.30, 0.78, 0.60, 1.0],
845         "question" | "help" | "faq" => [0.93, 0.75, 0.25, 1.0],
846         "quote" | "cite" => [0.60, 0.60, 0.65, 1.0],
847         "example" => [0.62, 0.55, 0.98, 1.0],
848         _ => [0.35, 0.62, 0.95, 1.0],
849     }
850 }
851 
852 fn title_case(s: &str) -> String {
853     let mut c = s.chars();
854     match c.next() {
855         Some(f) => f.to_uppercase().chain(c).collect(),
856         None => String::new(),
857     }
858 }
859 
860 #[cfg(test)]
861 mod tests {
862     use super::*;
863 
864     /// Every char 10 px at size 10, scaled with the size.
865     struct Fixed;
866     impl Measure for Fixed {
867         fn width(&mut self, text: &str, size: f32, _font: &str, _attrs: TextAttrs) -> f32 {
868             text.chars().count() as f32 * size
869         }
870     }
871 
872     fn theme() -> Theme {
873         Theme { body_font: "sans-serif".into(), mono_font: "monospace".into(), size: 10.0 }
874     }
875 
876     fn texts(l: &Layout) -> Vec<(String, f32, f32)> {
877         l.draws
878             .iter()
879             .filter_map(|d| match d {
880                 Draw::Text { text, x, y, .. } => Some((text.clone(), *x, *y)),
881                 _ => None,
882             })
883             .collect()
884     }
885 
886     #[test]
887     fn wraps_words_and_merges_runs() {
888         // "aaa bbb ccc" at 10px/char: 110 px; at width 80 the third word wraps.
889         let doc = blocks("aaa bbb ccc\n");
890         let l = layout(&doc, 80.0, &theme(), &mut Fixed, &|_| true);
891         let t = texts(&l);
892         assert_eq!(t.len(), 2, "{t:?}");
893         assert_eq!(t[0].0, "aaa bbb");
894         assert_eq!(t[1].0, "ccc");
895         assert_eq!(t[1].1, 0.0);
896         assert!(t[1].2 > t[0].2);
897         assert_eq!(l.height, 30.0);
898     }
899 
900     #[test]
901     fn styles_split_runs_and_links_hit() {
902         let doc = blocks("go **bold** to [[Note]] now\n");
903         let l = layout(&doc, 1000.0, &theme(), &mut Fixed, &|_| true);
904         let t: Vec<String> = texts(&l).into_iter().map(|t| t.0).collect();
905         assert_eq!(t, ["go", "bold", "to", "Note", "now"]);
906         let (r, hit) = &l.hits[0];
907         assert_eq!(hit, &Hit::Link(SpanLink::Note { target: "Note".into(), subpath: None }));
908         assert!(l.hit(r.x + 1.0, r.y + 1.0).is_some());
909         assert!(l.hit(0.0, 1.0).is_none());
910     }
911 
912     #[test]
913     fn punctuation_glued_to_a_styled_word_wraps_with_it() {
914         // Code is 9 px/char. At width 80 (layout's minimum) "aaaa bbb"
915         // fits (77 px) but "aaaa bbb," does not (87): unglued, the comma
916         // alone would start the next line.
917         let doc = blocks("aaaa `bbb`, cc\n");
918         let l = layout(&doc, 80.0, &theme(), &mut Fixed, &|_| true);
919         let t = texts(&l);
920         let bbb = t.iter().find(|t| t.0 == "bbb").unwrap();
921         let comma = t.iter().find(|t| t.0.starts_with(',')).unwrap();
922         assert_eq!((bbb.1, comma.1), (0.0, 27.0), "{t:?}");
923         assert!(bbb.2 > t[0].2, "{t:?}");
924     }
925 
926     #[test]
927     fn long_word_breaks_by_chars() {
928         let doc = blocks("abcdefghij\n");
929         let l = layout(&doc, 80.0, &theme(), &mut Fixed, &|_| true);
930         let t: Vec<String> = texts(&l).into_iter().map(|t| t.0).collect();
931         assert_eq!(t, ["abcdefgh", "ij"]);
932     }
933 
934     #[test]
935     fn tasks_hit_with_their_line_and_blocks_map_lines() {
936         let doc = blocks("# Head\n\n- [ ] one\n- [x] two\n\nend\n");
937         let l = layout(&doc, 400.0, &theme(), &mut Fixed, &|_| true);
938         let tasks: Vec<_> = l.hits.iter().filter_map(|(_, h)| match h {
939             Hit::Task { line, status } => Some((*line, *status)),
940             _ => None,
941         }).collect();
942         assert_eq!(tasks, [(2, ' '), (3, 'x')]);
943         assert_eq!(l.lines.iter().map(|(l, _)| *l).collect::<Vec<_>>(), [0, 2, 3, 5]);
944         assert!(l.y_of_line(5) > l.y_of_line(2));
945     }
946 
947     #[test]
948     fn image_embeds_draw_sized_and_others_stay_links() {
949         let doc = blocks("![[a.png|200]]\n\n![[big.png]]\n\n![[Note]]\n");
950         let image = |t: &str| (t != "Note").then_some(EmbedImage { id: 7, width: 1000, height: 500 });
951         let l = layout_with(&doc, 400.0, &theme(), &mut Fixed, &|_| true, &image);
952         let rects: Vec<Rect> = l.draws.iter().filter_map(|d| match d {
953             Draw::Image { rect, .. } => Some(*rect),
954             _ => None,
955         }).collect();
956         assert_eq!((rects[0].width, rects[0].height), (200.0, 100.0));
957         // Wider than the column: scaled down whole.
958         assert_eq!((rects[1].width, rects[1].height), (400.0, 200.0));
959         assert_eq!(rects.len(), 2);
960         assert!(texts(&l).iter().any(|(t, _, _)| t.contains("Note")));
961     }
962 
963     #[test]
964     fn inline_images_flow_with_the_text_and_heighten_their_line() {
965         let doc = blocks("ab ![[i.png|30]] cd\n\nnext\n");
966         let image = |t: &str| (t == "i.png").then_some(EmbedImage { id: 1, width: 60, height: 40 });
967         let l = layout_with(&doc, 1000.0, &theme(), &mut Fixed, &|_| true, &image);
968         let img = l.draws.iter().find_map(|d| match d {
969             Draw::Image { rect, .. } => Some(*rect),
970             _ => None,
971         });
972         let img = img.expect("an inline image");
973         // |30 keeps the aspect: 30 x 20, after "ab " (Fixed: 10 px a char).
974         assert_eq!((img.width, img.height), (30.0, 20.0));
975         let t = texts(&l);
976         let ab = t.iter().find(|(s, _, _)| s == "ab").unwrap();
977         let cd = t.iter().find(|(s, _, _)| s == "cd").unwrap();
978         assert!(img.x > ab.1 && cd.1 > img.x + img.width, "{t:?} {img:?}");
979         // The line grew for the image: text sits below its top.
980         assert!(ab.2 > img.y);
981         // Without the image the embed shows as its link: the file name, not
982         // the size alias.
983         let l2 = layout(&doc, 1000.0, &theme(), &mut Fixed, &|_| true);
984         assert!(texts(&l2).iter().any(|(s, _, _)| s.contains("i.png")), "{:?}", texts(&l2));
985         assert!(!texts(&l2).iter().any(|(s, _, _)| s.contains("30")));
986         let next_with = t.iter().find(|(s, _, _)| s == "next").unwrap().2;
987         let next_without = texts(&l2).iter().find(|(s, _, _)| s == "next").unwrap().2;
988         assert!(next_with > next_without, "the taller line pushes what follows down");
989     }
990 
991     #[test]
992     fn hard_breaks_and_empty_input() {
993         let doc = blocks("one\ntwo\n");
994         let l = layout(&doc, 400.0, &theme(), &mut Fixed, &|_| true);
995         assert_eq!(texts(&l).len(), 2);
996         assert_eq!(layout(&[], 400.0, &theme(), &mut Fixed, &|_| true).height, 0.0);
997     }
998 }