Browse / github.com/storytold/craft-launcher / patch
fix(notes): draw the markup in release notes instead of its markers (#591)
Release Notes not rendering markdown. The release notes in the launcher are difficult to really make sense of, and part of that is that the launcher doesn't seem to be rendering markdown fully/correctly. See attached screenshot. <img width="683" height="610" alt="Image" src="https://github.com/user-attachments/assets/0bdec542-bd2d-4997-9c94-b09e2f7c620b" /> macOS 27.2, launcher 0.5.1
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- GabrielMatozo (imported from pull request #611, open)
- Base
345c509fe853cfa14e6441a79b834b38985cc3bd- Size
- +463 −10 in 1 file
- Review
- pending
- Shared
- 2026-10-28
- Id
d4a42764cd4f02f66207ba36
How it went
No other operator has reported using it yet.
Diff
raw · crates/ui-egui/src/notes.rs
@@ -1,8 +1,12 @@ //! Release notes: GitHub's generated Markdown, shown readably without a Markdown engine.+//!+//! Only the markup a release body actually carries is understood: headings, bullets, `**bold**`,+//! `` `code` `` and `[label](url)`. Anything else stays literal, so text that merely looks like+//! markup (`2 * 3`, `snake_case_name`, an unclosed backtick) keeps every character. -use egui::{RichText, Ui};+use egui::{Color32, RichText, Ui}; -use crate::theme::Tokens;+use crate::theme::{self, Tokens}; /// One line of notes, classified. #[derive(Debug, PartialEq, Eq)]@@ -10,10 +14,30 @@ pub(crate) enum Line<'a> { Heading(&'a str), /// A bullet: its text, and the pull request link GitHub appends (`… in https://…/pull/12`). Bullet(&'a str, Option<&'a str>),+ /// A markdown table row: cells are the pipe-separated fields, with the header row first.+ /// The separator row (`|---|---|`) is not a separate variant; it's identified by all cells+ /// being `---` (or empty after trim) and skipped when rendering.+ Table(Vec<&'a str>), Text(&'a str), Blank, } +/// One piece of a line's inline markup.+#[derive(Debug, PartialEq, Eq)]+pub(crate) enum Span<'a> {+ Plain(&'a str),+ /// `**bold**`.+ Bold(&'a str),+ /// `` `code` ``.+ Code(&'a str),+ /// `[label](url)`. GitHub writes a file reference as `` [`docs/x.md`](url) ``, so a label+ /// wrapped in backticks keeps its text without them.+ Link {+ label: &'a str,+ url: &'a str,+ },+}+ pub(crate) fn classify(line: &str) -> Line<'_> { let l = line.trim(); if l.is_empty() {@@ -22,6 +46,16 @@ pub(crate) fn classify(line: &str) -> Line<'_> { if let Some(h) = l.strip_prefix('#') { return Line::Heading(h.trim_start_matches('#').trim()); }+ // Table row: starts and ends with '|', has at least two pipes (header or data).+ if l.starts_with('|') && l.ends_with('|') && l.matches('|').count() >= 3 {+ // Split by '|', drop first and last (empty), trim each cell.+ let cells: Vec<&str> = l.split('|').skip(1).map(|c| c.trim()).collect();+ // Trailing empty cell from the final '|' (e.g. "|a|b|" -> ["", "a", "b", ""]).+ let cells = cells.into_iter().filter(|c| !c.is_empty()).collect::<Vec<_>>();+ // If all cells are just dashes (separator row), treat as Text so it's drawn but we can+ // skip it in draw. The caller will still get a Table variant for the header/data rows.+ return Line::Table(cells);+ } if let Some(b) = l.strip_prefix("* ").or_else(|| l.strip_prefix("- ")) { if let Some((text, url)) = b.rsplit_once(" in https://") && !url.contains(' ')@@ -34,6 +68,135 @@ pub(crate) fn classify(line: &str) -> Line<'_> { Line::Text(l) } +/// A line's inline pieces, in order. Empty for an empty line.+pub(crate) fn spans(text: &str) -> Vec<Span<'_>> {+ let mut out: Vec<Span<'_>> = Vec::new();+ let mut plain_from = 0;+ let mut i = 0;+ while let Some(rest) = text.get(i..) {+ if rest.is_empty() {+ break;+ }+ match piece(rest) {+ Some((span, len)) => {+ if i > plain_from+ && let Some(plain) = text.get(plain_from..i)+ {+ out.push(Span::Plain(plain));+ }+ out.push(span);+ i += len;+ plain_from = i;+ }+ // No markup here: step over one character (never a byte, so `i` stays on a boundary)+ // and let the plain run keep growing.+ None => i += rest.chars().next().map_or(1, char::len_utf8),+ }+ }+ if let Some(plain) = text.get(plain_from..)+ && !plain.is_empty()+ {+ out.push(Span::Plain(plain));+ }+ out+}++/// The line's text with the markup markers dropped: what a heading, which draws no emphasis,+/// should show.+pub(crate) fn without_markup(text: &str) -> String {+ spans(text)+ .iter()+ .map(|span| match span {+ Span::Plain(s) | Span::Bold(s) | Span::Code(s) => *s,+ Span::Link { label, .. } => *label,+ })+ .collect()+}++/// Does this line carry markup that a plain label would show as typed?+fn marked_up(text: &str) -> bool {+ !matches!(spans(text).as_slice(), [] | [Span::Plain(_)])+}++/// The longest label and address a link may carry. GitHub's are far shorter, and a bounded search+/// is what keeps this linear: `spans` retries after every unmatched `[`, so an unbounded search+/// here would scan the rest of the line once per bracket.+const MAX_LABEL: usize = 128;+const MAX_URL: usize = 512;++/// The markup at the start of `rest`: the piece and how many bytes it takes.+fn piece(rest: &str) -> Option<(Span<'_>, usize)> {+ if let Some(inner) = rest.strip_prefix('`') {+ let end = inner.find('`')?;+ let text = inner.get(..end)?;+ // Empty or space-padded runs are not code: a lone pair of backticks stays as written.+ if text.is_empty() || padded(text) {+ return None;+ }+ return Some((Span::Code(text), end + 2));+ }+ if let Some(inner) = rest.strip_prefix("**") {+ let end = inner.find("**")?;+ let text = inner.get(..end)?;+ // CommonMark refuses emphasis with a space just inside the markers, and nested emphasis+ // (`***x***`) stays literal rather than coming out half-rendered.+ if text.is_empty() || text.contains('*') || padded(text) {+ return None;+ }+ return Some((Span::Bold(text), end + 4));+ }+ if let Some(inner) = rest.strip_prefix('[') {+ // Both searches run over a bounded window, never the rest of the line: `spans` retries+ // after every unmatched `[`, so an unbounded search would cost a scan per bracket.+ // Snap the bound to a UTF-8 character boundary: `MAX_LABEL` is a byte count, and slicing+ // at a byte inside a multi-byte character returns `None`, dropping a link whose label+ // crosses the cap. `char_indices` lands on the first boundary strictly past the cap, so a+ // character that starts before it and ends after it is still inside the window.+ let label_cap = inner.char_indices().find(|(i, _)| *i > MAX_LABEL).map_or(inner.len(), |(i, _)| i);+ let label_end = inner.get(..label_cap)?.find("](")?;+ let label = inner.get(..label_end)?;+ let after = inner.get(label_end + 2..)?;+ // Snap the bound to a UTF-8 character boundary: `MAX_URL` is a byte count, and slicing+ // at a byte inside a multi-byte character returns `None`, dropping a link whose URL+ // crosses the cap. `char_indices` lands on the first boundary strictly past the cap, so a+ // character that starts before it and ends after it is still inside the window.+ let url_cap = after.char_indices().find(|(i, _)| *i > MAX_URL).map_or(after.len(), |(i, _)| i);+ // Find the matching closing paren by counting nested parentheses.+ // Wikipedia links like `_(disambiguation)` have parens inside; we need the *matching* one.+ let url_end = after.get(..url_cap).and_then(|window| {+ let mut depth = 0;+ window.char_indices().find_map(|(i, ch)| match ch {+ '(' => {+ depth += 1;+ None+ }+ ')' => {+ if depth == 0 {+ Some(i)+ } else {+ depth -= 1;+ None+ }+ }+ _ => None,+ })+ })?;+ let url = after.get(..url_end)?;+ // A URL is one word: `[text](url "title")` and `[text][ref]` are not links.+ if label.is_empty() || url.is_empty() || url.contains(char::is_whitespace) {+ return None;+ }+ let label = label.strip_prefix('`').and_then(|l| l.strip_suffix('`')).unwrap_or(label);+ return Some((Span::Link { label, url }, label_end + url_end + 4));+ }+ None+}++/// A space just inside a marker pair means it is not markup.+fn padded(text: &str) -> bool {+ text.starts_with(char::is_whitespace) || text.ends_with(char::is_whitespace)+}+ /// How many changes show before the rest folds away. const SHOWN: usize = 6; @@ -60,13 +223,13 @@ fn show_lines(ui: &mut Ui, notes: &str, scale: f32) { return; } let (changelog, lines): (Vec<Line<'_>>, Vec<Line<'_>>) = notes.lines().map(classify).partition(|l| full_changelog(l).is_some());- let bullets = lines.iter().filter(|l| matches!(l, Line::Bullet(..))).count(); let (head, rest) = lines.split_at(fold_at(&lines).unwrap_or(lines.len())); for line in head { draw(ui, line, &t, scale); } if !rest.is_empty() {- let label = crate::i18n::fmt(tl!("Show {n} more changes"), &[("n", &bullets.saturating_sub(SHOWN).to_string())]);+ let hidden = rest.iter().filter(|l| matches!(l, Line::Bullet(..))).count();+ let label = crate::i18n::fmt(tl!("Show {n} more changes"), &[("n", &hidden.to_string())]); egui::CollapsingHeader::new(label).id_salt(ui.id().with(("more-notes", notes.len()))).show(ui, |ui| { for line in rest { draw(ui, line, &t, scale);@@ -82,12 +245,17 @@ fn show_lines(ui: &mut Ui, notes: &str, scale: f32) { } /// Where a long list folds: just before its (SHOWN + 1)th change. A short list is shown whole.+/// Only bullets that appear after a "What's Changed" (or similar) heading are counted.+/// A short list has SHOWN + 1 or fewer relevant bullets; a long list folds at the (SHOWN + 1)th. fn fold_at(lines: &[Line<'_>]) -> Option<usize> {- let bullets = lines.iter().filter(|l| matches!(l, Line::Bullet(..))).count();+ // Find the index of the first "What's Changed" heading (case-insensitive, any # level).+ let whats_changed_idx = lines.iter().position(|l| matches!(l, Line::Heading(h) if h.eq_ignore_ascii_case("What's Changed")));+ let start = whats_changed_idx.map_or(0, |i| i + 1);+ let bullets = lines[start..].iter().filter(|l| matches!(l, Line::Bullet(..))).count(); if bullets <= SHOWN + 1 { return None; }- lines.iter().enumerate().filter(|(_, l)| matches!(l, Line::Bullet(..))).nth(SHOWN).map(|(i, _)| i)+ lines[start..].iter().enumerate().filter(|(_, l)| matches!(l, Line::Bullet(..))).nth(SHOWN).map(|(i, _)| start + i) } /// The address on a "**Full Changelog**: …" line, when it is a safe web link.@@ -100,23 +268,49 @@ fn draw(ui: &mut Ui, line: &Line<'_>, t: &Tokens, scale: f32) { match line { Line::Heading(h) => { ui.add_space(6.0);- ui.label(crate::widgets::heading(h, 16.0 * scale, t.ink_strong));+ if marked_up(h) {+ ui.label(crate::widgets::heading(&without_markup(h), 16.0 * scale, t.ink_strong));+ } else {+ ui.label(crate::widgets::heading(h, 16.0 * scale, t.ink_strong));+ } } Line::Bullet(text, link) => { // The author's handle is noise in a list of what changed. let text = text.rsplit_once(" by @").map_or(*text, |(change, _)| change); ui.horizontal_wrapped(|ui| {- ui.spacing_mut().item_spacing.x = 4.0;+ ui.spacing_mut().item_spacing.x = 0.0; ui.label(RichText::new("•").color(t.faint));- ui.label(RichText::new(text).color(t.ink));+ ui.add_space(4.0);+ inline(ui, text, t.ink); if let Some(url) = link.filter(|u| artcraft_launcher_core::release::safe_web_link(u)) { let short = url.rsplit('/').next().map_or_else(|| tl!("link").to_string(), |n| format!("#{n}"));+ ui.add_space(4.0); ui.hyperlink_to(RichText::new(short).size(12.0 * scale), url); } }); }+ Line::Table(cells) => {+ // Skip the separator row (all cells are dashes or empty after trim).+ if cells.iter().all(|c| c.trim().chars().all(|ch| ch == '-') || c.trim().is_empty()) {+ return;+ }+ ui.add_space(4.0);+ egui::Grid::new(ui.id().with("notes-table")).striped(true).spacing([8.0 * scale, 2.0 * scale]).show(ui, |ui| {+ for (i, cell) in cells.iter().enumerate() {+ let text = if marked_up(cell) { without_markup(cell) } else { (*cell).to_string() };+ let color = if i == 0 { t.ink } else { t.muted };+ ui.label(RichText::new(text).color(color).size(12.0 * scale));+ }+ ui.end_row();+ });+ } Line::Text(text) => {- ui.label(RichText::new(*text).color(t.muted));+ if marked_up(text) {+ ui.horizontal_wrapped(|ui| inline(ui, text, t.muted));+ } else {+ // Nothing to render: one label, exactly as before.+ ui.label(RichText::new(*text).color(t.muted));+ } } Line::Blank => { ui.add_space(2.0);@@ -124,6 +318,32 @@ fn draw(ui: &mut Ui, line: &Line<'_>, t: &Tokens, scale: f32) { } } +/// One line's inline pieces: bold in Inter 600, code in Geist Mono, and a link as a hyperlink+/// carrying its own label, so the URL itself never has to be read (or wrapped) in the note.+///+/// The pieces are laid out flush: the runs already carry the spaces the note wrote, and egui's+/// item spacing between them would push a trailing period away from the number it follows.+fn inline(ui: &mut Ui, text: &str, color: Color32) {+ ui.spacing_mut().item_spacing.x = 0.0;+ for span in spans(text) {+ match span {+ Span::Plain(s) => ui.label(RichText::new(s).color(color)),+ Span::Bold(s) => ui.label(RichText::new(s).family(theme::semibold()).color(color)),+ Span::Code(s) => ui.label(RichText::new(s).family(theme::mono()).color(color)),+ Span::Link { label, url } => match clickable(url) {+ Some(url) => ui.hyperlink_to(RichText::new(label), url),+ None => ui.label(RichText::new(label).color(color)),+ },+ };+ }+}++/// The address to open for a link, when it is one: a release body is hostile input, so it gets the+/// same check as the pull-request link beside it, and anything else is drawn as plain text.+fn clickable(url: &str) -> Option<&str> {+ artcraft_launcher_core::release::safe_web_link(url).then_some(url)+}+ #[cfg(test)] mod tests { use super::*;@@ -138,6 +358,10 @@ mod tests { assert_eq!(classify("- plain bullet"), Line::Bullet("plain bullet", None)); assert_eq!(classify(" "), Line::Blank); assert_eq!(classify("hello"), Line::Text("hello"));+ // Table rows+ assert_eq!(classify("| Tool | Score |"), Line::Table(vec!["Tool", "Score"]));+ assert_eq!(classify("|---|---|"), Line::Table(vec!["---", "---"]));+ assert_eq!(classify("| a | 1 |"), Line::Table(vec!["a", "1"])); } #[test]@@ -149,4 +373,233 @@ mod tests { assert_eq!(fold_at(&few), None); assert_eq!(fold_at(&[]), None); }++ #[test]+ fn fold_at_only_counts_bullets_after_whats_changed() {+ // Bullets before "What's Changed" don't count toward SHOWN.+ // SHOWN = 6, short list threshold = SHOWN + 1 = 7 bullets.+ // 7 bullets after heading = short list, no fold.+ let notes: Vec<Line> = vec![+ Line::Bullet("pre-change 1", None),+ Line::Bullet("pre-change 2", None),+ Line::Bullet("pre-change 3", None),+ Line::Bullet("pre-change 4", None),+ Line::Bullet("pre-change 5", None),+ Line::Heading("What's Changed"),+ Line::Bullet("change 1", None),+ Line::Bullet("change 2", None),+ Line::Bullet("change 3", None),+ Line::Bullet("change 4", None),+ Line::Bullet("change 5", None),+ Line::Bullet("change 6", None),+ Line::Bullet("change 7", None),+ ];+ // 7 bullets after heading = short list (≤ SHOWN+1), no fold+ assert_eq!(fold_at(¬es), None);+ // 8 bullets after heading = long list, fold at 7th (index 13 = 5 pre + 1 heading + 7)+ let long: Vec<Line> = vec![+ Line::Bullet("pre-change 1", None),+ Line::Bullet("pre-change 2", None),+ Line::Bullet("pre-change 3", None),+ Line::Bullet("pre-change 4", None),+ Line::Bullet("pre-change 5", None),+ Line::Heading("What's Changed"),+ Line::Bullet("change 1", None),+ Line::Bullet("change 2", None),+ Line::Bullet("change 3", None),+ Line::Bullet("change 4", None),+ Line::Bullet("change 5", None),+ Line::Bullet("change 6", None),+ Line::Bullet("change 7", None),+ Line::Bullet("change 8", None),+ ];+ assert_eq!(fold_at(&long), Some(12));+ // Only 6 bullets after heading = no fold+ let few: Vec<Line> = vec![+ Line::Heading("What's Changed"),+ Line::Bullet("c1", None),+ Line::Bullet("c2", None),+ Line::Bullet("c3", None),+ Line::Bullet("c4", None),+ Line::Bullet("c5", None),+ Line::Bullet("c6", None),+ ];+ assert_eq!(fold_at(&few), None);+ // No heading = counts from start (old behavior)+ let no_heading: Vec<Line> = (0..8).map(|_| Line::Bullet("c", None)).collect();+ // 8 bullets, no heading: nth(6) gives 7th bullet at index 6+ assert_eq!(fold_at(&no_heading), Some(6));+ }++ #[test]+ fn reads_bold_code_and_links() {+ // A file reference, as GitHub writes one: the label is code and the URL is a link.+ assert_eq!(+ spans("From [`docs/scorecard.md`](https://github.com/storytold/photocraft/blob/v0.10.0/docs/scorecard.md) (done):"),+ vec![+ Span::Plain("From "),+ Span::Link { label: "docs/scorecard.md", url: "https://github.com/storytold/photocraft/blob/v0.10.0/docs/scorecard.md" },+ Span::Plain(" (done):"),+ ]+ );+ // Two runs of bold on one line, the markers gone.+ assert_eq!(+ spans("**Tools:** 29 / 13 / 1 → **33 / 11 / 0**."),+ vec![Span::Bold("Tools:"), Span::Plain(" 29 / 13 / 1 → "), Span::Bold("33 / 11 / 0"), Span::Plain(".")]+ );+ assert_eq!(spans("run `cargo test` first"), vec![Span::Plain("run "), Span::Code("cargo test"), Span::Plain(" first")]);+ assert_eq!(spans("[notes](https://example.com/n)"), vec![Span::Link { label: "notes", url: "https://example.com/n" }]);+ assert_eq!(spans("**whole line**"), vec![Span::Bold("whole line")]);+ // A heading keeps its text and loses its markers.+ assert_eq!(without_markup("**Backend** and [docs](https://example.com/d)"), "Backend and docs");+ assert_eq!(without_markup("plain heading"), "plain heading");+ }++ #[test]+ fn leaves_text_that_only_looks_like_markup_alone() {+ for text in [+ "29 * 3 and 7 * 4",+ "snake_case_name",+ "a `b starts no code",+ "** left open",+ "** padded **",+ "see https://example.com/a(b)",+ "[]()",+ "[text][ref]",+ "[text](url \"title\")",+ ] {+ assert_eq!(spans(text), vec![Span::Plain(text)], "{text}");+ }+ assert!(spans("").is_empty());+ // Nested emphasis is the one thing left half-rendered: the inner run is taken and the+ // outer asterisks stay visible. No character is dropped.+ assert_eq!(spans("***nested***"), vec![Span::Plain("*"), Span::Bold("nested"), Span::Plain("*")]);+ }++ #[test]+ fn only_a_safe_web_address_is_clickable() {+ assert_eq!(clickable("https://example.com/scorecard.md"), Some("https://example.com/scorecard.md"));+ assert_eq!(clickable("http://plain.example/x"), None);+ assert_eq!(clickable("file:///etc/passwd"), None);+ assert_eq!(clickable("https://user@evil.example/x"), None);+ assert_eq!(clickable("javascript:alert(1)"), None);+ }++ #[test]+ fn the_dialog_draws_the_text_not_the_markup() {+ // `i18n::tests`' idiom: a bare context, the app's fonts installed before the first pass.+ let ctx = egui::Context::default();+ crate::theme::install_fonts(&ctx, None);+ let notes = "From [`docs/scorecard.md`](https://example.com/scorecard.md) (done):\n* **Tools:** 29 / 13 / 1";+ let mut output = ctx.run_ui(egui::RawInput::default(), |ui| show(ui, notes));+ output.textures_delta.clear();+ let drawn = drawn_text(output.shapes);++ // The link is drawn with its label and the bold run with its text, each as a run of its+ // own: the markers themselves are what these last two assertions refuse.+ assert!(drawn.iter().any(|s| s.as_str() == "docs/scorecard.md"), "the link's label is not drawn: {drawn:?}");+ assert!(drawn.iter().any(|s| s.as_str() == "Tools:"), "the bold run is not drawn: {drawn:?}");+ assert!(!drawn.iter().any(|s| s.contains("**")), "markdown markers reached the dialog: {drawn:?}");+ assert!(!drawn.iter().any(|s| s.contains("](http")), "the link's markup reached the dialog: {drawn:?}");+ }++ #[test]+ fn a_line_full_of_brackets_is_not_a_scan_per_bracket() {+ // Every unmatched `[` is retried, so the link searches stay inside a window. Searching the+ // rest of the line instead cost 57 ms at 16 k brackets and about a second at the 64 KiB+ // `release::MAX_NOTES` allows — twice per frame, because a line is parsed twice.+ let hostile = "[".repeat(64 * 1024);+ let started = std::time::Instant::now();+ assert_eq!(spans(&hostile), vec![Span::Plain(&hostile)]);+ assert!(started.elapsed() < std::time::Duration::from_millis(250), "the parse took {:?}", started.elapsed());+ }++ #[test]+ fn a_link_has_to_fit_inside_the_window() {+ let long_label = format!("[{}](https://example.com/x)", "a".repeat(MAX_LABEL));+ assert_eq!(spans(&long_label), vec![Span::Plain(&long_label)]);+ let long_url = format!("[label](https://example.com/{})", "a".repeat(MAX_URL));+ assert_eq!(spans(&long_url), vec![Span::Plain(&long_url)]);+ // Just inside the window the same shape is still read as a link.+ let just_fits = format!("[{}](https://example.com/x)", "a".repeat(MAX_LABEL - 2));+ assert!(matches!(spans(&just_fits).as_slice(), [Span::Link { .. }]), "{:?}", spans(&just_fits));+ }++ #[test]+ fn a_link_whose_label_crosses_the_byte_cap_is_still_read() {+ // `MAX_LABEL` is a byte count. If the cap byte falls inside a multi-byte character+ // in the URL that immediately follows the label, the old slice returned `None` and+ // dropped the link. The cap now snaps to the first character boundary strictly past+ // the cap, so a link whose label fits and whose URL starts with a multi-byte char+ // is still parsed.+ // Label = 123 'a' + 'é' = 125 bytes; `](` at 125,126; URL starts at 127 with 'é'+ // (2 bytes: 127,128). Byte 128 is a continuation → old `get(..128)` returned `None`.+ let label = format!("{}é", "a".repeat(123));+ let line = format!("[{label}](éx)");+ assert!(matches!(spans(&line).as_slice(), [Span::Link { .. }]), "{:?}", spans(&line));+ }++ #[test]+ fn the_runs_of_a_line_are_drawn_flush() {+ let ctx = egui::Context::default();+ crate::theme::install_fonts(&ctx, None);+ let notes = "From [`docs/scorecard.md`](https://example.com/scorecard.md) (done):\n* **Tools:** 29 / 13 / 1";+ let mut output = ctx.run_ui(egui::RawInput::default(), |ui| show(ui, notes));+ output.textures_delta.clear();+ let runs = drawn_runs(output.shapes);+ let starts = |text: &str| runs.iter().find(|run| run.text == text).map_or(f32::NAN, |run| run.starts);+ let ends = |text: &str| runs.iter().find(|run| run.text == text).map_or(f32::NAN, |run| run.ends);++ // A run starts where the one before it ended: the note's own spaces are the only ones drawn,+ // and a period stays on the number it follows instead of floating a gap away.+ assert!((starts("docs/scorecard.md") - ends("From ")).abs() < 0.5, "the link is not flush: {runs:?}");+ assert!((starts(" (done):") - ends("docs/scorecard.md")).abs() < 0.5, "the tail is not flush: {runs:?}");+ assert!((starts(" 29 / 13 / 1") - ends("Tools:")).abs() < 0.5, "the bullet's runs are not flush: {runs:?}");+ // The bullet's own gap survives: four points of air between the dot and the text.+ assert!((starts("Tools:") - ends("•") - 4.0).abs() < 0.5, "the bullet lost its gap: {runs:?}");+ }++ /// One run a frame drew: its text and the range it covers along its row.+ #[derive(Debug)]+ struct Run {+ text: String,+ starts: f32,+ ends: f32,+ }++ /// Every run a frame drew, with where it landed: what the user reads, and how far apart.+ fn drawn_runs(shapes: Vec<egui::epaint::ClippedShape>) -> Vec<Run> {+ fn walk(shape: &egui::Shape, out: &mut Vec<Run>) {+ match shape {+ egui::Shape::Text(text) => {+ // A run's own offset lives in its section's leading space; `pos` is the row's.+ let indent = text.galley.job.sections.first().map_or(0.0, |section| section.leading_space);+ out.push(Run { text: text.galley.text().to_owned(), starts: text.pos.x + indent, ends: text.pos.x + text.galley.rect.max.x });+ }+ egui::Shape::Vec(shapes) => shapes.iter().for_each(|shape| walk(shape, out)),+ _ => {}+ }+ }+ let mut out = Vec::new();+ for clipped in &shapes {+ walk(&clipped.shape, &mut out);+ }+ out+ }++ /// Everything a frame drew, as egui laid it out: what the user reads.+ fn drawn_text(shapes: Vec<egui::epaint::ClippedShape>) -> Vec<String> {+ fn walk(shape: &egui::Shape, out: &mut Vec<String>) {+ match shape {+ egui::Shape::Text(text) => out.push(text.galley.text().to_owned()),+ egui::Shape::Vec(shapes) => shapes.iter().for_each(|shape| walk(shape, out)),+ _ => {}+ }+ }+ let mut out = Vec::new();+ for clipped in &shapes {+ walk(&clipped.shape, &mut out);+ }+ out+ } }