gpui-kit 窗口级文本选择实战:用 gpui-base 让多个自绘文本参与者像连续文本一样被拖拽选中
gpui-kit 窗口级文本选择实战用 gpui-base 让多个自绘文本参与者像连续文本一样被拖拽选中【免费下载链接】gpui-kitRust GUI components for building fantastic cross-platform desktop application by using GPUI.项目地址: https://gitcode.com/GitHub_Trending/gp/gpui-kitgpui-base是 gpui-kit 仓库中的基础层对应crates/base它提供了一套窗口级文本选择基础设施一次拖拽可以跨越多个独立绘制的文本块同时每个文本块保留自己的布局与绘制逻辑。本文以 中文版《文本选择》文档 为核心骨架结合 text_selection.rs 与 selectable_text.rs 的源码实现完整讲解参与者接入方式、几何注册、选择投影、复制与模态隔离并给出可复制的代码示例与集成检查清单。读完本文你将能把任意自绘 GPUI 文本元素StyledText、TextLayout、虚拟化文档或自定义Element接入统一的窗口选择并获得与原生文本一致的多击选中、跨块连续高亮与复制行为。一、什么是窗口级文本选择在普通的 GPUI 应用里每个自绘文本元素往往各自处理自己的指针事件导致拖拽跨越多个元素时选择被割裂。gpui-base的文本选择基础设施解决的正是一类特定界面场景文档、消息流以及由多个自定义元素组成、但用户期望像连续文本一样选择的界面。它的设计哲学可以概括为gpui-base负责手势协调与区间投影应用负责布局与绘制。文本如何被排版、如何绘制选中背景全部由参与者自己决定跨参与者的手势、锚点、区间计算、复制文本的组织则由窗口级状态统一完成。这意味着你可以把StyledText、TextLayout、虚拟化文档或任意自定义 GPUIElement接入同一套选择逻辑而不必为每个实现重写一遍拖拽状态机。从源码看窗口级状态由 WindowSelectionState 承担它以HashMapEntityId, ParticipantRegistration维护当帧注册的全部参与者并持有锚点、光标、活动 scope、拖拽标记与自动滚动状态所有导出类型集中在 lib.rs 的导出区。二、核心角色与完整工作流一个可选择的窗口里存在三类角色中文文档将其概括为一个TextSelectionLayer元素持有选择状态与窗口级指针处理器每个可独立选择的文本参与者持有一个稳定的TextSelectionHandle渲染期间参与者注册当前几何并把选择快照投影到已排版的TextSelectionRun上。下文表格整理了全部关键 API 及其生命周期与用途对应英文版文档的 Key parts 表可在仓库中交叉核对API生命周期用途TextSelectionLayer每窗口一个安装窗口级指针处理与选择状态TextSelection静态 API查询与控制窗口选择TextSelectionHandle每个可选择参与者一个标识参与者存储其回调与投影结果TextSelectionRegistration每帧重建报告当前命中框、边界、滚动偏移、scope 与文档顺序TextSelectionRun绘制期间重建描述已排版文本供投影为 UTF-8 字节区间TextSelectionProjection由update_runs返回将每个提交的 run 与选中字节区间配对TextSelectionSnapshot选择变化时产生描述参与者的端点与覆盖范围TextSelectionEvent发送给订阅者报告选择变化、清除与自动滚动请求TextSelectionContentKey稳定内容身份标识虚拟化内容在选择端点处的身份完整流程如下与源码逐一对应在窗口根部保留一个TextSelectionLayer元素为每个可独立选择的参与者创建一个TextSelectionHandleprepaint 阶段调用TextSelectionHandle::register并传入TextSelectionRegistrationpaint 阶段把已排版的TextSelectionRun交给TextSelectionHandle::update_runs把返回的字节区间高亮绘制在字形后方通过TextSelection读取或清除窗口选择。此外安装好的 layer 自带熟悉的多击行为双击按与Input相同的边界规则选中一个词三击及之后选中以换行符分隔的逻辑行。其实现位于 text_boundary.rsword_range_at依据CharacterKind词、空白、换行、其他向两侧扩展词边界word_range_from_charsline_range_at通过rfind(\n)与find(\n)圈定逻辑行clip_offset_left负责把偏移收敛到 UTF-8 字符边界避免切分多字节字符。窗口状态属于被保留的TextSelectionLayer元素句柄与回调永远不持有或拥有这份内部状态——这正是TextSelectionHandle可以自由克隆、跨帧稳定的原因。三、安装窗口元素在窗口内容的稳定外层安装选择宿主使它能够接收拖拽、释放和复制操作。宿主应覆盖所有需要共同选择的参与者但不应改变它们的布局或样式use gpui::{Context, Render, Window}; use gpui_base::TextSelectionLayer; impl Render for AppView { fn render(mut self, window: mut Window, cx: mut ContextSelf) - impl IntoElement { div() .size_full() .child(TextSelectionLayer) // 必须是第一个 child .child(self.content.clone()) } }从源码看TextSelectionLayer 是一个零尺寸元素request_layout只请求一个默认样式布局prepaint 阶段调用GlobalState::begin_selection_frame()并通过稳定的元素 idwindow-text-selection在窗口元素状态中保留retain窗口级选择实体paint 阶段则安装鼠标按下、移动、释放与滚轮处理器并在帧末通过finish_frame清扫未注册的陈旧参与者Element 实现。两个关键约束保持它在第一位且每个窗口只挂载一个在第一次 prepaint 之前调用TextSelection::activate_scope会把 scope 存入PendingTextSelectionScopes全局直到 layer 绑定窗口状态时再取出acquire因此“先激活 scope、后首帧渲染”是允许的。四、创建稳定句柄每个参与者的SelectableTextHandle应保存在 entity 中或由稳定数据键派生。不要在每次渲染时创建新身份——否则拖拽途中重绘会丢失锚点、逻辑顺序或选择投影。use gpui::{Context, Subscription, Window}; use gpui_base::TextSelectionHandle; struct DocumentView { selection: TextSelectionHandle, _selection_refresh: Subscription, } impl DocumentView { fn new(window: Window, cx: mut ContextSelf) - Self { let selection TextSelectionHandle::new(, cx); let selection_refresh selection.refresh_window_on_change(window, cx); Self { selection, _selection_refresh: selection_refresh, } } }要点说明TextSelectionHandle::new(fallback_copy_text, cx)源码内部创建了一个SelectableTextStateentityfallback_copy_text在参与者投影 laid-out runs 或提供自定义复制行为之前作为复制文本的兜底可用set_fallback_copy_text替换会同时清空已投影文本缓存。refresh_window_on_change源码在该句柄的选择变化时只刷新所属窗口。返回的Subscription带有#[must_use]标注参与者渲染期间应保留它或显式调用.detach()使其在参与者 entity 的剩余生命周期内持续生效。当参与者需要事件通知或更精准的失效控制时改用subscribe。五、在 prepaint 注册几何文本最终布局完成后注册边界、行与字符位置。注册顺序必须与用户看到和辅助技术读取的逻辑顺序一致动态插入、删除或移动参与者时用稳定身份更新对应记录。每渲染帧调用一次TextSelectionHandle::register(registration, window, cx)源码它会把当前活动 scope 覆盖到注册记录上然后写入窗口状态use gpui::{App, Bounds, Hitbox, Pixels, Window}; use gpui_base::TextSelectionRegistration; fn register_selection( handle: gpui_base::TextSelectionHandle, hitbox: Hitbox, bounds: BoundsPixels, window: mut Window, cx: mut App, ) { handle.register( TextSelectionRegistration::new(hitbox, bounds) .with_document_order(0) .with_text_bounds(vec![bounds]), window, cx, ); }TextSelectionRegistration源码各字段含义bounds参与者在窗口坐标下的内容视口。text_bounds可见的、承载字形的区域列表。空白的拖拽不会启动文本选择——命中测试中inside_text要求指针落在某个 text_bounds 内endpoint 逻辑。document_order提供参与者之间的稳定排序用于跨参与者选择与复制组合。不要从HashMap迭代顺序或偶然的绘制顺序推导语义顺序。with_scroll_offset把窗口坐标映射进滚动后的内容坐标SelectionEndpoint::resolve用point scroll_offset bounds.origin还原窗口点。with_scope指定显式的不透明 scope若周围子树用.text_selection_scope(scope)包裹则该子树渲染期间会覆盖它element_ext.rs。with_self_scroll内部标记参与者自行滚动其内容拖拽自动滚动将直接驱动它而非向最近的滚动祖先合成滚轮事件。一个值得注意的机制当前帧未注册的句柄会自动停止参与。finish_frame在 paint 结束后按frame_generation清扫陈旧参与者因此参与者与生命周期元素谁先绘制都不影响注册的正确性。SelectableText元素正是这样实现的prepaint 里window.insert_hitbox后以with_document_order(self.document_order).with_text_bounds(vec![bounds])注册selectable_text.rs。六、把选择投影到文本 run绘制各 run 前查询当前窗口选择取得与本地文本相交的字节或字符范围再把范围转换为文本系统需要的高亮几何。注意 UTF-8 边界不要把字节偏移当作字符索引。use gpui::{App, Bounds, Pixels, SharedString, TextLayout}; use gpui_base::TextSelectionRun; fn selected_range( handle: gpui_base::TextSelectionHandle, text: SharedString, layout: TextLayout, bounds: BoundsPixels, cx: mut App, ) - Optionstd::ops::Rangeusize { handle .update_runs([TextSelectionRun::new(text, layout, bounds).with_document_order(0)], cx) .ranges() .iter() .next() .and_then(|range| range.clone()) }实现细节project_ranges 与 selection_range_for_run传入update_runs的必须是创建TextLayout时使用的精确文本TextSelectionRun同时保存text与layout且要求run.text.len() run.layout.len()两者都按字符计数。投影逐字符进行对每个字符位置用layout.position_for_index求字形位置再通过point_in_selection_band判定是否落入选择条带。条带判定区分“锚点光标在同一行”与跨行两种情形保证换行文本的中间整行被完整包含。返回的TextSelectionProjection.ranges()是UTF-8 安全的字节区间且保持输入顺序调用方可按索引与原始 run 配对复制组合时才使用document_order。update_runs同时缓存选中子串debug_assert校验区间落在字符边界上供TextSelection::selected_text查询快照变化或清除会立即失效缓存避免在等待重绘期间复制出上一次投影的旧文本。绘制顺序先在高亮几何背后绘制选中背景再正常绘制文本。选中背景的几何生成参考SelectableText::paint_selection用position_for_index求起止点再调用selection_quad_boundsselectable_text.rs生成高亮四边形。换行选择需要三类几何首行剩余部分从起始点到行尾中间整行从行首到行尾、整行宽度末行前缀从行首到结束点。selection_quad_bounds的测试wrapped_selection_paints_full_width_middle_lines精确断言了这三个四边形的坐标selectable_text.rs。对于多个 run给每个 run 稳定document_orderprojection.ranges()保留输入顺序以便配对原布局文档顺序则用于组合复制文本。选中颜色默认取主题的colors.selectiontoken可通过SelectableText::selection_color覆盖。七、完整 Rust 示例仓库在 showcase 中提供了可直接运行的完整示例命令为cargo run -p gpui-base-examples -- text-selection对应的完整源码位于 crates/base/examples/showcase/components/text_selection.rs要点如下通过SelectableText::with_handle(id, handle, text).document_order(n)把四个文本块标题 三段正文绑定到self.text_selection_handles[..]这组跨帧稳定的句柄上document_order依次为 0、1、2、3模拟一个多参与者组成的“文档”三段正文刻意设置为跨行换行的长文本其中国际段混排了café、déjà vu、Kraków、naïve、résumé等多字节字符用于验证 UTF-8 区间不切分字符底部 footer 通过TextSelection::has_selection/TextSelection::selected_text实时显示当前选中文本并提供 “Clear selection” 按钮调用TextSelection::clear示例自带测试text_selection_footer_stays_fixed_when_document_scrolls同文件 #L167-L191滚动内容后断言 footer 边界不变验证选择宿主与滚动区域的职责分离。若你的文本是单一独立 run也可直接用SelectableText::new(id, text)——它会在首次渲染时通过window.with_element_state按稳定元素 id 自动保留一个句柄并自动完成注册、投影与绘制selectable_text.rs测试local_handle_participates_in_window_selection验证了鼠标按下→移动→释放后TextSelection::selected_text返回整段文本同文件 #L272-L298。八、查询与控制窗口选择应用可以读取当前选中文本、主动清除选择并把复制命令连接到窗口状态。TextSelection的关联函数无需导入任何扩展 traituse gpui_base::TextSelection; let has_selection TextSelection::has_selection(window, cx); let text TextSelection::selected_text(window, cx); TextSelection::end(window, cx); // 结束一次拖拽保留其区间 TextSelection::clear(window, cx); // 清除窗口与参与者局部区间selected_text源码按逻辑文档顺序收集各参与者的复制项resolve_copy_items按document_order排序、过滤空白项并以换行符连接。它只在窗口与句柄状态租约释放后才调用参与者的复制回调因此回调内可以安全读取或更新选择状态不会产生重入借用。has_selection源码只要存在几何选择或任一参与者处于局部选择如 select-all即返回真。clear/clear_for_window清除窗口选择并同步派发各参与者的clear_with回调clear_for_window是面向已知窗口 id 的窄入口供宿主在回收旧窗口包装器时使用。end结束拖拽但保留可见选择以便 Shift-click 扩展。程序化更新选择后记得调用cx.notify()让所有受影响参与者重绘。若在 paint 中发现selected_text结果变化例如新投影了文本SelectableText会主动window.refresh()触发下一次绘制selectable_text.rs。九、高级参与者适配器普通纯文本通常只需要refresh_window_on_change与update_runs。富文本或虚拟化参与者可在句柄上配置额外行为方法用途refresh_window_on_change本句柄选择变化时只重绘所属窗口subscribe接收选择变化、清除、自动滚动的TextSelectionEventcopy_with导出源文本或包含当前未绘制的虚拟化内容set_fallback_copy_text替换参与者的兜底复制文本resolve_content_key_with为端点附加稳定的TextSelectionContentKeyfocus_with拖拽在参与者内部开始时聚焦该参与者clear_with窗口选择清除时同步清理参与者局部状态set_local_selection报告 select-all 等参与者局部选择这些回调均在选择状态租约之外调用可以安全地更新参与者或查询TextSelection不会引发重入的 entity 借用例如focus_with的实现通过window.defer延迟执行焦点回调见 SelectableTextState::focus。两个高级场景的实践拖拽自动滚动subscribe收到TextSelectionEvent::AutoScroll(Some(delta))时把该 delta 喂入参与者自己的滚动循环None则停止正的 delta 向底部滚动。窗口状态在指针移出可见区域时按AutoScroll::compute_delta计算增量默认向最近的滚动祖先合成滚轮事件并对事件位置做 1px 内缩以保证命中测试落在裁剪遮罩内self_scroll参与者则被直接通知。showcase 用比视口更高的内容演示了这一点。虚拟化文档观察TextSelectionEvent::SelectionChanged结合TextSelectionSnapshot::coverage()、window_points()与每个端点的content_point()、content_key()。TextSelectionCoverage源码区分四档Bounded只选中参与者两端点之间的区间、FromStart从参与者开头选中到端点、ToEnd从端点选中到参与者结尾、Full参与者完全落在两端点之间。覆盖范围的计算依据锚点与光标的document_ordercoverage_for。据此copy_with可以在端点落在未绘制区域时把未绘制的虚拟化内容一并导出。十、用 scope 隔离模态内容Dialog、Sheet 等模态内容应使用独立 scope避免一次选择跨越背景与前景。只有处于活动TextSelectionScopeId的句柄才参与选择。先设置窗口活动 scope再标记对应的渲染子树use gpui_base::{ElementExt as _, TextSelection, TextSelectionScopeId}; let dialog_scope TextSelectionScopeId::new(); TextSelection::activate_scope(dialog_scope, window, cx); let dialog dialog_content.text_selection_scope(dialog_scope);实现机制text_selection.rs 的 scope 部分TextSelectionScopeId由进程内原子计数器分配全局唯一为 scope 的语义生命周期保留它不要每帧重新分配。activate_scope若窗口状态尚未创建则先写入PendingTextSelectionScopes待首帧绑定否则切换活动 scope 并同步清除旧选择。.text_selection_scope(scope)由 element_ext.rs 的 ElementExt 提供包装成TextSelectionScopeMarker在request_layout/prepaint/paint三个阶段把 scope 压入按窗口隔离的 scope 栈再执行子树逻辑。scope 栈按窗口隔离TextSelectionScopeStacks以WindowId为键且用std::panic::catch_unwind包裹子树回调——即使带 scope 的子树在渲染中 panic栈也会被弹出不会污染后续渲染。测试scope_stack_cleared_when_subtree_panics验证了这一点text_selection.rs。切换活动 scope 会原子地清除此前选择模态关闭后恢复原 scope已卸载的参与者不应残留在窗口注册表中prune_dead_participants与帧末 sweep 会按弱引用与帧代际自动清理。十一、集成检查清单综合中文版文档与英文版文档的清单接入窗口级文本选择前请逐项确认窗口只安装一个相应 scope 的选择宿主每个自定义窗口根部、第一个 child 位置保留一个TextSelectionLayer参与者身份和逻辑顺序跨渲染稳定每个TextSelectionHandle在渲染间保持不使用每帧新建的身份也不从HashMap或绘制顺序推导语义顺序只在 prepaint 注册最终几何并正确处理 UTF-8传给update_runs的必须是创建TextLayout所用的精确文本字节区间始终落在字符边界上拖拽更新有界不在 render 中修改状态状态更新发生在事件回调中程序化修改后调用cx.notify()高亮绘制在字形之前解析、源码导出与虚拟文档知识留在参与者内部使用显式document_order与窗口局部 scope模态内容用独立 scope 隔离验证覆盖场景跨块拖拽、反向选择、Shift 扩展、双击/三击、复制含国际字符与多字节文本、动态内容增删、滚动含拖拽自动滚动与模态隔离。十二、继续深入中文版《文本选择》文档 与 英文版 Text Selection 文档本文的骨架与 API 表格源头crates/base/src/text_selection.rs窗口级状态机、命中测试、区间投影、scope 栈与自动滚动的完整实现约 3600 行含大量#[gpui::test]测试crates/base/src/selectable_text.rs开箱即用的SelectableText元素及高亮四边形生成crates/base/src/text_boundary.rs双击词边界与三击逻辑行边界规则crates/base/examples/showcase/components/text_selection.rs可运行的多参与者 showcase 及其滚动测试。若你的界面由多个自绘文本元素组成、却希望用户像阅读连续文本一样完成选择与复制gpui-base的这套窗口级选择基础设施就是为它准备的把排版留给参与者把手势与区间交给窗口状态一套接入、处处一致。【免费下载链接】gpui-kitRust GUI components for building fantastic cross-platform desktop application by using GPUI.项目地址: https://gitcode.com/GitHub_Trending/gp/gpui-kit创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考