TextView

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

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

#设置窗口

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

use gpui_kit::prelude::*;
use gpui_kit::{Context, Render, Window};
use gpui_kit::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_kit::component::init,其中已包含 Base 初始化;gpui-component::Root 也会安装窗口选择层。

TextView 默认支持选择。拖动选区靠近视口边缘时,共享选择层会自动滚动相关的 overflow_*_scroll 区域,不需要额外设置 TextView 的滚动或选择参数。只有明确需要禁用选择时才使用 .selectable(false)

#Markdown 与 HTML

短内容可以使用自动生成调用点 ID 的 helper,需要明确稳定 ID 时使用构造器:

use gpui_kit::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_kit::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_kit::base::Theme 的语义颜色。使用上层组件主题时,可调用 gpui_kit::component::text::text_view_style(cx.theme())

#语法高亮由使用者开启

gpui-base 默认不启用语法高亮,也不包含 tree-sitter 语言依赖。应用未提供 code_block_highlighter 时,围栏代码块只使用中性的代码背景和普通前景色。

回调接收 CodeBlock,并返回 UTF-8 字节范围及对应的 GPUI HighlightStyle

use gpui_kit::HighlightStyle;
use gpui_kit::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();无效范围会被丢弃。高亮器实现和语言注册完全由应用管理。

#Markdown 扩展

MarkdownExtensions 默认使用兼容 CommonMark/GFM 的解析方式。YAML frontmatter 不属于这两项标准,因此默认关闭。当 block parser 或插件处理 markdown_ast::Node::Yaml 时,需要明确启用该 construct:

use gpui_base::{MarkdownExtensions, TextView};

let extensions = MarkdownExtensions::default().frontmatter();

TextView::markdown("metadata", source)
    .markdown_extensions(extensions)

如果没有匹配的插件,已启用的 YAML frontmatter 会使用现有的 YAML code-block fallback。可以通过 .plugin(...) 挂载自定义插件; gpui-component 提供带主题样式的 FrontmatterPlugin;Base 不依赖该 presentation。

#Inline plugin

与 Block plugin 一样,Inline plugin 实现 MarkdownPlugin,通过 .plugin(...) 注册。MarkdownPlugin 默认 is_block() == false,使用 render_inline;Block plugin 继续使用 render

use gpui::{App, Styled, Window, div};
use gpui_base::{
    InlineElement, InlineRenderContext, MarkdownNode,
    MarkdownParseContext, MarkdownPlugin, TextView, markdown_ast,
};

struct FormulaPlugin;

impl MarkdownPlugin for FormulaPlugin {
    fn name(&self) -> &str {
        "formula"
    }

    fn parse(
        &self,
        node: &markdown_ast::Node,
        _: &MarkdownParseContext<'_>,
    ) -> Option<MarkdownNode> {
        let markdown_ast::Node::InlineMath(math) = node else {
            return None;
        };
        Some(
            MarkdownNode::new("formula", math.value.clone())
                .text(math.value.clone())
                .accessibility_label(format!("Formula: {}", math.value)),
        )
    }

    fn render_inline(
        &self,
        node: &MarkdownNode,
        _: &InlineRenderContext,
        _: &mut Window,
        _: &mut App,
    ) -> Option<InlineElement> {
        Some(InlineElement::new(div().italic().child(node.as_text().to_string())))
    }
}

TextView::markdown("inline-formulas", "Formulas $x^2$ and $y^2$")
    .plugin(FormulaPlugin)

render_inline 返回 Some(InlineElement::new(element)),支持任意 GPUI IntoElement,包括带样式的文本、图片和组合元素。样式、hover 和子元素事件直接使用原生 GPUI API。renderer 收到的 InlineRenderContext 包含实际文本样式、字号、行高和 rem 大小。这些渲染类型不依赖 Markdown;本例的解析和注册仍属于 Markdown API。

TextView 测量元素的固有尺寸,将整个元素作为一个原子对象排版。需要指定基线时,在 InlineElement 上调用 .with_baseline(px(...)),数值为从顶部到基线的逻辑像素距离。只能在对象前后换行。固定尺寸的元素即使超过行宽,也保留真实尺寸;需要限宽时使用 GPUI 样式约束。TextView 不会整体缩放元素子树。

当 parser 捕获值或插件配置改变,但注册名称不变时,使用 MarkdownExtensions::parser_revision(config_version) 触发重新解析。每次 render 重建相同配置时应保持该 revision 不变。

可以直接用原生 HoverCard 包裹 trigger 来显示资料卡。Markdown 示例使用 StyledText 设置淡色 @ 和用户名下划线,通过 Anchor::TopCenter 居中定位 HoverCard[@huacnlee](mention:huacnlee) 的纯文本复制输出账号,Markdown 复制保留原始链接语法。

选择以整个渲染元素为单位。双击选中对象,三击选中所在混排行;拖选可以双向跨越文字与连续对象。子元素事件保留原生 GPUI 行为,插件中的交互控件应与 TextView 的选择手势协调。

source_range() 返回包括分隔符在内的全局 UTF-8 字节范围。.text(...) 提供纯文本复制和降级内容;.markdown(...) 提供 Markdown 复制内容,默认使用节点原始源码。未提供纯文本时使用源码。.accessibility_label(...) 提供无障碍名称,默认使用纯文本。render_inline 返回 None 时使用原子文本降级。图片的加载中和失败内容由插件通过 img(...).with_loading(...).with_fallback(...) 提供。

异步资源应由应用缓存:保留 TextViewState,准备完成后通过弱 entity 更新缓存并调用 state.invalidate_inline_layout(cx)。这会重新测量行内内容和虚拟列表高度,不重解析文档,也不丢弃已有逻辑选区。缓存键应区分源码、字号和主题,过期结果应丢弃。渲染回调应读取已准备的资源,不应在布局期间同步调用公式排版引擎。examples/markdown 提供公式实现和预览缩放控件。

默认解析 inline math 语法,通过 Plugin 自定义渲染,无需额外开关。行内代码里的美元符号仍保留为代码。没有 Plugin 认领某个 math 节点时,TextView 按原始 $...$ 源码渲染为普通文本,因此正文中单纯出现美元符号的句子(spent $5 and $10)显示和复制都保持原样。块级公式同样会被解析:$$ 围栏产生块级节点,由 Block plugin(is_block() == true)渲染;无人认领时降级为代码块。

#保留状态与动态更新

内容需要持续更新时使用 TextViewState

use gpui_kit::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));

TextViewMotion 是视图的动效策略。Base 负责播放,但不带任何时长:所有时长默认为零,未加样式的视图会直接显示流式到达的文字。给 stream_fade 一个时长,更新追加的文字就会在落点处淡入;可选的 stream_fade_stagger 让同一次更新里后面的词比前一个词稍晚开始:

use std::time::Duration;

use gpui_kit::base::{Easing, TextView, TextViewMotion};

TextView::new(&document).motion(
    TextViewMotion::default()
        .with_stream_fade(Duration::from_millis(350))
        .with_stream_fade_stagger(Duration::from_millis(30))
        .with_stream_fade_easing(Easing::EaseOut),
)

不设错位时每次更新整块一起淡入。设了错位时,追加的文字按词拆分(词带上其后的空白),中日韩文字按字拆分;一次追加很长时会压缩错位,保证最后一个词在一个淡入时长内开始。追踪器比较的是渲染后的文字而不是源码字节,因此 set_text 传入以当前文本为前缀的更长文本会被视为追加;流式过程中被补齐的 Markdown 标记(**bo 变成粗体 bold)只让发生变化的字形重新淡入,不会整段闪烁。每次只比较更新触及的块,并且只在还有文字在淡入时才请求下一帧。系统开启减少动态效果时跳过淡入。

通过 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!("../../../../../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-examples -- text-view