Skip to content

TextView

gpui-base 现在拥有完整的 TextView 实现,可渲染 Markdown 和常用 HTML。解析、链接、图片、列表、表格、代码块、滚动、行数限制、插件、文本选择和复制都不依赖 gpui-component

上方可运行示例只依赖 gpui-base。其中 Rust 代码块特意没有着色,因为语法高亮默认不开启。

设置窗口

应用启动时调用一次 gpui_base::init,并在每个窗口渲染一个 TextSelectionLayer。它统一协调 TextViewSelectableText 和自定义文本 renderer 的选择行为。

rust
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 时使用构造器:

rust
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 的项目不需要先定义一套样式才能显示文本。

应用可以只覆盖自己设计系统负责的颜色:

rust
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

rust
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

rust
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 源码:

rust
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"
        );
    }
}
bash
cargo run -p gpui-base --example components -- text-view