`transcript-ui::tool` draws a card per call and a group per run, with the states, the collapsed-lays-out-nothing discipline and the one-card-per-result update. Screenshots in docs/bench/p1b-2026-09-06/. Includes a local fix to `List::place`'s reposition-vs-mov clash, which is about to be dropped for rustify's own. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
783 lines
32 KiB
Rust
783 lines
32 KiB
Rust
//! One markdown **block** (`client_core::markdown_blocks::Block`) rendered
|
|
//! for display: the plain text to draw, the [`SpanStyle`]s that style it,
|
|
//! the links inside it, and the [`BlockFrame`] the row builder puts around
|
|
//! it.
|
|
//!
|
|
//! This is the crate's answer to RUST.md's E2 finding against Masonry
|
|
//! ("rich inline text -- block-level yes, inline no, and both for the same
|
|
//! reason": `TextArea`'s `StyleSet` is one style for the whole editor,
|
|
//! `masonry/src/widgets/text_area.rs:43-44`'s `// TODO: RichTextInput`
|
|
//! beside it). iris's `SpanStyle` (`core/src/primitive/text.rs`) is
|
|
//! per-range, so bold/italic/inline-code/links inside one wrapped
|
|
//! paragraph render in their own style *and* the paragraph still wraps and
|
|
//! selects as one buffer.
|
|
//!
|
|
//! **Three widget shapes, not one per markdown feature** ([`BlockFrame`]).
|
|
//! A heading, a paragraph and a list are all *text with spans*; a fence
|
|
//! and a table are *verbatim text on a dark surface that pans sideways*;
|
|
//! a quote is *text behind a coloured bar*. Everything else markdown can
|
|
//! say is expressed in the spans, which cost no widgets and no layout
|
|
//! nodes. `app/.../Markdown.kt`'s component table is the reference for the
|
|
//! sizes and colours; docs/DECISIONS.md's 2026-09-06 entry records where
|
|
//! this deliberately differs.
|
|
//!
|
|
//! **What this deliberately does not attempt**, each for a reason recorded
|
|
//! here rather than silently dropped (see IRIS_TODO.md's dated entries for
|
|
//! the same list):
|
|
//! - **No background chip behind inline code.** Drawing one needs the
|
|
//! glyph run's own geometry (the way `TextEdit::draw`'s selection
|
|
//! highlight uses `selection.geometry(layout)`,
|
|
//! `iris/src/widget/text/edit.rs:99`), which is `TextEdit`-internal.
|
|
//! `SpanStyle` gives the code range a monospace family and the
|
|
//! palette's code colour instead -- visually distinct, just not
|
|
//! chip-shaped.
|
|
//! - **A list's indent is written in spaces**, not measured. Compose lays
|
|
//! an item out as a marker column beside a text column, which keeps a
|
|
//! wrapped second line aligned under the first; here the marker is part
|
|
//! of the same buffer, so a wrapped line returns to the left margin.
|
|
//! Doing better needs per-line indent in `TextAttrs`, which nothing else
|
|
//! wants yet.
|
|
//!
|
|
//! A heading's `SpanStyle::font_size` override does not also raise its
|
|
//! `line_height` (a buffer has one, set from the *base* font size in
|
|
//! `TextAttrs`), so a heading's own line looks slightly tighter than a
|
|
//! paragraph's -- visible, not incorrect, and not fixed here since it
|
|
//! needs `SpanStyle` to carry line-height too.
|
|
|
|
use client_core::highlight::{self, Kind, Language};
|
|
use client_core::markdown_blocks::{Block, BlockKind};
|
|
use iris::prelude::*;
|
|
use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
|
|
use std::ops::Range;
|
|
|
|
// `UiColor` is `Color<u8>` (`core/src/lib.rs`), not the 0..1 float triples
|
|
// its brighter/darker helpers might suggest -- these are plain 0..255 RGB.
|
|
// Catppuccin Mocha, the same values `app/.../Theme.kt` maps onto
|
|
// Material's roles, so a block drawn here and the same block drawn by the
|
|
// Compose app are the same colour rather than nearly.
|
|
const fn mocha(hex: u32) -> UiColor {
|
|
UiColor::new(
|
|
((hex >> 16) & 0xff) as u8,
|
|
((hex >> 8) & 0xff) as u8,
|
|
(hex & 0xff) as u8,
|
|
255,
|
|
)
|
|
}
|
|
|
|
/// Body text: Mocha Text, the Compose app's `onSurface`.
|
|
pub const TEXT_COLOR: UiColor = mocha(0xCDD6F4);
|
|
/// Inline code, and a fence with no language to highlight it by.
|
|
pub const CODE_COLOR: UiColor = mocha(0xCDD6F4);
|
|
/// A link. "Blue is what a link is on every Catppuccin surface, and the
|
|
/// one colour to leave alone" (`Theme.kt`'s `linkColor`).
|
|
pub const LINK_COLOR: UiColor = mocha(0x89B4FA);
|
|
/// A list's bullets and numbers: structure rather than words, so the
|
|
/// items of a list can be counted without reading them (`listMarkerColor`).
|
|
pub const MARKER_COLOR: UiColor = mocha(0xB4BEFE);
|
|
/// What every verbatim thing in this app sits on -- Mocha Crust, one step
|
|
/// *below* the page rather than above it (`Theme.kt`'s `rawSurface`).
|
|
pub const VERBATIM_BACKGROUND: UiColor = mocha(0x11111B);
|
|
/// A table's fill: Surface 0, the Compose app's `surfaceVariant`.
|
|
pub const TABLE_BACKGROUND: UiColor = mocha(0x313244);
|
|
/// A quote's bar and its text: the bar carries the structure, and the
|
|
/// words step back one shade from body text so a quote reads as quoted
|
|
/// without being hard to read.
|
|
pub const QUOTE_BAR_COLOR: UiColor = mocha(0x585B70);
|
|
pub const QUOTE_TEXT_COLOR: UiColor = mocha(0xA6ADC8);
|
|
const STRIKETHROUGH_COLOR: UiColor = mocha(0x6C7086);
|
|
|
|
/// Catppuccin Mocha as the highlighter's palette -- the same mapping
|
|
/// `Theme.kt`'s `catppuccinSyntax()` uses, so a `kotlin` fence is the same
|
|
/// colours in both apps.
|
|
fn syntax_color(kind: Kind) -> UiColor {
|
|
match kind {
|
|
Kind::Keyword => mocha(0xCBA6F7),
|
|
Kind::String => mocha(0xA6E3A1),
|
|
Kind::Literal => mocha(0xFAB387),
|
|
Kind::Comment => mocha(0x6C7086),
|
|
Kind::Metadata => mocha(0xF9E2AF),
|
|
Kind::Punctuation => mocha(0xA6ADC8),
|
|
Kind::Mark => mocha(0x89DCEB),
|
|
}
|
|
}
|
|
|
|
/// What a row builder puts *around* a block's text widget. Three, not one
|
|
/// per markdown feature -- see the module doc.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum BlockFrame {
|
|
/// Text and nothing else: a paragraph, a heading, a list, a rule.
|
|
Plain,
|
|
/// A dark rounded panel whose text does not wrap -- long lines pan
|
|
/// sideways, the way `CodeFence.kt`'s `horizontalScroll` does. Carries
|
|
/// its own fill, since a fence and a table are drawn on different
|
|
/// ones.
|
|
Verbatim { fill: UiColor },
|
|
/// A coloured bar down the left edge and an indent past it.
|
|
Quote,
|
|
}
|
|
|
|
/// The frame a block kind is drawn in. Pure, and the *only* place the
|
|
/// mapping is written: a new `BlockKind` shows up here as a compile error
|
|
/// rather than silently taking prose's appearance.
|
|
pub fn frame_of(kind: BlockKind) -> BlockFrame {
|
|
match kind {
|
|
BlockKind::Code => BlockFrame::Verbatim {
|
|
fill: VERBATIM_BACKGROUND,
|
|
},
|
|
BlockKind::Table => BlockFrame::Verbatim {
|
|
fill: TABLE_BACKGROUND,
|
|
},
|
|
BlockKind::Quote => BlockFrame::Quote,
|
|
BlockKind::Paragraph | BlockKind::Heading | BlockKind::List | BlockKind::Other => {
|
|
BlockFrame::Plain
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A tappable range of a block's text and where it points.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct Link {
|
|
/// Byte range into [`Rendered::text`].
|
|
pub range: Range<usize>,
|
|
pub url: String,
|
|
}
|
|
|
|
/// One block, ready to draw. Not `Debug`: `SpanStyle` is not, and adding
|
|
/// it there for this would be a change to iris for a test's benefit.
|
|
#[derive(Clone, Default)]
|
|
pub struct Rendered {
|
|
pub text: String,
|
|
pub spans: Vec<SpanStyle>,
|
|
pub links: Vec<Link>,
|
|
}
|
|
|
|
impl Rendered {
|
|
/// The link `byte` falls inside, if any -- what a tap resolves
|
|
/// through. Half-open, so the offset one past a link's last character
|
|
/// (where a tap just after it lands) is *not* in it.
|
|
pub fn link_at(&self, byte: usize) -> Option<&Link> {
|
|
self.links.iter().find(|l| l.range.contains(&byte))
|
|
}
|
|
}
|
|
|
|
/// The heading ladder, in points at a 16pt body: it starts near the body
|
|
/// text and descends, because these are headings inside a chat message
|
|
/// rather than the top of a document. The numbers are Material's
|
|
/// `headlineSmall`/`titleLarge`/`titleMedium`/`titleSmall`/`labelMedium`/
|
|
/// `labelSmall`, which is what `Markdown.kt`'s `markdownTypography` picks
|
|
/// -- kept as literals rather than derived from `base_size` so the two
|
|
/// apps agree exactly.
|
|
fn heading_size(level: HeadingLevel) -> f32 {
|
|
match level {
|
|
HeadingLevel::H1 => 24.0,
|
|
HeadingLevel::H2 => 22.0,
|
|
HeadingLevel::H3 => 16.0,
|
|
HeadingLevel::H4 => 14.0,
|
|
HeadingLevel::H5 => 12.0,
|
|
HeadingLevel::H6 => 11.0,
|
|
}
|
|
}
|
|
|
|
/// The bullet at each depth, cycling past the third: a disc, a ring, a
|
|
/// square -- the ladder a browser draws, so a nested list is told from its
|
|
/// parent by the glyph as well as by the indent. Same three
|
|
/// `MarkdownPieces.kt` uses.
|
|
const BULLETS: [&str; 3] = ["\u{2022} ", "\u{25e6} ", "\u{25aa} "];
|
|
|
|
/// A block-level separator inside one block's own text (a list item's
|
|
/// paragraphs, a quote's): two never run into each other with no gap, but
|
|
/// an empty `out` gets no leading blank.
|
|
fn ensure_blank_line(out: &mut String) {
|
|
if !out.is_empty() && !out.ends_with("\n\n") {
|
|
while out.ends_with('\n') {
|
|
out.pop();
|
|
}
|
|
out.push_str("\n\n");
|
|
}
|
|
}
|
|
|
|
fn ensure_line(out: &mut String) {
|
|
if !out.is_empty() && !out.ends_with('\n') {
|
|
out.push('\n');
|
|
}
|
|
}
|
|
|
|
/// One top-level block, rendered. `base_size` is the row's ordinary
|
|
/// paragraph font size; a heading overrides it per span.
|
|
pub fn render_block(block: &Block, base_size: f32) -> Rendered {
|
|
match block.kind {
|
|
// A table is the one block markdown states as a grid and iris has
|
|
// no grid widget for. Rendered as padded monospace instead --
|
|
// see [`table_text`].
|
|
BlockKind::Table => table_text(&block.source),
|
|
_ => render_markdown(&block.source, base_size),
|
|
}
|
|
}
|
|
|
|
/// One markdown source string rendered into plain text plus the spans that
|
|
/// style it. `base_size` is the row's ordinary paragraph font size, needed
|
|
/// only so a heading's override is relative to it rather than a hardcoded
|
|
/// absolute the caller cannot retune.
|
|
pub fn render_markdown(src: &str, base_size: f32) -> Rendered {
|
|
let _ = base_size; // headings use the fixed Material ladder; see `heading_size`
|
|
let mut out = String::new();
|
|
let mut spans = Vec::new();
|
|
let mut links = Vec::new();
|
|
// Stack of start byte offsets for whatever inline/block styling is
|
|
// currently open -- pulldown-cmark's `Start`/`End` events are always
|
|
// balanced and each `End` already names its own kind (`TagEnd`), so a
|
|
// plain offset stack (rather than a tree, or repeating the kind here
|
|
// too) is enough. A link's destination rides along beside its offset,
|
|
// since `TagEnd::Link` does not carry it.
|
|
let mut open: Vec<(usize, Option<String>)> = Vec::new();
|
|
// One entry per open list: `Some(next number)` for an ordered list,
|
|
// `None` for a bulleted one. Depth is this vector's length, which is
|
|
// what picks the bullet glyph.
|
|
let mut lists: Vec<Option<u64>> = Vec::new();
|
|
// The language of the fence currently open, so `TagEnd::CodeBlock` can
|
|
// highlight what was collected between the two.
|
|
let mut fence_language: Option<Language> = None;
|
|
|
|
let parser = Parser::new_ext(src, options());
|
|
for event in parser {
|
|
match event {
|
|
Event::Start(tag) => match tag {
|
|
Tag::Heading { .. }
|
|
| Tag::Emphasis
|
|
| Tag::Strong
|
|
| Tag::Strikethrough
|
|
| Tag::Image { .. } => open.push((out.len(), None)),
|
|
Tag::Link { dest_url, .. } => open.push((out.len(), Some(dest_url.to_string()))),
|
|
Tag::CodeBlock(kind) => {
|
|
fence_language = match &kind {
|
|
CodeBlockKind::Fenced(info) => {
|
|
// Only the first word: "rust,ignore" and
|
|
// "console session" are both written.
|
|
highlight::fence_language(info.split_whitespace().next())
|
|
}
|
|
CodeBlockKind::Indented => None,
|
|
};
|
|
ensure_blank_line(&mut out);
|
|
open.push((out.len(), None));
|
|
}
|
|
Tag::Item => {
|
|
ensure_line(&mut out);
|
|
let depth = lists.len().max(1);
|
|
out.push_str(&" ".repeat(depth - 1));
|
|
let start = out.len();
|
|
match lists.last_mut() {
|
|
Some(Some(n)) => {
|
|
out.push_str(&format!("{n}. "));
|
|
*n += 1;
|
|
}
|
|
_ => out.push_str(BULLETS[(depth - 1) % BULLETS.len()]),
|
|
}
|
|
spans.push(SpanStyle::new(start..out.len()).color(MARKER_COLOR));
|
|
}
|
|
Tag::List(first) => lists.push(first),
|
|
Tag::Paragraph | Tag::BlockQuote(_) => ensure_blank_line(&mut out),
|
|
_ => {}
|
|
},
|
|
// Only the tag kinds that pushed onto `open` (Start, above) are
|
|
// popped here -- `List`/`Item`/`Paragraph`/`BlockQuote`/`Table`
|
|
// and friends push nothing, since they need no span, and must
|
|
// not touch this stack or they would pop an unrelated styled
|
|
// range still open around them.
|
|
Event::End(
|
|
tag_end @ (TagEnd::Heading(_)
|
|
| TagEnd::Emphasis
|
|
| TagEnd::Strong
|
|
| TagEnd::Strikethrough
|
|
| TagEnd::Link
|
|
| TagEnd::Image
|
|
| TagEnd::CodeBlock),
|
|
) => {
|
|
let Some((start, dest)) = open.pop() else {
|
|
continue;
|
|
};
|
|
if matches!(tag_end, TagEnd::CodeBlock) {
|
|
// A fence's trailing newline is the fence marker's, not
|
|
// the code's -- kept and it draws an empty last line.
|
|
while out.ends_with('\n') {
|
|
out.pop();
|
|
}
|
|
}
|
|
let range = start..out.len();
|
|
if range.is_empty() {
|
|
continue;
|
|
}
|
|
match tag_end {
|
|
TagEnd::Heading(level) => {
|
|
spans.push(SpanStyle::new(range).font_size(heading_size(level)).bold());
|
|
}
|
|
TagEnd::Emphasis => spans.push(SpanStyle::new(range).italic()),
|
|
TagEnd::Strong => spans.push(SpanStyle::new(range).bold()),
|
|
TagEnd::Strikethrough => {
|
|
spans.push(SpanStyle::new(range).color(STRIKETHROUGH_COLOR));
|
|
}
|
|
// An image draws as its alt text until the port has a
|
|
// transcript image widget (IRIS_TODO's "scaled
|
|
// thumbnail"); marked as a link so it is at least
|
|
// followable rather than silently inert.
|
|
TagEnd::Link | TagEnd::Image => {
|
|
spans.push(SpanStyle::new(range.clone()).color(LINK_COLOR).underline());
|
|
if let Some(url) = dest {
|
|
links.push(Link { range, url });
|
|
}
|
|
}
|
|
TagEnd::CodeBlock => {
|
|
spans.push(
|
|
SpanStyle::new(range.clone())
|
|
.family(Family::Monospace)
|
|
.color(CODE_COLOR),
|
|
);
|
|
// After the monospace span, so the per-token
|
|
// colours win where they overlap it.
|
|
if let Some(language) = fence_language.take() {
|
|
highlight_into(&mut spans, &out, range, language);
|
|
}
|
|
}
|
|
_ => unreachable!("filtered by the outer match arm"),
|
|
}
|
|
}
|
|
Event::Text(text) => out.push_str(&text),
|
|
// Inline code (single backticks) is one atomic event with no
|
|
// `Start`/`End` pair of its own, unlike a fenced block -- so it
|
|
// is spanned directly here instead of through the `open` stack.
|
|
Event::Code(text) => {
|
|
let start = out.len();
|
|
out.push_str(&text);
|
|
spans.push(
|
|
SpanStyle::new(start..out.len())
|
|
.family(Family::Monospace)
|
|
.color(CODE_COLOR),
|
|
);
|
|
}
|
|
Event::SoftBreak => out.push(' '),
|
|
Event::HardBreak => out.push('\n'),
|
|
Event::Rule => {
|
|
ensure_line(&mut out);
|
|
out.push_str("\u{2500}\u{2500}\u{2500}\n");
|
|
}
|
|
Event::TaskListMarker(done) => {
|
|
let start = out.len();
|
|
out.push_str(if done { "[x] " } else { "[ ] " });
|
|
spans.push(SpanStyle::new(start..out.len()).color(MARKER_COLOR));
|
|
}
|
|
Event::End(TagEnd::List(_)) => {
|
|
lists.pop();
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
while out.ends_with('\n') {
|
|
out.pop();
|
|
}
|
|
// A span left pointing past the text a later trim shortened would draw
|
|
// against nothing; markdown that ends inside an open emphasis is
|
|
// ordinary mid-stream input, not a defect.
|
|
spans.retain(|s| s.range.end <= out.len());
|
|
links.retain(|l| l.range.end <= out.len());
|
|
Rendered {
|
|
text: out,
|
|
spans,
|
|
links,
|
|
}
|
|
}
|
|
|
|
/// The same option set `client_core::markdown_blocks` splits with, so a
|
|
/// block boundary there and the styling here cannot disagree about what
|
|
/// the source means.
|
|
fn options() -> Options {
|
|
Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TABLES | Options::ENABLE_TASKLISTS
|
|
}
|
|
|
|
/// `client_core::highlight`'s spans for the code at `range` inside `text`,
|
|
/// appended to `spans`.
|
|
///
|
|
/// The highlighter indexes **chars** and `SpanStyle` indexes **bytes**
|
|
/// (`highlight`'s module doc), so the offsets are walked once rather than
|
|
/// converted per span -- a fence is scanned on every delta that lands in
|
|
/// it, and it is the only block a delta re-renders.
|
|
pub(crate) fn highlight_into(spans: &mut Vec<SpanStyle>, text: &str, range: Range<usize>, language: Language) {
|
|
let code = &text[range.clone()];
|
|
// char index -> byte offset within `code`, plus the end, so a span's
|
|
// `end` is always in range.
|
|
let bytes: Vec<usize> = code
|
|
.char_indices()
|
|
.map(|(i, _)| i)
|
|
.chain(std::iter::once(code.len()))
|
|
.collect();
|
|
for span in highlight::spans_of(code, language) {
|
|
let (Some(&start), Some(&end)) = (bytes.get(span.start), bytes.get(span.end)) else {
|
|
debug_assert!(
|
|
false,
|
|
"highlight span {}..{} outside {} chars of code",
|
|
span.start,
|
|
span.end,
|
|
bytes.len() - 1
|
|
);
|
|
continue;
|
|
};
|
|
spans.push(
|
|
SpanStyle::new(range.start + start..range.start + end)
|
|
.family(Family::Monospace)
|
|
.color(syntax_color(span.kind)),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The widest a table column is allowed to get before its cells wrap
|
|
/// inside it, in characters. Chosen the way `Markdown.kt`'s 136dp
|
|
/// `tableCellWidth` was -- what fits three columns across a phone -- but
|
|
/// counted in monospace characters, which is the unit a padded table has:
|
|
/// three 28-character columns plus separators is about 90 characters,
|
|
/// which is what a 16pt mono face gives on a 1080px phone before the
|
|
/// sideways pan starts.
|
|
const TABLE_MAX_COL: usize = 28;
|
|
|
|
/// A GFM table as **padded monospace columns**, with the header bold and a
|
|
/// rule under it.
|
|
///
|
|
/// iris has no grid widget, and building one for the one block kind that
|
|
/// needs it would be a widget per markdown feature -- what this crate's
|
|
/// module doc says it will not do. A monospace face makes character counts
|
|
/// and pixel widths the same thing, so padding each cell to its column's
|
|
/// width *is* alignment, the column widths are measured from the cells,
|
|
/// and the block reuses `BlockFrame::Verbatim`'s sideways pan for a table
|
|
/// too wide to fit. docs/DECISIONS.md, 2026-09-06, has what this trades.
|
|
pub fn table_text(src: &str) -> Rendered {
|
|
let rows = table_cells(src);
|
|
if rows.is_empty() {
|
|
return Rendered::default();
|
|
}
|
|
let columns = rows.iter().map(Vec::len).max().unwrap_or(0);
|
|
// Each cell wrapped to the cap first, so a column's width is the
|
|
// widest *line* it will actually draw rather than the longest cell.
|
|
let wrapped: Vec<Vec<Vec<String>>> = rows
|
|
.iter()
|
|
.map(|row| row.iter().map(|c| wrap_cell(c, TABLE_MAX_COL)).collect())
|
|
.collect();
|
|
let widths: Vec<usize> = (0..columns)
|
|
.map(|c| {
|
|
wrapped
|
|
.iter()
|
|
.filter_map(|row| row.get(c))
|
|
.flat_map(|lines| lines.iter())
|
|
.map(|l| l.chars().count())
|
|
.max()
|
|
.unwrap_or(0)
|
|
})
|
|
.collect();
|
|
|
|
let mut out = String::new();
|
|
let mut spans = Vec::new();
|
|
for (r, row) in wrapped.iter().enumerate() {
|
|
let height = row.iter().map(Vec::len).max().unwrap_or(1);
|
|
let start = out.len();
|
|
for line in 0..height {
|
|
if !out.is_empty() {
|
|
out.push('\n');
|
|
}
|
|
for (c, width) in widths.iter().enumerate() {
|
|
if c > 0 {
|
|
out.push_str(" ");
|
|
}
|
|
let text = row.get(c).and_then(|l| l.get(line)).map(String::as_str);
|
|
let text = text.unwrap_or("");
|
|
out.push_str(text);
|
|
// The last column is not padded: trailing spaces widen
|
|
// the block's measured width for nothing.
|
|
if c + 1 < widths.len() {
|
|
for _ in text.chars().count()..*width {
|
|
out.push(' ');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if r == 0 {
|
|
spans.push(SpanStyle::new(start..out.len()).bold());
|
|
out.push('\n');
|
|
let rule: usize = widths.iter().sum::<usize>() + 2 * widths.len().saturating_sub(1);
|
|
let rule_start = out.len();
|
|
out.extend(std::iter::repeat_n('\u{2500}', rule));
|
|
spans.push(SpanStyle::new(rule_start..out.len()).color(QUOTE_BAR_COLOR));
|
|
}
|
|
}
|
|
Rendered {
|
|
text: out,
|
|
spans,
|
|
links: Vec::new(),
|
|
}
|
|
}
|
|
|
|
/// The cells of a GFM table, row by row, as their plain text.
|
|
fn table_cells(src: &str) -> Vec<Vec<String>> {
|
|
let mut rows: Vec<Vec<String>> = Vec::new();
|
|
let mut cell = String::new();
|
|
let mut in_cell = false;
|
|
for event in Parser::new_ext(src, options()) {
|
|
match event {
|
|
Event::Start(Tag::TableHead) | Event::Start(Tag::TableRow) => rows.push(Vec::new()),
|
|
Event::Start(Tag::TableCell) => {
|
|
cell.clear();
|
|
in_cell = true;
|
|
}
|
|
Event::End(TagEnd::TableCell) => {
|
|
in_cell = false;
|
|
if let Some(row) = rows.last_mut() {
|
|
row.push(cell.trim().to_string());
|
|
}
|
|
}
|
|
Event::Text(text) | Event::Code(text) if in_cell => cell.push_str(&text),
|
|
Event::SoftBreak | Event::HardBreak if in_cell => cell.push(' '),
|
|
_ => {}
|
|
}
|
|
}
|
|
rows.retain(|r| !r.is_empty());
|
|
rows
|
|
}
|
|
|
|
/// `text` broken onto lines of at most `width` characters, at spaces where
|
|
/// there are any. A word longer than the column is left over-long rather
|
|
/// than cut mid-word: the column then widens for it, which is visible and
|
|
/// correct, where cutting would silently lose characters.
|
|
fn wrap_cell(text: &str, width: usize) -> Vec<String> {
|
|
let mut lines = Vec::new();
|
|
let mut line = String::new();
|
|
for word in text.split_whitespace() {
|
|
let extra = if line.is_empty() { 0 } else { 1 };
|
|
if !line.is_empty() && line.chars().count() + extra + word.chars().count() > width {
|
|
lines.push(std::mem::take(&mut line));
|
|
}
|
|
if !line.is_empty() {
|
|
line.push(' ');
|
|
}
|
|
line.push_str(word);
|
|
}
|
|
lines.push(line);
|
|
lines
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use client_core::markdown_blocks::split_blocks;
|
|
|
|
fn block(src: &str) -> Rendered {
|
|
let blocks = split_blocks(src);
|
|
assert_eq!(blocks.len(), 1, "test wants exactly one block: {blocks:?}");
|
|
render_block(&blocks[0], 16.0)
|
|
}
|
|
|
|
#[test]
|
|
fn plain_paragraph_has_no_spans() {
|
|
let r = render_markdown("just some words", 16.0);
|
|
assert_eq!(r.text, "just some words");
|
|
assert!(r.spans.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn bold_and_italic_produce_spans_over_the_right_range() {
|
|
let r = render_markdown("a **bold** and *italic* word", 16.0);
|
|
assert_eq!(r.text, "a bold and italic word");
|
|
let bold = r.spans.iter().find(|s| s.bold && !s.italic).unwrap();
|
|
assert_eq!(&r.text[bold.range.clone()], "bold");
|
|
let italic = r.spans.iter().find(|s| s.italic).unwrap();
|
|
assert_eq!(&r.text[italic.range.clone()], "italic");
|
|
}
|
|
|
|
#[test]
|
|
fn heading_gets_a_bigger_font_size_span() {
|
|
let r = render_markdown("# A Title", 16.0);
|
|
assert!(r.text.starts_with("A Title"));
|
|
let heading = r.spans.iter().find(|s| s.font_size.is_some()).unwrap();
|
|
assert_eq!(&r.text[heading.range.clone()], "A Title");
|
|
assert_eq!(heading.font_size, Some(24.0));
|
|
}
|
|
|
|
/// Every level draws at its own size, so two levels of nesting are
|
|
/// never the same -- `Markdown.kt`'s reason for the ladder.
|
|
#[test]
|
|
fn every_heading_level_is_a_different_size() {
|
|
let mut sizes = Vec::new();
|
|
for level in 1..=6 {
|
|
let src = format!("{} h", "#".repeat(level));
|
|
let r = render_markdown(&src, 16.0);
|
|
sizes.push(r.spans.iter().find_map(|s| s.font_size).unwrap());
|
|
}
|
|
let mut sorted = sizes.clone();
|
|
sorted.sort_by(|a, b| b.partial_cmp(a).unwrap());
|
|
sorted.dedup();
|
|
assert_eq!(sizes, sorted, "the ladder must descend with no repeats");
|
|
}
|
|
|
|
#[test]
|
|
fn a_link_keeps_its_text_and_its_url_and_can_be_hit() {
|
|
let r = render_markdown("see [the docs](https://example.com) for more", 16.0);
|
|
assert!(r.text.contains("the docs"));
|
|
assert!(
|
|
!r.text.contains("example.com"),
|
|
"the URL should not leak into the visible text"
|
|
);
|
|
let link = r.spans.iter().find(|s| s.underline).unwrap();
|
|
assert_eq!(&r.text[link.range.clone()], "the docs");
|
|
let at = r.text.find("docs").unwrap();
|
|
assert_eq!(r.link_at(at).unwrap().url, "https://example.com");
|
|
assert!(r.link_at(0).is_none(), "the word 'see' is not the link");
|
|
let past = r.text.find("for").unwrap();
|
|
assert!(r.link_at(past).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn fenced_code_block_is_monospaced_and_highlighted_by_its_language() {
|
|
let r = block("```rust\nlet x = 1; // note\n```");
|
|
assert_eq!(r.text, "let x = 1; // note");
|
|
let keyword = r
|
|
.spans
|
|
.iter()
|
|
.find(|s| s.color == Some(syntax_color(Kind::Keyword)))
|
|
.expect("a rust fence colours its keywords");
|
|
assert_eq!(&r.text[keyword.range.clone()], "let");
|
|
let comment = r
|
|
.spans
|
|
.iter()
|
|
.find(|s| s.color == Some(syntax_color(Kind::Comment)))
|
|
.unwrap();
|
|
assert_eq!(&r.text[comment.range.clone()], "// note");
|
|
assert!(r.spans.iter().all(|s| s.range.end <= r.text.len()));
|
|
}
|
|
|
|
/// The half the change had no reason to touch: a fence in a language
|
|
/// the highlighter has no rules for must be plain rather than
|
|
/// coloured by the nearest language's (`CodeFence.kt`'s
|
|
/// `fenceLanguage` doc).
|
|
#[test]
|
|
fn a_fence_in_an_unknown_language_is_monospace_and_uncoloured() {
|
|
let r = block("```brainfuck\nlet x = 1;\n```");
|
|
assert_eq!(r.text, "let x = 1;");
|
|
assert_eq!(r.spans.len(), 1);
|
|
// `Family` is not `Debug`, so this is `assert!` rather than
|
|
// `assert_eq!`.
|
|
assert!(r.spans[0].family == Some(Family::Monospace));
|
|
assert_eq!(r.spans[0].color, Some(CODE_COLOR));
|
|
}
|
|
|
|
/// Multi-byte characters are where a char-indexed highlighter and a
|
|
/// byte-indexed span list disagree if the conversion is missing.
|
|
#[test]
|
|
fn highlight_spans_are_byte_offsets_even_with_multibyte_code() {
|
|
let r = block("```rust\nlet s = \"café ☕\"; // é\n```");
|
|
for span in &r.spans {
|
|
assert!(
|
|
r.text.is_char_boundary(span.range.start)
|
|
&& r.text.is_char_boundary(span.range.end),
|
|
"span {:?} is not on a char boundary of {:?}",
|
|
span.range,
|
|
r.text
|
|
);
|
|
}
|
|
let string = r
|
|
.spans
|
|
.iter()
|
|
.find(|s| s.color == Some(syntax_color(Kind::String)))
|
|
.unwrap();
|
|
assert_eq!(&r.text[string.range.clone()], "\"café ☕\"");
|
|
}
|
|
|
|
#[test]
|
|
fn an_unterminated_fence_still_renders_what_arrived() {
|
|
let r = block("```rust\nlet x = 1;");
|
|
assert_eq!(r.text, "let x = 1;");
|
|
assert!(
|
|
r.spans
|
|
.iter()
|
|
.any(|s| s.color == Some(syntax_color(Kind::Keyword)))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_bulleted_list_gets_a_marker_per_item_and_indents_nesting() {
|
|
let r = block("- one\n- two\n - deep");
|
|
assert_eq!(r.text, "\u{2022} one\n\u{2022} two\n \u{25e6} deep");
|
|
let markers: Vec<_> = r
|
|
.spans
|
|
.iter()
|
|
.filter(|s| s.color == Some(MARKER_COLOR))
|
|
.map(|s| r.text[s.range.clone()].to_string())
|
|
.collect();
|
|
assert_eq!(markers, ["\u{2022} ", "\u{2022} ", "\u{25e6} "]);
|
|
}
|
|
|
|
#[test]
|
|
fn a_numbered_list_counts_from_the_number_it_was_written_with() {
|
|
let r = block("3. three\n4. four");
|
|
assert_eq!(r.text, "3. three\n4. four");
|
|
let markers: Vec<_> = r
|
|
.spans
|
|
.iter()
|
|
.filter(|s| s.color == Some(MARKER_COLOR))
|
|
.map(|s| r.text[s.range.clone()].to_string())
|
|
.collect();
|
|
assert_eq!(markers, ["3. ", "4. "]);
|
|
}
|
|
|
|
#[test]
|
|
fn a_quote_is_its_text_and_takes_the_quote_frame() {
|
|
let blocks = split_blocks("> quoted words\n> still quoted");
|
|
assert_eq!(frame_of(blocks[0].kind), BlockFrame::Quote);
|
|
let r = render_block(&blocks[0], 16.0);
|
|
assert_eq!(r.text, "quoted words still quoted");
|
|
}
|
|
|
|
#[test]
|
|
fn each_block_kind_maps_to_the_frame_it_is_drawn_in() {
|
|
use BlockKind::*;
|
|
assert_eq!(frame_of(Paragraph), BlockFrame::Plain);
|
|
assert_eq!(frame_of(Heading), BlockFrame::Plain);
|
|
assert_eq!(frame_of(List), BlockFrame::Plain);
|
|
assert_eq!(frame_of(Other), BlockFrame::Plain);
|
|
assert_eq!(frame_of(Quote), BlockFrame::Quote);
|
|
assert!(matches!(frame_of(Code), BlockFrame::Verbatim { .. }));
|
|
assert!(matches!(frame_of(Table), BlockFrame::Verbatim { .. }));
|
|
assert_ne!(
|
|
frame_of(Code),
|
|
frame_of(Table),
|
|
"a fence and a table sit on different fills"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_table_pads_its_columns_to_the_widest_cell() {
|
|
let r = block("| a | bb |\n|---|---|\n| cccc | d |");
|
|
let lines: Vec<&str> = r.text.lines().collect();
|
|
assert_eq!(lines[0], "a bb");
|
|
assert_eq!(lines[1], "\u{2500}".repeat(8));
|
|
assert_eq!(lines[2], "cccc d");
|
|
let bold = r.spans.iter().find(|s| s.bold).unwrap();
|
|
assert_eq!(&r.text[bold.range.clone()], "a bb");
|
|
}
|
|
|
|
/// The fixture's own table shape: a long cell wraps inside its column
|
|
/// instead of making the row one enormous line.
|
|
#[test]
|
|
fn a_long_table_cell_wraps_inside_its_column() {
|
|
let long = "one two three four five six seven eight nine ten eleven twelve";
|
|
let r = block(&format!("| k | v |\n|---|---|\n| a | {long} |"));
|
|
for line in r.text.lines() {
|
|
assert!(
|
|
line.chars().count() <= TABLE_MAX_COL + 1 + 2 + 1,
|
|
"line too wide: {line:?}"
|
|
);
|
|
}
|
|
assert!(r.text.contains("twelve"));
|
|
}
|
|
|
|
#[test]
|
|
fn a_task_list_marks_its_boxes() {
|
|
let r = block("- [x] done\n- [ ] not");
|
|
assert!(r.text.contains("[x] done"));
|
|
assert!(r.text.contains("[ ] not"));
|
|
}
|
|
}
|