TextView
gpui-base 现在拥有完整的 TextView 实现,可渲染 Markdown 和常用 HTML。解析、链接、图片、列表、表格、代码块、滚动、行数限制、插件、文本选择和复制都不依赖 gpui-component。
上方可运行示例只依赖 gpui-base。其中 Rust 代码块特意没有着色,因为语法高亮默认不开启。
设置窗口
应用启动时调用一次 gpui_base::init,并在每个窗口渲染一个 TextSelectionLayer。它统一协调 TextView、SelectableText 和自定义文本 renderer 的选择行为。
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\n选择并复制这段 **Markdown**。",
))
}
}如果应用已经调用 gpui_component::init,其中已包含 Base 初始化;gpui-component::Root 也会安装窗口选择层。
TextView 默认支持选择。拖动选区靠近视口边缘时,共享选择层会自动滚动相关的 overflow_*_scroll 区域,不需要额外设置 TextView 的滚动或选择参数。只有明确需要禁用选择时才使用 .selectable(false)。
Markdown 与 HTML
短内容可以使用自动生成调用点 ID 的 helper,需要明确稳定 ID 时使用构造器:
use gpui_base::{html, markdown, TextView};
let short_markdown = markdown("一段 **Markdown**。");
let short_html = html("<p>一段 <strong>HTML</strong>。</p>");
let preview = TextView::markdown("document-preview", markdown_source).scrollable(true);
let article = TextView::html("article", html_source);scrollable(true) 让视图填满容器并垂直滚动;未设置时视图随内容增长。max_lines(n) 可把非滚动预览限制在最多 n 行正文高度。
可直接使用的默认样式
所有构造方式都会使用 TextViewStyle::default()。默认值已经包含可读的正文、次要文字、链接、选择色、代码背景、边框、标题、段落、行内代码和表格样式。只使用 Base 的项目不需要先定义一套样式才能显示文本。
应用可以只覆盖自己设计系统负责的颜色:
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) 可读取 gpui_base::Theme 的语义颜色。使用上层组件主题时,可调用 gpui_component::text::text_view_style(cx.theme())。
语法高亮由使用者开启
gpui-base 默认不启用语法高亮,也不包含 tree-sitter 语言依赖。应用未提供 code_block_highlighter 时,围栏代码块只使用中性的代码背景和普通前景色。
回调接收 CodeBlock,并返回 UTF-8 字节范围及对应的 GPUI HighlightStyle:
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()
})范围相对于 CodeBlock::code();无效范围会被丢弃。高亮器实现和语言注册完全由应用管理。
保留状态与动态更新
内容需要持续更新时使用 TextViewState:
use gpui_base::{TextView, TextViewState};
let document = cx.new(|cx| TextViewState::markdown(initial_source, cx));
TextView::new(&document)
document.update(cx, |state, cx| state.set_text(updated_source, cx));通过 SelectionFormat 可以选择复制渲染文本或 Markdown 源码。链接路由、代码块操作、表格操作、图片和 Markdown 插件继续使用与兼容 API 相同的 builder,详见 gpui-component TextView 文档。
可运行源码
网页预览和本地命令使用同一份 Base-only 源码:
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