☰
BongoCat 模型窗口右键拖动缩放:从 ADR-0057 看无重建实时 Resize 的设计与实现
2026/10/1 22:38:09 网站建设 项目流程
  • 桌面应用

【免费下载链接】BongoCat

🐱 BongoCat — A cross-platform interactive desktop pet that brings fun to your desktop!

项目地址:https://gitcode.com/gh_mirrors/bong/BongoCat
点击查看免费下载

导读

本文以架构决策记录 ADR-0057(模型窗口用右键拖动缩放) 为骨架,拆解 BongoCat 桌面宠物在 Windows 与 macOS 上实现"按住右键拖动即缩放模型窗口"这一交互的完整设计:包括与右键上下文菜单的按键冲突消解、缩放值的位移映射与配置契约收敛、拖动起点的窗口几何反算,以及"不重建原生窗口与 GPU 资源、就地 resize"的实时渲染路径。读完本文,你将掌握这套交互在 bongocat-overlay crate 中的落点:便携判定逻辑位于 resize_drag.rs,Windows 侧由窗口过程 window_proc.rs 驱动,macOS 侧由 localNSEventmonitor(resize_monitor.rs)驱动,并理解它为什么能保证每次拖动结果都能被配置契约接受、不会累积舍入误差、也不会与 hover 隐藏功能相互打架。

背景:为什么窗口缩放曾经如此"重"

在 ADR-0057 之前,模型窗口的尺寸只有一个来源:配置项overlay.scale_percent。而且改变这个值意味着重建整个窗口:

  • OverlaySessionOptions::requires_window_recreation把scale_percent列为重建条件(该判定逻辑位于 session.rs 的tick中);
  • 重建会重新创建原生窗口(HWND / NSPanel)与 GPU 资源;
  • GpuModel::prepare还会重新加载全部模型纹理。

也就是说,旧交互只有"设置页滑块"这一条路,且每次调整都伴随一次肉眼可见的窗口重建与资源重载。此外,两个平台此前都不存在任何窗口 resize 通路:渲染尺寸在窗口创建时固化进 swapchain/RTV/mask 纹理(Windows)或CAMetalLayer的 drawable size(macOS),crates/bongocat-overlay/src/windows/与crates/bongocat-overlay/src/macos/里没有 resize 代码。

更麻烦的是按键已经被占用:右键当前被上下文菜单独占。Windows 拦截WM_CONTEXTMENU | WM_NCRBUTTONUP并转发成OverlayContextMenuRequest,macOS 用NSEventlocal monitor 监听RightMouseUp,两者都交给应用的muda菜单。任何新的右键交互都必须先解决这个"同一个按键、两种意图"的冲突。

旧版(pre-refactor分支的src/pages/main/index.vue)曾在主窗口实现过类似交互:

if (buttons !== 2 || !shiftKey) return // 右键 + Shift const delta = (movementX + movementY) * 0.5 catStore.window.scale = round(clamp(scale + delta, 10, 500))

它要求按住右键 + Shift,范围10–500,并且直接改window.scale(与设置页滑块同源)。ADR-0057 的产品实现没有移植这个交互,而是重新设计了一套更严格的方案。

核心决策一:用"位移阈值"区分拖动与菜单点击

旧版要求按住 Shift,是因为用mousemove无法区分"单击右键"与"按住右键拖动"。ADR-0057 用位移阈值直接解决同一个问题,且不要求 Shift:

  • 右键按下后,指针位移(欧氏距离)超过3px才算缩放(RESIZE_DRAG_THRESHOLD = 3.0);
  • 未越阈值的右键仍然是上下文菜单;
  • 越阈值之后,即使缩放没有实际变化,这次右键也不再弹菜单。

位移始终相对按下点测量,因此缓慢拖动不会因为单帧位移很小而被误判成单击,一次手抖也不会因为累计位移而被误判成拖动。这在 resize_drag.rs 的observe中有明确实现:先算(dx * dx + dy * dy).sqrt()是否超过阈值,dragging一旦置位就再也不会回退;测试 resize_drag.rs 专门验证了"单帧位移小但累计越过阈值仍算拖动",resize_drag.rs 验证了"不动就永不 resize"。

// resize_drag.rs 中的阈值判定(简化) let dx = pointer.0 - self.origin.0; let dy = pointer.1 - self.origin.1; if !self.dragging && (dx * dx + dy * dy).sqrt() <= RESIZE_DRAG_THRESHOLD { return None; // 还没越过阈值:仍是潜在的一次右键单击 } self.dragging = true;

核心决策二:位移→缩放的映射与配置契约收敛

映射公式沿用旧版的系数,但范围收敛到当前配置契约:

scale = clamp(按下时的缩放 + (dx + dy) * 0.5, 25, 400)
  • 系数RESIZE_DRAG_PERCENT_PER_PIXEL = 0.5:每个指针像素对应 0.5% 缩放。向右下拖动放大、向左上拖动缩小,向右上/左下拖动时两个分量相互抵消;
  • 范围25–400%(旧版是10–500%)与配置契约完全一致——OverlayConfig::scale_percent、OverlaySettings::is_valid以及 session.rs 的validate_options中25..=400的校验都是同一个边界。这样做的意义在于:拖动产生的结果必须总能被配置接受,否则一次拖动会产出一个存不进去的值,设置页滑块也找不到可显示的值。要放宽范围,需要同时改 schema、fixture、设置页滑块与校验,属于另一次契约变更(ADR 已明确说明)。

resize_drag.rs 的observe按requested.round().clamp(...)得到整数缩放,再通过ResizeBase::dimensions计算窗口尺寸:

// resize_drag.rs:尺寸由基准与缩放重新算出 pub(crate) fn dimensions(self, scale_percent: u16) -> (u32, u32) { let factor = f64::from(scale_percent) / 100.0; ( cover_window_dimension(self.width * factor), cover_window_dimension(self.height * factor), ) }

注意尺寸是由基准与缩放重新算出,而不是在旧尺寸上累加像素——因此连续拖动不会累积舍入误差。cover_window_dimension位于 dimensions.rs,负责ceil与最小/最大窗口尺寸钳制。

核心决策三:拖动起点反算自窗口实际宽度

拖动开始时,按下时的缩放不是读配置里的overlay.scale_percent,而是由窗口当前宽度反算:

scale = clamp(round(width / base * 100), 25, 400)

理由很关键:窗口几何与配置并不总是同步。用户拖动过一次、显示器 DPI 变了、或者手工编辑过配置之后,配置值与窗口实际大小会脱节。如果拿配置值当起点,第一次指针移动就会把窗口跳到另一个尺寸;反算让拖动始终从"用户看到的大小"继续。

ResizeBase::scale_percent_for_width实现了反算(resize_drag.rs),并对结果round().clamp(25, 400),保证写回的是一个合法值。测试 resize_drag.rs 验证了反算的往返一致性(350→100、700→200、175→50),并验证了越界宽度(1、100000)会被钳到契约边界;测试 resize_drag.rs 专门演示了"窗口在 420px 而配置仍写 100%(对应 350px)时,拖动从 420 开始、第一次移动得到 140%,而不是从 350 跳变"。

而ResizeBase本身要求两个维度都是有限正数(resize_drag.rs),非法值直接None,此时右键退化为普通菜单点击。

基准尺寸与 DPI 处理

100%的基准尺寸是default_overlay_window_dimensions(canvas),与窗口创建时使用的同一个值(dimensions.rs)。它由DEFAULT_OVERLAY_WINDOW_WIDTH与模型的CanvasInfo宽高比推导,保证非方形模型不会被拉伸。

单位问题由平台层处理,拖动状态机本身不需要知道 DPI:

  • Windows:拖动状态机工作在物理像素(SetWindowPos的单位)。resize_base_for_dpi把逻辑基准按窗口当前 DPI 换算成物理像素(geometry.rs),换算公式为(logical * dpi + 48) / 96。窗口过程在begin_resize时用GetDpiForWindow(hwnd)取当前DPI 而非创建时的 DPI,因为窗口移动到别的显示器后必须按那块屏幕的像素缩放(见 window_proc.rs 的注释)。
  • macOS:ResizeBase直接以点为单位,NSEvent::mouseLocation给出的也是屏幕坐标。

核心决策四:锚点与实时跟随——不重建窗口的就地 resize

锚点:左上角保持不动

窗口左上角保持不动,与旧版setSize的观感一致:

  • Windows:SetWindowPos(..., SWP_NOMOVE)只改尺寸不动位置;
  • macOS:NSWindowframe 原点在左下角,所以按"顶边不动"修正origin.y,即top = frame.origin.y + frame.size.height,再setFrame:display:设置origin = (frame.origin.x, top - size.height)。这是 resize_monitor.rs 的apply_resize所做的事,注释明确说明这样才能让右键拖动的手感等同于旧版setSize。

实时跟随:逐帧 resize,不重建、不重载纹理

拖动过程中窗口逐帧改尺寸,渲染器就地resize:

  • Windows:IDXGISwapChain1::ResizeBuffers+ 重建 RTV/staging/每个 mesh 的 mask target(见 session.rs 的sync_window_size与 session.rs 的resize);
  • macOS:setFrame:display:+ 重设 drawable size + 重建 mask 纹理。

与尺寸无关的资源——device、pipelines、composition graph、模型纹理、顶点/索引缓冲——全程保留。

值得注意 Windows 端的一个细节:在 session.rs 的resize中,ResizeBuffers之后会立即绘制一帧再返回窗口循环,否则 compositor 可能把刚 resize 的 HWND 显示成未初始化的透明帧(注释原文:"Fill it before returning to the window loop so the compositor cannot show the resized HWND as a transparent frame")。这正是 ADR 修订说明(2026-09-24)要解决的问题:缩放写回配置不再重建 HWND/NSPanel,统一改为在现有原生窗口上更新几何,并在尺寸变化后立即填充新的 swap-chain/drawable。

拖动状态在 tick 中的同步

每次 frame tick 都会调用self.overlay.sync_window_size()(session.rs),让 swap chain 与 mask target 跟上拖动直接改掉的窗口尺寸,然后再绘制。如果此时渲染器尺寸没变(例如没有拖动),resize会因尺寸相等而成为 no-op(window.rs 中先比较self.bounds()? == bounds)。

平台适配:窗口过程 vs NSEvent monitor

Windows的拖动状态存在OverlayWindowState.drag里,由窗口过程 window_proc.rs 驱动:

  • WM_NCRBUTTONDOWN | WM_RBUTTONDOWN触发begin_resize:取当前 DPI 换算基准、反算起点缩放、SetCapture(hwnd)捕获鼠标(保证指针移出窗口后消息仍能送达);
  • WM_NCRBUTTONUP | WM_RBUTTONUP之前的 move 消息喂给drag.observe,有变化就apply_resize(SetWindowPos+SWP_NOMOVE);
  • 松手时先取走 drag 状态再ReleaseCapture()——因为释放捕获会同步投递WM_CAPTURECHANGED,它会清空 drag 状态,顺序反了会把一次拖动变成菜单点击(window_proc.rs);
  • 越过阈值:drag.finish()若有新值就经resize_sender上报;未越阈值:走context_menu_sender弹菜单;
  • WM_CAPTURECHANGED(捕获被别的窗口抢走或系统取消)直接清空 drag,避免状态卡死。

macOS的 overlay 没有可做命中测试的窗口(NSPanel 无内容、不可交互),所以 localNSEventmonitor 是唯一能感知右键拖动的方式(resize_monitor.rs 的模块注释明确说明了这一点)。它用NSEventMask::RightMouseDown | RightMouseDragged | RightMouseUp注册,按windowNumber过滤本窗口的事件:

  • 坐标轴翻转:AppKit 屏幕坐标向上增长,而拖动数学假设 y 轴向下为正(旧版 webview 与 Windows 适配器都如此),所以resize_pointer()把 y 取反一次(resize_monitor.rs);
  • 不用窗口自身坐标空间——拖动过程中 frame 一直在变,同一屏幕位置会读出不同值;
  • monitor 看到进程内所有右键事件,这也是它必须把菜单请求交回应用的原因:monitor 与 tray 菜单不能同时认领同一次点击。

幂等:写回配置后的尺寸不再被乘第二次

缩放写回配置后,runtime snapshot 的变化会让 frame tick 走"原地尺寸更新"分支(而非重建)。该分支先判断窗口尺寸是否已经等于新缩放对应的尺寸:bounds_match_scale用abs_diff <= 1的 1px 容差(bounds.rs),吸收物理/逻辑换算的取整误差。相等时不再按比例重算——否则拖动刚设好的尺寸会被乘第二次;需要改变尺寸时只更新现有 HWND/NSPanel 与 swap-chain/drawable,并在返回 frame loop 前立即绘制一帧,不替换原生窗口。

// session.rs 中 tick 的缩放更新分支(结构示意) if next_options.scale_percent != self.options.scale_percent { let bounds = self.overlay.window.bounds()?; let bounds = if self.bounds_match_scale(bounds, next_options.scale_percent) { bounds // 拖动已把窗口设成这个尺寸:不再按比例重算 } else { bounds.rescale(self.options.scale_percent, next_options.scale_percent) }; self.overlay.resize(bounds)?; }

bounds_match_scale在 session.rs 中按窗口当前 DPI 重新换算基准后再比较(Windows 端),macOS 端同理。而requires_window_recreation(next_options)仍保留给真正需要重建的选项(如角半径变化),缩放单独走了就地路径。

核心决策五:持久化——overlay 只提请求,配置的写入方仍是应用

拖动结束时,最终缩放通过新的OverlayInteractionSinks::resize_sender报给应用(Windows 在WM_RBUTTONUP分支try_send(OverlayResizeOutcome { scale_percent }),macOS 在finish_resize同样try_send)。应用侧提交SettingsCommand::SetOverlaySettings写回overlay.scale_percent;窗口几何仍由既有的 placement 通路(OverlayWindowPlacementDebouncer→OverlayWindowPlacementChanged)落到window-state.json。overlay 只提出请求,配置的写入方仍然是应用——这与 ADR 一贯的"配置单一写入者"原则一致。

同时要注意ResizeDrag::finish的语义(resize_drag.rs):没有越过阈值(菜单点击)、或越过阈值但缩放没有实际变化时返回None——前者是菜单点击,后者没有需要持久化的新值。测试 resize_drag.rs 验证了"对角线位移相互抵消、缩放不变时不写回"。

边界行为:click-through 与 hover 隐藏的互斥

  • click-through:穿透模式下窗口返回HTTRANSPARENT(Windows,见 window_proc.rs 的命中测试分支)或ignoresMouseEvents(macOS),收不到指针消息,因此不可拖动、也没有右键菜单。这与既有的"非穿透模式才可拖动"一致,不是本次引入的限制。
  • 与 hover 隐藏的互斥:拖动进行期间hide_on_pointer_hover不再生效。该功能会把窗口淡出并让指针事件穿透,恰好会中断窗口自己正在执行的拖动。两个平台都在计算 hover 状态时把"拖动中"当作"指针不在窗口内":
    • Windows 在 session.rs 的update_hover_presentation中检查!self.overlay.window.is_resize_dragging();
    • macOS 的 resize_monitor.rs 提供is_resize_dragging(),判断drag状态是否存在。

因此拖动期间窗口保持可见,拖动结束后恢复原有的 hover 行为。

便携性设计:无 GPU 也可测试的判定内核

判定、映射、钳制与尺寸计算全部落在 resize_drag.rs,是纯计算模块,不依赖 GPU 与原生窗口;平台适配器只负责三件事:读取指针、改原生窗口尺寸、把最终缩放报出去。这个分层带来了极高的可测试性——resize_drag.rs内的mod tests覆盖了:

测试点验证内容源码位置
宽度反算往返350/700/175 → 100/200/50,357 → 102(取整防漂移)resize_drag.rs
越界钳制宽度 1 / 100000 分别钳到 25% / 400%同上
漂移窗口不跳变420px 窗口从 420 起算,不跳到配置的 350resize_drag.rs
基准合法性零/负/NaN/Inf 维度拒绝resize_drag.rs
不动不缩放原地右键永远不 resize、可弹菜单resize_drag.rs
阈值相对按下点单帧小位移累计越过阈值仍算拖动resize_drag.rs
放大/缩小方向右下放大 200%、左上缩小 50%、触底 25%(88px)resize_drag.rs
缩放钳制±5000px 拖动钳到 400% / 25%resize_drag.rs
无变化不写回对角线抵消:越过阈值但不弹菜单、不写回resize_drag.rs
同位重复观察同一位置只应用一次尺寸resize_drag.rs
非有限坐标忽略NaN / Inf 指针位置直接忽略resize_drag.rs
极端宽高比超宽模型缩到 25% 时尺寸下限优先,仍可被配置接受resize_drag.rs

影响与权衡(Consequences)

  • 行为变化:右键不再无条件弹菜单。按住右键移动超过3px开始缩放,松手后不弹菜单;原地按下松开仍是原来的菜单。
  • 行为变化:缩放范围是25–400%(旧版为10–500%)。这是配置契约的范围,放宽需要同时改 schema、fixture、设置页滑块与校验。
  • 性能权衡:拖动期间每个 frame tick 都会做一次 GPU resize——Windows 重建 render target、staging 纹理与全部 mask target,macOS 重建全部 mask 纹理。这些资源都很小,但拖动时的帧率低于静止时是预期结果,尤其在有 mask 的模型上。
  • 写回无重建:拖动结束后的写回不再重建 overlay 窗口,只让 runtime snapshot 与已完成的窗口几何对齐;设置页直接改缩放时,现有窗口也会原地调整尺寸并立即绘制新尺寸第一帧,避免未初始化透明 back buffer/drawable 暴露。
  • 单一配置来源:缩放值从此有两个来源(设置页与右键拖动),两者写入同一个overlay.scale_percent,因此设置页滑块在拖动结束后会显示拖动结果。
  • 位置与尺寸分离:scale_percent在config.json,实际几何在window-state.json。若两者不一致(例如手工编辑配置),下一次拖动会以当前窗口宽度反推的缩放为起点,并在结束时把两者对齐。

未验证项与后续关注

ADR 明确列出的未验证项(截至记录日期):

  • 两平台的实机拖动观感未经人眼核验;
  • Windows 的ResizeBuffers路径、macOS 的 drawable resize 与 mask 重建同样未经过真机验证;
  • 当前代码已通过 Windows 构建检查,但真实拖动、显示器/DPI 切换与 compositor 透明帧仍需目标硬件 smoke 测试。

这些是理解本 ADR 时必须注意的边界:文档描述的是已实现且通过构建检查的设计,而非已经过全平台实机验收的最终交互。

小结

ADR-0057 展示了一个典型的"与既有交互争抢同一按键"的架构决策过程:用欧氏位移阈值区分菜单与拖动、把结果钳制进配置契约保证可持久化、以窗口实际宽度反算起点避免跳变、以"就地 resize + 立即填充新帧"取代窗口重建、以 1px 容差的bounds_match_scale保证幂等。整个交互的可计算部分被剥离成 resize_drag.rs 的纯函数模块,平台差异收敛为"读指针、改几何、报结果"三个适配动作,是便携性分层与可测试性设计的直接范例。若需进一步了解窗口几何/placement 通路,可继续阅读 bounds.rs、placement.rs 与 dimensions.rs。

  • 桌面应用

【免费下载链接】BongoCat

🐱 BongoCat — A cross-platform interactive desktop pet that brings fun to your desktop!

项目地址:https://gitcode.com/gh_mirrors/bong/BongoCat
点击查看免费下载

相关推荐

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询