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**! # 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 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. 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, img, prelude::FluentBuilder as _, px, relative, rems, AnyElement, App, DefiniteLength,
Div, ElementId, FontStyle, FontWeight, Half, HighlightStyle, InteractiveElement as _, Div, ElementId, FontStyle, FontWeight, Half, HighlightStyle, InteractiveElement as _,
InteractiveText, IntoElement, Length, ObjectFit, ParentElement, Rems, RenderOnce, SharedString, 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 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}; use super::{utils::list_item_prefix, TextViewStyle};
@ -44,12 +47,22 @@ impl From<Span> for ElementId {
#[derive(Debug, Default, Clone)] #[derive(Debug, Default, Clone)]
pub struct ImageNode { pub struct ImageNode {
pub url: SharedUri, pub url: SharedUri,
pub link: Option<LinkMark>,
pub title: Option<SharedString>, pub title: Option<SharedString>,
pub alt: Option<SharedString>, pub alt: Option<SharedString>,
pub width: Option<DefiniteLength>, pub width: Option<DefiniteLength>,
pub height: 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 { impl PartialEq for ImageNode {
fn eq(&self, other: &Self) -> bool { fn eq(&self, other: &Self) -> bool {
self.url == other.url && self.title == other.title && self.alt == other.alt self.url == other.url && self.title == other.title && self.alt == other.alt
@ -60,37 +73,24 @@ impl PartialEq for ImageNode {
pub struct TextNode { pub struct TextNode {
/// The text content. /// The text content.
pub text: String, pub text: String,
pub image: Option<ImageNode>,
/// The text styles, each tuple contains the range of the text and the style. /// The text styles, each tuple contains the range of the text and the style.
pub marks: Vec<(Range<usize>, InlineTextStyle)>, pub marks: Vec<(Range<usize>, InlineTextStyle)>,
} }
#[derive(Debug, Clone, PartialEq, IntoElement)] #[derive(Debug, Default, Clone, PartialEq, IntoElement)]
pub enum Paragraph { pub struct Paragraph {
Texts { pub(super) span: Option<Span>,
span: Option<Span>, pub(super) children: Vec<TextNode>,
children: Vec<TextNode>,
},
Image {
span: Option<Span>,
image: ImageNode,
},
}
impl Default for Paragraph {
fn default() -> Self {
Self::Texts {
span: None,
children: vec![],
}
}
} }
impl From<String> for Paragraph { impl From<String> for Paragraph {
fn from(value: String) -> Self { fn from(value: String) -> Self {
Self::Texts { Self {
span: None, span: None,
children: vec![TextNode { children: vec![TextNode {
text: value.clone(), text: value.clone(),
image: None,
marks: vec![], marks: vec![],
}], }],
} }
@ -141,80 +141,60 @@ pub struct TableCell {
impl Paragraph { impl Paragraph {
pub fn clear(&mut self) { pub fn clear(&mut self) {
match self { self.span = None;
Self::Texts { children, .. } => children.clear(), self.children.clear();
Self::Image { .. } => *self = Self::default(),
}
} }
pub fn is_image(&self) -> bool { pub fn is_image(&self) -> bool {
matches!(self, Self::Image { .. }) false
} }
pub fn set_span(&mut self, span: Span) { pub fn set_span(&mut self, span: Span) {
match self { self.span = Some(span);
Self::Texts { span: s, .. } => *s = Some(span),
Self::Image { span: s, .. } => *s = Some(span),
}
} }
pub fn push_str(&mut self, text: &str) { pub fn push_str(&mut self, text: &str) {
if let Self::Texts { children, .. } = self { self.children.push(TextNode {
children.push(TextNode {
text: text.to_string(), text: text.to_string(),
image: None,
marks: vec![(0..text.len(), InlineTextStyle::default())], marks: vec![(0..text.len(), InlineTextStyle::default())],
}); });
} }
}
pub fn push(&mut self, text: TextNode) { pub fn push(&mut self, text: TextNode) {
if let Self::Texts { children, .. } = self { self.children.push(text);
children.push(text);
}
} }
pub fn set_image(&mut self, image: ImageNode) { pub fn push_image(&mut self, image: ImageNode) {
*self = Self::Image { span: None, image }; self.children.push(TextNode {
text: String::new(),
image: Some(image),
marks: vec![],
});
} }
pub fn is_empty(&self) -> bool { pub fn is_empty(&self) -> bool {
match self { self.children.is_empty()
Self::Texts { .. } => self.text_len() == 0, || self
Self::Image { .. } => false, .children
} .iter()
.all(|node| node.text.is_empty() && node.image.is_none())
} }
/// Return length of children text. /// Return length of children text.
pub fn text_len(&self) -> usize { pub fn text_len(&self) -> usize {
match self { self.children
Self::Texts { children, .. } => { .iter()
let mut len = 0; .map(|node| node.text.len())
for text_node in children.iter() { .sum::<usize>()
len = text_node.text.len().max(len);
}
len
}
Self::Image { .. } => 1,
}
} }
/// Try to merge two paragraphs, if they are both text elements. /// Try to merge two paragraphs, if they are both text elements.
/// ///
/// - Returns `true` if other have merge into self. /// - Returns `true` if other have merge into self.
/// - Returns `false` if not able to merge. /// - Returns `false` if not able to merge.
pub fn try_merge(&mut self, other: &Self) -> bool { pub fn merge(&mut self, other: &Self) {
if let Self::Texts { children, .. } = self { self.children.extend(other.children.clone());
if let Self::Texts {
children: other_children,
..
} = other
{
children.extend(other_children.clone());
return true;
}
}
false
} }
} }
@ -307,23 +287,86 @@ impl Node {
impl RenderOnce for Paragraph { impl RenderOnce for Paragraph {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
match self { let span = self.span;
Self::Texts { span, children } => { let children = self.children;
let mut child_nodes: Vec<AnyElement> = vec![];
let mut text = String::new(); let mut text = String::new();
let mut highlights: Vec<(Range<usize>, HighlightStyle)> = vec![]; let mut highlights: Vec<(Range<usize>, HighlightStyle)> = vec![];
let mut links: Vec<(Range<usize>, LinkMark)> = vec![]; let mut links: Vec<(Range<usize>, LinkMark)> = vec![];
let mut offset = 0; let mut offset = 0;
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() { for text_node in children.into_iter() {
let text_len = text_node.text.len(); let text_len = text_node.text.len();
let part = if text.len() == 0 { text.push_str(&text_node.text);
// trim start for first text
text_node.text.trim_start()
} else {
text_node.text.as_str()
};
text.push_str(&part);
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(),
);
text.clear();
links.clear();
highlights.clear();
offset = 0;
} else {
let mut node_highlights = vec![]; let mut node_highlights = vec![];
for (range, style) in text_node.marks { for (range, style) in text_node.marks {
let inner_range = (offset + range.start)..(offset + range.end); let inner_range = (offset + range.start)..(offset + range.end);
@ -359,40 +402,17 @@ impl RenderOnce for Paragraph {
} }
highlights = gpui::combine_highlights(highlights, node_highlights).collect(); highlights = gpui::combine_highlights(highlights, node_highlights).collect();
offset += text_len; offset += text_len;
} }
ix += 1;
}
let text_style = window.text_style(); if text.len() > 0 {
let element_id: ElementId = span.unwrap_or_default().into(); // Add the last text node
let styled_text = child_nodes.push(inline_text(ix, text, links, highlights, window));
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) div().id(span.unwrap_or_default()).children(child_nodes)
.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()
}
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(),
}
} }
} }
@ -733,8 +753,8 @@ impl Node {
impl Paragraph { impl Paragraph {
fn to_markdown(&self) -> String { fn to_markdown(&self) -> String {
let mut text = match self { let mut text = self
Paragraph::Texts { children, .. } => children .children
.iter() .iter()
.map(|text_node| { .map(|text_node| {
let mut text = text_node.text.clone(); let mut text = text_node.text.clone();
@ -755,19 +775,20 @@ impl Paragraph {
text = format!("[{}]({})", &text_node.text[range.clone()], link.url); text = format!("[{}]({})", &text_node.text[range.clone()], link.url);
} }
} }
text
}) if let Some(image) = &text_node.image {
.collect::<Vec<_>>()
.join(""),
Paragraph::Image { image, .. } => {
let alt = image.alt.clone().unwrap_or_default(); let alt = image.alt.clone().unwrap_or_default();
let title = image let title = image
.title .title
.clone() .clone()
.map_or(String::new(), |t| format!(" \"{}\"", t)); .map_or(String::new(), |t| format!(" \"{}\"", t));
format!("![{}]({}{})", alt, image.url, title) text.push_str(&format!("![{}]({}{})", alt, image.url, title))
} }
};
text
})
.collect::<Vec<_>>()
.join("");
text.push_str("\n\n"); text.push_str("\n\n");
text text

View file

@ -416,6 +416,7 @@ fn parse_paragraph(
)); ));
paragraph.push(element::TextNode { paragraph.push(element::TextNode {
text: text.clone(), text: text.clone(),
image: None,
marks: marks.clone(), marks: marks.clone(),
}); });
} }
@ -434,6 +435,7 @@ fn parse_paragraph(
)); ));
paragraph.push(TextNode { paragraph.push(TextNode {
text: text.clone(), text: text.clone(),
image: None,
marks: marks.clone(), marks: marks.clone(),
}); });
} }
@ -452,6 +454,7 @@ fn parse_paragraph(
)); ));
paragraph.push(TextNode { paragraph.push(TextNode {
text: text.clone(), text: text.clone(),
image: None,
marks: marks.clone(), marks: marks.clone(),
}); });
} }
@ -470,6 +473,7 @@ fn parse_paragraph(
)); ));
paragraph.push(TextNode { paragraph.push(TextNode {
text: text.clone(), text: text.clone(),
image: None,
marks: marks.clone(), marks: marks.clone(),
}); });
} }
@ -494,6 +498,7 @@ fn parse_paragraph(
)); ));
paragraph.push(TextNode { paragraph.push(TextNode {
text: text.clone(), text: text.clone(),
image: None,
marks: marks.clone(), marks: marks.clone(),
}); });
} }
@ -509,8 +514,9 @@ fn parse_paragraph(
let title = attr_value(attrs, local_name!("title")); let title = attr_value(attrs, local_name!("title"));
let (width, height) = attr_width_height(attrs); let (width, height) = attr_width_height(attrs);
paragraph.set_image(ImageNode { paragraph.push_image(ImageNode {
url: src.into(), url: src.into(),
link: None,
alt: alt.map(Into::into), alt: alt.map(Into::into),
width, width,
height, height,
@ -526,6 +532,7 @@ fn parse_paragraph(
} }
paragraph.push(element::TextNode { paragraph.push(element::TextNode {
text: text.clone(), text: text.clone(),
image: None,
marks: marks.clone(), marks: marks.clone(),
}); });
} }
@ -538,6 +545,7 @@ fn parse_paragraph(
} }
paragraph.push(TextNode { paragraph.push(TextNode {
text: text.clone(), text: text.clone(),
image: None,
marks: marks.clone(), 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 title = attr_value(&attrs, local_name!("title"));
let (width, height) = attr_width_height(&attrs); let (width, height) = attr_width_height(&attrs);
let image = Paragraph::Image { let mut paragraph = Paragraph::default();
span: None, paragraph.push_image(ImageNode {
image: ImageNode {
url: src.into(), url: src.into(),
link: None,
title: title.map(Into::into), title: title.map(Into::into),
alt: alt.map(Into::into), alt: alt.map(Into::into),
width, width,
height, height,
}, });
};
if children.len() > 0 { if children.len() > 0 {
children.push(element::Node::Paragraph(image)); children.push(element::Node::Paragraph(paragraph));
Some(element::Node::Root { children }) Some(element::Node::Root { children })
} else { } else {
Some(element::Node::Paragraph(image)) Some(element::Node::Paragraph(paragraph))
} }
} }
local_name!("ul") | local_name!("ol") => { 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 last child is paragraph, merge child
if let Some(last_child) = children.last_mut() { if let Some(last_child) = children.last_mut() {
if let element::Node::Paragraph(last_paragraph) = last_child { if let element::Node::Paragraph(last_paragraph) = last_child {
if last_paragraph.try_merge(&child_paragraph) { last_paragraph.merge(&child_paragraph);
continue; continue;
};
} }
} }
@ -774,7 +780,7 @@ fn consume_paragraph(children: &mut Vec<element::Node>, paragraph: &mut Paragrap
mod tests { mod tests {
use gpui::{px, relative}; use gpui::{px, relative};
use crate::text::element::{Node, Paragraph}; use crate::text::element::{Node, Paragraph, TextNode};
use super::trim_text; use super::trim_text;
@ -860,15 +866,20 @@ mod tests {
let node = super::parse_html(html).unwrap(); let node = super::parse_html(html).unwrap();
assert_eq!( assert_eq!(
node, node,
Node::Paragraph(Paragraph::Image { Node::Paragraph(Paragraph {
span: None, span: None,
image: super::ImageNode { children: vec![TextNode {
text: String::new(),
marks: vec![],
image: Some(super::ImageNode {
url: "https://example.com/image.png".to_string().into(), url: "https://example.com/image.png".to_string().into(),
alt: Some("Example".to_string().into()), alt: Some("Example".to_string().into()),
width: Some(px(100.).into()), width: Some(px(100.).into()),
height: Some(px(200.).into()), height: Some(px(200.).into()),
title: Some("Example Image".to_string().into()) title: Some("Example Image".to_string().into()),
} ..Default::default()
}),
}],
}) })
); );
@ -876,15 +887,20 @@ mod tests {
let node = super::parse_html(html).unwrap(); let node = super::parse_html(html).unwrap();
assert_eq!( assert_eq!(
node, node,
Node::Paragraph(Paragraph::Image { Node::Paragraph(Paragraph {
span: None, span: None,
image: super::ImageNode { children: vec![TextNode {
text: String::new(),
marks: vec![],
image: Some(super::ImageNode {
url: "https://example.com/image.png".to_string().into(), url: "https://example.com/image.png".to_string().into(),
alt: Some("Example".to_string().into()), alt: Some("Example".to_string().into()),
width: Some(relative(0.8)), width: Some(relative(0.8)),
height: None, height: None,
title: Some("Example Image".to_string().into()) 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 { paragraph.push(element::TextNode {
text: text.clone(), text: text.clone(),
image: None,
marks: vec![( marks: vec![(
0..text.len(), 0..text.len(),
InlineTextStyle { InlineTextStyle {
@ -246,6 +247,7 @@ fn parse_paragraph(paragraph: &mut Paragraph, node: &mdast::Node) -> String {
} }
paragraph.push(element::TextNode { paragraph.push(element::TextNode {
text: text.clone(), text: text.clone(),
image: None,
marks: vec![( marks: vec![(
0..text.len(), 0..text.len(),
InlineTextStyle { InlineTextStyle {
@ -262,6 +264,7 @@ fn parse_paragraph(paragraph: &mut Paragraph, node: &mdast::Node) -> String {
} }
paragraph.push(element::TextNode { paragraph.push(element::TextNode {
text: text.clone(), text: text.clone(),
image: None,
marks: vec![( marks: vec![(
0..text.len(), 0..text.len(),
InlineTextStyle { InlineTextStyle {
@ -275,6 +278,7 @@ fn parse_paragraph(paragraph: &mut Paragraph, node: &mdast::Node) -> String {
text = val.value.clone(); text = val.value.clone();
paragraph.push(element::TextNode { paragraph.push(element::TextNode {
text: text.clone(), text: text.clone(),
image: None,
marks: vec![( marks: vec![(
0..text.len(), 0..text.len(),
InlineTextStyle { InlineTextStyle {
@ -285,26 +289,36 @@ fn parse_paragraph(paragraph: &mut Paragraph, node: &mdast::Node) -> String {
}); });
} }
Node::Link(val) => { 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(); let mut child_paragraph = Paragraph::default();
for child in val.children.iter() { for child in val.children.iter() {
text.push_str(&parse_paragraph(&mut child_paragraph, &child)); text.push_str(&parse_paragraph(&mut child_paragraph, &child));
} }
paragraph.push(element::TextNode {
text: text.clone(), // FIXME: GPUI InteractiveText does not support inline images yet.
marks: vec![( // So here we push images to the paragraph directly.
0..text.len(), 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 { InlineTextStyle {
link: Some(LinkMark { link: link_mark.clone(),
url: val.url.clone().into(),
title: val.title.clone().map(|s| s.into()),
}),
..Default::default() ..Default::default()
}, },
)], ));
}); }
paragraph.merge(&child_paragraph);
} }
Node::Image(raw) => { Node::Image(raw) => {
paragraph.set_image(ImageNode { paragraph.push_image(ImageNode {
url: raw.url.clone().into(), url: raw.url.clone().into(),
title: raw.title.clone().map(|t| t.into()), title: raw.title.clone().map(|t| t.into()),
alt: Some(raw.alt.clone().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(); text = raw.value.clone();
paragraph.push(element::TextNode { paragraph.push(element::TextNode {
text: text.clone(), text: text.clone(),
image: None,
marks: vec![( marks: vec![(
0..text.len(), 0..text.len(),
InlineTextStyle { InlineTextStyle {
@ -328,6 +343,7 @@ fn parse_paragraph(paragraph: &mut Paragraph, node: &mdast::Node) -> String {
text = raw.value.clone(); text = raw.value.clone();
paragraph.push(element::TextNode { paragraph.push(element::TextNode {
text: text.clone(), text: text.clone(),
image: None,
marks: vec![(0..text.len(), InlineTextStyle::default())], 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(); text = "\n".to_owned();
paragraph.push(element::TextNode { paragraph.push(element::TextNode {
text: text.clone(), text: text.clone(),
image: None,
marks: vec![(0..text.len(), InlineTextStyle::default())], marks: vec![(0..text.len(), InlineTextStyle::default())],
}); });
} else { } else {