Files
gpui-native-kit/crates/editor/src/markdown.rs
T

1136 lines
35 KiB
Rust

//! 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<InlineSpan>,
/// 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<MarkdownTableCell>,
pub header: bool,
}
/// A table, header row first.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct MarkdownTable {
pub rows: Vec<MarkdownTableRow>,
}
/// One rendered block of a markdown document.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum MarkdownBlock {
Heading {
level: MarkdownHeading,
spans: Vec<InlineSpan>,
/// How many lists the heading's content sits under.
indent: usize,
/// Whether a blockquote wraps the heading.
quote: bool,
},
Paragraph {
spans: Vec<InlineSpan>,
/// How many lists the paragraph's content sits under.
indent: usize,
/// Whether a blockquote wraps the paragraph.
quote: bool,
},
ListItem {
marker: ListMarker,
spans: Vec<InlineSpan>,
/// How many lists wrap this item's list, zero for the outermost.
indent: usize,
quote: bool,
},
Code {
language: Option<SharedString>,
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<MarkdownBlock>,
/// 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<SharedString>,
}
impl MarkdownViewState {
/// Builds the state, parsing `source`.
pub fn new<I, S>(id: I, source: S) -> Self
where
I: Into<ElementId>,
S: Into<SharedString>,
{
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<S>(&mut self, source: S, cx: &mut Context<Self>)
where
S: Into<SharedString>,
{
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<L>(mut self, label: L) -> Self
where
L: Into<SharedString>,
{
self.a11y_label = Some(label.into());
self
}
}
impl Render for MarkdownViewState {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> 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<bool>,
}
/// The table being assembled, when the parser is inside one.
enum TableBuilder {
None,
Body { rows: Vec<MarkdownTableRow> },
}
/// 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<Option<InlineStyle>>,
}
impl InlineBuilder {
fn push_style(&mut self, style: Option<InlineStyle>) {
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<InlineSpan> {
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<MarkdownBlock> {
let mut blocks = Vec::new();
let mut inline = InlineBuilder::default();
let mut lists: Vec<ListFrame> = Vec::new();
// Whether each open list item already flushed content rows.
let mut items: Vec<bool> = Vec::new();
let mut quote_depth = 0usize;
let mut table = TableBuilder::None;
let mut code: Option<CodeBuilder> = None;
// The fence info string arrives on Start, its text follows.
let mut code_language: Option<SharedString> = 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<MarkdownBlock>,
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<ListMarker>,
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<usize>, HighlightStyle)> = Vec::new();
let mut family_overrides: Vec<(Range<usize>, SharedString)> = Vec::new();
let mut link_ranges: Vec<Range<usize>> = Vec::new();
let mut destinations: Vec<SharedString> = 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()
}