TextView
gpui-base owns the complete TextView implementation for rendering Markdown and common HTML. It includes document parsing, links, images, lists, tables, code blocks, scrolling, line clamping, plugins, selection, and copying without depending on gpui-component.
The live example above uses only gpui-base. Its fenced Rust block is intentionally unhighlighted: syntax highlighting is opt-in.
Set up the window
Call gpui_base::init once during application startup and render one TextSelectionLayer per window. The layer coordinates selection across TextView, SelectableText, and custom text renderers.
use gpui::prelude::*;
use gpui::{Context, Render, Window};
use gpui_base::{TextSelectionLayer, TextView};
impl Render for AppView {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div()
.size_full()
.child(TextSelectionLayer)
.child(TextView::markdown(
"readme",
"# Hello\n\nSelect and copy this **Markdown**.",
))
}
}If the application already calls gpui_component::init, Base initialization is included. gpui-component::Root also installs the window selection layer.
TextView is selectable by default. While dragging a selection near a viewport edge, the shared selection layer scrolls the related overflow_*_scroll region automatically; no TextView scroll or selection parameter is required. Use .selectable(false) only to disable selection explicitly.
Markdown and HTML
Use the helpers for call-site-derived IDs, or constructors when an explicit stable ID is useful:
use gpui_base::{html, markdown, TextView};
let short_markdown = markdown("A **short** message.");
let short_html = html("<p>A <strong>short</strong> message.</p>");
let preview = TextView::markdown("document-preview", markdown_source).scrollable(true);
let article = TextView::html("article", html_source);scrollable(true) makes the view fill its container and scroll vertically. Without it, the view grows to fit its content. max_lines(n) clamps a non-scrollable preview to at most n body-text lines.
Complete default styling
Every constructor starts with TextViewStyle::default(). The default contains readable neutral foreground, muted, link, selection, code-background, border, heading, paragraph, inline-code, and table styles. A Base-only application does not need to construct a style before rendering text.
Override only the values owned by your design system:
use gpui_base::TextViewStyle;
let style = TextViewStyle::default()
.with_foreground(app_colors.foreground)
.with_muted_foreground(app_colors.muted_foreground)
.with_link(app_colors.link)
.with_selection(app_colors.selection);
TextView::markdown("themed", source).style(style)TextViewStyle::from_theme(&theme) maps the semantic colors from a gpui_base::Theme. Applications using the higher-level component theme can use gpui_component::text::text_view_style(cx.theme()).
Syntax highlighting is opt-in
gpui-base does not enable syntax highlighting and has no tree-sitter language dependency. Fenced code blocks use the neutral code surface and plain foreground until the application supplies code_block_highlighter.
The callback receives a CodeBlock and returns byte ranges paired with GPUI HighlightStyle values:
use gpui::HighlightStyle;
use gpui_base::TextView;
TextView::markdown("highlighted", source).code_block_highlighter(|block| {
my_highlighter(block.lang(), block.code())
.into_iter()
.map(|(range, color)| {
(
range,
HighlightStyle {
color: Some(color),
..Default::default()
},
)
})
.collect()
})Ranges are UTF-8 byte ranges relative to CodeBlock::code(). Invalid ranges are discarded. The highlighter implementation and its language registrations remain entirely application-owned.
Retained state and streaming updates
Use TextViewState when content changes without replacing the view:
use gpui_base::{TextView, TextViewState};
let document = cx.new(|cx| TextViewState::markdown(initial_source, cx));
// Render
TextView::new(&document)
// Later
document.update(cx, |state, cx| state.set_text(updated_source, cx));Selection can copy rendered text or Markdown source through SelectionFormat. Link routing, code-block actions, table actions, images, and custom Markdown plugins use the same builders as the compatibility API documented on the gpui-component TextView page.
Runnable source
The live preview and native command use the same Base-only source:
use gpui_base::{TextView, TextViewStyle};
use super::*;
use crate::showcase::palette::ExamplePalette;
pub const MARKDOWN: &str = include_str!("../../../../story/examples/fixtures/test.md");
fn text_view_style(palette: ExamplePalette) -> TextViewStyle {
let is_dark = palette.canvas == ExamplePalette::for_dark(true).canvas;
TextViewStyle::default()
.with_foreground(gpui::rgb(palette.foreground).into())
.with_muted_foreground(gpui::rgb(palette.muted_foreground).into())
.with_link(gpui::rgb(palette.resolve(0x007fff)).into())
.with_code_background(gpui::rgb(palette.elevated).into())
.with_border(gpui::rgb(palette.border).into())
.with_inline_code(gpui::HighlightStyle {
background_color: Some(gpui::rgb(palette.elevated).into()),
..Default::default()
})
.with_dark(is_dark)
}
impl BaseShowcase {
pub(in super::super) fn text_view(&self, window: &Window) -> impl IntoElement {
let palette = ExamplePalette::from_window(window);
let style = text_view_style(palette);
div()
.id("text-view-example")
.debug_selector(|| "text-view-example".into())
.w_full()
.h(px(560.))
.max_h_full()
.text_color(gpui::rgb(palette.foreground))
.child(
div()
.debug_selector(|| "text-view-markdown".into())
.size_full()
.min_h_0()
.overflow_hidden()
.child(
TextView::new(&self.text_view)
.size_full()
.px_4()
.scrollable(true)
.style(style),
),
)
}
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use gpui::{
Modifiers, MouseButton, ScrollDelta, ScrollWheelEvent, TestAppContext, VisualTestContext,
point, px,
};
use gpui_base::{TextSelection, TextViewStyle};
use super::text_view_style;
use crate::showcase::BaseShowcase;
use crate::showcase::palette::ExamplePalette;
#[test]
fn text_view_style_uses_dark_palette_colors() {
let style = text_view_style(ExamplePalette::for_dark(true));
assert_eq!(style.foreground(), gpui::rgb(0xffffff).into());
assert_eq!(style.muted_foreground(), gpui::rgb(0xa3a3a3).into());
assert_eq!(style.code_background(), gpui::rgb(0x262626).into());
assert_eq!(style.border(), gpui::rgb(0x404040).into());
assert_eq!(style.selection(), TextViewStyle::default().selection());
assert!(style.is_dark());
}
#[gpui::test]
fn text_view_showcase_renders_with_base_defaults(cx: &mut TestAppContext) {
cx.update(gpui_base::init);
let (view, cx) =
cx.add_window_view(|window, cx| BaseShowcase::new("text-view", window, cx));
let cx: &mut VisualTestContext = cx;
cx.run_until_parked();
let example = cx
.debug_bounds("text-view-example")
.expect("example bounds");
let markdown = cx
.debug_bounds("text-view-markdown")
.expect("Markdown bounds");
let document = view.read_with(cx, |view, cx| view.text_view.read(cx).bounds());
assert_eq!(markdown.left(), example.left());
assert_eq!(markdown.right(), example.right());
assert_eq!(document.left() - example.left(), px(16.));
assert_eq!(example.right() - document.right(), px(16.));
}
#[gpui::test]
fn text_view_showcase_drag_selection_settles(cx: &mut TestAppContext) {
cx.update(gpui_base::init);
let (_, cx) = cx.add_window_view(|window, cx| BaseShowcase::new("text-view", window, cx));
let cx: &mut VisualTestContext = cx;
cx.run_until_parked();
let bounds = cx
.debug_bounds("text-view-example")
.expect("example bounds");
// Exercise selection inside the visible, virtualized Markdown blocks.
let start = point(bounds.left() + px(36.), bounds.top() + px(36.));
let end = point(bounds.right() - px(36.), bounds.top() + px(180.));
cx.simulate_mouse_down(start, MouseButton::Left, Modifiers::default());
cx.simulate_mouse_move(end, MouseButton::Left, Modifiers::default());
cx.simulate_mouse_up(end, MouseButton::Left, Modifiers::default());
assert!(cx.update(|window, cx| TextSelection::has_selection(window, cx)));
}
#[gpui::test]
fn text_view_showcase_scrolls_the_document_inside_a_fixed_viewport(cx: &mut TestAppContext) {
cx.update(gpui_base::init);
let (view, cx) =
cx.add_window_view(|window, cx| BaseShowcase::new("text-view", window, cx));
let cx: &mut VisualTestContext = cx;
cx.run_until_parked();
let viewport = cx
.debug_bounds("text-view-markdown")
.expect("Markdown viewport bounds");
let example = cx
.debug_bounds("text-view-example")
.expect("TextView example bounds");
let scroll_before = view.read_with(cx, |view, cx| {
let offset = view.text_view.read(cx).list_state().logical_scroll_top();
(offset.item_ix, offset.offset_in_item)
});
cx.simulate_event(ScrollWheelEvent {
position: example.center(),
delta: ScrollDelta::Pixels(point(px(0.), px(-120.))),
..Default::default()
});
cx.update(|window, cx| window.draw(cx).clear(cx));
let after = cx
.debug_bounds("text-view-markdown")
.expect("Markdown viewport bounds after scrolling");
let scroll_after = view.read_with(cx, |view, cx| {
let offset = view.text_view.read(cx).list_state().logical_scroll_top();
(offset.item_ix, offset.offset_in_item)
});
assert_eq!(
after, viewport,
"the TextView viewport itself must stay fixed"
);
assert_ne!(
scroll_after, scroll_before,
"the TextView's virtual list must consume the wheel event"
);
}
#[gpui::test]
fn dragging_selection_scrolls_the_containing_region_without_text_view_parameters(
cx: &mut TestAppContext,
) {
cx.update(gpui_base::init);
let (view, cx) =
cx.add_window_view(|window, cx| BaseShowcase::new("text-view", window, cx));
let cx: &mut VisualTestContext = cx;
cx.run_until_parked();
let markdown = cx
.debug_bounds("text-view-markdown")
.expect("Markdown section bounds");
let scroll_before = view.read_with(cx, |view, cx| {
let offset = view.text_view.read(cx).list_state().logical_scroll_top();
(offset.item_ix, offset.offset_in_item)
});
let start = point(markdown.left() + px(24.), markdown.top() + px(24.));
let edge = point(markdown.left() + px(120.), markdown.bottom() - px(2.));
cx.simulate_mouse_down(start, MouseButton::Left, Modifiers::default());
cx.simulate_mouse_move(edge, MouseButton::Left, Modifiers::default());
cx.executor().advance_clock(Duration::from_millis(64));
cx.run_until_parked();
cx.simulate_mouse_up(edge, MouseButton::Left, Modifiers::default());
let scroll_after = view.read_with(cx, |view, cx| {
let offset = view.text_view.read(cx).list_state().logical_scroll_top();
(offset.item_ix, offset.offset_in_item)
});
assert!(
scroll_after != scroll_before,
"dragging at the viewport edge must scroll the TextView document"
);
cx.executor().advance_clock(Duration::from_millis(64));
cx.run_until_parked();
let scroll_stopped = view.read_with(cx, |view, cx| {
let offset = view.text_view.read(cx).list_state().logical_scroll_top();
(offset.item_ix, offset.offset_in_item)
});
assert_eq!(
scroll_stopped, scroll_after,
"selection auto-scroll must stop on mouse-up"
);
}
}cargo run -p gpui-base --example components -- text-view