forked from reya/gpui-native-kit
1113 lines
34 KiB
Rust
1113 lines
34 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, 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>,
|
|
}
|
|
|
|
/// 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.id.clone(), "v-scrollbar"), 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>,
|
|
}
|
|
|
|
/// 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: Option<Vec<MarkdownTableRow>> = None;
|
|
let mut code: Option<String> = 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(String::new());
|
|
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 = Some(Vec::new())
|
|
}
|
|
Tag::TableHead => {
|
|
if let Some(rows) = &mut table {
|
|
rows.push(MarkdownTableRow {
|
|
cells: Vec::new(),
|
|
header: true,
|
|
});
|
|
}
|
|
}
|
|
Tag::TableRow => {
|
|
if let Some(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.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(text, grammar),
|
|
None => plain(text),
|
|
};
|
|
blocks.push(MarkdownBlock::Code {
|
|
language,
|
|
lines,
|
|
indent: lists.len(),
|
|
quote: quote_depth > 0,
|
|
});
|
|
}
|
|
TagEnd::Table => {
|
|
if let Some(rows) = table.take() {
|
|
blocks.push(MarkdownBlock::Table {
|
|
table: MarkdownTable { rows },
|
|
indent: lists.len(),
|
|
quote: quote_depth > 0,
|
|
});
|
|
}
|
|
}
|
|
TagEnd::TableCell => {
|
|
let spans = inline.finish();
|
|
let Some(rows) = &mut table else {
|
|
continue;
|
|
};
|
|
let Some(row) = rows.last_mut() else {
|
|
continue;
|
|
};
|
|
row.cells.push(MarkdownTableCell { spans });
|
|
}
|
|
TagEnd::Emphasis
|
|
| TagEnd::Strong
|
|
| TagEnd::Strikethrough
|
|
| TagEnd::Link
|
|
| TagEnd::Image => inline.pop_style(),
|
|
_ => {}
|
|
},
|
|
Event::Text(text) => {
|
|
if let Some(buffer) = code.as_mut() {
|
|
buffer.push_str(&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);
|
|
}
|
|
}
|
|
// Footnotes are disabled in the parser options.
|
|
Event::FootnoteReference(_)
|
|
| 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 id = id.clone();
|
|
move || id.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()
|
|
}
|