TextView
TextView renders formatted text in GPUI. It supports Markdown and simple HTML, text selection, code block actions, and custom Markdown plugins for project-specific syntax.
The canonical implementation now lives in gpui-base; this module remains a compatibility re-export and provides component-theme adaptation. Base-only setup, complete default styling, and opt-in syntax highlighting are documented on GPUI Base TextView.
TextView is selectable by default and uses the shared window selection engine from gpui-base. Use .selectable(false) only when selection must be disabled. See GPUI Base Text Selection when integrating plain text or a custom renderer with the same selection.
#Import
use gpui_kit::component::text::{markdown, TextView};
#Usage
#Markdown
Use the markdown helper when you only need to render Markdown text:
use gpui_kit::component::text::markdown;
markdown("# Hello\n\nThis is **Markdown**.")
.scrollable(true)
You can also construct a TextView directly when you need a stable id:
use gpui_kit::component::text::TextView;
TextView::markdown("preview", markdown_source)
#HTML
TextView::html("html-preview", "<strong>Hello</strong>")
#Clamp to a number of lines
Use max_lines to render a bounded preview of rich content — for example a
collapsed “show more” section. The view’s height is capped at n × the base
line height, and a line of glyphs is never cut in half: a line that would
straddle the bottom of the box is left out whole, across paragraphs, lists,
headings, code blocks and tables:
TextView::markdown("preview", markdown_source).max_lines(5)
Nothing is shown with less than a line of itself to show, so the border and padding a table row leads with never strands at the bottom. Whatever has more than that is cut on the box edge and keeps the part that fits, so an image crossing the edge shows instead of disappearing and leaving blank space behind.
TextViewState::is_clamped() reports whether the previous painted frame
actually clipped content, so the caller can decide whether to render an
“expand” affordance. n counts lines of body text, so paragraph spacing and
taller lines mean fewer of them fit inside the capped height, and a line taller
than the whole budget keeps the part that fits rather than emptying the box.
max_lines only applies to the fit-content mode and is ignored when
scrollable is set.
#Fade in streamed text
A chat reply arrives in chunks. stream_fade(true) fades each chunk in where
it lands instead of popping it onto the screen, the way Claude reveals a
response:
TextView::new(&self.reply).stream_fade(true)
The fade follows the rendered text. Whatever a push_str, or a set_text
whose text extends the current one, adds starts transparent and reaches full
color over 350 ms on an ease-out curve, the timing measured from Claude:
longer than the 50–300 ms a model’s chunks arrive at, so consecutive chunks
overlap into one gradient tail rather than the newest chunk blinking in. Code
in fenced blocks and text in table cells fade the same way. Markdown that
completes as it streams (**bo becoming bold bold) fades the changed
glyphs rather than the whole paragraph. Text that replaces the current
content shows at once, and so does everything when the system asks for
reduced motion. Nothing animates unless the view opts in.
Pass a TextViewMotion through .motion(...) to choose the duration or
easing yourself, or to reveal each chunk word by word; see
GPUI Base TextView.
#Touch Selection
On a touch screen, a long press selects the word under the finger and keeps
following the finger while it stays down. Lifting it opens an edit menu with
Copy and Select All over the selection and puts a grab handle at each end.
Dragging a handle moves that end while the other stays put; Select All
selects the view that was pressed, and its handles keep working on the result.
The handles and the menu are drawn by Root for the whole
window selection, so they cover a selection that spans several views. A tap
elsewhere clears them, and the menu steps aside while the content scrolls
under a finger.
#Link Click Handling
Use on_link_click when links should be routed by the application instead of
being opened directly by App::open_url. The callback receives the resolved
URL and the original GPUI ClickEvent, so it can distinguish mouse buttons,
keyboard activation, touch, and modifier keys:
use gpui_kit::ClickEvent;
use gpui_kit::component::text::markdown;
markdown("[Open the project](https://github.com/longbridge/gpui-kit)")
.on_link_click(|url, event, _window, cx| {
if event.is_right_click() {
println!("Show a context menu for {url}");
return;
}
match event {
ClickEvent::Mouse(click) if click.up.modifiers.control => {
println!("Open {url} in an internal view");
}
_ => cx.open_url(url),
}
})
Installing a handler consumes the link event and disables the default URL
opening behavior. If no handler is installed, links continue to use
App::open_url as usual. The callback is used for both text links and linked
images.
#Images
A Markdown  or HTML <img src> renders through GPUI’s img
element, and src decides where the bytes come from:
http://andhttps://URLs are fetched with the application’s HTTP client.data:URLs are decoded in place, so a document can embed its own images (data:image/png;base64,…, or a percent-encodeddata:image/svg+xml,…). Any image format GPUI can decode is accepted; adata:URL with another media type is left to the loader and reports an error like any other unreachable image.- Every other value — a relative path,
file://, a custom scheme — is passed through as a URI.TextViewnever reads the filesystem or the asset bundle on a document’s behalf.
To resolve those other sources, or to change how any image is loaded, wrap the
TextView in an element that installs a GPUI ImageCache. Every img inside
it, including the ones the document produces, asks that cache for its
Resource before falling back to the default loader:
use gpui_kit::{ImageCache, ImageCacheProvider};
div()
.image_cache(app_image_cache.clone())
.child(markdown(""))
ImageCache::load receives the Resource::Uri and decides how to turn it into
a RenderImage, so the application owns the loading policy while the document
stays plain Markdown.
#Markdown Plugins
Use .plugin(...) to support custom Markdown formats. A plugin owns both parsing and rendering, so callers only need to attach it to the TextView:
markdown(source)
.plugin(TickerPlugin::new())
A Markdown plugin implements MarkdownPlugin:
use gpui_kit::{App, IntoElement, ParentElement as _, Window};
use gpui_kit::component::text::{
markdown_ast, MarkdownNode, MarkdownParseContext, MarkdownPlugin,
};
struct TickerNode {
symbol: String,
}
struct TickerPlugin;
impl TickerPlugin {
fn new() -> Self {
Self
}
}
impl MarkdownPlugin for TickerPlugin {
fn is_block(&self) -> bool {
true
}
fn name(&self) -> &str {
"ticker"
}
fn parse(
&self,
node: &markdown_ast::Node,
cx: &MarkdownParseContext<'_>,
) -> Option<MarkdownNode> {
let markdown_ast::Node::Paragraph(paragraph) = node else {
return None;
};
let [markdown_ast::Node::Text(text)] = paragraph.children.as_slice() else {
return None;
};
let symbol = text.value.strip_prefix('$')?;
Some(
MarkdownNode::new(
"ticker",
TickerNode {
symbol: symbol.to_string(),
},
)
.text(format!("${symbol}"))
.markdown(cx.node_source(node).unwrap_or(text.value.as_str())),
)
}
fn render(
&self,
node: &MarkdownNode,
_window: &mut Window,
_cx: &mut App,
) -> impl IntoElement {
let ticker = node.data::<TickerNode>().expect("ticker node data");
gpui_kit::div().child(format!("${}", ticker.symbol))
}
}
Then attach it to a Markdown TextView:
markdown("$AAPL.US")
.plugin(TickerPlugin::new())
#MarkdownNode
MarkdownNode is the neutral data passed between parse and render.
MarkdownNode::new("ticker", TickerNode { symbol })
.text("$AAPL.US")
.markdown("$AAPL.US")
nameis the stable node name used to match the renderer.datais typed parser output read withnode.data::<T>().textis the plain text representation used by selection and fallback rendering.markdownis the Markdown representation used when the document is serialized back to Markdown.
#Block Plugins
Return true from is_block() to use the block parser and renderer:
fn is_block(&self) -> bool {
true
}
Inline plugins use the default is_block() == false and return Option<InlineElement> from render_inline. Wrap any GPUI element with InlineElement::new(...), use native styles and events, and set an optional baseline. TextView measures and selects the whole element as one atom, with plain/Markdown copying, text fallback, and explicit asynchronous layout invalidation. See Inline plugin for the contract and .plugin(...) registration example. The component facade exports the same InlineElement and InlineRenderContext types.
#YAML Frontmatter
YAML frontmatter is opt-in because it is not part of CommonMark or GFM. Enable
the parser construct and attach FrontmatterPlugin to render top-level mappings
as a DescriptionList:
use gpui_component::text::{markdown, FrontmatterPlugin, MarkdownExtensions};
let extensions = MarkdownExtensions::default().frontmatter();
markdown("---\nname: example\ndescription: Example metadata.\n---")
.markdown_extensions(extensions)
.plugin(FrontmatterPlugin::new())
Values are rendered as plain text. Simple unquoted values and block scalars
using |- or >- are supported; literal scalars preserve content indentation.
Quoted values, inline comments, collections, aliases, other block headers, and
more-indented folded lines fall back to a YAML code block, preserving the source
instead of displaying an incorrectly interpreted value.
#Code Block Actions
You can render controls for Markdown code blocks:
markdown(source)
.code_block_actions(|code_block, _window, _cx| {
gpui_kit::div().child(format!("Run {}", code_block.lang().unwrap_or_default()))
})