顶部横栏窗口拖动:回归防护

August 25, 2026 · View on GitHub

适用范围

修改 src/app.rs 的自定义标题栏、仓库标签、工具栏、窗口控制按钮、主题应用路径时,必须遵守本文。

已知问题

顶部横栏曾出现两个表现:

  • 难拖:窗口拖动区与菜单、标签、工具栏或窗口控制按钮重叠,指针事件互相抢占;或可拖区域过窄。
  • 卡顿 / 像冻结:拖动帧重复做样式重建,或拖动请求短路了正常内容绘制。

原问题记录:Codex 聊天 019f1b4c-75fe-74c2-879e-643d1def7257。早期助手诊断已被聊天上下文压缩;以下规则由现有源码与回归测试核验。

不可破坏的实现规则

  1. 原生窗口拖动只能从排除前景控件后的空白区域启动。

    • egui 可在整行标题背景上注册透明候选交互,但原生 StartDrag 必须通过 TitleBarHitMap 排除菜单和窗口控制按钮的实际矩形。logo 是纯装饰,仍属于可拖动空白区。
    • 标题栏候选区顶部保留 TITLE_DRAG_TOP_INSET,避开无边框窗口的缩放命中边缘。
    • 源仓库页面可使用标题行和标签条之间的空白缝隙。
    • 不得将标签区、右侧工具区、菜单区、窗口控制按钮区注册为窗口拖动区。
  2. 在空白热区的左键按下帧、且在任何刷新和布局前请求原生窗口移动。

    • 标题栏的 StartDragrequest_native_title_drag_on_pointer_downApp::update 的第一步根据同一套几何边界发出;不能等待 TopBottomPanel、菜单或仓库 UI 绘制完毕。
    • Sense::click_and_drag() 仍用于标题空白区的双击等 egui 交互,但不能作为原生移动的 触发时机。
    • 不得在菜单、标签、工具栏、窗口按钮热区发送该命令。
  3. 拖动请求不能停止内容绘制。

    • 发送 StartDrag 后,当前帧仍须完整绘制 CentralPanel 和其余 UI。
    • 不得以“本帧已请求拖动”为由提前返回 update
  4. 拖动热路径不得做重活。

    • 正常版本不写日志(包括内存队列)、不构建调试字符串、不扫描仓库、不做阻塞操作。
    • 当前用户要求复现诊断,临时日志只能记录少量原子数值;鼠标松开后才格式化并写入按日期分割的 window-drag-YYYY-MM-DD.log。验收后必须删除。
    • 需要排查布局时,使用受控辅助线;验收后删除。不要在拖动路径留下诊断代码。
  5. 主题仅在值变化时应用。

    • update 使用 theme::apply_if_needed,不得每帧直接调用 theme::apply
    • theme::apply 会构建 Visuals、克隆 Style 并调用 ctx.set_style,不应进入持续拖动帧。
  6. 无边框窗口的尺寸调整必须显式处理。

    • 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 的首步根据按下坐标发送 StartDragdrag_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_painting
    • update_skips_reapplying_unchanged_theme_during_window_drag_frames
    • 标题栏与源仓库标签拖动区相关测试