顶部横栏窗口拖动:回归防护
August 25, 2026 · View on GitHub
适用范围
修改 src/app.rs 的自定义标题栏、仓库标签、工具栏、窗口控制按钮、主题应用路径时,必须遵守本文。
已知问题
顶部横栏曾出现两个表现:
- 难拖:窗口拖动区与菜单、标签、工具栏或窗口控制按钮重叠,指针事件互相抢占;或可拖区域过窄。
- 卡顿 / 像冻结:拖动帧重复做样式重建,或拖动请求短路了正常内容绘制。
原问题记录:Codex 聊天 019f1b4c-75fe-74c2-879e-643d1def7257。早期助手诊断已被聊天上下文压缩;以下规则由现有源码与回归测试核验。
不可破坏的实现规则
-
原生窗口拖动只能从排除前景控件后的空白区域启动。
- egui 可在整行标题背景上注册透明候选交互,但原生
StartDrag必须通过TitleBarHitMap排除菜单和窗口控制按钮的实际矩形。logo 是纯装饰,仍属于可拖动空白区。 - 标题栏候选区顶部保留
TITLE_DRAG_TOP_INSET,避开无边框窗口的缩放命中边缘。 - 源仓库页面可使用标题行和标签条之间的空白缝隙。
- 不得将标签区、右侧工具区、菜单区、窗口控制按钮区注册为窗口拖动区。
- egui 可在整行标题背景上注册透明候选交互,但原生
-
在空白热区的左键按下帧、且在任何刷新和布局前请求原生窗口移动。
- 标题栏的
StartDrag由request_native_title_drag_on_pointer_down在App::update的第一步根据同一套几何边界发出;不能等待TopBottomPanel、菜单或仓库 UI 绘制完毕。 Sense::click_and_drag()仍用于标题空白区的双击等 egui 交互,但不能作为原生移动的 触发时机。- 不得在菜单、标签、工具栏、窗口按钮热区发送该命令。
- 标题栏的
-
拖动请求不能停止内容绘制。
- 发送
StartDrag后,当前帧仍须完整绘制CentralPanel和其余 UI。 - 不得以“本帧已请求拖动”为由提前返回
update。
- 发送
-
拖动热路径不得做重活。
- 正常版本不写日志(包括内存队列)、不构建调试字符串、不扫描仓库、不做阻塞操作。
- 当前用户要求复现诊断,临时日志只能记录少量原子数值;鼠标松开后才格式化并写入按日期分割的
window-drag-YYYY-MM-DD.log。验收后必须删除。 - 需要排查布局时,使用受控辅助线;验收后删除。不要在拖动路径留下诊断代码。
-
主题仅在值变化时应用。
update使用theme::apply_if_needed,不得每帧直接调用theme::apply。theme::apply会构建Visuals、克隆Style并调用ctx.set_style,不应进入持续拖动帧。
-
无边框窗口的尺寸调整必须显式处理。
with_decorations(false)会使客户区覆盖原生边框;不能假设 Windows 仍会自动给出四边和四角的 调整大小命中。- 在
App::update的开始处,边缘按下应优先发送ViewportCommand::BeginResize;只有未命中 尺寸调整边缘时,才可判定标题栏StartDrag。 - 悬停四边/四角必须设置对应调整大小光标。最大化窗口不注册这些热区。
当前设计
const TITLE_DRAG_TOP_INSET: f32 = WINDOW_RESIZE_BORDER + 1.0;
fn title_drag_candidate_rect(rect: Rect) -> Rect {
Rect::from_min_max(
Pos2::new(rect.left(), rect.top() + TITLE_DRAG_TOP_INSET),
rect.right_bottom(),
)
}
标题背景下方注册整行透明候选交互,随后绘制 logo、菜单和最小化/最大化/关闭按钮。绘制时只把菜单
和窗口按钮的实际矩形汇总为 TitleBarHitMap,装饰性 logo 不进入排除区;下一次鼠标按下时,
App::update 在任何布局和刷新之前
使用该命中图排除控件,仅在剩余空白区域立即发送 StartDrag。候选交互负责双击最大化等 egui
行为,不能代替原生按下帧判定。
命中图同时记录窗口矩形、DPI 与语言。任一值变化时旧图视为过期,该次不确定的按下不得启动拖动; 当前帧完成标题栏布局后会生成新图。这样窗口缩放或语言切换不会复用旧的按钮边界,也不需要把菜单 宽度硬编码为某个固定保留值。
透明拖拽层在 egui 中不能“只渲染一次后继续接收事件”,因为 egui 是即时模式;它的绘制交互可缓存
边界矩形,但原生 StartDrag 与悬停光标必须从当前 screen_rect 直接推导。窗口缩放会改变右边界,
若原生判定复用上一帧缓存,缩放后的首次按下会命中旧区域,表现为新增空白处拖不动或热区错位。
本次根因与禁止回退
- 旧实现把
ViewportCommand::StartDrag放在response.drag_started()。该事件会在鼠标移动越过 拖动阈值后的后续帧才出现;egui 0.31 / winit 要求窗口移动命令紧跟左键按下,延后调用会失效。 - 后续把命令移到标题栏绘制结束处,并临时保留点击日志;日志证明按下到命令之间仍会经过
约 19–30ms 的刷新与布局。该延迟足以让原生拖动偶发失效,因此必须在
update起始处发出, 并彻底移除诊断热路径。 - 主题 token、颜色和
theme::apply_if_needed没有改动标题栏命中或StartDrag代码。颜色改动不是 直接原因;它至多让原有时序缺陷更容易暴露。 - 不得把主热区缩成窗口最上缘或标题栏底部的 6–8px 条带:前者会与 Windows 缩放边缘竞争,后者过窄, 容易在首次移动后才命中,重现同一时序问题。
- 正确组合:保留标题栏中部的大块空白热区;在
App::update的首步根据按下坐标发送StartDrag;drag_started()可用于标签排序等应用内拖放,但不能用于启动原生窗口移动。 - 无边框窗口无法调整大小的根因通常不是
with_min_inner_size,而是客户区已覆盖系统边框却未派发BeginResize。最小尺寸只约束成功开始后的拖动结果,不能替代边缘命中处理。 - “有时能拖、有时不能”通常不是 Windows 随机失效,而是命令晚于按下事件或空白热区与交互控件 重叠。整行透明候选层不能直接等价为整行原生拖动区;必须先应用前景控件排除命中图。
- 缩放后不得无条件复用上一帧的前景控件矩形。原生判定先比较
TitleBarHitMap的窗口矩形、DPI 与 语言;不一致时拒绝拖动并等待当前布局重建命中图。 - 窗口尺寸持久化不得在主鼠标按下或按住期间同步保存。曾有实现以
!primary_down || elapsed >= 600ms作为保存条件;尺寸变更停留 600ms 后,下一次按下标题栏会在StartDrag之前触发配置序列化、加密和系统密钥库访问,造成拖动迟钝甚至错过原生拖动时机。只可在 鼠标松开且静默期结束后保存,并用request_repaint_after等待该时刻。 - Windows 上不得依赖 winit 0.30 的
drag_window()状态门来启动无边框窗口拖动。一次没有进入WM_ENTERSIZEMOVE/WM_EXITSIZEMOVE的标题栏短按可能让其内部dragging标志永久停留为true,随后所有StartDrag/BeginResize请求都会被丢弃。Windows 路径应在按下帧直接ReleaseCapture并投递WM_NCLBUTTONDOWN(标题栏使用HTCAPTION,缩放使用对应边角命中值); 其他平台继续使用 egui 的 viewport 命令。
修改后的验收
- 100%、125%、150% DPI:抓住标题栏中部空白区域可连续移动窗口。
- 菜单、标签、工具按钮、最小化/最大化/关闭按钮可正常点击,不触发窗口移动。
- 源仓库页面:标题行与标签条之间的空白缝隙可拖动,标签自身仍可切换和排序。
- 快速反复拖动 10 秒:内容持续绘制,无白屏、跳帧或明显卡顿。
- 未最大化时,四边和四角显示对应尺寸调整光标,并可调整到但不小于配置的最小尺寸。
- 修改主题后立即生效;普通帧不重复设置
Style。 - 保持或补充下列测试:
window_drag_request_keeps_content_paintingupdate_skips_reapplying_unchanged_theme_during_window_drag_frames- 标题栏与源仓库标签拖动区相关测试