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.
Highlight ranges
An application that searches a document, or points at a citation inside it,
paints its ranges with set_range_highlights. The application owns the
search: it finds its ranges in rendered_text(), the text the view shows, and
hands them back with the colors to paint them in, a stronger one for the
current result:
use gpui_kit::component::{
ActiveTheme as _,
text::{RangeHighlight, RangeHighlightError, TextViewState},
};
fn highlight_matches(
state: &mut TextViewState,
query: &str,
current_match: usize,
cx: &mut Context<TextViewState>,
) -> Result<(), RangeHighlightError> {
let (color, current_color) = (cx.theme().warning.opacity(0.3), cx.theme().warning);
let text = state.rendered_text();
let matches = if query.is_empty() {
Vec::new()
} else {
text.as_str().match_indices(query).collect()
};
let highlights = matches.into_iter().enumerate().map(|(ix, (start, found))| {
RangeHighlight::new(
start..start + found.len(),
if ix == current_match { current_color } else { color },
)
});
state.set_range_highlights(highlights, cx)
}rendered_text() is the text plain copy produces: hello **world** reads
hello world, escapes are resolved, and heading and list markers are left
out. Offsets are UTF-8 byte offsets, so the ranges str search returns can be
passed as they are, and a repeated phrase is addressed by where it occurs. The
text is built the first time it is read.
A highlight is painted behind the text and under the selection, so wrapping, alignment, syntax colors, links, selection and copy stay as they were. Where highlights overlap, the later one paints over the earlier. A range that crosses from one block into the next paints in both. Text that belongs to no block is left unpainted: the line breaks between blocks, the spaces between table cells, custom blocks, HTML blocks and inline plugin objects. Only a range that is reversed, out of bounds or not on a character boundary is rejected, and the whole set with it.
When the content changes, a highlight follows its block and stays as far as
the block's text is unchanged. Text appended while streaming, through
push_str or set_text, keeps the highlights before it, and an edit keeps
those before and after it. After an edit inside a table, the cells in and
after the edited row lose theirs, since a cell is only known by its place in
the table. The view notifies when its text changes: observe the state and
search the new rendered_text() again. Compute ranges and call
set_range_highlights in the same state update so the ranges address the
current text. Backgrounds that are part of the text, such as
<mark> and syntax highlighting, paint over a range highlight (inline code's
background is painted under it), and highlights do not fade in with streamed
text. HTML views do not support range highlights.
Scroll to a range
reveal_range scrolls to a range of the same text, such as the current
result when the user steps to the next one:
state.reveal_range(current_range, cx)?;It scrolls the line the range starts on into view, down to a line in the
middle of a long paragraph, and leaves the view where it is when that line, or
a whole block revealed, is already visible. An empty range reveals the line of
its position. A scrollable view scrolls itself. A fit-content view scrolls
the nearest enclosing gpui::list, as a chat transcript is, as long as the
row that holds the view is laid out: scroll to that row first when it may be
off screen. Any other scroll container, such as a div with
overflow_y_scroll, scrolls through on_reveal, which receives the line's
bounds in window coordinates:
let scroll = scroll_handle.clone();
TextView::new(&state).on_reveal(move |line, _, _| {
let viewport = scroll.bounds();
let mut offset = scroll.offset();
if line.bottom() > viewport.bottom() {
offset.y -= line.bottom() - viewport.bottom();
} else if line.top() < viewport.top() {
offset.y += viewport.top() - line.top();
}
scroll.set_offset(offset);
})A range that covers no block's text, such as a custom block's, scrolls its
whole block into a scrollable view. Only the latest reveal is carried out. It
follows the content the way highlights do, and it is dropped when its text
changes, when the view clamps its lines with max_lines, or when it cannot be
shown within a second, so it never scrolls long after it was asked for. Text
scrolled sideways inside a table stays where it is, a block revealed whole and
taller than the view shows its end when it comes from below, a scrollable
view inside an application list scrolls only itself, and views sharing one
state share one reveal.
Revealing is best effort. Ok(()) means the range is valid for the current
text and the request was taken, not that the view has scrolled, and a dropped
request is not reported.
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()))
})Documentation license: original prose and illustrations for which GPUI Kit holds licensing rights are also offered under CC BY 4.0. When copying or adapting, credit GPUI Kit, link the source (https://gpui-kit.com/component/text-view) and https://creativecommons.org/licenses/by/4.0/, and indicate changes. Code examples and software source use Apache-2.0; third-party material retains its terms; existing Apache-2.0 permissions remain.
Bundled from GPUI Kit. Documentation prose: CC BY 4.0; code examples: Apache-2.0. Changes: documentation links localized, asset URLs made absolute, and this attribution added.