refactor: markdown rendering into rich_text component

This commit is contained in:
2026-06-06 19:30:05 +01:00
parent 6a127aab9e
commit 5cd153ce58
13 changed files with 575 additions and 324 deletions

View File

@@ -144,6 +144,23 @@ fn issue_filter_fixture_key(filter: &str) -> &str {
mod tests {
use super::*;
fn assert_markdown_table(body: &str, header: &str) {
assert!(
body.contains(header),
"pull request markdown fixture should contain table header {header:?}"
);
assert!(
body.contains("| ---"),
"pull request markdown fixture should contain a markdown table delimiter row"
);
}
fn assert_markdown_emphasis(body: &str) {
assert!(body.contains("**Fast path:**"));
assert!(body.contains("*Background refresh*"));
assert!(body.contains("~~Empty refreshes~~"));
}
#[test]
fn list_pull_request_fixtures_parse_with_current_filter_strings() {
let authored = list_pull_requests(Some("author:@me state:open"), 1)
@@ -173,7 +190,7 @@ mod tests {
.expect("spacing token pull request fixture should parse");
assert_eq!(merged.state, issues::PullRequestState::Merged);
assert!(merged.body.contains("| Stage | Owner | Status |"));
assert_markdown_table(&merged.body, "| Stage | Owner | Status |");
assert_eq!(
merged.author.as_ref().map(|author| author.login.as_ref()),
Some("rorycraft")
@@ -192,6 +209,7 @@ mod tests {
.body
.contains("./scripts/failover promote-standby")
);
assert_markdown_table(&documented_failover.body, "| Step | Owner | State |");
assert_eq!(
documented_failover
.author
@@ -209,6 +227,7 @@ mod tests {
Some(chrono::DateTime::parse_from_rfc3339("2026-04-24T06:40:00Z").unwrap())
);
assert!(dashboard_markdown.body.contains("```rust"));
assert_markdown_table(&dashboard_markdown.body, "| Case | Expected behavior |");
assert_eq!(dashboard_markdown.base_branch_name.as_ref(), "main");
assert_eq!(
dashboard_markdown.head_branch_name.as_ref(),
@@ -233,6 +252,11 @@ mod tests {
.map(|author| author.login.as_ref()),
Some("kennethnym")
);
assert_markdown_table(
&cached_repo_picker.body,
"| Cache path | Expected behavior |",
);
assert_markdown_emphasis(&cached_repo_picker.body);
assert_eq!(cached_repo_picker.base_branch_name.as_ref(), "main");
assert_eq!(
cached_repo_picker.head_branch_name.as_ref(),
@@ -257,6 +281,7 @@ mod tests {
.map(|author| author.login.as_ref()),
Some("leaferiksen")
);
assert_markdown_table(&worker_split.body, "| Boundary | Responsibility |");
assert_eq!(worker_split.base_branch_name.as_ref(), "main");
assert_eq!(
worker_split.head_branch_name.as_ref(),
@@ -273,6 +298,7 @@ mod tests {
.map(|author| author.login.as_ref()),
Some("mariahops")
);
assert_markdown_table(&spacing_tokens.body, "| Surface | Before | After |");
assert_eq!(spacing_tokens.base_branch_name.as_ref(), "main");
assert_eq!(
spacing_tokens.head_branch_name.as_ref(),

View File

@@ -87,8 +87,8 @@ impl gpui::RenderOnce for Button {
let theme = app::current_theme(cx);
let icon_color = match self.variant {
| Variant::Primary => theme.colors.accent_on_solid,
| Variant::Secondary => theme.colors.text,
| Variant::Primary => theme.colors.accent_on_solid,
| Variant::Secondary => theme.colors.text,
};
let mut children: Vec<AnyElement> = Vec::with_capacity(3);

View File

@@ -1,14 +1,15 @@
// markdown treesitter playground: https://ikatyang.github.io/tree-sitter-markdown/
use std::{
ops::Range,
sync::{Arc, LazyLock},
use std::sync::{Arc, LazyLock};
use gpui::{AppContext, FontWeight, ParentElement, Styled, div, relative, rems};
use crate::{
app,
component::rich_text::{RichText, RichTextContent, RichTextContentBuilder, rich_text},
theme,
};
use gpui::{AppContext, ParentElement, Refineable, Styled, div, px, relative, rems};
use crate::{app, theme};
const MARKDOWN_KIND_ID_SETEXT_H1_UNDERLINE: u16 = 8;
const MARKDOWN_KIND_ID_SETEXT_H2_UNDERLINE: u16 = 9;
const MARKDOWN_KIND_ID_ATX_H1_MARKER: u16 = 11;
@@ -94,12 +95,25 @@ pub(crate) struct MarkdownText {
}
enum ContentBlock {
Text {
decoration: Option<gpui::SharedString>,
text: gpui::SharedString,
highlights: Vec<(Range<usize>, gpui::HighlightStyle)>,
links: Vec<(Range<usize>, gpui::SharedString)>,
style: gpui::StyleRefinement,
Heading {
font_size: gpui::Rems,
font_weight: gpui::FontWeight,
mt: gpui::Rems,
mb: gpui::Rems,
content: RichTextContent,
},
Code {
content: gpui::SharedString,
},
Paragraph {
decoration: Option<String>,
content: RichTextContent,
},
Empty,
Table {
row_count: usize,
col_count: usize,
cells: Vec<RichTextContent>,
},
}
@@ -112,14 +126,6 @@ pub(crate) fn new(content: Arc<str>, cx: &mut gpui::Context<MarkdownText>) -> Ma
view
}
impl Styled for ContentBlock {
fn style(&mut self) -> &mut gpui::StyleRefinement {
match self {
| ContentBlock::Text { style, .. } => style,
}
}
}
impl MarkdownText {
fn on_create(&mut self, cx: &gpui::Context<Self>) {
let content = Arc::clone(&self.content);
@@ -154,17 +160,18 @@ impl MarkdownText {
let mut is_first_heading = true;
fn block_for_node(
fn build_rich_text_for_node(
cursor: &mut tree_sitter::TreeCursor,
builder: &mut RichTextContentBuilder,
content: &str,
// byte_offset is the number of bytes to offset the content start byte by
byte_offset: usize,
theme: &theme::Theme,
) -> ContentBlock {
parent_style: Option<gpui::HighlightStyle>,
) {
let node_start_byte = cursor.node().start_byte();
let mut highlights: Vec<(Range<usize>, gpui::HighlightStyle)> = Vec::new();
let mut links: Vec<(Range<usize>, gpui::SharedString)> = Vec::new();
let style = parent_style.unwrap_or_default();
cursor.goto_first_child();
@@ -179,56 +186,76 @@ impl MarkdownText {
}
match node.kind_id() {
| MARKDOWN_KIND_ID_EMPHASIS => {
highlights.push((
node_range!(),
gpui::HighlightStyle {
font_style: Some(gpui::FontStyle::Italic),
..Default::default()
},
));
| MARKDOWN_KIND_ID_TEXT => {
println!(
"current node start byte {} parent node start byte {}",
node.start_byte(),
node_start_byte
);
if let Some(t) = node.utf8_text(content.as_ref()).ok() {
builder.push_text(t, style);
}
| MARKDOWN_KIND_ID_STRONG_EMPHASIS => highlights.push((
node_range!(),
gpui::HighlightStyle {
}
| MARKDOWN_KIND_ID_EMPHASIS => {
build_rich_text_for_node(
cursor,
builder,
content,
byte_offset,
theme,
Some(gpui::HighlightStyle {
font_style: Some(gpui::FontStyle::Italic),
..style
}),
);
}
| MARKDOWN_KIND_ID_STRONG_EMPHASIS => {
build_rich_text_for_node(
cursor,
builder,
content,
byte_offset,
theme,
Some(gpui::HighlightStyle {
font_weight: Some(gpui::FontWeight::BOLD),
..Default::default()
},
)),
..style
}),
);
}
| MARKDOWN_KIND_ID_LINK => {
if cursor.goto_first_child() {
highlights.push((
node_range!(),
gpui::HighlightStyle {
color: Some(theme.colors.link.into()),
underline: Some(gpui::UnderlineStyle {
color: Some(theme.colors.link.into()),
thickness: px(1.),
wavy: false,
}),
..Default::default()
},
));
| MARKDOWN_KIND_ID_LINK => {
cursor.goto_first_child();
if cursor.goto_next_sibling()
&& let Ok(src) = cursor.node().utf8_text(content.as_bytes())
{
links.push((
node_range!(),
gpui::SharedString::from(String::from(src)),
));
let (description, src) =
if cursor.node().kind_id() == MARKDOWN_KIND_ID_LINK_DESTINATION {
let node = cursor.node();
let src = &content[node_range!()];
(src, src)
} else {
let node = cursor.node();
let description = &content[node_range!()];
if cursor.goto_next_sibling() {
debug_assert!(
cursor.node().kind_id() == MARKDOWN_KIND_ID_LINK_DESTINATION
);
let node = cursor.node();
(description, &content[node_range!()])
} else {
// the link src is invalid, use an empty string as a fallback
// link on click handler will ignore empty string
links.push((node_range!(), "".into()))
// no src for this link node
(description, "")
}
}
}
};
| _ => {
// extend here to support more markdown node stylings
}
builder.push_link(description, src.to_owned());
cursor.goto_parent();
}
| _ => {
// extend here to support more styles
}
};
if !cursor.goto_next_sibling() {
@@ -237,35 +264,11 @@ impl MarkdownText {
}
cursor.goto_parent();
ContentBlock::Text {
decoration: None,
text: gpui::SharedString::new(
&content[(node_start_byte + byte_offset)..cursor.node().end_byte()],
),
highlights: highlights,
links: links,
style: gpui::StyleRefinement::default(),
}
}
loop {
let current_node = cursor.node();
fn render_fallback_content(
cursor: &tree_sitter::TreeCursor,
content: &str,
blocks: &mut Vec<ContentBlock>,
) {
blocks.push(ContentBlock::Text {
decoration: None,
text: gpui::SharedString::new(&content[cursor.node().byte_range()]),
highlights: Vec::new(),
links: Vec::new(),
style: gpui::StyleRefinement::default(),
});
}
fn render_list_node(
cursor: &mut tree_sitter::TreeCursor,
content: &str,
@@ -281,10 +284,7 @@ impl MarkdownText {
// tight_list <-- recursive point
// go to list_item node
if !cursor.goto_first_child() {
render_fallback_content(&cursor, content, blocks);
return false;
}
cursor.goto_first_child();
let mut list_index: Option<usize> = None;
@@ -305,51 +305,37 @@ impl MarkdownText {
let marker_content = &content[marker_node.byte_range()];
let list_marker_char = match marker_content {
// unordered list item
| "-" | "+" | "*" => Some("".to_string()),
// unordered list item
| "-" | "+" | "*" => "".to_string(),
| marker_content if ORDERED_LIST_MARKER_REGEX.is_match(marker_content) => {
let i = list_index.get_or_insert_with(|| {
marker_content
.strip_suffix('.')
.unwrap()
.parse::<usize>()
.unwrap()
});
let j = *i;
*i = j + 1;
Some(format!("{j}."))
}
| marker_content if ORDERED_LIST_MARKER_REGEX.is_match(marker_content) => {
let i = list_index.get_or_insert_with(|| {
marker_content
.strip_suffix('.')
.unwrap()
.parse::<usize>()
.unwrap()
});
let j = *i;
*i = j + 1;
format!("{j}.")
}
| _ => None,
};
let Some(list_marker_char) = list_marker_char else {
render_fallback_content(&cursor, content, blocks);
return false;
| _ => "".to_string(),
};
// go to paragraph sibling node
let block = if cursor.goto_next_sibling() {
let mut b = block_for_node(cursor, content, 0, theme);
match b {
| ContentBlock::Text {
ref mut decoration, ..
} => *decoration = Some(list_marker_char.into()),
let mut builder = RichTextContentBuilder::new();
build_rich_text_for_node(cursor, &mut builder, content, 0, theme, None);
ContentBlock::Paragraph {
decoration: Some(list_marker_char.clone()),
content: builder.build(),
}
b
} else {
ContentBlock::Text {
decoration: Some(list_marker_char.into()),
text: gpui::SharedString::default(),
highlights: Vec::new(),
links: Vec::new(),
style: gpui::StyleRefinement::default(),
}
}
.text_sm()
.text_color(theme.colors.text)
.p(rems(indentation as f32));
// empty block
ContentBlock::Empty
};
blocks.push(block);
@@ -374,150 +360,179 @@ impl MarkdownText {
}
match current_node.kind_id() {
| MARKDOWN_KIND_ID_ATX_HEADING => {
if !cursor.goto_first_child() {
render_fallback_content(&cursor, &self.content, &mut self.blocks);
continue;
}
| MARKDOWN_KIND_ID_ATX_HEADING => {
cursor.goto_first_child();
let marker_node_kind = cursor.node().kind_id();
let marker_node_kind = cursor.node().kind_id();
let block = if cursor.goto_next_sibling()
&& cursor.node().kind_id() == MARKDOWN_KIND_ID_HEADING_CONTENT
{
// because HEADING_CONTENT node includes the space after the heading marker
// offset by 1 to exclude the space
block_for_node(&mut cursor, &self.content, 1, theme)
} else {
ContentBlock::Text {
decoration: None,
text: gpui::SharedString::new(&self.content[current_node.byte_range()]),
highlights: Vec::new(),
links: Vec::new(),
style: gpui::StyleRefinement::default(),
}
};
let Some(content) = (if cursor.goto_next_sibling()
&& cursor.node().kind_id() == MARKDOWN_KIND_ID_HEADING_CONTENT
{
let mut builder = RichTextContentBuilder::new();
// because HEADING_CONTENT node includes the space after the heading marker
// offset by 1 to exclude the space
build_rich_text_for_node(
&mut cursor,
&mut builder,
&self.content,
1,
theme,
None,
);
Some(builder.build())
} else {
None
}) else {
continue;
};
let mut block = match marker_node_kind {
| MARKDOWN_KIND_ID_ATX_H1_MARKER => block
.text_size(rems(2.25))
.font_weight(gpui::FontWeight::EXTRA_BOLD)
.mb_6(),
| MARKDOWN_KIND_ID_ATX_H2_MARKER => block
.text_2xl()
.font_weight(gpui::FontWeight::BOLD)
.mt_12()
.mb_4(),
| MARKDOWN_KIND_ID_ATX_H3_MARKER => block
.text_xl()
.font_weight(gpui::FontWeight::SEMIBOLD)
.mt_8()
.mb_3(),
| MARKDOWN_KIND_ID_ATX_H4_MARKER => block
.text_base()
.font_weight(gpui::FontWeight::SEMIBOLD)
.mt_6()
.mb_2(),
| _ => block,
}
.text_color(theme.colors.text);
if is_first_heading {
is_first_heading = false;
block = block.mt_0();
}
cursor.goto_parent();
self.blocks.push(block);
}
| MARKDOWN_KIND_ID_PARAGRAPH => {
let block = block_for_node(&mut cursor, &self.content, 0, theme)
.text_color(theme.colors.text)
.text_sm();
self.blocks.push(block);
}
| MARKDOWN_KIND_ID_TIGHT_LIST => {
let is_rendered =
render_list_node(&mut cursor, &self.content, &mut self.blocks, theme, 0);
if !is_rendered {
continue;
let mut block = match marker_node_kind {
| MARKDOWN_KIND_ID_ATX_H1_MARKER => ContentBlock::Heading {
font_size: rems(2.25),
font_weight: gpui::FontWeight::EXTRA_BOLD,
mt: rems(0.),
mb: rems(1.5),
content,
},
| MARKDOWN_KIND_ID_ATX_H2_MARKER => ContentBlock::Heading {
font_size: rems(1.5),
font_weight: gpui::FontWeight::BOLD,
mt: rems(1.5),
mb: rems(1.),
content,
},
| MARKDOWN_KIND_ID_ATX_H3_MARKER => ContentBlock::Heading {
font_size: rems(1.25),
font_weight: gpui::FontWeight::SEMIBOLD,
mt: rems(2.),
mb: rems(0.75),
content,
},
| MARKDOWN_KIND_ID_ATX_H4_MARKER => ContentBlock::Heading {
font_size: rems(1.),
font_weight: FontWeight::SEMIBOLD,
mt: rems(1.5),
mb: rems(0.5),
content,
},
| MARKDOWN_KIND_ID_ATX_H5_MARKER | MARKDOWN_KIND_ID_ATX_H6_MARKER | _ => {
ContentBlock::Heading {
font_size: rems(1.),
font_weight: FontWeight::NORMAL,
mt: rems(1.5),
mb: rems(0.5),
content,
}
}
};
| MARKDOWN_KIND_ID_FENCED_CODE_BLOCK => {
// expected tree shape:
// fenced_code_block
// ├── info_string? (present if there is a language annotation)
// └── code_fence_content? (present if there is some content between the backticks)
if is_first_heading {
is_first_heading = false;
}
if !cursor.goto_first_child() {
render_fallback_content(&cursor, &self.content, &mut self.blocks);
continue;
}
cursor.goto_parent();
let content = if cursor.node().kind_id() == MARKDOWN_KIND_ID_INFO_STRING {
// skipping info string (which annotates the code block)
if cursor.goto_next_sibling() {
// this is code_fence_content node
gpui::SharedString::new(
cursor
.node()
.utf8_text(self.content.as_bytes())
.unwrap_or_default(),
)
} else {
gpui::SharedString::default()
}
} else {
// assuming the current node is already code_fence_content
self.blocks.push(block);
}
| MARKDOWN_KIND_ID_PARAGRAPH => {
let mut builder = RichTextContentBuilder::new();
// because HEADING_CONTENT node includes the space after the heading marker
// offset by 1 to exclude the space
build_rich_text_for_node(&mut cursor, &mut builder, &self.content, 0, theme, None);
self.blocks.push(ContentBlock::Paragraph {
decoration: None,
content: builder.build(),
});
}
| MARKDOWN_KIND_ID_TIGHT_LIST => {
let is_rendered =
render_list_node(&mut cursor, &self.content, &mut self.blocks, theme, 0);
if !is_rendered {
continue;
}
}
| MARKDOWN_KIND_ID_FENCED_CODE_BLOCK => {
// expected tree shape:
// fenced_code_block
// ├── info_string? (present if there is a language annotation)
// └── code_fence_content? (present if there is some content between the backticks)
if !cursor.goto_first_child() {
self.blocks.push(ContentBlock::Empty);
continue;
}
let content = if cursor.node().kind_id() == MARKDOWN_KIND_ID_INFO_STRING {
// skipping info string (which annotates the code block)
if cursor.goto_next_sibling() {
// this is code_fence_content node
gpui::SharedString::new(
cursor
.node()
.utf8_text(self.content.as_bytes())
.unwrap_or_default(),
)
};
cursor.goto_parent();
let block = ContentBlock::Text {
decoration: None,
text: content,
highlights: Vec::new(),
links: Vec::new(),
style: gpui::StyleRefinement::default(),
} else {
gpui::SharedString::default()
}
.text_sm()
.text_color(theme.colors.text)
.line_height(relative(1.2))
.font_family("Menlo")
.px_3()
.py_2()
.rounded_sm()
.bg(theme.colors.code_bg)
.border_1()
.my_4()
.border_color(theme.colors.code_border);
} else {
// assuming the current node is already code_fence_content
gpui::SharedString::new(
cursor
.node()
.utf8_text(self.content.as_bytes())
.unwrap_or_default(),
)
};
self.blocks.push(block);
}
cursor.goto_parent();
| _ => {
println!(
"[WARN] formatting not implemenetd for node type {:?}",
current_node.kind()
);
self.blocks.push(ContentBlock::Code { content });
}
let block = block_for_node(&mut cursor, &self.content, 0, theme)
.text_color(theme.colors.text)
.text_sm();
// | MARKDOWN_KIND_ID_TABLE => {
// cursor.goto_first_child();
// debug_assert!(cursor.node().kind_id() == MARKDOWN_KIND_ID_TABLE_HEADER_ROW);
self.blocks.push(block);
}
// let col_count = cursor.node().child_count();
// // markdown tables aren't usually that big
// // lets assume the average markdown table has 10 rows (inc header)
// // preallocate the vec with capacity row * col, should be big enough to avoid realloc
// let min_row_count = 10;
// // cell text blocks are stored in row-major order
// let cell_blocks: Vec<ContentBlock> = Vec::with_capacity(col_count * min_row_count);
// cursor.goto_first_child();
// debug_assert!(cursor.node().kind_id() == MARKDOWN_KIND_ID_TABLE_CELL);
// loop {
// let cell_node = cursor.node();
// let cell_text_block = rich_text_for_node(&mut cursor, &self.content, 1, theme);
// cell_blocks.push(ContentBlock::Paragraph(cell_text_block));
// if !cursor.goto_next_sibling() {
// break;
// }
// }
// }
| _ => {
println!(
"[WARN] formatting not implemenetd for node type {:?}",
current_node.kind()
);
let mut builder = RichTextContentBuilder::new();
build_rich_text_for_node(&mut cursor, &mut builder, &self.content, 0, theme, None);
self.blocks.push(ContentBlock::Paragraph {
decoration: None,
content: builder.build(),
});
}
}
if !cursor.goto_next_sibling() {
@@ -533,59 +548,67 @@ impl gpui::Render for MarkdownText {
_window: &mut gpui::Window,
cx: &mut gpui::prelude::Context<Self>,
) -> impl gpui::prelude::IntoElement {
let children = self.blocks.iter().enumerate().map(|(i, block)| {
match block {
| ContentBlock::Text {
decoration,
text,
highlights,
links,
style,
} => {
let styled_text =
gpui::StyledText::new(text.clone()).with_highlights(highlights.clone());
let theme = app::current_theme(cx);
let content = if links.is_empty() {
div().w_full().child(styled_text)
} else {
// if link in block, interactive text is needed
// to handle link clicks
let (link_ranges, srcs): (Vec<_>, Vec<_>) = links.iter().cloned().unzip();
let children = self
.blocks
.iter()
.enumerate()
.map(|(i, block)| match block {
| ContentBlock::Heading {
font_size,
font_weight,
mt,
mb,
content,
} => div()
.min_w_0()
.mt(gpui::Length::from(*mt))
.mb(gpui::Length::from(*mb))
.text_size(gpui::AbsoluteLength::from(*font_size))
.font_weight(*font_weight)
.child(rich_text(content.clone())),
let weak = cx.entity();
let t = gpui::InteractiveText::new(i, styled_text).on_click(
link_ranges,
move |i, _, cx| {
if let Some(src) = srcs.get(i) {
weak.update(cx, |this, cx| {
this.on_open_link(src, cx);
cx.notify();
})
}
},
);
| ContentBlock::Paragraph {
decoration,
content,
} => match decoration {
| None => div().min_w_0().child(rich_text(content.clone())),
| Some(decoration) => div()
.w_full()
.flex()
.flex_row()
.gap_2()
.items_start()
.text_color(theme.colors.text)
.child(decoration.clone())
.child(div().flex_1().min_w_0().child(rich_text(content.clone()))),
},
div().w_full().child(t)
};
| ContentBlock::Code { content } => div()
.min_w_0()
.w_full()
.text_sm()
.text_color(theme.colors.text)
.line_height(relative(1.2))
.font_family("Menlo")
.px_3()
.py_2()
.rounded_sm()
.bg(theme.colors.code_bg)
.border_1()
.my_4()
.border_color(theme.colors.code_border)
.child(content.clone()),
let mut div = match decoration {
| Some(d) => div()
.w_full()
.flex()
.flex_row()
.gap_2()
.items_start()
.child(d.clone())
.child(div().flex_1().min_w_0().child(content)),
| None => div().w_full().child(content),
};
| ContentBlock::Table {
row_count,
col_count,
cells,
} => div(),
div.style().refine(&style);
div
}
}
});
| ContentBlock::Empty => div(),
});
div().w_full().children(children)
}

View File

@@ -4,6 +4,7 @@ pub(crate) mod diff_view;
pub(crate) mod file_tree;
pub(crate) mod font_icon;
pub(crate) mod markdown;
pub(crate) mod rich_text;
pub(crate) mod segmented_control;
pub(crate) mod text;
pub(crate) mod text_input;

201
src/component/rich_text.rs Normal file
View File

@@ -0,0 +1,201 @@
use std::rc::Rc;
use gpui::{IntoElement, ParentElement, Styled, div, px};
use crate::{app, util::syntax_highlight};
pub(crate) struct RichTextContentBuilder {
raw_content: String,
annotations: Vec<Annotation>,
}
#[derive(Clone)]
pub(crate) struct RichTextContent {
elements: Rc<[RichTextElement]>,
links: Rc<[gpui::SharedString]>,
}
#[derive(gpui::IntoElement)]
pub(crate) struct RichText {
content: RichTextContent,
on_click: Option<Rc<dyn Fn(&RichTextClickTarget, &mut gpui::Window, &mut gpui::App)>>,
}
enum Annotation {
Text {
style: gpui::HighlightStyle,
range: std::ops::Range<usize>,
},
Image {
src: gpui::SharedString,
range: std::ops::Range<usize>,
},
Link {
src: gpui::SharedString,
range: std::ops::Range<usize>,
},
}
enum RichTextElement {
Text {
content: gpui::SharedString,
highlights: Vec<syntax_highlight::HighlightedRange>,
links: Vec<(std::ops::Range<usize>)>,
link_i_offset: usize,
},
Image {
src: gpui::SharedString,
description: gpui::SharedString,
},
}
enum RichTextClickTarget {
Link(gpui::SharedString),
}
pub(crate) fn rich_text(content: RichTextContent) -> RichText {
RichText {
content,
on_click: None,
}
}
impl RichTextContentBuilder {
pub(crate) fn new() -> Self {
Self {
raw_content: String::new(),
annotations: Vec::new(),
}
}
pub(crate) fn push_text(&mut self, text: &str, style: gpui::HighlightStyle) {
let start = self.raw_content.len();
let end = start + text.len();
self.raw_content.push_str(text);
self.annotations.push(Annotation::Text {
style,
range: start..end,
});
}
pub(crate) fn push_link(&mut self, text: &str, src: String) {
let start = self.raw_content.len();
let end = start + text.len();
self.raw_content.push_str(text);
self.annotations.push(Annotation::Link {
src: src.into(),
range: start..end,
});
}
pub(crate) fn build(self) -> RichTextContent {
let mut text_start = 0;
let mut text_end = 0;
let mut highlights: Vec<(std::ops::Range<usize>, gpui::HighlightStyle)> = Vec::new();
let mut links: Vec<gpui::SharedString> = Vec::new();
let mut link_ranges: Vec<(std::ops::Range<usize>)> = Vec::new();
let mut elements: Vec<RichTextElement> = Vec::new();
let mut link_i_offset = 0;
for annotation in self.annotations {
match annotation {
| Annotation::Text { style, range } => {
highlights.push(((range.start - text_start)..(range.end - text_start), style));
text_end = range.end;
}
| Annotation::Link { src, range } => {
highlights.push((
(range.start - text_start)..(range.end - text_start),
gpui::HighlightStyle {
underline: Some(gpui::UnderlineStyle {
thickness: px(1.),
..Default::default()
}),
..Default::default()
},
));
links.push(src);
link_ranges.push((range.start - text_start)..(range.end - text_start));
text_end = range.end;
}
| Annotation::Image { src, range } => {
elements.push(RichTextElement::Text {
content: gpui::SharedString::new(&self.raw_content[text_start..text_end]),
highlights: highlights.clone(),
links: link_ranges.clone(),
link_i_offset,
});
highlights.clear();
link_ranges.clear();
link_i_offset = links.len();
text_start = range.end;
text_end = range.end + 1;
}
}
}
if !highlights.is_empty() || !link_ranges.is_empty() {
elements.push(RichTextElement::Text {
content: gpui::SharedString::new(&self.raw_content[text_start..text_end]),
highlights: highlights.clone(),
links: link_ranges.clone(),
link_i_offset,
});
}
RichTextContent {
elements: Rc::from(elements),
links: Rc::from(links),
}
}
}
impl gpui::RenderOnce for RichText {
fn render(self, _window: &mut gpui::Window, cx: &mut gpui::App) -> impl gpui::IntoElement {
let theme = app::current_theme(cx);
let children = self
.content
.elements
.iter()
.enumerate()
.map(|(i, elem)| match elem {
| RichTextElement::Text {
content,
highlights,
links,
link_i_offset,
} => {
let styled_text =
gpui::StyledText::new(content).with_highlights(highlights.into_iter().cloned());
if links.is_empty() {
styled_text.into_any_element()
} else {
let on_click = self.on_click.as_ref().map(Rc::clone);
let all_links = Rc::clone(&self.content.links);
let link_i_offset = *link_i_offset;
gpui::InteractiveText::new(i, styled_text)
.on_click(links.clone(), move |i, window, cx| {
if let Some(f) = &on_click {
let link = all_links[i + link_i_offset].clone();
f(&RichTextClickTarget::Link(link), window, cx);
}
})
.into_any_element()
}
}
| RichTextElement::Image { src, description } => todo!(),
});
div()
.flex()
.flex_row()
.flex_wrap()
.text_color(theme.colors.text)
.children(children)
}
}