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 }