Skip to content

fix(ui): fix candidate position and DPI scaling on mixed-DPI screens - #1971

Open
MaaBlock wants to merge 1 commit into
rime:masterfrom
MaaBlock:fix/mixed-dpi-multi-monitor
Open

MaaBlock wants to merge 1 commit into
rime:masterfrom
MaaBlock:fix/mixed-dpi-multi-monitor

Conversation

@MaaBlock

@MaaBlock MaaBlock commented Oct 30, 2026 •

Copy link
Copy Markdown

问题描述

在混合 DPI 多显示器环境下(例如主屏 2560x1440 @ 150% DPI、副屏 1920x1080 @ 100% DPI),当非 Per-Monitor DPI Aware 的宿主程序(如 32 位桌面客户端、传统 System-DPI-Aware / Unaware 应用)位于副屏时,存在以下问题:

  1. 候选框飞回主屏:宿主线程处于 DWM DPI 虚拟化上下文,ITfContextView::GetTextExt 返回的是按宿主逻辑 DPI 缩放后的虚拟化坐标,而候选窗以 Per-Monitor DPI Aware V2 运行,直接将该坐标传给 SetWindowPos 会使候选框偏离输入框甚至落回主屏。
  2. 第二段输入起候选框字体过大且右侧被裁剪:
    • WeaselPanel 仅在 CreateWindowEx 时设置了 ThreadDpiAwarenessScope,而后续由宿主线程直接触发的 MoveTo / Refresh / _ResizeWindow / _Reposition 未切换线程 DPI 上下文,导致跨 DPI 操作 SetWindowPos 和 GetClientRect 被 Win32 按宿主线程 DPI 与窗口 PMv2 DPI 的比值二次缩放,触发 DirectComposition 交换链尺寸与窗口真实物理客户区尺寸失配。
    • 第一段输入上屏销毁布局(m_layout == nullptr)后,第二段输入首帧的 MoveTo(rc) 直接被丢弃,紧接着的 Refresh() 在原位置(或默认 CW_USEDEFAULT 所在主屏)按主屏 150% DPI 完成排版,随后才移回 100% 副屏且未重排。
    • _Reposition 原地覆写 m_inputPos.bottom = y,在跨屏触发 WM_DPICHANGED 重入或带阴影/吸底翻转重算时破坏幂等性。

修改内容

  1. TSF 坐标向物理屏幕坐标转换(WeaselTSF/Composition.cpp)
    • 当宿主线程非 Per-Monitor DPI Aware 时,解析有效顶层宿主窗口
      oot(显式判空)。
    • 若顶层窗口未跨显示器且目标矩形位于窗口内,通过窗口逻辑矩形到物理矩形的仿射映射还原物理坐标;若窗口横跨不同显示器或目标点超出窗口边界,则优先对左上/右下角逐点调用 LogicalToPhysicalPointForPerMonitorDPI。
  2. 统一抽取出公共 DPI API 动态加载与 ThreadDpiAwarenessScope(include/WeaselUtility.h、WeaselUI/WeaselPanel.cpp)
    • 在 WeaselUtility.h 中提供通过 InitOnceExecuteOnce 线程安全懒加载(兼容 /Zc:threadSafeInit- 与旧版系统)的 User32DpiApi 与 ThreadDpiAwarenessScope,供 WeaselTSF 与 WeaselUI 共用。
    • 在 WeaselPanel 跨线程上下文的外部入口(Create、ShowWindow、MoveTo、Refresh、RepositionPreview、_ResizeWindow、_Reposition、_CaptureRect)统一加上 ThreadDpiAwarenessScope;窗口过程 MsgHandler 由系统自动切换至窗口创建时的 PMv2 上下文,不重复包裹。
  3. 跨屏初始定位与 _Reposition 幂等化(WeaselUI/WeaselPanel.cpp)
    • Create() 使用父窗口物理左上角作为初始位置(替代恒落主屏的 CW_USEDEFAULT)。
    • MoveTo(rc) 在 m_layout == nullptr 时通过 _PrimeTargetMonitorPosition(rc) 记录 m_inputPos 并将窗口预置到目标显示器,确保后续 Refresh() 直接按目标显示器 DPI 排版。
    • 移除 _Reposition 对 m_inputPos.bottom 的原地写回及 �dj 分支,使 _Reposition() 对同一 m_inputPos 保持幂等。

手工回归测试清单(Regression Checklist)

  • 双屏不同 DPI 矩阵(主屏 150% + 副屏 100%,副屏位于左侧负坐标及右侧正坐标):
    • Per-Monitor DPI Aware V2 宿主(记事本、资源管理器、Edge/Chrome)在主/副屏连续多段输入。
    • System-DPI-Aware / DPI-Unaware 宿主(32 位桌面 QQ 等传统客户端)在主/副屏第 1 段、第 2+ 段连续输入,候选框位置紧贴光标、字号符合当前屏幕 DPI、无右侧/底部裁剪。
  • 跨显示器骑跨窗口:
    • 宿主窗口横跨 150% 主屏与 100% 副屏交界处,输入框分别位于主屏侧、交界处、副屏侧时输入定位正常。
  • _Reposition 布局与边界场景:
    • 开启阴影(shadow_radius > 0、shadow_offset_x/y 正/零/负值)时,MoveTo 与 Refresh 连续触发不产生纵向漂移。
    • 竖排候选(LAYOUT_VERTICAL + �ertical_auto_reverse)在屏幕底部边缘触发吸底上翻(m_sticky / m_istorepos),候选词增减、翻页及上屏结束后状态恢复正常。
    • 竖排文字(LAYOUT_VERTICAL_TEXT 左起/右起)及屏幕顶部/右侧工作区边界(
      cWorkArea)截断正常。

@MaaBlock
MaaBlock marked this pull request as draft October 30, 2026 14:11
@MaaBlock
MaaBlock marked this pull request as ready for review October 30, 2026 14:11
@MaaBlock MaaBlock changed the title fix(ui): resolve candidate window position and DPI scaling in non-PMv2 apps across mixed-DPI monitors fix(ui): fix candidate position and DPI scaling on mixed-DPI screens Oct 30, 2026
@MaaBlock
MaaBlock force-pushed the fix/mixed-dpi-multi-monitor branch from 54e7b80 to 4e3cb41 Compare October 30, 2026 14:30
@fxliang

fxliang commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

没完全看懂,让LLM看了下

合理的部分:

  • 在 TSF 出口统一转成物理坐标:CGetTextExtEditSession 拿到的是宿主 DPI 上下文下的坐标,而候选窗在 server/本地 TSF 中都以 PMv2 运行,转换后再交给 UI 是对的。
  • 给外部入口加 ThreadDpiAwarenessScope:MoveTo、Refresh、_ResizeWindow、Create 等不是窗口过程内部调用,确实可能运行在宿主线程的 System/Unaware 上下文中,SetWindowPos/GetClientRect 会被二次虚拟化。这里加 scope 是必要的。
  • MoveTo 在 m_layout == nullptr 时先 _PrimeTargetMonitorPosition:确实修复了“第二段输入 MoveTo 被丢弃、随后 Refresh 按主屏 DPI 布局”的问题。
  • 去掉 _Reposition 对 m_inputPos.bottom 的原地写回:这使 _Reposition 幂等,避免跨屏/WM_DPICHANGED 时重复叠加阴影偏移,方向正确。
  • 动态加载 API + InitOnceExecuteOnce 对 Win8.1 和 /Zc:threadSafeInit- 的兼容处理也合理。
    需要注意/建议修改的点:
  1. MsgHandler 里的 scope 基本是多余的。Windows 在调用窗口过程时本来就会自动切到窗口创建时的 DPI 上下文,PMv2 窗口的消息回调已经是 PMv2。这里每条消息多两次 SetThreadDpiAwarenessContext,收益很小,建议只保留真正跨上下文的外部入口(MoveTo/Refresh/Create 等)。
  2. “兼容跨显示器骑跨窗口”这个说法偏乐观。ScaleRectBetweenWindowBounds 用整个顶层窗口的逻辑矩形到物理矩形做仿射缩放,只在“整窗处于同一缩放比例”时严格正确;窗口横跨不同 DPI 显示器时不是严格分段映射。建议至少判断目标点/窗口是否跨屏,跨屏时走逐点 LogicalToPhysicalPointForPerMonitorDPI 或按显示器拆分。
  3. _Reposition 语义变化较大:现在 Refresh 里的 _Reposition() 也会应用阴影 Y 偏移并清 m_istorepos。从幂等角度是合理的,但会影响带阴影、竖排、吸顶/吸底等场景,建议补回归用例。
  4. 回退路径可能传入空 HWND:ResolveHostRootWindow 可能返回 nullptr,随后仍调用 LogicalToPhysicalPointForPerMonitorDPI(root, ...),建议显式判空。
  5. Composition.cpp 和 WeaselPanel.cpp 各写了一套 DPI API 动态加载逻辑,建议抽公共 helper,减少重复。
  6. 没有自动化测试/CI 检查;这种 UI 修复主要靠手动测试,至少把 PR 描述里的双屏矩阵和“跨屏骑跨”场景固化成回归清单。
    整体我认为可以合入,但建议先处理第 1、2 点;第 3 点是主要的行为风险点。

- Convert DWM-virtualized TSF GetTextExt coordinates from non-Per-Monitor-DPI host threads to physical screen coordinates via single-monitor window bounds mapping or per-point LogicalToPhysicalPointForPerMonitorDPI when straddling monitors.
- Share User32DpiApi (initialized via InitOnceExecuteOnce for /Zc:threadSafeInit-) and ThreadDpiAwarenessScope in WeaselUtility.h, and apply scope at external WeaselPanel entry points outside WindowProc.
- Preserve incoming input position and prime target monitor placement when MoveTo arrives before layout creation, spawn popup on parent's physical monitor instead of (0, 0), and keep _Reposition idempotent across cross-monitor DPI changes.
@MaaBlock
MaaBlock force-pushed the fix/mixed-dpi-multi-monitor branch from 4e3cb41 to 0cb6394 Compare November 3, 2026 15:17
@MaaBlock

MaaBlock commented Nov 3, 2026

Copy link
Copy Markdown
Author

感谢细致审阅!已针对上述 6 点建议完成修改并更新提交(�cb6394):

  1. 移除 MsgHandler 内冗余的 ThreadDpiAwarenessScope:
    • 已去掉 WeaselPanel::MsgHandler 入口的 ThreadDpiAwarenessScope,仅保留真正可能从宿主 System/Unaware 线程直接调用的外部入口(Create、ShowWindow、MoveTo、Refresh、RepositionPreview、_ResizeWindow、_Reposition、_CaptureRect)。
  2. 跨显示器骑跨窗口处理与边界判断:
    • 在 ConvertRectToPhysicalScreen 中增加了 IsRectWithinBounds 与 IsRectOnSingleMonitor(检查四角所属 HMONITOR):仅当顶层窗口在逻辑与物理坐标系下均处于同一显示器且输入矩形位于窗口内时,才使用 ScaleRectBetweenWindowBounds 整窗仿射映射;一旦窗口横跨多个不同显示器或输入点超出窗口边界,优先对左上/右下角逐点调用 LogicalToPhysicalPointForPerMonitorDPI(root, ...)。同步修正了代码注释与 PR 描述。
  3. 关于 _Reposition 幂等化对阴影、竖排与吸底翻转的影响说明:
    • 旧实现之所以区分 _Reposition(true)(MoveTo)与 _Reposition(false)(Refresh),是因为旧代码在函数末尾执行了 m_inputPos.bottom = y 原地写回,导致 Refresh 若再次减去阴影 offsetY 就会重复叠加。
    • 去掉 m_inputPos.bottom = y 后,m_inputPos 始终保持为 {rc.left, rc.top, rc.right, rc.bottom + 6} 基准值,因此 MoveTo 和 Refresh 调用 _Reposition() 都基于同一基准坐标计算一次阴影偏移,不会在 Refresh 时发生跳变;同时当候选框在屏幕底部触发上翻(y > rcWorkArea.bottom || m_sticky)时,无论从 MoveTo 还是 Refresh 进入都会一致地维持 m_sticky 与 m_istorepos,而在上屏结束(_UpdateHideCandidates 将 m_sticky 置 alse)后的 Refresh() 中也能及时将 m_istorepos 复位为 alse(修复了旧版 Refresh 因 �dj == false 残留旧 m_istorepos 的问题)。
  4. ResolveHostRootWindow 空句柄判空:
    • 已在 ConvertRectToPhysicalScreen 获取
      oot 后立即显式判空(if (!root) return logicalRect;),并在 TryConvertRectByPerMonitorDpiApi 内再次校验
      oot != nullptr,避免向 LogicalToPhysicalPointForPerMonitorDPI 传入空 HWND。
  5. 抽取公共 DPI API 动态加载 helper:
    • 已将 User32DpiApi、GetUser32DpiApi()(基于 InitOnceExecuteOnce)和 ThreadDpiAwarenessScope 统一抽取到 include/WeaselUtility.h,消除了 Composition.cpp 与 WeaselPanel.cpp 的重复定义。
  6. 回归测试清单:
    • 已将双屏 DPI 组合、跨屏骑跨窗口、带阴影、竖排自动翻转(�ertical_auto_reverse)及工作区吸顶/吸底场景整理固化到 PR 描述的回归清单中。

@MaaBlock

MaaBlock commented Nov 4, 2026

Copy link
Copy Markdown
Author

没完全看懂,让LLM看了下

合理的部分:

  • 在 TSF 出口统一转成物理坐标:CGetTextExtEditSession 拿到的是宿主 DPI 上下文下的坐标,而候选窗在 server/本地 TSF 中都以 PMv2 运行,转换后再交给 UI 是对的。
  • 给外部入口加 ThreadDpiAwarenessScope:MoveTo、Refresh、_ResizeWindow、Create 等不是窗口过程内部调用,确实可能运行在宿主线程的 System/Unaware 上下文中,SetWindowPos/GetClientRect 会被二次虚拟化。这里加 scope 是必要的。
  • MoveTo 在 m_layout == nullptr 时先 _PrimeTargetMonitorPosition:确实修复了“第二段输入 MoveTo 被丢弃、随后 Refresh 按主屏 DPI 布局”的问题。
  • 去掉 _Reposition 对 m_inputPos.bottom 的原地写回:这使 _Reposition 幂等,避免跨屏/WM_DPICHANGED 时重复叠加阴影偏移,方向正确。
  • 动态加载 API + InitOnceExecuteOnce 对 Win8.1 和 /Zc:threadSafeInit- 的兼容处理也合理。
    需要注意/建议修改的点:
  1. MsgHandler 里的 scope 基本是多余的。Windows 在调用窗口过程时本来就会自动切到窗口创建时的 DPI 上下文,PMv2 窗口的消息回调已经是 PMv2。这里每条消息多两次 SetThreadDpiAwarenessContext,收益很小,建议只保留真正跨上下文的外部入口(MoveTo/Refresh/Create 等)。
  2. “兼容跨显示器骑跨窗口”这个说法偏乐观。ScaleRectBetweenWindowBounds 用整个顶层窗口的逻辑矩形到物理矩形做仿射缩放,只在“整窗处于同一缩放比例”时严格正确;窗口横跨不同 DPI 显示器时不是严格分段映射。建议至少判断目标点/窗口是否跨屏,跨屏时走逐点 LogicalToPhysicalPointForPerMonitorDPI 或按显示器拆分。
  3. _Reposition 语义变化较大:现在 Refresh 里的 _Reposition() 也会应用阴影 Y 偏移并清 m_istorepos。从幂等角度是合理的,但会影响带阴影、竖排、吸顶/吸底等场景,建议补回归用例。
  4. 回退路径可能传入空 HWND:ResolveHostRootWindow 可能返回 nullptr,随后仍调用 LogicalToPhysicalPointForPerMonitorDPI(root, ...),建议显式判空。
  5. Composition.cpp 和 WeaselPanel.cpp 各写了一套 DPI API 动态加载逻辑,建议抽公共 helper,减少重复。
  6. 没有自动化测试/CI 检查;这种 UI 修复主要靠手动测试,至少把 PR 描述里的双屏矩阵和“跨屏骑跨”场景固化成回归清单。
    整体我认为可以合入,但建议先处理第 1、2 点;第 3 点是主要的行为风险点。

我主屏幕dpi缩放150%,副屏dpi100%,然后我在副屏用老版qq,就会出现,内容放大了150%,输入法窗口只有100%,导致只显示了2/3,然后让ai顺手修了

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants