use std::ops::Range;
use std::rc::Rc;
use std::sync::Arc;
use gpui::{
AnyElement, App, BorderStyle, Bounds, CursorStyle, DefiniteLength, DispatchPhase, Div, Edges,
ElementId, Entity, FontStyle, FontWeight, GlobalElementId, Hitbox, HitboxBehavior, Hsla,
ImageSource, InteractiveElement, IntoElement, KeyContext, MouseButton, MouseDownEvent,
MouseEvent, MouseMoveEvent, MouseUpEvent, ParentElement, Pixels, Refineable as _, ScrollHandle,
SharedString, Stateful, StatefulInteractiveElement as _, StrikethroughStyle, StyleRefinement,
Styled, StyledImage as _, TextAlign, TextStyleRefinement, Window, div, img, point,
prelude::FluentBuilder as _, px, quad, rems,
};
use icons::IconName;
use pulldown_cmark::{Alignment, BlockQuoteKind, HeadingLevel};
use theme::ActiveTheme as _;
use util::ResultExt as _;
use crate::builder::{MarkdownElementBuilder, MetadataCellStyle, alignment_to_text_align, h_flex};
use crate::controls::{Checkbox, CopyButton, icon_svg, simple_tooltip};
use crate::entity::{
AutoscrollBehavior, CodeBlockRenderer, Markdown, SelectMode, Selection,
resolve_code_block_language,
};
use crate::parser::{
CodeBlockKind, MarkdownEvent, MarkdownTag, MarkdownTagEnd, ParsedMetadataBlock,
};
use crate::rendered::{RenderedMarkdown, RenderedText};
use crate::style::{CopyButtonVisibility, HeadingLevelStyles, MarkdownStyle, WrapButtonVisibility};
pub type CodeSpanLinkCallback = Arc<dyn Fn(&str, &App) -> Option<SharedString> + 'static>;
type SourceClickCallback = Box<dyn Fn(usize, usize, &mut Window, &mut App) -> bool>;
pub(crate) type CheckboxToggleCallback = Rc<dyn Fn(Range<usize>, bool, &mut Window, &mut App)>;
pub struct MarkdownElement {
pub(crate) markdown: Entity<Markdown>,
pub(crate) style: MarkdownStyle,
code_block_renderer: CodeBlockRenderer,
on_url_click: Option<Rc<dyn Fn(SharedString, &mut Window, &mut App)>>,
code_span_link: Option<CodeSpanLinkCallback>,
on_source_click: Option<SourceClickCallback>,
on_checkbox_toggle: Option<CheckboxToggleCallback>,
pub(crate) image_resolver: Option<Box<dyn Fn(&str) -> Option<ImageSource>>>,
show_root_block_markers: bool,
autoscroll: AutoscrollBehavior,
}
impl MarkdownElement {
pub fn new(markdown: Entity<Markdown>, style: MarkdownStyle) -> Self {
Self {
markdown,
style,
code_block_renderer: CodeBlockRenderer::Default {
copy_button_visibility: CopyButtonVisibility::VisibleOnHover,
wrap_button_visibility: WrapButtonVisibility::Hidden,
border: false,
},
on_url_click: None,
code_span_link: None,
on_source_click: None,
on_checkbox_toggle: None,
image_resolver: None,
show_root_block_markers: false,
autoscroll: AutoscrollBehavior::Propagate,
}
}
#[cfg(any(test, feature = "test-support"))]
pub fn rendered_text(
markdown: Entity<Markdown>,
cx: &mut gpui::VisualTestContext,
style: impl FnOnce(&Window, &App) -> MarkdownStyle,
) -> String {
use gpui::size;
let (text, _) = cx.draw(
Default::default(),
size(px(600.0), px(600.0)),
|window, cx| Self::new(markdown, style(window, cx)),
);
text.text
.lines
.iter()
.map(|line| line.layout.wrapped_text())
.collect::<Vec<_>>()
.join("\n")
}
pub fn code_block_renderer(mut self, variant: CodeBlockRenderer) -> Self {
self.code_block_renderer = variant;
self
}
pub fn on_url_click(
mut self,
handler: impl Fn(SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_url_click = Some(Rc::new(handler));
self
}
pub fn on_code_span_link(
mut self,
callback: impl Fn(&str, &App) -> Option<SharedString> + 'static,
) -> Self {
self.code_span_link = Some(Arc::new(callback));
self
}
pub fn on_source_click(
mut self,
handler: impl Fn(usize, usize, &mut Window, &mut App) -> bool + 'static,
) -> Self {
self.on_source_click = Some(Box::new(handler));
self
}
pub fn on_checkbox_toggle(
mut self,
handler: impl Fn(Range<usize>, bool, &mut Window, &mut App) + 'static,
) -> Self {
self.on_checkbox_toggle = Some(Rc::new(handler));
self
}
pub fn image_resolver(
mut self,
resolver: impl Fn(&str) -> Option<ImageSource> + 'static,
) -> Self {
self.image_resolver = Some(Box::new(resolver));
self
}
pub fn show_root_block_markers(mut self) -> Self {
self.show_root_block_markers = true;
self
}
pub fn scroll_handle(mut self, scroll_handle: ScrollHandle) -> Self {
self.autoscroll = AutoscrollBehavior::Controlled(scroll_handle);
self
}
fn push_markdown_code_span(
&self,
builder: &mut MarkdownElementBuilder,
text: &str,
range: Range<usize>,
cx: &App,
) {
let link_url = if builder.code_block_stack.is_empty()
&& builder.link_depth == 0
&& !self.style.prevent_mouse_interaction
{
self.code_span_link
.as_ref()
.and_then(|callback| callback(text, cx))
} else {
None
};
if let Some(url) = link_url {
builder.push_link(url.clone(), range.clone());
let link_style = self
.style
.link_callback
.as_ref()
.and_then(|callback| callback(url.as_ref(), cx))
.unwrap_or_else(|| self.style.link.clone());
builder.push_text_style(self.style.inline_code.clone());
builder.push_text_style(link_style);
builder.push_text(text, range);
builder.pop_text_style();
builder.pop_text_style();
} else {
let mut code_style = self.style.inline_code.clone();
if builder.link_depth > 0 {
code_style.color = self.style.link.color.or(code_style.color);
}
builder.push_text_style(code_style);
builder.push_text(text, range);
builder.pop_text_style();
}
}
pub(crate) fn push_markdown_image(
&self,
builder: &mut MarkdownElementBuilder,
range: &Range<usize>,
source: ImageSource,
dest_url: SharedString,
alt_text: Option<SharedString>,
width: Option<DefiniteLength>,
height: Option<DefiniteLength>,
) {
let enclosing_link_url = (builder.link_depth > 0)
.then(|| builder.rendered_links.last())
.flatten()
.map(|link| link.destination_url.clone());
let fallback_opens_image_url = enclosing_link_url.is_none();
let image_element = {
let wrapper = div().id(("markdown-image-link", range.start)).min_w_0();
let wrapper = if !self.style.prevent_mouse_interaction
&& let Some(url) = enclosing_link_url
{
let click_url = url.clone();
let markdown = self.markdown.clone();
let url_click = self.on_url_click.clone();
wrapper
.cursor_pointer()
.on_click(move |_, window, cx| {
if let Some(ref on_url_click) = url_click {
on_url_click(click_url.clone(), window, cx);
} else {
cx.open_url(&click_url);
}
})
.capture_any_mouse_down(move |event, _window, cx| {
if event.button == MouseButton::Right {
markdown.update(cx, |md, _| {
md.capture_for_context_menu(Some(url.clone()), None)
});
}
})
} else {
wrapper
};
wrapper.child(
img(source)
.id(("markdown-image", range.start))
.min_w_0()
.max_w_full()
.rounded_md()
.mr_1()
.mb_1()
.when_some(height, |this, height| this.h(height))
.when_some(width, |this, width| this.w(width))
.with_fallback(move || {
image_fallback_element(
dest_url.clone(),
alt_text.clone(),
fallback_opens_image_url,
)
}),
)
};
builder.push_image_child(image_element);
}
pub(crate) fn push_markdown_paragraph(
&self,
builder: &mut MarkdownElementBuilder,
range: &Range<usize>,
markdown_end: usize,
text_align_override: Option<TextAlign>,
) {
let align = text_align_override.unwrap_or(self.style.base_text_style.text_align);
let mut paragraph = div().when(!self.style.height_is_multiple_of_line_height, |el| {
el.mb_2().line_height(rems(1.3))
});
paragraph = match align {
TextAlign::Center => paragraph.text_center(),
TextAlign::Left => paragraph.text_left(),
TextAlign::Right => paragraph.text_right(),
};
builder.push_text_style(TextStyleRefinement {
text_align: Some(align),
..Default::default()
});
builder.push_div(paragraph, range, markdown_end);
}
pub(crate) fn pop_markdown_paragraph(&self, builder: &mut MarkdownElementBuilder) {
builder.pop_div();
builder.pop_text_style();
}
pub(crate) fn push_markdown_heading(
&self,
builder: &mut MarkdownElementBuilder,
level: HeadingLevel,
range: &Range<usize>,
markdown_end: usize,
text_align_override: Option<TextAlign>,
) {
let align = text_align_override.unwrap_or(self.style.base_text_style.text_align);
let mut heading = div().mt_4().mb_2();
heading = apply_heading_style(
heading,
level,
self.style.heading_level_styles.as_ref(),
self.style.heading_border_color,
);
heading = match align {
TextAlign::Center => heading.text_center(),
TextAlign::Left => heading.text_left(),
TextAlign::Right => heading.text_right(),
};
let heading_style = self.style.heading.clone();
let heading_text_style = heading_style.text.clone();
heading.style().refine(&heading_style);
builder.push_text_style(TextStyleRefinement {
text_align: Some(align),
..heading_text_style
});
builder.push_div(heading, range, markdown_end);
}
pub(crate) fn pop_markdown_heading(&self, builder: &mut MarkdownElementBuilder) {
builder.pop_div();
builder.pop_text_style();
}
pub(crate) fn push_markdown_block_quote(
&self,
builder: &mut MarkdownElementBuilder,
kind: Option<BlockQuoteKind>,
range: &Range<usize>,
markdown_end: usize,
) {
let border_color = self
.style
.block_quote_kind_colors
.for_kind(kind, self.style.block_quote_border_color);
let header = kind.map(|kind| {
let (icon_name, label) = match kind {
BlockQuoteKind::Note => (IconName::Info, "Note"),
BlockQuoteKind::Tip => (IconName::Sparkle, "Tip"),
BlockQuoteKind::Important => (IconName::Chat, "Important"),
BlockQuoteKind::Warning => (IconName::Warning, "Warning"),
BlockQuoteKind::Caution => (IconName::Stop, "Caution"),
};
h_flex()
.gap_1()
.mb_1()
.child(icon_svg(icon_name, px(14.), border_color))
.child(
div()
.text_color(border_color)
.font_weight(FontWeight::BOLD)
.child(label),
)
.into_any_element()
});
let block_div = div().pl_4().mb_2().border_l_4().border_color(border_color);
let block_div = match header {
Some(header) => block_div.child(header),
None => block_div,
};
builder.push_text_style(self.style.block_quote.clone());
builder.push_div(block_div, range, markdown_end);
}
pub(crate) fn pop_markdown_block_quote(&self, builder: &mut MarkdownElementBuilder) {
builder.pop_div();
builder.pop_text_style();
}
fn push_metadata_block(
&self,
builder: &mut MarkdownElementBuilder,
source: &str,
metadata_block: &ParsedMetadataBlock,
markdown_end: usize,
cx: &App,
) {
let content_range = &metadata_block.content_range;
if let Some(rows) = metadata_block.rows.as_deref() {
builder.push_div(
div()
.grid()
.grid_cols(2)
.w_full()
.mb_2()
.border_1()
.border_color(cx.theme().colors().border)
.rounded_sm()
.overflow_hidden(),
content_range,
markdown_end,
);
for (row_index, row) in rows.iter().enumerate() {
self.push_metadata_cell(
builder,
source,
row.key.clone(),
content_range,
markdown_end,
MetadataCellStyle {
row_index,
is_key: true,
},
cx,
);
self.push_metadata_cell(
builder,
source,
row.value.clone(),
content_range,
markdown_end,
MetadataCellStyle {
row_index,
is_key: false,
},
cx,
);
}
builder.pop_div();
} else {
let mut metadata_block = div().w_full().rounded_md();
metadata_block.style().refine(&self.style.code_block);
builder.push_text_style(self.style.code_block.text.to_owned());
builder.push_code_block(None);
builder.push_div(metadata_block, content_range, markdown_end);
builder.push_text(&source[content_range.clone()], content_range.clone());
builder.trim_trailing_newline();
builder.pop_div();
builder.pop_code_block();
builder.pop_text_style();
}
}
fn push_metadata_cell(
&self,
builder: &mut MarkdownElementBuilder,
source: &str,
text_range: Range<usize>,
block_range: &Range<usize>,
markdown_end: usize,
cell_style: MetadataCellStyle,
cx: &App,
) {
builder.push_div(
div()
.flex()
.flex_col()
.min_w_0()
.px_2()
.py_1()
.border_color(cx.theme().colors().border)
.when(cell_style.row_index > 0, |this| this.border_t_1())
.when(!cell_style.is_key, |this| this.border_l_1())
.when(cell_style.is_key, |this| {
this.bg(cx.theme().colors().panel_background)
}),
block_range,
markdown_end,
);
let text_style = if cell_style.is_key {
TextStyleRefinement {
color: Some(cx.theme().colors().text_muted),
font_weight: Some(FontWeight::SEMIBOLD),
..Default::default()
}
} else {
TextStyleRefinement::default()
};
builder.push_text_style(text_style);
builder.push_text(&source[text_range.clone()], text_range);
builder.pop_text_style();
builder.pop_div();
}
pub(crate) fn push_markdown_list_item(
&self,
builder: &mut MarkdownElementBuilder,
bullet: AnyElement,
range: &Range<usize>,
markdown_end: usize,
) {
builder.push_div(
div()
.when(!self.style.height_is_multiple_of_line_height, |el| {
el.mb_1().gap_1().line_height(rems(1.3))
})
.flex()
.flex_row()
.items_start()
.child(bullet),
range,
markdown_end,
);
builder.push_div(div().flex_1().w_0(), range, markdown_end);
}
pub(crate) fn pop_markdown_list_item(&self, builder: &mut MarkdownElementBuilder) {
builder.pop_div();
builder.pop_div();
}
fn paint_highlight_range(
start: usize,
end: usize,
color: Hsla,
rendered_text: &RenderedText,
window: &mut Window,
) {
for bounds in rendered_text.bounds_for_source_range(start..end) {
window.paint_quad(quad(
bounds,
Pixels::ZERO,
color,
Edges::default(),
Hsla::transparent_black(),
BorderStyle::default(),
));
}
}
fn paint_selection(&self, rendered_text: &RenderedText, window: &mut Window, cx: &mut App) {
let selection = self.markdown.read(cx).selection.clone();
Self::paint_highlight_range(
selection.start,
selection.end,
self.style.selection_background_color,
rendered_text,
window,
);
}
fn paint_search_highlights(
&self,
rendered_text: &RenderedText,
window: &mut Window,
cx: &mut App,
) {
let markdown = self.markdown.read(cx);
let active_index = markdown.active_search_highlight;
let colors = cx.theme().colors();
let highlight_bounds = rendered_text.bounds_for_sorted_source_ranges(
markdown
.search_highlights
.iter()
.enumerate()
.map(|(ix, range)| (ix, range.clone())),
);
for (highlight_ix, bounds) in highlight_bounds {
let color = if Some(highlight_ix) == active_index {
colors.search_active_match_background
} else {
colors.search_match_background
};
window.paint_quad(quad(
bounds,
Pixels::ZERO,
color,
Edges::default(),
Hsla::transparent_black(),
BorderStyle::default(),
));
}
}
fn paint_mouse_listeners(
&mut self,
hitbox: &Hitbox,
rendered_text: &RenderedText,
window: &mut Window,
cx: &mut App,
) {
if self.style.prevent_mouse_interaction {
return;
}
let is_hovering_clickable = hitbox.is_hovered(window)
&& !self.markdown.read(cx).selection.pending
&& rendered_text
.source_index_for_position(window.mouse_position())
.ok()
.is_some_and(|source_index| {
rendered_text.link_for_source_index(source_index).is_some()
|| rendered_text
.footnote_ref_for_source_index(source_index)
.is_some()
});
if is_hovering_clickable {
window.set_cursor_style(CursorStyle::PointingHand, hitbox);
} else {
window.set_cursor_style(CursorStyle::IBeam, hitbox);
}
let on_open_url = self.on_url_click.take();
let on_source_click = self.on_source_click.take();
self.on_mouse_event(window, cx, {
let hitbox = hitbox.clone();
let rendered_text = rendered_text.clone();
move |markdown, event: &MouseDownEvent, phase, window, _cx| {
if phase.capture()
&& event.button == MouseButton::Right
&& hitbox.is_hovered(window)
{
let link = rendered_text
.source_index_for_position(event.position)
.ok()
.and_then(|ix| rendered_text.link_for_source_index(ix))
.map(|link| link.destination_url.clone());
markdown.capture_for_context_menu(link, Some(&rendered_text));
}
}
});
self.on_mouse_event(window, cx, {
let rendered_text = rendered_text.clone();
let hitbox = hitbox.clone();
move |markdown, event: &MouseDownEvent, phase, window, cx| {
if hitbox.is_hovered(window) {
if phase.bubble() && event.button != MouseButton::Right {
let position_result =
rendered_text.source_index_for_position(event.position);
if let Ok(source_index) = position_result {
if let Some(footnote_ref) =
rendered_text.footnote_ref_for_source_index(source_index)
{
markdown.pressed_footnote_ref = Some(footnote_ref.clone());
} else if let Some(link) =
rendered_text.link_for_source_index(source_index)
{
markdown.pressed_link = Some(link.clone());
}
}
if markdown.pressed_footnote_ref.is_none()
&& markdown.pressed_link.is_none()
{
let source_index = match position_result {
Ok(ix) | Err(ix) => ix,
};
if let Some(handler) = on_source_click.as_ref() {
let blocked = handler(source_index, event.click_count, window, cx);
if blocked {
markdown.selection = Selection::default();
markdown.pressed_link = None;
window.prevent_default();
cx.notify();
return;
}
}
let (range, mode, reversed) = match event.click_count {
1 if event.modifiers.shift => {
let tail = markdown.selection.tail();
let reversed = source_index < tail;
let range = if reversed {
source_index..tail
} else {
tail..source_index
};
(range, SelectMode::Character, reversed)
}
1 => {
let range = source_index..source_index;
(range, SelectMode::Character, false)
}
2 => {
let range = rendered_text.surrounding_word_range(source_index);
(range.clone(), SelectMode::Word(range), false)
}
3 => {
let range = rendered_text.surrounding_line_range(source_index);
(range.clone(), SelectMode::Line(range), false)
}
_ => {
let range = 0..rendered_text
.lines
.last()
.map(|line| line.source_end)
.unwrap_or(0);
(range, SelectMode::All, false)
}
};
markdown.selection = Selection {
start: range.start,
end: range.end,
reversed,
pending: true,
mode,
};
window.focus(&markdown.focus_handle, cx);
}
window.prevent_default();
cx.notify();
}
} else if phase.capture() && event.button == MouseButton::Left {
markdown.selection = Selection::default();
markdown.pressed_link = None;
cx.notify();
}
}
});
self.on_mouse_event(window, cx, {
let rendered_text = rendered_text.clone();
let hitbox = hitbox.clone();
let was_hovering_clickable = is_hovering_clickable;
move |markdown, event: &MouseMoveEvent, phase, window, cx| {
if phase.capture() {
return;
}
if markdown.selection.pending {
let source_index = match rendered_text.source_index_for_position(event.position)
{
Ok(ix) | Err(ix) => ix,
};
markdown.selection.set_head(source_index, &rendered_text);
markdown.autoscroll_code_block(source_index, event.position);
markdown.autoscroll_request = Some(source_index);
cx.notify();
} else {
let is_hovering_clickable = hitbox.is_hovered(window)
&& rendered_text
.source_index_for_position(event.position)
.ok()
.is_some_and(|source_index| {
rendered_text.link_for_source_index(source_index).is_some()
|| rendered_text
.footnote_ref_for_source_index(source_index)
.is_some()
});
if is_hovering_clickable != was_hovering_clickable {
cx.notify();
}
}
}
});
self.on_mouse_event(window, cx, {
let rendered_text = rendered_text.clone();
move |markdown, event: &MouseUpEvent, phase, window, cx| {
if phase.bubble() {
let source_index = rendered_text.source_index_for_position(event.position).ok();
if let Some(pressed_footnote_ref) = markdown.pressed_footnote_ref.take()
&& source_index
.and_then(|ix| rendered_text.footnote_ref_for_source_index(ix))
== Some(&pressed_footnote_ref)
{
if let Some(source_index) =
markdown.footnote_definition_content_start(&pressed_footnote_ref.label)
{
markdown.autoscroll_request = Some(source_index);
cx.notify();
}
} else if let Some(pressed_link) = markdown.pressed_link.take()
&& source_index.and_then(|ix| rendered_text.link_for_source_index(ix))
== Some(&pressed_link)
{
if let Some(open_url) = on_open_url.as_ref() {
open_url(pressed_link.destination_url, window, cx);
} else {
cx.open_url(&pressed_link.destination_url);
}
}
} else if markdown.selection.pending {
markdown.selection.pending = false;
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
{
let text = rendered_text
.text_for_range(markdown.selection.start..markdown.selection.end);
cx.write_to_primary(gpui::ClipboardItem::new_string(text))
}
cx.notify();
}
}
});
}
fn autoscroll(
&self,
rendered_text: &RenderedText,
window: &mut Window,
cx: &mut App,
) -> Option<()> {
let autoscroll_index = self
.markdown
.update(cx, |markdown, _| markdown.autoscroll_request.take())?;
let (position, line_height) = rendered_text.position_for_source_index(autoscroll_index)?;
match &self.autoscroll {
AutoscrollBehavior::Controlled(scroll_handle) => {
let viewport = scroll_handle.bounds();
let margin = line_height * 3.;
let top_goal = viewport.top() + margin;
let bottom_goal = viewport.bottom() - margin;
let current_offset = scroll_handle.offset();
let new_offset_y = if position.y < top_goal {
current_offset.y + (top_goal - position.y)
} else if position.y + line_height > bottom_goal {
current_offset.y + (bottom_goal - (position.y + line_height))
} else {
current_offset.y
};
scroll_handle.set_offset(point(
current_offset.x,
new_offset_y.clamp(-scroll_handle.max_offset().y, Pixels::ZERO),
));
}
AutoscrollBehavior::Propagate => {
let text_style = self.style.base_text_style.clone();
let font_id = window.text_system().resolve_font(&text_style.font());
let font_size = text_style.font_size.to_pixels(window.rem_size());
let em_width = window.text_system().em_width(font_id, font_size).unwrap();
window.request_autoscroll(Bounds::from_corners(
point(position.x - 3. * em_width, position.y - 3. * line_height),
point(position.x + 3. * em_width, position.y + 3. * line_height),
));
}
}
Some(())
}
fn on_mouse_event<T: MouseEvent>(
&self,
window: &mut Window,
_cx: &mut App,
mut f: impl 'static
+ FnMut(&mut Markdown, &T, DispatchPhase, &mut Window, &mut gpui::Context<Markdown>),
) {
window.on_mouse_event({
let markdown = self.markdown.downgrade();
move |event, phase, window, cx| {
markdown
.update(cx, |markdown, cx| f(markdown, event, phase, window, cx))
.log_err();
}
});
}
}
impl Styled for MarkdownElement {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style.container_style
}
}
impl gpui::Element for MarkdownElement {
type RequestLayoutState = RenderedMarkdown;
type PrepaintState = Hitbox;
fn id(&self) -> Option<ElementId> {
None
}
fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
None
}
fn request_layout(
&mut self,
_id: Option<&GlobalElementId>,
_inspector_id: Option<&gpui::InspectorElementId>,
window: &mut Window,
cx: &mut App,
) -> (gpui::LayoutId, Self::RequestLayoutState) {
let mut builder = MarkdownElementBuilder::new(
&self.style.container_style,
self.style.base_text_style.clone(),
self.style.syntax.clone(),
);
let (parsed_markdown, images, active_root_block) = {
let markdown = self.markdown.read(cx);
(
markdown.parsed_markdown.clone(),
markdown.images_by_source_offset.clone(),
markdown.active_root_block(),
)
};
let markdown_end = if let Some(last) = parsed_markdown.events.last() {
last.0.end
} else {
0
};
let mut code_block_ids = collections::HashSet::default();
let mut current_img_block_range: Option<Range<usize>> = None;
let mut handled_html_block = false;
let mut rendered_metadata_block = false;
for (index, (range, event)) in parsed_markdown.events.iter().enumerate() {
if let Some(current_img_block_range) = ¤t_img_block_range
&& current_img_block_range.end > range.end
{
continue;
}
if handled_html_block {
if let MarkdownEvent::End(MarkdownTagEnd::HtmlBlock) = event {
handled_html_block = false;
} else {
continue;
}
}
if rendered_metadata_block {
if matches!(event, MarkdownEvent::End(MarkdownTagEnd::MetadataBlock(_))) {
rendered_metadata_block = false;
}
continue;
}
match event {
MarkdownEvent::RootStart => {
if self.show_root_block_markers {
builder.push_root_block(range, markdown_end);
}
}
MarkdownEvent::RootEnd(root_block_index) => {
if self.show_root_block_markers {
builder.pop_root_block(
active_root_block == Some(*root_block_index),
cx.theme().colors().border,
cx.theme().colors().border_variant,
);
}
}
MarkdownEvent::Start(tag) => {
match tag {
MarkdownTag::Image { dest_url, .. } => {
let alt_text = collect_image_alt_text(
&parsed_markdown.events[index..],
&parsed_markdown.source,
);
if let Some(image) = images.get(&range.start) {
current_img_block_range = Some(range.clone());
self.push_markdown_image(
&mut builder,
range,
image.clone().into(),
dest_url.clone(),
alt_text,
None,
None,
);
} else if let Some(source) = self
.image_resolver
.as_ref()
.and_then(|resolve| resolve(dest_url.as_ref()))
{
current_img_block_range = Some(range.clone());
self.push_markdown_image(
&mut builder,
range,
source,
dest_url.clone(),
alt_text,
None,
None,
);
}
}
MarkdownTag::Paragraph => {
let text_align_override = builder
.table
.current_cell_alignment()
.and_then(alignment_to_text_align);
self.push_markdown_paragraph(
&mut builder,
range,
markdown_end,
text_align_override,
);
}
MarkdownTag::Heading { level, .. } => {
let text_align_override = builder
.table
.current_cell_alignment()
.and_then(alignment_to_text_align);
self.push_markdown_heading(
&mut builder,
*level,
range,
markdown_end,
text_align_override,
);
}
MarkdownTag::BlockQuote(kind) => {
self.push_markdown_block_quote(
&mut builder,
*kind,
range,
markdown_end,
);
}
MarkdownTag::CodeBlock { kind, .. } => {
let language = resolve_code_block_language(kind);
let is_indented = matches!(kind, CodeBlockKind::Indented);
let scroll_handle = if self.style.code_block_overflow_x_scroll {
self.markdown.update(cx, |markdown, _| {
markdown.code_block_scroll_handle(range.start)
})
} else {
None
};
if scroll_handle.is_some() {
code_block_ids.insert(range.start);
}
match (&self.code_block_renderer, is_indented) {
(CodeBlockRenderer::Default { .. }, _) | (_, true) => {
let mut parent_container: AnyDiv =
div().group("code_block").relative().w_full().into();
if let CodeBlockRenderer::Default { border: true, .. } =
&self.code_block_renderer
{
parent_container = parent_container
.rounded_md()
.border_1()
.border_color(cx.theme().colors().border_variant);
}
parent_container.style().refine(&self.style.code_block);
builder.push_div(parent_container, range, markdown_end);
let code_block = div()
.id(("code-block", range.start))
.rounded_lg()
.map(|mut code_block| {
if let Some(scroll_handle) = scroll_handle.as_ref() {
code_block.style().restrict_scroll_to_axis =
Some(true);
code_block
.flex()
.overflow_x_scroll()
.track_scroll(scroll_handle)
} else {
code_block.w_full()
}
});
builder.push_text_style(self.style.code_block.text.to_owned());
builder.push_code_block(language);
builder.push_div(code_block, range, markdown_end);
}
(CodeBlockRenderer::Custom { .. }, _) => {}
}
}
MarkdownTag::HtmlBlock => {
builder.push_div(div(), range, markdown_end);
if let Some(block) = parsed_markdown.html_blocks.get(&range.start) {
self.render_html_block(block, &mut builder, markdown_end, cx);
handled_html_block = true;
}
}
MarkdownTag::List(bullet_index) => {
builder.push_list(*bullet_index);
builder.push_div(div().pl_2p5(), range, markdown_end);
}
MarkdownTag::Item => {
let bullet =
if let Some((task_range, MarkdownEvent::TaskListMarker(checked))) =
parsed_markdown.events.get(index.saturating_add(1))
{
let source = &parsed_markdown.source()[range.clone()];
let checked = *checked;
let checkbox = Checkbox::new(
ElementId::Name(source.to_string().into()),
checked,
);
if let Some(on_toggle) = self.on_checkbox_toggle.clone() {
let task_source_range = task_range.clone();
checkbox
.on_click(move |_state, window, cx| {
on_toggle(
task_source_range.clone(),
!checked,
window,
cx,
);
})
.into_any_element()
} else {
checkbox.into_any_element()
}
} else if let Some(bullet_index) = builder.next_bullet_index() {
div().child(format!("{}.", bullet_index)).into_any_element()
} else {
div().child("•").into_any_element()
};
self.push_markdown_list_item(&mut builder, bullet, range, markdown_end);
}
MarkdownTag::Emphasis => builder.push_text_style(TextStyleRefinement {
font_style: Some(FontStyle::Italic),
..Default::default()
}),
MarkdownTag::Strong => builder.push_text_style(TextStyleRefinement {
font_weight: Some(FontWeight::BOLD),
color: Some(cx.theme().colors().text),
..Default::default()
}),
MarkdownTag::Strikethrough => {
builder.push_text_style(TextStyleRefinement {
strikethrough: Some(StrikethroughStyle {
thickness: px(1.),
color: None,
}),
..Default::default()
})
}
MarkdownTag::Link { dest_url, .. } => {
if builder.code_block_stack.is_empty() {
builder.link_depth += 1;
builder.push_link(dest_url.clone(), range.clone());
let style = self
.style
.link_callback
.as_ref()
.and_then(|callback| callback(dest_url, cx))
.unwrap_or_else(|| self.style.link.clone());
builder.push_text_style(style)
}
}
MarkdownTag::FootnoteDefinition(label) => {
if !builder.rendered_footnote_separator {
builder.rendered_footnote_separator = true;
builder.push_div(
div()
.border_t_1()
.mt_2()
.border_color(self.style.rule_color),
range,
markdown_end,
);
builder.pop_div();
}
builder.push_div(
div()
.pt_1()
.mb_1()
.line_height(rems(1.3))
.text_size(rems(0.85))
.flex()
.flex_row()
.items_start()
.gap_2()
.child(
div().text_size(rems(0.85)).child(format!("{}.", label)),
),
range,
markdown_end,
);
builder.push_div(div().flex_1().w_0(), range, markdown_end);
}
MarkdownTag::MetadataBlock(_) => {
if let Some(metadata_block) =
parsed_markdown.metadata_blocks.get(&range.start)
{
self.push_metadata_block(
&mut builder,
&parsed_markdown.source,
metadata_block,
markdown_end,
cx,
);
rendered_metadata_block = true;
}
}
MarkdownTag::Table(alignments) => {
builder.table.start(alignments.clone());
let column_count = alignments.len();
builder.push_div(
div()
.id(("table", range.start))
.grid()
.grid_cols(column_count as u16)
.when(self.style.table_columns_min_size, |this| {
this.grid_cols_min_content(column_count as u16)
})
.when(!self.style.table_columns_min_size, |this| {
this.grid_cols(column_count as u16)
})
.w_full()
.mb_2()
.border(px(1.5))
.border_color(cx.theme().colors().border)
.rounded_sm()
.overflow_hidden(),
range,
markdown_end,
);
}
MarkdownTag::TableHead => {
builder.table.start_head();
builder.push_text_style(TextStyleRefinement {
font_weight: Some(FontWeight::SEMIBOLD),
..Default::default()
});
}
MarkdownTag::TableRow => {
builder.table.start_row();
}
MarkdownTag::TableCell => {
let is_header = builder.table.in_head;
let row_index = builder.table.row_index;
let col_index = builder.table.col_index;
let alignment = builder.table.current_cell_alignment();
let text_align = alignment
.and_then(alignment_to_text_align)
.unwrap_or(self.style.base_text_style.text_align);
let mut cell_div = div()
.flex()
.flex_col()
.h_full()
.when(col_index > 0, |this| this.border_l_1())
.when(row_index > 0, |this| this.border_t_1())
.border_color(cx.theme().colors().border)
.px_1()
.py_0p5()
.when(is_header, |this| {
this.bg(cx.theme().colors().title_bar_background)
})
.when(!is_header && row_index % 2 == 1, |this| {
this.bg(cx.theme().colors().panel_background)
});
cell_div = match alignment {
Some(Alignment::Center) => cell_div.items_center(),
Some(Alignment::Right) => cell_div.items_end(),
_ => cell_div,
};
builder.push_text_style(TextStyleRefinement {
text_align: Some(text_align),
..Default::default()
});
builder.push_div(cell_div, range, markdown_end);
builder.push_div(
div()
.flex()
.flex_col()
.flex_1()
.w_full()
.justify_center()
.text_align(text_align),
range,
markdown_end,
);
}
_ => log::debug!("unsupported markdown tag {:?}", tag),
}
}
MarkdownEvent::End(tag) => match tag {
MarkdownTagEnd::Image => {
current_img_block_range.take();
}
MarkdownTagEnd::Paragraph => {
self.pop_markdown_paragraph(&mut builder);
}
MarkdownTagEnd::Heading(_) => {
self.pop_markdown_heading(&mut builder);
}
MarkdownTagEnd::BlockQuote(_kind) => {
self.pop_markdown_block_quote(&mut builder);
}
MarkdownTagEnd::CodeBlock => {
builder.trim_trailing_newline();
builder.pop_div();
builder.pop_code_block();
builder.pop_text_style();
if let CodeBlockRenderer::Default {
copy_button_visibility,
wrap_button_visibility,
..
} = &self.code_block_renderer
&& (*copy_button_visibility != CopyButtonVisibility::Hidden
|| *wrap_button_visibility != WrapButtonVisibility::Hidden)
{
let copy_button_visibility = *copy_button_visibility;
let wrap_button_visibility = *wrap_button_visibility;
builder.modify_current_div(|el| {
let content_range = crate::parser::extract_code_block_content_range(
&parsed_markdown.source()[range.clone()],
);
let content_range = content_range.start + range.start
..content_range.end + range.start;
let code = parsed_markdown.source()[content_range].to_string();
let any_hover = copy_button_visibility
== CopyButtonVisibility::VisibleOnHover
|| wrap_button_visibility
== WrapButtonVisibility::VisibleOnHover;
let any_always = copy_button_visibility
== CopyButtonVisibility::AlwaysVisible
|| wrap_button_visibility
== WrapButtonVisibility::AlwaysVisible;
let use_hover = any_hover && !any_always;
let button_row = h_flex()
.gap_0p5()
.absolute()
.bg(cx.theme().colors().editor_background)
.when_else(
use_hover,
|this| {
this.top_1()
.right_1()
.opacity(0.)
.group_hover("code_block", |el| el.opacity(1.))
},
|this| this.top_1p5().right_1p5(),
)
.when(
wrap_button_visibility != WrapButtonVisibility::Hidden,
|this| {
let is_wrapped = self
.markdown
.read(cx)
.is_code_block_wrapped(range.start);
this.child(render_wrap_code_block_button(
range.start,
is_wrapped,
self.markdown.clone(),
))
},
)
.when(
copy_button_visibility != CopyButtonVisibility::Hidden,
|this| {
this.child(render_copy_code_block_button(
range.end,
code,
self.markdown.clone(),
))
},
);
el.child(button_row)
});
}
builder.pop_div();
}
MarkdownTagEnd::HtmlBlock => builder.pop_div(),
MarkdownTagEnd::List(_) => {
builder.pop_list();
builder.pop_div();
}
MarkdownTagEnd::Item => {
self.pop_markdown_list_item(&mut builder);
}
MarkdownTagEnd::Emphasis => builder.pop_text_style(),
MarkdownTagEnd::Strong => builder.pop_text_style(),
MarkdownTagEnd::Strikethrough => builder.pop_text_style(),
MarkdownTagEnd::Link => {
if builder.code_block_stack.is_empty() {
builder.link_depth = builder.link_depth.saturating_sub(1);
builder.pop_text_style()
}
}
MarkdownTagEnd::Table => {
builder.pop_div();
builder.table.end();
}
MarkdownTagEnd::TableHead => {
builder.pop_text_style();
builder.table.end_head();
}
MarkdownTagEnd::TableRow => {
builder.table.end_row();
}
MarkdownTagEnd::TableCell => {
builder.replace_pending_checkbox(self.on_checkbox_toggle.clone());
builder.pop_div();
builder.pop_div();
builder.pop_text_style();
builder.table.end_cell();
}
MarkdownTagEnd::FootnoteDefinition => {
builder.pop_div();
builder.pop_div();
}
MarkdownTagEnd::MetadataBlock(_) => {}
_ => log::debug!("unsupported markdown tag end: {:?}", tag),
},
MarkdownEvent::Text => {
builder.push_text(&parsed_markdown.source[range.clone()], range.clone());
}
MarkdownEvent::SubstitutedText(text) => {
builder.push_text(text, range.clone());
}
MarkdownEvent::Code => {
self.push_markdown_code_span(
&mut builder,
&parsed_markdown.source[range.clone()],
range.clone(),
cx,
);
}
MarkdownEvent::SubstitutedCode(text) => {
self.push_markdown_code_span(&mut builder, text, range.clone(), cx);
}
MarkdownEvent::Html => {
let html = &parsed_markdown.source[range.clone()];
if html.starts_with("<!--") {
builder.html_comment = true;
}
if html.trim_end().ends_with("-->") {
builder.html_comment = false;
continue;
}
if builder.html_comment {
continue;
}
builder.push_text(html, range.clone());
}
MarkdownEvent::InlineHtml => {
let html = &parsed_markdown.source[range.clone()];
if let Some(code) = html
.strip_prefix("<code>")
.and_then(|html| html.strip_suffix("</code>"))
{
let code_start = range.start + "<code>".len();
self.push_markdown_code_span(
&mut builder,
code,
code_start..code_start + code.len(),
cx,
);
continue;
}
if html.starts_with("<code>") {
builder.push_text_style(self.style.inline_code.clone());
continue;
}
if html.trim_end().starts_with("</code>") {
builder.pop_text_style();
continue;
}
builder.push_text(&parsed_markdown.source[range.clone()], range.clone());
}
MarkdownEvent::Rule => {
builder.push_div(
div()
.border_b_1()
.my_2()
.border_color(self.style.rule_color),
range,
markdown_end,
);
builder.pop_div()
}
MarkdownEvent::SoftBreak if !self.style.soft_break_as_hard_break => {
builder.push_soft_break(range.clone());
}
MarkdownEvent::SoftBreak | MarkdownEvent::HardBreak => {
builder.push_line_break(range.clone());
}
MarkdownEvent::TaskListMarker(_) => {
}
MarkdownEvent::FootnoteReference(label) => {
builder.push_footnote_ref(label.clone(), range.clone());
builder.push_text_style(self.style.link.clone());
builder.push_text(&format!("[{label}]"), range.clone());
builder.pop_text_style();
}
}
}
if self.style.code_block_overflow_x_scroll {
let code_block_ids = code_block_ids;
self.markdown.update(cx, move |markdown, _| {
markdown.retain_code_block_scroll_handles(&code_block_ids);
});
} else {
self.markdown
.update(cx, |markdown, _| markdown.clear_code_block_scroll_handles());
}
let mut rendered_markdown = builder.build();
let child_layout_id = rendered_markdown.element.request_layout(window, cx);
let layout_id = window.request_layout(gpui::Style::default(), [child_layout_id], cx);
(layout_id, rendered_markdown)
}
fn prepaint(
&mut self,
_id: Option<&GlobalElementId>,
_inspector_id: Option<&gpui::InspectorElementId>,
bounds: Bounds<Pixels>,
rendered_markdown: &mut Self::RequestLayoutState,
window: &mut Window,
cx: &mut App,
) -> Self::PrepaintState {
let focus_handle = self.markdown.read(cx).focus_handle.clone();
window.set_focus_handle(&focus_handle, cx);
window.set_view_id(self.markdown.entity_id());
let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal);
rendered_markdown.element.prepaint(window, cx);
self.autoscroll(&rendered_markdown.text, window, cx);
hitbox
}
fn paint(
&mut self,
_id: Option<&GlobalElementId>,
_inspector_id: Option<&gpui::InspectorElementId>,
_bounds: Bounds<Pixels>,
rendered_markdown: &mut Self::RequestLayoutState,
hitbox: &mut Self::PrepaintState,
window: &mut Window,
cx: &mut App,
) {
let mut context = KeyContext::default();
context.add("Markdown");
window.set_key_context(context);
window.on_action(std::any::TypeId::of::<crate::entity::Copy>(), {
let entity = self.markdown.clone();
let text = rendered_markdown.text.clone();
move |_, phase, window, cx| {
let text = text.clone();
if phase == DispatchPhase::Bubble {
entity.update(cx, move |this, cx| this.copy(&text, window, cx))
}
}
});
window.on_action(std::any::TypeId::of::<crate::entity::CopyAsMarkdown>(), {
let entity = self.markdown.clone();
move |_, phase, window, cx| {
if phase == DispatchPhase::Bubble {
entity.update(cx, move |this, cx| this.copy_as_markdown(window, cx))
}
}
});
self.paint_mouse_listeners(hitbox, &rendered_markdown.text, window, cx);
rendered_markdown.element.paint(window, cx);
self.paint_search_highlights(&rendered_markdown.text, window, cx);
self.paint_selection(&rendered_markdown.text, window, cx);
}
}
fn collect_image_alt_text(
events_from_image_start: &[(Range<usize>, MarkdownEvent)],
source: &str,
) -> Option<SharedString> {
let mut alt_text = String::new();
for (range, event) in events_from_image_start.iter().skip(1) {
match event {
MarkdownEvent::End(MarkdownTagEnd::Image) => break,
MarkdownEvent::Text => alt_text.push_str(&source[range.clone()]),
_ => {}
}
}
if alt_text.is_empty() {
None
} else {
Some(alt_text.into())
}
}
fn image_fallback_element(
dest_url: SharedString,
alt_text: Option<SharedString>,
open_image_url_on_click: bool,
) -> AnyElement {
let link_label = alt_text
.filter(|alt| !alt.is_empty())
.unwrap_or_else(|| dest_url.clone());
let label = format!("Failed to Load: {link_label}");
div()
.id("image-fallback")
.min_w_0()
.text_color(gpui::red())
.child(label)
.tooltip(simple_tooltip(
"Image failed to load. Check the logs for more details.",
))
.when(open_image_url_on_click, |this| {
this.cursor_pointer()
.on_click(move |_, _, cx| cx.open_url(&dest_url))
})
.into_any_element()
}
fn apply_heading_style(
mut heading: Div,
level: HeadingLevel,
custom_styles: Option<&HeadingLevelStyles>,
border_color: Option<Hsla>,
) -> Div {
heading = match level {
HeadingLevel::H1 => heading.text_3xl(),
HeadingLevel::H2 => heading.text_2xl(),
HeadingLevel::H3 => heading.text_xl(),
HeadingLevel::H4 => heading.text_lg(),
HeadingLevel::H5 => heading.text_base(),
HeadingLevel::H6 => heading.text_sm(),
};
heading = match level {
HeadingLevel::H1 => heading,
_ => heading.mt_6(),
};
if let Some(border_color) = border_color
&& matches!(
level,
HeadingLevel::H1 | HeadingLevel::H2 | HeadingLevel::H3
)
{
heading = heading.pb_1().border_b_1().border_color(border_color);
}
if let Some(styles) = custom_styles {
let style_opt = match level {
HeadingLevel::H1 => &styles.h1,
HeadingLevel::H2 => &styles.h2,
HeadingLevel::H3 => &styles.h3,
HeadingLevel::H4 => &styles.h4,
HeadingLevel::H5 => &styles.h5,
HeadingLevel::H6 => &styles.h6,
};
if let Some(style) = style_opt {
heading.style().text = style.clone();
}
}
heading
}
fn render_wrap_code_block_button(
id: usize,
is_wrapped: bool,
markdown: Entity<Markdown>,
) -> impl IntoElement {
let tooltip = if is_wrapped {
"Unwrap Content"
} else {
"Wrap Content"
};
let button_id = ElementId::NamedChild(
Arc::new(ElementId::from(("wrap-code-block", markdown.entity_id()))),
id.to_string().into(),
);
div()
.id(button_id)
.flex()
.items_center()
.justify_center()
.size(px(20.))
.rounded_xs()
.cursor_pointer()
.child(icon_svg(
IconName::ArrowRightLeft,
px(14.),
gpui::rgb(0xA0AEC0),
))
.tooltip(simple_tooltip(tooltip))
.on_click(move |_event, _window, cx| {
markdown.update(cx, |markdown, cx| {
markdown.toggle_code_block_wrap(id);
cx.notify();
});
})
}
fn render_copy_code_block_button(
id: usize,
code: String,
markdown: Entity<Markdown>,
) -> impl IntoElement {
let id = ElementId::NamedChild(
Arc::new(ElementId::from((
"copy-markdown-code",
markdown.entity_id(),
))),
id.to_string().into(),
);
CopyButton::new(id, code)
}
impl IntoElement for MarkdownElement {
type Element = Self;
fn into_element(self) -> Self::Element {
self
}
}
pub enum AnyDiv {
Div(Div),
Stateful(Stateful<Div>),
}
impl AnyDiv {
pub(crate) fn into_any_element(self) -> AnyElement {
match self {
Self::Div(div) => div.into_any_element(),
Self::Stateful(div) => div.into_any_element(),
}
}
}
impl From<Div> for AnyDiv {
fn from(value: Div) -> Self {
Self::Div(value)
}
}
impl From<Stateful<Div>> for AnyDiv {
fn from(value: Stateful<Div>) -> Self {
Self::Stateful(value)
}
}
impl Styled for AnyDiv {
fn style(&mut self) -> &mut StyleRefinement {
match self {
Self::Div(div) => div.style(),
Self::Stateful(div) => div.style(),
}
}
}
impl ParentElement for AnyDiv {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
match self {
Self::Div(div) => div.extend(elements),
Self::Stateful(div) => div.extend(elements),
}
}
}