//! The markdown view: a read-only rendering of a markdown document as //! formatted text. Parsing runs once per source change and produces blocks; //! rendering virtualizes the visible blocks and resolves every style from //! the theme at draw time. Images, footnotes, math, and HTML render nothing; //! long code lines clip rather than scroll. use gpui::prelude::FluentBuilder as _; use gpui::{ AnyElement, App, Context, Div, ElementId, FontStyle, FontWeight, HighlightStyle, InteractiveElement as _, InteractiveText, IntoElement, ListAlignment, ListOffset, ListState, ParentElement as _, Pixels, Render, SharedString, StatefulInteractiveElement as _, StrikethroughStyle, Styled as _, StyledText, UnderlineStyle, Window, div, list, px, relative, }; use gpui_native_core::scrollbar::{Scrollbar, ScrollbarVisibility}; use gpui_native_icons::{IconRole, icon}; use gpui_native_theme::ActiveTheme as _; use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd}; use std::ops::Range; use crate::highlight::{HighlightedCode, highlight_with_tree, plain}; use crate::utils::{code_line, grammar_language}; /// Heading scale steps relative to the root font size, H1 first. const HEADING_SCALES: [f32; 6] = [1.9, 1.5, 1.25, 1.1, 1.0, 0.95]; /// The bottom padding between sibling blocks, in root font sizes. const BLOCK_GAP: f32 = 0.75; /// The tighter bottom padding between list items, in root font sizes. const ITEM_GAP: f32 = 0.2; /// The room a list marker column takes, in root font sizes. const MARKER_WIDTH: f32 = 1.75; /// The text inset behind a quote bar, in root font sizes. const QUOTE_INSET: f32 = 1.0; /// The indent each list level adds, in root font sizes. const INDENT_STEP: f32 = 1.5; /// The body line height. const BODY_LINE_HEIGHT: f32 = 1.6; /// The code line height inside fenced blocks. const CODE_LINE_HEIGHT: f32 = 1.5; /// A heading level, 1 to 6. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum MarkdownHeading { H1, H2, H3, H4, H5, H6, } impl MarkdownHeading { /// The level as a number, 1 to 6. pub fn level(self) -> usize { match self { MarkdownHeading::H1 => 1, MarkdownHeading::H2 => 2, MarkdownHeading::H3 => 3, MarkdownHeading::H4 => 4, MarkdownHeading::H5 => 5, MarkdownHeading::H6 => 6, } } } /// The marker drawn before a list item. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ListMarker { /// An unordered bullet. Bullet, /// An ordered item, carrying its number in the list. Ordered(u64), /// A task item, `true` when checked. Task(bool), } /// One run of inline text sharing a style. #[derive(Clone, Debug, PartialEq, Eq)] pub struct InlineSpan { pub text: SharedString, pub style: InlineStyle, } /// The inline style a text run carries. #[derive(Clone, Debug, PartialEq, Eq)] pub enum InlineStyle { Text, Emphasis, Strong, StrongEmphasis, Strikethrough, /// Inline code. Code, /// A link with its destination. Link { destination: SharedString, }, } /// One cell of a table row. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct MarkdownTableCell { pub spans: Vec, /// 1-based column position, so renderers can align to the table's columns. pub column: usize, } /// A table row; the header row precedes the body rows. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct MarkdownTableRow { pub cells: Vec, pub header: bool, } /// A table, header row first. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct MarkdownTable { pub rows: Vec, } /// One rendered block of a markdown document. #[derive(Clone, Debug, PartialEq, Eq)] pub enum MarkdownBlock { Heading { level: MarkdownHeading, spans: Vec, /// How many lists the heading's content sits under. indent: usize, /// Whether a blockquote wraps the heading. quote: bool, }, Paragraph { spans: Vec, /// How many lists the paragraph's content sits under. indent: usize, /// Whether a blockquote wraps the paragraph. quote: bool, }, ListItem { marker: ListMarker, spans: Vec, /// How many lists wrap this item's list, zero for the outermost. indent: usize, quote: bool, }, Code { language: Option, lines: HighlightedCode, /// How many lists the block sits under. indent: usize, /// Whether a blockquote wraps the block. quote: bool, }, ThematicBreak { indent: usize, quote: bool, }, Table { table: MarkdownTable, indent: usize, quote: bool, }, } /// Retained state for a markdown view. pub struct MarkdownViewState { id: ElementId, source: SharedString, blocks: Vec, /// Whether fenced code blocks highlight with the code view's palette. highlight_code: bool, /// Drives the vertical scroll of the virtualized block list. handle: ListState, a11y_label: Option, } impl MarkdownViewState { /// Builds the state, parsing `source`. pub fn new(id: I, source: S) -> Self where I: Into, S: Into, { let source = source.into(); let blocks = parse(&source, true); let count = blocks.len(); Self { id: id.into(), source, blocks, highlight_code: true, handle: ListState::new(count, ListAlignment::Top, px(400.)), a11y_label: None, } } /// Renders fenced code blocks without the code view's highlighting. pub fn plain_code(mut self) -> Self { self.highlight_code = false; self.reparse(); self } /// Replaces the source and re-parses it. pub fn set_source(&mut self, source: S, cx: &mut Context) where S: Into, { let source = source.into(); if self.source == source { return; } self.source = source; self.reparse(); cx.notify(); } fn reparse(&mut self) { self.blocks = parse(&self.source, self.highlight_code); self.handle.reset(self.blocks.len()); self.handle.scroll_to_reveal_item(0); } /// The markdown source the view renders. pub fn source(&self) -> &str { &self.source } /// The parsed blocks. pub fn blocks(&self) -> &[MarkdownBlock] { &self.blocks } /// Whether fenced code highlights with the code view's palette. pub fn highlight_code(&self) -> bool { self.highlight_code } /// Scrolls `block` to the top of the view. pub fn scroll_to_block(&self, block: usize) { let item_ix = block.min(self.blocks.len().saturating_sub(1)); self.handle.scroll_to(ListOffset { item_ix, offset_in_item: px(0.), }); } /// Overrides the accessibility label of the view. pub fn accessibility_label(mut self, label: L) -> Self where L: Into, { self.a11y_label = Some(label.into()); self } } impl Render for MarkdownViewState { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let id = self.id.clone(); let state = cx.entity(); let blocks = list(self.handle.clone(), move |block_ix, _window, cx| { let view = state.read(cx); match view.blocks.get(block_ix) { Some(block) => render_block(block, block_ix, &view.id, cx), None => div().into_any_element(), } }) .h_full() .w_full(); div() .id((id.clone(), "view")) .relative() .size_full() .child(blocks) .child( Scrollbar::vertical(self.handle.clone()).visibility(ScrollbarVisibility::Platform), ) .when_some(self.a11y_label.clone(), |view, label| { view.aria_label(label) }) } } /// One open list, tracking ordered numbering and an in-flight task marker. struct ListFrame { ordered: bool, next_number: u64, task: Option, } /// The table being assembled, when the parser is inside one. enum TableBuilder { None, Body { rows: Vec }, } /// One open code block, accumulating its raw text. #[derive(Default)] struct CodeBuilder { text: String, } impl CodeBuilder { fn push(&mut self, text: &str) { self.text.push_str(text); } } /// Accumulates inline spans across inline events. #[derive(Default)] struct InlineBuilder { runs: Vec<(String, InlineStyle)>, styles: Vec>, } impl InlineBuilder { fn push_style(&mut self, style: Option) { self.styles.push(style); } fn pop_style(&mut self) { self.styles.pop(); } fn active(&self) -> InlineStyle { let mut strong = false; let mut emphasis = false; for style in self.styles.iter().rev().flatten() { match style { InlineStyle::Strong if emphasis => return InlineStyle::StrongEmphasis, InlineStyle::Strong => strong = true, InlineStyle::Emphasis if strong => return InlineStyle::StrongEmphasis, InlineStyle::Emphasis => emphasis = true, other => return other.clone(), } } match (strong, emphasis) { (true, true) => InlineStyle::StrongEmphasis, (true, false) => InlineStyle::Strong, (false, true) => InlineStyle::Emphasis, (false, false) => InlineStyle::Text, } } fn push_text(&mut self, text: &str) { if text.is_empty() { return; } let style = self.active(); if let Some((run, run_style)) = self.runs.last_mut() && *run_style == style { run.push_str(text); return; } self.runs.push((text.to_string(), style)); } /// Emits one span per contiguous style run. fn finish(&mut self) -> Vec { self.runs .drain(..) .map(|(text, style)| InlineSpan { text: SharedString::from(text), style, }) .collect() } } /// The markdown extensions the view parses, on top of CommonMark. fn options() -> Options { let mut options = Options::empty(); options.insert(Options::ENABLE_TABLES); options.insert(Options::ENABLE_STRIKETHROUGH); options.insert(Options::ENABLE_TASKLISTS); options } fn heading(level: HeadingLevel) -> MarkdownHeading { match level { HeadingLevel::H1 => MarkdownHeading::H1, HeadingLevel::H2 => MarkdownHeading::H2, HeadingLevel::H3 => MarkdownHeading::H3, HeadingLevel::H4 => MarkdownHeading::H4, HeadingLevel::H5 => MarkdownHeading::H5, HeadingLevel::H6 => MarkdownHeading::H6, } } /// Parses `source` into blocks. fn parse(source: &str, highlight_code: bool) -> Vec { let mut blocks = Vec::new(); let mut inline = InlineBuilder::default(); let mut lists: Vec = Vec::new(); // Whether each open list item already flushed content rows. let mut items: Vec = Vec::new(); let mut quote_depth = 0usize; let mut table = TableBuilder::None; let mut code: Option = None; // The fence info string arrives on Start, its text follows. let mut code_language: Option = None; for event in Parser::new_ext(source, options()) { match event { Event::Start(tag) => match tag { Tag::Paragraph | Tag::TableCell | Tag::Item => { inline = InlineBuilder::default(); if matches!(tag, Tag::Item) { // The marker is only in flight while its own item is open. if let Some(frame) = lists.last_mut() { frame.task = None; } items.push(false); } } Tag::Heading { level, .. } => { flush_item_inline( &mut inline, &mut items, &mut lists, &mut blocks, quote_depth, ); inline = InlineBuilder::default(); blocks.push(MarkdownBlock::Heading { level: heading(level), spans: Vec::new(), indent: lists.len(), quote: quote_depth > 0, }); } Tag::BlockQuote(_) => { flush_item_inline( &mut inline, &mut items, &mut lists, &mut blocks, quote_depth, ); quote_depth += 1; } Tag::List(start) => { flush_item_inline( &mut inline, &mut items, &mut lists, &mut blocks, quote_depth, ); lists.push(ListFrame { ordered: start.is_some(), next_number: start.unwrap_or_default(), task: None, }); } Tag::CodeBlock(kind) => { flush_item_inline( &mut inline, &mut items, &mut lists, &mut blocks, quote_depth, ); code = Some(CodeBuilder::default()); if let CodeBlockKind::Fenced(info) = kind { let info = info.trim(); code_language = (!info.is_empty()).then(|| SharedString::from(info.to_string())); } } Tag::Table(_) => { flush_item_inline( &mut inline, &mut items, &mut lists, &mut blocks, quote_depth, ); table = TableBuilder::Body { rows: Vec::new() } } Tag::TableHead => { if let TableBuilder::Body { rows } = &mut table { rows.push(MarkdownTableRow { cells: Vec::new(), header: true, }); } } Tag::TableRow => { if let TableBuilder::Body { rows } = &mut table { rows.push(MarkdownTableRow { cells: Vec::new(), header: false, }); } } Tag::Emphasis | Tag::Strong | Tag::Strikethrough => { let style = match tag { Tag::Emphasis => InlineStyle::Emphasis, Tag::Strong => InlineStyle::Strong, _ => InlineStyle::Strikethrough, }; inline.push_style(Some(style)); } Tag::Link { dest_url, .. } => inline.push_style(Some(InlineStyle::Link { destination: SharedString::from(dest_url.to_string()), })), // Images render as their alt text. Tag::Image { .. } => inline.push_style(None), Tag::HtmlBlock | Tag::MetadataBlock(_) => {} _ => {} }, Event::End(tag_end) => match tag_end { TagEnd::Paragraph => { let spans = inline.finish(); let quote = quote_depth > 0; match items.last_mut() { // The item's first paragraph carries its marker row. Some(flushed @ false) => { *flushed = true; blocks.push(MarkdownBlock::ListItem { marker: take_marker(&mut lists), spans, indent: lists.len().saturating_sub(1), quote, }); } _ => { if let Some(item) = items.last_mut() { *item = true; } blocks.push(MarkdownBlock::Paragraph { spans, indent: lists.len(), quote, }); } } } TagEnd::Heading(_) => { let spans = inline.finish(); if let Some(MarkdownBlock::Heading { spans: slots, .. }) = blocks.last_mut() { *slots = spans; } } TagEnd::BlockQuote(_) => quote_depth = quote_depth.saturating_sub(1), TagEnd::List(_) => { lists.pop(); } TagEnd::Item => { let flushed = items.pop().unwrap_or(false); let spans = inline.finish(); if spans.is_empty() && flushed { continue; } let indent = lists.len(); let quote = quote_depth > 0; if flushed { blocks.push(MarkdownBlock::Paragraph { spans, indent, quote, }); } else { blocks.push(MarkdownBlock::ListItem { marker: take_marker(&mut lists), spans, indent: indent.saturating_sub(1), quote, }); } } TagEnd::CodeBlock => { let built = code.take().unwrap_or_default(); let text = built.text.trim_end_matches('\n'); let language = code_language.take(); let lines = match highlight_code .then(|| language.as_deref().and_then(grammar_language)) .flatten() { Some(grammar) => highlight_with_tree(text, grammar, None).0, None => plain(text), }; blocks.push(MarkdownBlock::Code { language, lines, indent: lists.len(), quote: quote_depth > 0, }); } TagEnd::Table => { let built = std::mem::replace(&mut table, TableBuilder::None); if let TableBuilder::Body { rows } = built { blocks.push(MarkdownBlock::Table { table: MarkdownTable { rows }, indent: lists.len(), quote: quote_depth > 0, }); } } TagEnd::TableCell => { let spans = inline.finish(); let TableBuilder::Body { rows } = &mut table else { continue; }; let Some(row) = rows.last_mut() else { continue; }; row.cells.push(MarkdownTableCell { spans, column: row.cells.len() + 1, }); } TagEnd::Emphasis | TagEnd::Strong | TagEnd::Strikethrough | TagEnd::Link | TagEnd::Image => inline.pop_style(), _ => {} }, Event::Text(text) => { if let Some(built) = code.as_mut() { built.push(&text); } else { inline.push_text(&text); } } Event::Code(text) => { inline.push_style(Some(InlineStyle::Code)); inline.push_text(&text); inline.pop_style(); } Event::SoftBreak | Event::HardBreak => inline.push_text(" "), Event::Rule => { flush_item_inline( &mut inline, &mut items, &mut lists, &mut blocks, quote_depth, ); blocks.push(MarkdownBlock::ThematicBreak { indent: lists.len(), quote: quote_depth > 0, }); } Event::TaskListMarker(checked) => { if let Some(frame) = lists.last_mut() { frame.task = Some(checked); } } Event::FootnoteReference(label) => inline.push_text(&label), // HTML and math render nothing in this view. Event::InlineHtml(_) | Event::Html(_) | Event::InlineMath(_) | Event::DisplayMath(_) => {} } } blocks } /// Computes the current item's marker and advances ordered numbering. fn take_marker(lists: &mut [ListFrame]) -> ListMarker { let Some(frame) = lists.last_mut() else { return ListMarker::Bullet; }; let marker = match frame.task { Some(checked) => ListMarker::Task(checked), None if frame.ordered => ListMarker::Ordered(frame.next_number), None => ListMarker::Bullet, }; if frame.ordered { frame.next_number += 1; } marker } /// Flushes an open tight item's inline text as its marker row. fn flush_item_inline( inline: &mut InlineBuilder, items: &mut [bool], lists: &mut [ListFrame], blocks: &mut Vec, quote_depth: usize, ) { let Some(item) = items.last_mut() else { return; }; let spans = inline.finish(); if spans.is_empty() { return; } *item = true; blocks.push(MarkdownBlock::ListItem { marker: take_marker(lists), spans, indent: lists.len().saturating_sub(1), quote: quote_depth > 0, }); } /// Renders one parsed block as a list row. fn render_block(block: &MarkdownBlock, block_ix: usize, id: &ElementId, cx: &App) -> AnyElement { let theme = cx.theme(); let gap = theme.root_font_size * BLOCK_GAP; let block_id = ElementId::from(SharedString::from(format!("{id}-block-{block_ix}"))); match block { MarkdownBlock::Heading { level, spans, indent, quote, } => heading_row( *level, spans, *indent, *quote, if block_ix == 0 { px(0.) } else { gap * 0.5 }, &block_id, cx, ), MarkdownBlock::Paragraph { spans, indent, quote, } => text_row(spans, *indent, *quote, gap, None, &block_id, cx), MarkdownBlock::ListItem { marker, spans, indent, quote, } => text_row( spans, *indent, *quote, theme.root_font_size * ITEM_GAP, Some(*marker), &block_id, cx, ), MarkdownBlock::Code { lines, indent, quote, .. } => code_row(lines, *indent, *quote, &block_id, cx), MarkdownBlock::ThematicBreak { indent, quote } => rule_row(*indent, *quote, &block_id, cx), MarkdownBlock::Table { table, indent, quote, } => table_row(table, *indent, *quote, &block_id, cx), } } /// The shared block frame: ambient text style, indent, spacing, quote bar. fn block_frame( indent: usize, quote: bool, top: Pixels, bottom: Pixels, id: &ElementId, cx: &App, ) -> Div { let theme = cx.theme(); let content = div() .debug_selector({ let name = SharedString::from(format!("{id}")); move || name.to_string() }) .w_full() .flex() .flex_col() .font_family(".SystemUIFont") .text_size(theme.sizes.small.text_size) .line_height(relative(BODY_LINE_HEIGHT)) .text_color(theme.colors.foreground) .pt(top) .pb(bottom) .when(quote, |frame| { frame .border_l_2() .border_color(theme.colors.border) .pl(theme.root_font_size * QUOTE_INSET) }); let indent_px = theme.root_font_size * INDENT_STEP * indent as f32; if indent_px > px(0.) { div().w_full().pl(indent_px).child(content) } else { content } } fn heading_row( level: MarkdownHeading, spans: &[InlineSpan], indent: usize, quote: bool, top: Pixels, id: &ElementId, cx: &App, ) -> AnyElement { let theme = cx.theme(); let scale = HEADING_SCALES[(level.level() - 1).min(HEADING_SCALES.len() - 1)]; block_frame(indent, quote, top, theme.root_font_size * BLOCK_GAP, id, cx) .text_size(theme.root_font_size * scale) .line_height(relative(1.3)) .font_weight(FontWeight::SEMIBOLD) .child(text_child(spans, id, cx)) .into_any_element() } fn text_row( spans: &[InlineSpan], indent: usize, quote: bool, bottom: Pixels, marker: Option, id: &ElementId, cx: &App, ) -> AnyElement { let theme = cx.theme(); let mut frame = block_frame(indent, quote, px(0.), bottom, id, cx); if let Some(marker) = marker { let line_height = theme.sizes.small.text_size * BODY_LINE_HEIGHT; let marker_cell = div() .flex_none() .w(theme.root_font_size * MARKER_WIDTH) .h(line_height) .flex() .items_center() .justify_end() .pr_1() .child(match marker { ListMarker::Bullet => div() .text_color(theme.colors.muted_foreground) .child("•") .into_any_element(), ListMarker::Ordered(number) => div() .text_color(theme.colors.muted_foreground) .whitespace_nowrap() .child(format!("{number}.")) .into_any_element(), ListMarker::Task(checked) => task_box(checked, cx), }); frame = frame.child( div() .flex() .items_start() .child(marker_cell) .child(div().flex_1().min_w_0().child(text_child(spans, id, cx))), ); } else { frame = frame.child(text_child(spans, id, cx)); } frame.into_any_element() } /// A read-only task item indicator, matching the checkbox component. fn task_box(checked: bool, cx: &App) -> AnyElement { let theme = cx.theme(); let side = theme.sizes.small.dense_icon_size; let glyph = side * 0.8; div() .flex_none() .size(side) .rounded(theme.radius.sm) .border_1() .when(checked, |box_element| { box_element .bg(theme.ramps.primary.base) .border_color(theme.ramps.primary.base) .child( icon(IconRole::Check) .size(glyph) .color(theme.colors.primary_foreground), ) }) .when(!checked, |box_element| { box_element .bg(theme.colors.input) .border_color(theme.colors.border) }) .into_any_element() } fn code_row( lines: &HighlightedCode, indent: usize, quote: bool, id: &ElementId, cx: &App, ) -> AnyElement { let theme = cx.theme(); let text_size = theme.sizes.small.text_size; let line_height = text_size * CODE_LINE_HEIGHT; let mut plate = div() .w_full() .flex() .flex_col() .rounded(theme.radius.control) .bg(theme.colors.muted) .p_2() .font_family(theme.mono_font) .text_size(text_size) .line_height(relative(CODE_LINE_HEIGHT)) .overflow_hidden(); for line in &lines.lines { plate = plate.child( div() .h(line_height) .whitespace_nowrap() .overflow_hidden() .child(code_line(line, theme.colors, theme.ramps)), ); } block_frame( indent, quote, px(0.), theme.root_font_size * BLOCK_GAP, id, cx, ) .child(plate) .into_any_element() } fn rule_row(indent: usize, quote: bool, id: &ElementId, cx: &App) -> AnyElement { let theme = cx.theme(); block_frame( indent, quote, px(0.), theme.root_font_size * BLOCK_GAP, id, cx, ) .child(div().h(px(1.)).w_full().bg(theme.colors.border)) .into_any_element() } fn table_row( table: &MarkdownTable, indent: usize, quote: bool, id: &ElementId, cx: &App, ) -> AnyElement { let theme = cx.theme(); let colors = theme.colors; let width = table .rows .iter() .map(|row| row.cells.len()) .max() .unwrap_or(0); let row_count = table.rows.len(); let mut grid = div() .w_full() .flex() .flex_col() .border_1() .border_color(colors.border) .rounded(theme.radius.control) .overflow_hidden(); for (row_ix, row) in table.rows.iter().enumerate() { let mut row_element = div().w_full().flex(); if row_ix + 1 < row_count { row_element = row_element.border_b_1().border_color(colors.border); } if row.header { row_element = row_element.bg(colors.muted); } for (column_ix, cell) in row.cells.iter().enumerate() { let cell_id = ElementId::from(SharedString::from(format!( "{id}-cell-{row_ix}-{column_ix}" ))); row_element = row_element.child( div() .flex_1() .min_w_0() .px_2() .py_1() .when(row.header, |cell_element| { cell_element.font_weight(FontWeight::SEMIBOLD) }) .child(text_child(&cell.spans, &cell_id, cx)), ); } // Ragged tables pad missing cells so rows align. for _ in row.cells.len()..width { row_element = row_element.child(div().flex_1().min_w_0().px_2().py_1()); } grid = grid.child(row_element); } block_frame( indent, quote, px(0.), theme.root_font_size * BLOCK_GAP, id, cx, ) .child(grid) .into_any_element() } /// Builds the block's text element. fn text_child(spans: &[InlineSpan], id: &ElementId, cx: &App) -> AnyElement { let theme = cx.theme(); let mut text = String::new(); let mut highlights: Vec<(Range, HighlightStyle)> = Vec::new(); let mut family_overrides: Vec<(Range, SharedString)> = Vec::new(); let mut link_ranges: Vec> = Vec::new(); let mut destinations: Vec = Vec::new(); for span in spans { let start = text.len(); text.push_str(&span.text); let range = start..text.len(); match &span.style { InlineStyle::Text => {} InlineStyle::Emphasis => highlights.push(( range, HighlightStyle { font_style: Some(FontStyle::Italic), ..Default::default() }, )), InlineStyle::Strong => highlights.push(( range, HighlightStyle { font_weight: Some(FontWeight::SEMIBOLD), ..Default::default() }, )), InlineStyle::StrongEmphasis => highlights.push(( range, HighlightStyle { font_style: Some(FontStyle::Italic), font_weight: Some(FontWeight::SEMIBOLD), ..Default::default() }, )), InlineStyle::Strikethrough => highlights.push(( range, HighlightStyle { strikethrough: Some(StrikethroughStyle { thickness: px(1.), color: Some(theme.colors.muted_foreground), }), ..Default::default() }, )), InlineStyle::Code => { highlights.push(( range.clone(), HighlightStyle { background_color: Some(theme.colors.muted), ..Default::default() }, )); family_overrides.push((range, SharedString::from(theme.mono_font))); } InlineStyle::Link { destination } => { highlights.push(( range.clone(), HighlightStyle { color: Some(theme.colors.accent), underline: Some(UnderlineStyle { thickness: px(1.), color: None, wavy: false, }), ..Default::default() }, )); link_ranges.push(range); destinations.push(destination.clone()); } } } let styled = StyledText::new(text) .with_highlights(highlights) .with_font_family_overrides(family_overrides); if destinations.is_empty() { return styled.into_any_element(); } InteractiveText::new(id.clone(), styled) .on_click(link_ranges, move |range_ix, _, cx| { if let Some(destination) = destinations.get(range_ix) { cx.open_url(destination.as_ref()); } }) .into_any_element() }