text_view: Fix inline image render. (#1166)

GPUI does not support inline image into an `InteractiveText` yet. So
here, let it render into a block line.

<img width="1201" height="902" alt="image"
src="https://github.com/user-attachments/assets/797baeae-8764-417a-a907-e0d22956bf62"
/>

<img width="1064" height="805" alt="image"
src="https://github.com/user-attachments/assets/a5ef7000-aa71-4c1b-8a82-2a0f36649fd6"
/>
This commit is contained in:
Jason Lee 2025-08-21 16:30:58 +08:00 committed by GitHub
parent 8ed135d41a
commit 554082b969
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 275 additions and 219 deletions

View file

@ -1,5 +1,7 @@
# Hello, **World**!
Build Status [![Build Status](https://github.com/longbridge/gpui-component/actions/workflows/ci.yml/badge.svg)](https://github.com/longbridge/gpui-component/actions/workflows/ci.yml) of [GPUI Component](https://github.com/longbridge/gpui-component).
This is first paragraph, there have **BOLD**, _italic_, and ~strikethrough~, `code` text.
This is an additional demonstration paragraph in English demonstrating more content for [Markdown GFM](https://github.github.com/gfm/). It includes various stylistic elements and plain text.

View file

@ -4,11 +4,14 @@ use gpui::{
div, img, prelude::FluentBuilder as _, px, relative, rems, AnyElement, App, DefiniteLength,
Div, ElementId, FontStyle, FontWeight, Half, HighlightStyle, InteractiveElement as _,
InteractiveText, IntoElement, Length, ObjectFit, ParentElement, Rems, RenderOnce, SharedString,
SharedUri, Styled, StyledImage as _, StyledText, Window,
SharedUri, StatefulInteractiveElement, Styled, StyledImage as _, StyledText, Window,
};
use markdown::mdast;
use crate::{h_flex, highlighter::SyntaxHighlighter, v_flex, ActiveTheme as _, Icon, IconName};
use crate::{
h_flex, highlighter::SyntaxHighlighter, tooltip::Tooltip, v_flex, ActiveTheme as _, Icon,
IconName,
};
use super::{utils::list_item_prefix, TextViewStyle};
@ -44,12 +47,22 @@ impl From<Span> for ElementId {
#[derive(Debug, Default, Clone)]
pub struct ImageNode {
pub url: SharedUri,
pub link: Option<LinkMark>,
pub title: Option<SharedString>,
pub alt: Option<SharedString>,
pub width: Option<DefiniteLength>,
pub height: Option<DefiniteLength>,
}
impl ImageNode {
pub fn title(&self) -> String {
self.title
.clone()
.unwrap_or_else(|| self.alt.clone().unwrap_or_default())
.to_string()
}
}
impl PartialEq for ImageNode {
fn eq(&self, other: &Self) -> bool {
self.url == other.url && self.title == other.title && self.alt == other.alt
@ -60,37 +73,24 @@ impl PartialEq for ImageNode {
pub struct TextNode {
/// The text content.
pub text: String,
pub image: Option<ImageNode>,
/// The text styles, each tuple contains the range of the text and the style.
pub marks: Vec<(Range<usize>, InlineTextStyle)>,
}
#[derive(Debug, Clone, PartialEq, IntoElement)]
pub enum Paragraph {
Texts {
span: Option<Span>,
children: Vec<TextNode>,
},
Image {
span: Option<Span>,
image: ImageNode,
},
}
impl Default for Paragraph {
fn default() -> Self {
Self::Texts {
span: None,
children: vec![],
}
}
#[derive(Debug, Default, Clone, PartialEq, IntoElement)]
pub struct Paragraph {
pub(super) span: Option<Span>,
pub(super) children: Vec<TextNode>,
}
impl From<String> for Paragraph {
fn from(value: String) -> Self {
Self::Texts {
Self {
span: None,
children: vec![TextNode {
text: value.clone(),
image: None,
marks: vec![],
}],
}
@ -141,80 +141,60 @@ pub struct TableCell {
impl Paragraph {
pub fn clear(&mut self) {
match self {
Self::Texts { children, .. } => children.clear(),
Self::Image { .. } => *self = Self::default(),
}
self.span = None;
self.children.clear();
}
pub fn is_image(&self) -> bool {
matches!(self, Self::Image { .. })
false
}
pub fn set_span(&mut self, span: Span) {
match self {
Self::Texts { span: s, .. } => *s = Some(span),
Self::Image { span: s, .. } => *s = Some(span),
}
self.span = Some(span);
}
pub fn push_str(&mut self, text: &str) {
if let Self::Texts { children, .. } = self {
children.push(TextNode {
text: text.to_string(),
marks: vec![(0..text.len(), InlineTextStyle::default())],
});
}
self.children.push(TextNode {
text: text.to_string(),
image: None,
marks: vec![(0..text.len(), InlineTextStyle::default())],
});
}
pub fn push(&mut self, text: TextNode) {
if let Self::Texts { children, .. } = self {
children.push(text);
}
self.children.push(text);
}
pub fn set_image(&mut self, image: ImageNode) {
*self = Self::Image { span: None, image };
pub fn push_image(&mut self, image: ImageNode) {
self.children.push(TextNode {
text: String::new(),
image: Some(image),
marks: vec![],
});
}
pub fn is_empty(&self) -> bool {
match self {
Self::Texts { .. } => self.text_len() == 0,
Self::Image { .. } => false,
}
self.children.is_empty()
|| self
.children
.iter()
.all(|node| node.text.is_empty() && node.image.is_none())
}
/// Return length of children text.
pub fn text_len(&self) -> usize {
match self {
Self::Texts { children, .. } => {
let mut len = 0;
for text_node in children.iter() {
len = text_node.text.len().max(len);
}
len
}
Self::Image { .. } => 1,
}
self.children
.iter()
.map(|node| node.text.len())
.sum::<usize>()
}
/// Try to merge two paragraphs, if they are both text elements.
///
/// - Returns `true` if other have merge into self.
/// - Returns `false` if not able to merge.
pub fn try_merge(&mut self, other: &Self) -> bool {
if let Self::Texts { children, .. } = self {
if let Self::Texts {
children: other_children,
..
} = other
{
children.extend(other_children.clone());
return true;
}
}
false
pub fn merge(&mut self, other: &Self) {
self.children.extend(other.children.clone());
}
}
@ -307,92 +287,132 @@ impl Node {
impl RenderOnce for Paragraph {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
match self {
Self::Texts { span, children } => {
let mut text = String::new();
let mut highlights: Vec<(Range<usize>, HighlightStyle)> = vec![];
let mut links: Vec<(Range<usize>, LinkMark)> = vec![];
let mut offset = 0;
let span = self.span;
let children = self.children;
for text_node in children.into_iter() {
let text_len = text_node.text.len();
let part = if text.len() == 0 {
// trim start for first text
text_node.text.trim_start()
} else {
text_node.text.as_str()
};
text.push_str(&part);
let mut child_nodes: Vec<AnyElement> = vec![];
let mut node_highlights = vec![];
for (range, style) in text_node.marks {
let inner_range = (offset + range.start)..(offset + range.end);
let mut text = String::new();
let mut highlights: Vec<(Range<usize>, HighlightStyle)> = vec![];
let mut links: Vec<(Range<usize>, LinkMark)> = vec![];
let mut offset = 0;
let mut highlight = HighlightStyle::default();
if style.bold {
highlight.font_weight = Some(FontWeight::BOLD);
}
if style.italic {
highlight.font_style = Some(FontStyle::Italic);
}
if style.strikethrough {
highlight.strikethrough = Some(gpui::StrikethroughStyle {
thickness: gpui::px(1.),
..Default::default()
});
}
if style.code {
highlight.background_color = Some(cx.theme().accent);
fn inline_text(
ix: usize,
text: String,
links: Vec<(Range<usize>, LinkMark)>,
highlights: Vec<(Range<usize>, HighlightStyle)>,
window: &Window,
) -> AnyElement {
let text_style = window.text_style();
let styled_text =
StyledText::new(text).with_default_highlights(&text_style, highlights);
let link_ranges = links
.iter()
.map(|(range, _)| range.clone())
.collect::<Vec<_>>();
InteractiveText::new(ix, styled_text)
.on_click(link_ranges, {
let links = links.clone();
move |ix, _, cx| {
if let Some((_, link)) = &links.get(ix) {
// Stop propagation to prevent the parent element from handling the event.
//
// For example the text in a checkbox label, click link need avoid toggle check state.
cx.stop_propagation();
cx.open_url(&link.url);
}
}
})
.into_any_element()
}
let mut ix = 0;
for text_node in children.into_iter() {
let text_len = text_node.text.len();
text.push_str(&text_node.text);
if let Some(link_mark) = style.link {
highlight.color = Some(cx.theme().link);
highlight.underline = Some(gpui::UnderlineStyle {
thickness: gpui::px(1.),
..Default::default()
});
if let Some(image) = &text_node.image {
if text.len() > 0 {
child_nodes.push(inline_text(
ix,
text.clone(),
links.clone(),
highlights.clone(),
window,
));
}
child_nodes.push(
img(image.url.clone())
.id(ix)
.object_fit(ObjectFit::Contain)
.max_w(relative(1.))
.when_some(image.width, |this, width| this.w(width))
.when_some(image.link.clone(), |this, link| {
let title = image.title();
this.cursor_pointer()
.tooltip(move |window, cx| {
Tooltip::new(title.clone()).build(window, cx)
})
.on_click(move |_, _, cx| {
cx.stop_propagation();
cx.open_url(&link.url);
})
})
.into_any_element(),
);
links.push((inner_range.clone(), link_mark));
}
text.clear();
links.clear();
highlights.clear();
offset = 0;
} else {
let mut node_highlights = vec![];
for (range, style) in text_node.marks {
let inner_range = (offset + range.start)..(offset + range.end);
node_highlights.push((inner_range, highlight));
let mut highlight = HighlightStyle::default();
if style.bold {
highlight.font_weight = Some(FontWeight::BOLD);
}
if style.italic {
highlight.font_style = Some(FontStyle::Italic);
}
if style.strikethrough {
highlight.strikethrough = Some(gpui::StrikethroughStyle {
thickness: gpui::px(1.),
..Default::default()
});
}
if style.code {
highlight.background_color = Some(cx.theme().accent);
}
highlights = gpui::combine_highlights(highlights, node_highlights).collect();
if let Some(link_mark) = style.link {
highlight.color = Some(cx.theme().link);
highlight.underline = Some(gpui::UnderlineStyle {
thickness: gpui::px(1.),
..Default::default()
});
offset += text_len;
links.push((inner_range.clone(), link_mark));
}
node_highlights.push((inner_range, highlight));
}
let text_style = window.text_style();
let element_id: ElementId = span.unwrap_or_default().into();
let styled_text =
StyledText::new(text).with_default_highlights(&text_style, highlights);
let link_ranges = links
.iter()
.map(|(range, _)| range.clone())
.collect::<Vec<_>>();
InteractiveText::new(element_id, styled_text)
.on_click(link_ranges, {
let links = links.clone();
move |ix, _, cx| {
if let Some((_, link)) = &links.get(ix) {
// Stop propagation to prevent the parent element from handling the event.
//
// For example the text in a checkbox label, click link need avoid toggle check state.
cx.stop_propagation();
cx.open_url(&link.url);
}
}
})
.into_any_element()
highlights = gpui::combine_highlights(highlights, node_highlights).collect();
offset += text_len;
}
Self::Image { image, .. } => img(image.url)
.object_fit(ObjectFit::Contain)
.max_w(relative(1.))
.when_some(image.width, |this, width| this.w(width))
.into_any_element(),
ix += 1;
}
if text.len() > 0 {
// Add the last text node
child_nodes.push(inline_text(ix, text, links, highlights, window));
}
div().id(span.unwrap_or_default()).children(child_nodes)
}
}
@ -733,41 +753,42 @@ impl Node {
impl Paragraph {
fn to_markdown(&self) -> String {
let mut text = match self {
Paragraph::Texts { children, .. } => children
.iter()
.map(|text_node| {
let mut text = text_node.text.clone();
for (range, style) in &text_node.marks {
if style.bold {
text = format!("**{}**", &text_node.text[range.clone()]);
}
if style.italic {
text = format!("*{}*", &text_node.text[range.clone()]);
}
if style.strikethrough {
text = format!("~~{}~~", &text_node.text[range.clone()]);
}
if style.code {
text = format!("`{}`", &text_node.text[range.clone()]);
}
if let Some(link) = &style.link {
text = format!("[{}]({})", &text_node.text[range.clone()], link.url);
}
let mut text = self
.children
.iter()
.map(|text_node| {
let mut text = text_node.text.clone();
for (range, style) in &text_node.marks {
if style.bold {
text = format!("**{}**", &text_node.text[range.clone()]);
}
text
})
.collect::<Vec<_>>()
.join(""),
Paragraph::Image { image, .. } => {
let alt = image.alt.clone().unwrap_or_default();
let title = image
.title
.clone()
.map_or(String::new(), |t| format!(" \"{}\"", t));
format!("![{}]({}{})", alt, image.url, title)
}
};
if style.italic {
text = format!("*{}*", &text_node.text[range.clone()]);
}
if style.strikethrough {
text = format!("~~{}~~", &text_node.text[range.clone()]);
}
if style.code {
text = format!("`{}`", &text_node.text[range.clone()]);
}
if let Some(link) = &style.link {
text = format!("[{}]({})", &text_node.text[range.clone()], link.url);
}
}
if let Some(image) = &text_node.image {
let alt = image.alt.clone().unwrap_or_default();
let title = image
.title
.clone()
.map_or(String::new(), |t| format!(" \"{}\"", t));
text.push_str(&format!("![{}]({}{})", alt, image.url, title))
}
text
})
.collect::<Vec<_>>()
.join("");
text.push_str("\n\n");
text

View file

@ -416,6 +416,7 @@ fn parse_paragraph(
));
paragraph.push(element::TextNode {
text: text.clone(),
image: None,
marks: marks.clone(),
});
}
@ -434,6 +435,7 @@ fn parse_paragraph(
));
paragraph.push(TextNode {
text: text.clone(),
image: None,
marks: marks.clone(),
});
}
@ -452,6 +454,7 @@ fn parse_paragraph(
));
paragraph.push(TextNode {
text: text.clone(),
image: None,
marks: marks.clone(),
});
}
@ -470,6 +473,7 @@ fn parse_paragraph(
));
paragraph.push(TextNode {
text: text.clone(),
image: None,
marks: marks.clone(),
});
}
@ -494,6 +498,7 @@ fn parse_paragraph(
));
paragraph.push(TextNode {
text: text.clone(),
image: None,
marks: marks.clone(),
});
}
@ -509,8 +514,9 @@ fn parse_paragraph(
let title = attr_value(attrs, local_name!("title"));
let (width, height) = attr_width_height(attrs);
paragraph.set_image(ImageNode {
paragraph.push_image(ImageNode {
url: src.into(),
link: None,
alt: alt.map(Into::into),
width,
height,
@ -526,6 +532,7 @@ fn parse_paragraph(
}
paragraph.push(element::TextNode {
text: text.clone(),
image: None,
marks: marks.clone(),
});
}
@ -538,6 +545,7 @@ fn parse_paragraph(
}
paragraph.push(TextNode {
text: text.clone(),
image: None,
marks: marks.clone(),
});
}
@ -611,22 +619,21 @@ fn parse_node(node: &Rc<Node>, paragraph: &mut Paragraph) -> Option<element::Nod
let title = attr_value(&attrs, local_name!("title"));
let (width, height) = attr_width_height(&attrs);
let image = Paragraph::Image {
span: None,
image: ImageNode {
url: src.into(),
title: title.map(Into::into),
alt: alt.map(Into::into),
width,
height,
},
};
let mut paragraph = Paragraph::default();
paragraph.push_image(ImageNode {
url: src.into(),
link: None,
title: title.map(Into::into),
alt: alt.map(Into::into),
width,
height,
});
if children.len() > 0 {
children.push(element::Node::Paragraph(image));
children.push(element::Node::Paragraph(paragraph));
Some(element::Node::Root { children })
} else {
Some(element::Node::Paragraph(image))
Some(element::Node::Paragraph(paragraph))
}
}
local_name!("ul") | local_name!("ol") => {
@ -647,9 +654,8 @@ fn parse_node(node: &Rc<Node>, paragraph: &mut Paragraph) -> Option<element::Nod
// If last child is paragraph, merge child
if let Some(last_child) = children.last_mut() {
if let element::Node::Paragraph(last_paragraph) = last_child {
if last_paragraph.try_merge(&child_paragraph) {
continue;
};
last_paragraph.merge(&child_paragraph);
continue;
}
}
@ -774,7 +780,7 @@ fn consume_paragraph(children: &mut Vec<element::Node>, paragraph: &mut Paragrap
mod tests {
use gpui::{px, relative};
use crate::text::element::{Node, Paragraph};
use crate::text::element::{Node, Paragraph, TextNode};
use super::trim_text;
@ -860,15 +866,20 @@ mod tests {
let node = super::parse_html(html).unwrap();
assert_eq!(
node,
Node::Paragraph(Paragraph::Image {
Node::Paragraph(Paragraph {
span: None,
image: super::ImageNode {
url: "https://example.com/image.png".to_string().into(),
alt: Some("Example".to_string().into()),
width: Some(px(100.).into()),
height: Some(px(200.).into()),
title: Some("Example Image".to_string().into())
}
children: vec![TextNode {
text: String::new(),
marks: vec![],
image: Some(super::ImageNode {
url: "https://example.com/image.png".to_string().into(),
alt: Some("Example".to_string().into()),
width: Some(px(100.).into()),
height: Some(px(200.).into()),
title: Some("Example Image".to_string().into()),
..Default::default()
}),
}],
})
);
@ -876,15 +887,20 @@ mod tests {
let node = super::parse_html(html).unwrap();
assert_eq!(
node,
Node::Paragraph(Paragraph::Image {
Node::Paragraph(Paragraph {
span: None,
image: super::ImageNode {
url: "https://example.com/image.png".to_string().into(),
alt: Some("Example".to_string().into()),
width: Some(relative(0.8)),
height: None,
title: Some("Example Image".to_string().into())
}
children: vec![TextNode {
text: String::new(),
marks: vec![],
image: Some(super::ImageNode {
url: "https://example.com/image.png".to_string().into(),
alt: Some("Example".to_string().into()),
width: Some(relative(0.8)),
height: None,
title: Some("Example Image".to_string().into()),
..Default::default()
}),
}],
})
);
}

View file

@ -230,6 +230,7 @@ fn parse_paragraph(paragraph: &mut Paragraph, node: &mdast::Node) -> String {
}
paragraph.push(element::TextNode {
text: text.clone(),
image: None,
marks: vec![(
0..text.len(),
InlineTextStyle {
@ -246,6 +247,7 @@ fn parse_paragraph(paragraph: &mut Paragraph, node: &mdast::Node) -> String {
}
paragraph.push(element::TextNode {
text: text.clone(),
image: None,
marks: vec![(
0..text.len(),
InlineTextStyle {
@ -262,6 +264,7 @@ fn parse_paragraph(paragraph: &mut Paragraph, node: &mdast::Node) -> String {
}
paragraph.push(element::TextNode {
text: text.clone(),
image: None,
marks: vec![(
0..text.len(),
InlineTextStyle {
@ -275,6 +278,7 @@ fn parse_paragraph(paragraph: &mut Paragraph, node: &mdast::Node) -> String {
text = val.value.clone();
paragraph.push(element::TextNode {
text: text.clone(),
image: None,
marks: vec![(
0..text.len(),
InlineTextStyle {
@ -285,26 +289,36 @@ fn parse_paragraph(paragraph: &mut Paragraph, node: &mdast::Node) -> String {
});
}
Node::Link(val) => {
let link_mark = Some(LinkMark {
url: val.url.clone().into(),
title: val.title.clone().map(|s| s.into()),
});
let mut child_paragraph = Paragraph::default();
for child in val.children.iter() {
text.push_str(&parse_paragraph(&mut child_paragraph, &child));
}
paragraph.push(element::TextNode {
text: text.clone(),
marks: vec![(
0..text.len(),
// FIXME: GPUI InteractiveText does not support inline images yet.
// So here we push images to the paragraph directly.
for child in child_paragraph.children.iter_mut() {
if let Some(image) = child.image.as_mut() {
image.link = link_mark.clone();
}
child.marks.push((
0..child.text.len(),
InlineTextStyle {
link: Some(LinkMark {
url: val.url.clone().into(),
title: val.title.clone().map(|s| s.into()),
}),
link: link_mark.clone(),
..Default::default()
},
)],
});
));
}
paragraph.merge(&child_paragraph);
}
Node::Image(raw) => {
paragraph.set_image(ImageNode {
paragraph.push_image(ImageNode {
url: raw.url.clone().into(),
title: raw.title.clone().map(|t| t.into()),
alt: Some(raw.alt.clone().into()),
@ -315,6 +329,7 @@ fn parse_paragraph(paragraph: &mut Paragraph, node: &mdast::Node) -> String {
text = raw.value.clone();
paragraph.push(element::TextNode {
text: text.clone(),
image: None,
marks: vec![(
0..text.len(),
InlineTextStyle {
@ -328,6 +343,7 @@ fn parse_paragraph(paragraph: &mut Paragraph, node: &mdast::Node) -> String {
text = raw.value.clone();
paragraph.push(element::TextNode {
text: text.clone(),
image: None,
marks: vec![(0..text.len(), InlineTextStyle::default())],
});
}
@ -337,6 +353,7 @@ fn parse_paragraph(paragraph: &mut Paragraph, node: &mdast::Node) -> String {
text = "\n".to_owned();
paragraph.push(element::TextNode {
text: text.clone(),
image: None,
marks: vec![(0..text.len(), InlineTextStyle::default())],
});
} else {