Bevy 可编辑文本滚动机制迁移:从 TextScroll 组件到 EditableText::viewport
在 Bevy 的 UI 文本编辑(EditableText)系统中,滚动状态的存储方式经历了一次结构性调整:bevy_ui::widget::TextScroll 组件被移除,取而代之的是直接内嵌在 EditableText 组件中的 bevy_text::TextViewport 视口。阅读完本文后,你将掌握旧版 TextScroll 到 EditableText::viewport 的逐项迁移方法,并理解视口尺寸同步(sync_editable_text_viewports)与光标自动显现(caret reveal)这两项新机制的底层实现原理。
迁移内容总览
根据官方迁移指南 _release-content/migration-guides/editable_text_scrolling.md(对应 PR #24634),本次变更包含三个要点:
| 旧版(已移除) | 新版(替代方案) | 说明 |
|---|---|---|
bevy_ui::widget::TextScroll 组件 |
EditableText::viewport(类型 bevy_text::TextViewport) |
EditableText::viewport.offset 是 TextScroll 的直接替代 |
scroll_editable_text 系统 |
无需替代 | 光标显现行为现在在 TextEdit 应用时自动处理 |
| (无) | 新系统 bevy_ui::widget::sync_editable_text_viewports |
将每个 EditableText 的视口尺寸与其对应 ComputedNode 的尺寸保持同步 |
核心思想是:滚动不再是一个需要外部系统驱动、单独挂在实体上的"附加组件",而是可编辑文本自身状态的一部分。视口的偏移(offset)由编辑操作直接修改,视口的尺寸(size)则由布局系统自动同步。
新类型 TextViewport:滚动状态的载体
TextViewport 定义在 crates/bevy_text/src/scroll.rs:
/// The region of the editable text layout visible to the user.
///
/// Scrolling changes the offset, size depends on the layout.
#[derive(Debug, Clone, Copy, Default, PartialEq, Reflect)]
pub struct TextViewport {
/// The top-left corner of the text viewport in text-layout coordinates.
pub offset: Vec2,
/// The size of the viewport in text-layout coordinates.
pub size: Vec2,
}
坐标约定来自该模块的文档说明(crates/bevy_text/src/scroll.rs):
- 坐标处于文本布局空间(text layout space),向右、向下增长;
- 采用"原点 + 尺寸"的表示法而非 min-max 矩形,因为尺寸一般是固定的,这样可避免浮点误差累积;
- 若某个轴上文本布局小于视口,则该轴的 offset 会被钳制为零(不可滚动)。
offset 字段就是旧 TextScroll 的直接替代。TextViewport 提供了一组滚动方法(crates/bevy_text/src/scroll.rs),理解它们有助于在自定义系统中直接操作视口:
rect():把视口转成Rect;clamp_inside(max):将offset钳制在0..=max - size内,防止滚动越界;scroll_by(displacement, max):按位移量滚动(连续、无量化),随后钳制;scroll_to(point, max):以最小移动量让指定点进入视野;scroll_by_lines(scroll_lines, content_size, line_bounds):按视觉行数量滚动,支持小数行(在行间距内插值),因此能兼容换行文本和不等高行,而不会把视口"吸附"到行边界;reveal_caret(caret, max, caret_margin, lines):以最小移动量让光标重新进入可见区域。
配套的 TextLineYBounds(crates/bevy_text/src/scroll.rs)描述单个视觉行的垂直边界,可直接从 Parley 的 Line 构造(from_line);scrollable_text_layout_width(crates/bevy_text/src/scroll.rs)则根据 LineBreak 策略(NoWrap/WordBoundary 保留溢出宽度,AnyCharacter/WordOrCharacter 只允许视口宽度)决定水平方向可滚动的范围,并在必要时纳入尾部光标的位置。
滚动操作统一由 TextEdit 驱动
迁移指南指出"文本视口完全通过 TextEdit 控制"(the text viewport is controlled exclusively through TextEdits)。crates/bevy_text/src/text_edit.rs 中 TextEdit 枚举新增了三个滚动变体:
/// Scroll vertically by the given number of visual lines, increasing downwards.
///
/// Fractional values scroll by the corresponding fraction of a visual line.
ScrollByLines(f32),
/// Scroll the minimum amount such that the given point is in view.
ScrollTo(Vec2),
/// Scroll by the given displacement
ScrollBy(Vec2),
它们的执行路径在 TextEdit::apply 中(crates/bevy_text/src/text_edit.rs):先 driver.refresh_layout() 刷新文本布局,再把 TextViewport 与可滚动内容尺寸(scroll_content_size,即布局宽高与光标矩形 max 的逐分量最大值)一起传入对应的视口方法。注意这三个滚动变体在 is_destructive() 中被归为非破坏性操作——它们只移动视野,不改变文本内容。
EditableText 组件(crates/bevy_text/src/editing.rs)则通过 pending_edits 队列与 apply_pending_edits 方法管理这些编辑:
pub struct EditableText {
/// A [`parley::PlainEditor`], tracking both the text content and cursor position.
pub editor: PlainEditor<TextBrush>,
/// The bounds of the visible portion of the text layout.
pub viewport: TextViewport,
/// Text edit actions that have been requested but not yet applied.
pub pending_edits: Vec<TextEdit>,
// ...
/// Cursor reveal margins as fractions of the viewport size.
pub cursor_margin: Vec2,
// ...
}
典型用法是通过 queue_edit 把滚动请求排队,随后由 Bevy 内部的 apply_text_edits 系统在更新周期的正确时机统一应用:
use bevy_text::{EditableText, TextEdit};
fn scroll_input_focus_to_bottom(
mut text_input: Query<&mut EditableText, (With<InputFocus>, With<Focus>)>,
) {
if let Some(mut editable) = text_input.get_single_mut() {
// 向下滚动 3 个视觉行
editable.queue_edit(TextEdit::ScrollByLines(3.0));
// 或者按位移量滚动(单位:文本布局坐标)
// editable.queue_edit(TextEdit::ScrollBy(bevy_math::Vec2::new(0.0, 20.0)));
// 或者滚动到某个点(最小移动量)
// editable.queue_edit(TextEdit::ScrollTo(bevy_math::Vec2::new(0.0, 60.0)));
}
}
apply_pending_edits 还会正确处理异步剪贴板场景(如 wasm32 目标上的粘贴):未完成的读取会被存到 pending_paste,其余编辑保持 FIFO 顺序留待后续帧处理(见 crates/bevy_text/src/editing.rs 的注释与实现)。
光标自动显现:scroll_editable_text 系统为何被移除
旧版中需要 scroll_editable_text 系统在滚动后单独处理"让光标重新可见";现在这一步被内联到了 TextEdit::apply 的执行路径中。观察 crates/bevy_text/src/text_edit.rs 可以发现,Insert、Backspace、Delete、方向键移动(Left/Right/WordLeft 等)等绝大多数编辑分支在驱动器操作之后都会紧跟一次 reveal_cursor(driver, viewport, cursor_margin) 调用:
fn reveal_cursor(
driver: &mut PlainEditorDriver<'_, TextBrush>,
viewport: &mut TextViewport,
cursor_margin: Vec2,
) {
driver.refresh_layout();
let Some(cursor) = cursor_reveal_rect(driver) else {
return;
};
let layout = driver.layout();
viewport.reveal_caret(
cursor,
Vec2::new(layout.full_width(), layout.height()),
cursor_margin,
layout.lines().map(|line| TextLineYBounds::from_line(&line)),
);
}
这里有一个值得注意的细节:cursor_reveal_rect(crates/bevy_text/src/text_edit.rs)返回的光标矩形会额外向外扩展一个数字 0 的字形 advance 宽度(取不到字形度量时回退为 0.6 × font_size)。这意味着横向滚动时除了把光标本身滚进视野,还会为"接下来要敲的字符"预留出一个字符宽度的空间——这正是模块文档中所描述的 "keeping one 0-advance of space visible from the caret position onward" 规则。
reveal_caret 的完整显现规则(crates/bevy_text/src/scroll.rs):
cursor_margin(EditableText字段,默认Vec2::splat(0.2),取值被钳制在0.0..=0.5,即视口尺寸的比例)在视口四边内缩出一个"光标安全区",光标在该区域内移动时视口保持不动;- 横向越出安全区时,以最小移动量把光标滚回区内;
- 纵向显现的是 Parley 光标矩形(覆盖整条视觉行),且偏移量会朝着滚动方向钳到下一个视觉行的起点,使上下移动光标后视口仍与某一行对齐;
- 若光标或其所在行在某轴上大到装不进安全区,则视口在该轴上改为以它为中心;
- 无论如何,视口不会滚出布局边界;某轴上视口大于布局时,该轴完全不滚动。
这些行为在 crates/bevy_text/src/scroll.rs 的单元测试中有大量精确断言覆盖,例如 text_view_reveal_caret_quantizes_vertical_scroll_to_lines(纵向显现会量化到行起点)、text_view_reveal_caret_rounds_margin_scroll_in_direction(边距滚动按方向取整)、text_view_reveal_caret_scrolls_horizontally_and_clamps(横向滚动与边界钳制)等;crates/bevy_text/src/text_edit.rs 的测试 cursor_navigation_reveals_cursor、scroll_right_includes_caret_reveal 则验证了"键盘导航后光标自动滚回视野"以及"右侧对齐空输入框光标也能被滚进视野"这类端到端行为。
sync_editable_text_viewports:视口尺寸与 UI 布局的同步
视口的 offset 由编辑操作驱动,而 size 则由 bevy_ui 的新系统维护。sync_editable_text_viewports 定义在 crates/bevy_ui/src/widget/text_input_layout.rs:
/// Syncs each [`EditableText`]'s viewport size with their `ComputedNode`'s content size before text edits are applied.
pub fn sync_editable_text_viewports(mut query: Query<(&mut EditableText, &ComputedNode)>) {
for (mut editable_text, computed_node) in &mut query {
let size = computed_node.content_box().size();
if editable_text.viewport.size != size {
editable_text.viewport.size = size;
editable_text.editor.set_width(Some(size.x));
}
}
}
该函数做了两件事:把视口尺寸同步为对应 UI 节点 content box 的尺寸;当尺寸变化时顺带更新 Parley PlainEditor 的布局宽度(set_width),保证文本换行依据的是最新视口宽度。注意文档注释强调它运行在"文本编辑被应用之前"——在 crates/bevy_ui/src/lib.rs 中它与 update_editable_text_layout 等文本布局系统一同注册,而 crates/bevy_ui_widgets/src/text_input.rs 中也显式使用了 .after(sync_editable_text_viewports) 来保证输入组件的后续系统读到的是最新视口尺寸。这个顺序保证了:每一帧编辑发生时,clamp_inside、reveal_caret 等操作所使用的 size 始终与 UI 节点实际尺寸一致。
与之配合的还有 update_editable_text_layout(crates/bevy_ui/src/widget/text_input_layout.rs):当获得输入焦点时立即调用 viewport.reveal_caret 把光标滚入视野,之后每帧都会按 scrollable_text_layout_width 的结果把 offset 重新钳制在可滚动范围内。也就是说,视口偏移的"上限检查"既有编辑路径中的即时钳制,也有布局系统每帧的兜底钳制,双保险防止任何途径(包括你手动写入 viewport.offset)造成越界。
迁移实操:旧写法与新写法的对照
迁移时通常涉及两类代码:读取/写入滚动位置,以及移除对已删除系统/组件的引用。
1. 移除 TextScroll 的插入与读取,改写 viewport.offset
旧版典型模式是把一个 TextScroll 组件挂到实体上,并在系统里更新它(可能还依赖 scroll_editable_text)。新写法直接操作 EditableText:
use bevy_text::{EditableText, TextEdit};
use bevy_ui::widget::sync_editable_text_viewports; // 无需手动注册,默认插件已包含
// 读滚动位置(直接替代旧的 TextScroll 值)
fn get_scroll_offset(q: Query<&EditableText>) -> bevy_math::Vec2 {
q.single().viewport.offset
}
// 直接写滚动位置:写入后会被布局系统的钳制逻辑约束在可滚动范围内
fn jump_scroll(q: Query<&mut EditableText>) {
q.single_mut().viewport.offset = bevy_math::Vec2::new(0.0, 40.0);
}
2. 若原来自己实现了滚动逻辑,改为排队 TextEdit
// 旧:自行计算 TextScroll 并 insert —— 现在应改用标准编辑通道
fn on_page_down(q: Query<&mut EditableText>) {
q.single_mut().queue_edit(TextEdit::ScrollByLines(-4.0));
}
3. 删除对 scroll_editable_text 的引用(例如自己注册系统时设置的 After(scroll_editable_text) 排序约束),光标显现现在由编辑应用自动完成,无需补偿逻辑。
迁移验证可以直接参考仓库内的现成用例:bevy_text 的滚动测试(cargo test -p bevy_text scroll)覆盖了行滚动、小数行、边界钳制、光标显现的全部规则;bevy_ui_widgets 的 crates/bevy_ui_widgets/src/text_input.rs 则演示了输入组件如何以 sync_editable_text_viewports 为顺序基准编排自己的系统。
小结
这次迁移把可编辑文本的滚动从"独立组件 + 独立系统"的两段式结构,收敛为 EditableText::viewport 单一状态源:offset 由 TextEdit(ScrollBy/ScrollTo/ScrollByLines)与编辑时的自动 reveal_cursor 共同维护,size 由 sync_editable_text_viewports 与 UI 布局同步,越界由 clamp_inside 及布局系统的每帧钳制兜底。对升级项目而言,只需做三件事:把 TextScroll 的读写替换为 viewport.offset、删掉 scroll_editable_text 相关引用、把自定义滚动逻辑迁移到 TextEdit 队列。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00