From 2eec4a6bbc679751dcb27ca91093226c8e81e4a4 Mon Sep 17 00:00:00 2001 From: chinboy Date: Sun, 19 Jul 2026 17:53:59 +0800 Subject: [PATCH] docs(Dialog): correct Linux overlay geometry design --- ...7-19-linux-overlay-dialog-bounds-design.md | 206 ++++++++++-------- 1 file changed, 114 insertions(+), 92 deletions(-) diff --git a/docs/superpowers/specs/2026-07-19-linux-overlay-dialog-bounds-design.md b/docs/superpowers/specs/2026-07-19-linux-overlay-dialog-bounds-design.md index 9db46cbbc..da654949f 100644 --- a/docs/superpowers/specs/2026-07-19-linux-overlay-dialog-bounds-design.md +++ b/docs/superpowers/specs/2026-07-19-linux-overlay-dialog-bounds-design.md @@ -1,17 +1,22 @@ -# Linux Overlay Dialog 可见客户区与阴影边界修复设计 +# Linux Overlay Dialog 主体、Mask 与阴影边界修复设计 ## 1. 背景与目标 Dialog/MessageBox 生命周期重构把 Overlay Dialog 的 mask 与 surface 合并到同一个 -`OverlayDialogPresenter`,但新的布局实现把整个 TopLevel OverlayLayer 当作 owner 可用区。 -在 Linux Wayland CSD 窗口中,OverlayLayer 还包含窗口阴影和标题栏使用的装饰区域,因此出现三个同源问题: +`OverlayDialogPresenter`。重构后的布局把整个 TopLevel visual layer 当成 Dialog 可用区,遗漏了 Linux +窗口标题栏和装饰几何。第一轮修复又把三个语义不同的矩形合并成一个 `ownerBounds`,并把 Dialog 自身的 +BoxShadow 当成布局边距,因而只部分修复了问题,并引入了新的可见错误: -- Dialog 可以拖到标题栏下面,header 和正文被标题栏覆盖。 -- 拖动约束只使用 `DialogSurface.Bounds`,没有计算绘制在 Bounds 外的 BoxShadow。 -- Mask 与 Dialog 没有使用同一个可见客户区,标题栏下沿会暴露本应被遮挡的 Dialog 或页面内容。 +- Dialog 主体虽不再进入标题栏,但拖动和最大尺寸为自身 BoxShadow 预留了空白。 +- `HostWidth` / `HostHeight` 的有效可用范围被 Dialog BoxShadow 缩小,与主体尺寸契约不一致。 +- Mask 复用标题栏以下的 Dialog 可用区,没有覆盖标题栏和窗口边框。 +- Mask 再次扣除窗口左右及底部装饰,与既有 `WindowVisualLayerClip` 形成二次内缩,底边和圆角处出现缝隙。 -本次修复的目标是让 Linux Overlay Dialog 的 mask、placement、拖动、缩放和最大化共享一个准确的可见客户区, -并让普通状态 Dialog 的完整阴影始终位于该区域内。 +本次修复把以下三套几何彻底分开: + +1. **Mask bounds**:覆盖完整 visual layer,由窗口的统一 frame clip 裁成可见窗口轮廓。 +2. **Dialog body bounds**:仅表示标题栏以下、窗口阴影以内的 Dialog 主体可用区。 +3. **Dialog BoxShadow extents**:纯绘制外扩,不参与主体测量、尺寸限制或位置约束。 本设计是 [`2026-07-17-dialog-messagebox-lifecycle-redesign-design.md`](2026-07-17-dialog-messagebox-lifecycle-redesign-design.md) @@ -21,10 +26,10 @@ Dialog/MessageBox 生命周期重构把 Overlay Dialog 的 mask 与 surface 合 包含: -- Linux Wayland CSD 窗口的标题栏、窗口阴影和客户区几何。 +- Linux Wayland CSD 窗口的标题栏、窗口阴影、圆角和客户区几何。 - Linux 非 CSD 窗口的 managed title bar 与 frame shadow 几何。 - Overlay Dialog 的 mask、初始定位、拖动、缩放、最大化和恢复。 -- Dialog 自绘 BoxShadow 的四边外扩。 +- Dialog 自绘 BoxShadow 与 `HostWidth` / `HostHeight` 的尺寸语义。 - owner resize、窗口状态和装饰几何变化后的重新约束。 - 回归测试和目标 Desktop Controls 测试。 @@ -38,124 +43,137 @@ Dialog/MessageBox 生命周期重构把 Overlay Dialog 的 mask 与 surface 合 ## 3. 根因 -### 3.1 Owner bounds 退化为整个 OverlayLayer +### 3.1 Dialog body bounds 退化为整个 visual layer -`OverlayDialogPresenter.ResolveOwnerBounds` 当前直接返回: +TopLevel popup visual layer 保持窗口 surface 的完整坐标系;Linux 正常窗口的该坐标系还包括窗口阴影和标题栏区域。 +Dialog 主体如果直接使用整层 bounds,就可以进入标题栏或窗口阴影区域。 -```text -Rect(0, 0, layerWidth, layerHeight) -``` +AtomUI Window 已经提供两组平台几何: -Linux CSD 下,`VisualLayerManager` 覆盖整个 TopLevel surface;实际页面内容则通过 -`WindowDecorationMargin` 内缩到窗口阴影内沿和标题栏下方。Presenter 忽略该 margin 后, -Dialog 的 Y 最小值仍是 0,所以可进入标题栏区域。 +- CSD 的 `WindowDecorationMargin` 与内容 Panel 使用同一边界,表示窗口内容布局范围。 +- 非 CSD 的 `FrameShadowThickness` 加 visible managed title bar 表示标题栏以下的主体范围。 -重构前的 `OverlayDialogHost.CalculateOwnerBounds` 已经使用 `WindowDecorationMargin` 处理 CSD, -该平台几何在重构时没有迁移到新的 Presenter。 +这些值只应用于 Dialog 主体,不应用于 mask。 -### 3.2 Surface bounds 不包含 overlay shadow +### 3.2 Dialog BoxShadow 被误当成主体布局边距 -`DialogSurface` 内的 `ShadowsAwareContainer` 使用 `IsOverlayMode=True`。此模式保持 Dialog 主体测量尺寸不变, -并在主体 Bounds 外绘制 shadow。当前 `ConstrainSurfacePosition` 只约束主体矩形,因此主体贴边时阴影必然越界或被裁剪。 +`DialogSurface` 的 `PART_ShadowHost` 使用 `ShadowsAwareContainer.IsOverlayMode=True`。该模式明确保持 child 的测量和 +arrange 尺寸不变,只把 BoxShadow 绘制到主体 Bounds 外。当前 Presenter 却读取该 BoxShadow thickness: -### 3.3 Mask 与 surface 分别依赖偶然层级 +- 用它再次缩小 `_surface.MaxWidth` / `_surface.MaxHeight`。 +- 用它把拖动、缩放和启动位置的 clamp 向内推。 -Mask 当前 stretch 到整个 Presenter。CSD 标题栏由更高的 drawn-decoration overlay 覆盖,视觉结果依赖两个层级的偶然重叠, -而 Dialog placement 又使用整层 bounds。Mask、Dialog 和可见页面客户区没有共同几何真源,标题栏下沿因此可能出现错误露底。 +这与 `IsOverlayMode` 以及 `HostWidth` / `HostHeight` 表示 surface 主体尺寸的契约冲突。BoxShadow 是视觉装饰, +允许在窗口边缘由上层 frame clip 裁剪,不应制造主体与边界之间的空白。 + +### 3.3 Mask 错误复用 Dialog body bounds + +Mask 和 Dialog 主体当前都使用 `ResolveOwnerBounds`。这会让 mask 从标题栏下方开始,并在 CSD 下同时扣除 +`WindowDecorationMargin` 的左右和底部,导致 mask 没有完整覆盖窗口。 + +AtomUI Window 已有 `WindowVisualLayerClip`,它把整个 `VisualLayerManager` 按 `FrameShadowThickness` 和 Window +`CornerRadius` 统一裁剪,并刻意不改变 visual layer 坐标系。Mask 再构造一个内缩矩形或单独圆角,会重复窗口 +frame clip,产生边框间隙和抗锯齿接缝。 ## 4. 方案比较 -### 方案 A:Presenter 内统一可见客户区(采用) +### 方案 A:分离 mask、Dialog 主体和视觉阴影几何(采用) -`OverlayDialogPresenter` 从 owner TopLevel 解析一个 Linux 可见客户区,并把同一个 Rect 用于 mask、surface sizing、 -placement、拖动、缩放、最大化和恢复。普通 Dialog 在该 Rect 内再扣除自身 shadow extents。 +- Mask 填满 presenter visual layer。 +- Linux Dialog 主体单独解析标题栏以下的 body bounds。 +- Dialog BoxShadow 不进入任何尺寸或位置计算。 +- 窗口阴影和圆角只由 `WindowVisualLayerClip` 裁剪。 -优点:恢复被重构遗漏的平台语义;改动集中;不改变 DialogLayer、HostWidth/HostHeight 或公共契约。 +优点:每套几何只有一个职责和真源;符合既有 Window clip 与 `IsOverlayMode` 契约;不会重复圆角计算。 -### 方案 B:缩小整个 DialogOverlayLayer(不采用) +### 方案 B:手动计算 mask frame bounds 并绑定圆角(不采用) -让 `DialogOverlayLayer` 自身只覆盖内容区。 +单独使用 `FrameShadowThickness` 缩小 mask,再把 Window CornerRadius 复制给 mask。 -问题:会改变所有 Dialog 的 mask hit testing、嵌套层叠和激活范围,也会把平台窗口几何职责提升到多 Dialog 容器, -影响面大于本次修复。 +问题:重复 `WindowVisualLayerClip` 的职责;布局舍入和抗锯齿稍有差异就会再次产生细缝;窗口状态变化还需要额外同步。 -### 方案 C:让 shadow 参与 DialogSurface 测量(不采用) +### 方案 C:继续共享一个 bounds,通过特殊分支补边(不采用) -关闭 `ShadowsAwareContainer.IsOverlayMode` 或给 surface 增加 shadow padding。 +保留 `ownerBounds`,为 mask 清除部分 margin,并为 Dialog shadow 增加条件开关。 -问题:会改变 `HostWidth`/`HostHeight`、自然尺寸、resize handle、motion anchor 和 Window-hosted surface 的尺寸语义, -同时不能解决标题栏与 mask 几何错误。 +问题:继续混合不同坐标语义,容易在 CSD、非 CSD、最大化和装饰变化时产生新的边界组合 bug。 ## 5. 详细设计 -### 5.1 单一 owner bounds +### 5.1 Mask bounds -Presenter 解析 owner TopLevel;仅当 owner 是 AtomUI `Window` 且 `OsType=Linux` 时应用 Linux 装饰规则。 +`PART_MaskMotionActor` 始终使用 presenter 的完整 layer bounds: + +```text +maskBounds = Rect(0, 0, layerWidth, layerHeight) +``` + +在 AtomUI Window 中,mask 位于 `WindowVisualLayerClip` 下方的 VisualLayerManager 内,因此: + +- Mask 覆盖标题栏、内容和窗口可见边框。 +- 外部窗口阴影由 frame clip 排除,不会被 mask 着色。 +- Window CornerRadius 由同一个 frame clip 一次性裁剪,mask 不再建立第二套圆角边界。 +- 最大化和全屏时 Window 现有 CornerRadius / FrameShadowThickness 状态继续决定最终轮廓。 + +非 Linux 继续保持现有整层 mask 行为。 + +### 5.2 Linux Dialog body bounds + +Presenter 只为 Dialog 主体解析 Linux 可用区。 Linux CSD: ```text -ownerBounds = layerBounds deflated by WindowDecorationMargin +dialogBodyBounds = layerBounds deflated by WindowDecorationMargin ``` -`WindowDecorationMargin` 是平台和 Avalonia 共同发布的 CSD 内容边界,包含标题栏及窗口装饰占用, -不能再次叠加 `TitleBarHeight`。 +`WindowDecorationMargin` 与 CSD 内容 Panel 使用同一布局边界,已经包含标题栏和窗口装饰占用,不再叠加 +`TitleBarHeight`。 Linux 非 CSD: ```text -visibleFrame = layerBounds deflated by FrameShadowThickness -ownerBounds = visibleFrame excluding the visible managed title bar height +visibleFrame = layerBounds deflated by FrameShadowThickness +dialogBodyBounds = visibleFrame excluding the visible managed title bar height ``` -FullScreen 下标题栏不可见,不扣除 title bar;Maximized 下保留标题栏,但 frame shadow 通常为零。 - -非 Linux owner 继续使用整个 layer bounds,保持既有行为。 +FullScreen 下标题栏不可见,不扣 title bar;Maximized 下保留标题栏,但 frame shadow 通常为零。 所有 deflate 计算把负宽高截为零,避免极小窗口或异常装饰值传入 `Math.Clamp`。 -### 5.2 Mask 几何 +### 5.3 Dialog body size 与 BoxShadow -Modal mask 使用解析后的 `ownerBounds`: - -- 左上角等于客户区左上角。 -- 宽高等于客户区宽高。 -- 不进入标题栏或窗口阴影区域。 -- 下沿和页面客户区精确一致,不依赖 drawn title bar 覆盖 mask 来制造边界。 - -Mask 与 Dialog 继续由同一个 `OverlayDialogPresenter` 拥有,topmost、关闭和 motion 语义不变。 - -### 5.3 Shadow-aware surface bounds - -`DialogSurface` 继续保持主体尺寸语义。Presenter 在 template 已应用后读取 `PART_ShadowHost.BoxShadow.Thickness()`, -得到 `left/top/right/bottom` 外扩。 - -普通状态可用主体矩形为: +`HostWidth` / `HostHeight`、`HostMin*` 和 `HostMax*` 只约束 `DialogSurface` 主体: ```text -surfaceBounds.X = ownerBounds.X + shadow.Left -surfaceBounds.Y = ownerBounds.Y + shadow.Top -surfaceBounds.Width = ownerBounds.Width - shadow.Left - shadow.Right -surfaceBounds.Height = ownerBounds.Height - shadow.Top - shadow.Bottom +surface.MaxWidth <= dialogBodyBounds.Width +surface.MaxHeight <= dialogBodyBounds.Height ``` -初始 placement 仍按 Dialog 主体尺寸和现有 anchor/offset 计算,再 clamp 到 `surfaceBounds`。 -拖动和缩放使用同一 clamp,确保主体外加 shadow 后完全落在 `ownerBounds` 内。 +不再读取 `PART_ShadowHost.BoxShadow.Thickness()` 来缩小上述范围。显式 `HostWidth=320`、`HostHeight=180` 时, +surface 主体仍为 `320 × 180`;BoxShadow 在该矩形外绘制。 -`HostWidth`/`HostHeight` 仍表示主体尺寸;当请求尺寸大于可用主体矩形时,现有 min/max 解析会把实际 surface 尺寸限制到可用范围。 +初始 placement、拖动和缩放都直接 clamp 主体矩形: -非 Linux 不扣除 Dialog shadow,避免扩大本次行为变更范围。 +```text +dialogBodyBounds.Left <= surface.Left +surface.Right <= dialogBodyBounds.Right +dialogBodyBounds.Top <= surface.Top +surface.Bottom <= dialogBodyBounds.Bottom +``` + +当主体位于边缘时,外扩 BoxShadow 可以被 `WindowVisualLayerClip` 裁剪。Presenter 不为阴影预留空白。 ### 5.4 最大化与恢复 -最大化 Overlay Dialog 填充 Linux `ownerBounds`,因此不会覆盖标题栏或 window shadow。 -最大化仍使用无圆角 surface;恢复后重新读取当前 owner bounds、当前 shadow extents 和 Dialog placement。 +最大化 Overlay Dialog 填充 `dialogBodyBounds`,因此主体仍不会覆盖标题栏或窗口外部阴影。 +最大化继续使用无圆角 surface;恢复后按当前 body bounds 和 Dialog placement 重新布局。 -最大化不改变 Dialog 自绘 shadow 的主题契约。本次只保证最大化主体的客户区边界;普通状态的完整 shadow 是严格约束对象。 +Mask 独立填满 visual layer,不随 Dialog 最大化或恢复改变覆盖范围。 ### 5.5 更新时机与生命周期 -Presenter 在以下时机重新解析几何: +Presenter 在以下时机重新解析 Dialog body 几何: - 首次加入 DialogLayer 并完成 template/layout。 - DialogLayer/TopLevel size 变化。 @@ -163,23 +181,23 @@ Presenter 在以下时机重新解析几何: - Window CSD、WindowDecorationMargin、FrameShadowThickness、TitleBarHeight、标题栏可见性或 WindowState 变化。 - surface size 变化、最大化和恢复。 -新增订阅由 Presenter 现有 disposable owner 持有,并在 `DisposeAsync` 释放;不增加全局服务、timer、dispatcher delay 或 suppression flag。 +现有订阅继续由 Presenter 的 `_bindings` 持有并在 `DisposeAsync` 释放。不增加全局服务、timer、dispatcher delay、 +suppression flag 或新的 public contract。 ## 6. 测试设计 先添加失败回归,再修改生产代码。 -`OverlayDialogPresenterTests` 增加: +`OverlayDialogPresenterTests` 增加或修正: -1. Linux CSD modal mask 精确匹配标题栏以下的客户区,没有露底间距。 -2. Linux CSD Dialog 拖到顶部时,主体和 top shadow 都位于客户区内。 -3. Linux CSD Dialog 拖到左、右、下边界时,四边 shadow 都不越界。 -4. Linux owner resize 或装饰边界变化后,mask 与 Dialog 重新约束到同一 owner bounds。 -5. Linux 非 CSD managed title bar 不属于 Dialog 可用区。 -6. 非 Linux fixture 保留当前 layer bounds 与位置断言,防止范围外行为变化。 - -现有测试相应调整:原先“surface 主体贴到 Presenter 四边”的断言改为验证 Linux 的完整 visual bounds; -通用非 Linux 测试继续保持原断言。 +1. Linux CSD mask 的 origin 为 `(0, 0)`,尺寸等于完整 presenter layer,不扣标题栏或装饰边距。 +2. Linux 非 CSD mask 同样覆盖完整 layer;窗口 frame clip 是唯一外轮廓裁剪者。 +3. Linux CSD Dialog 拖到左上边界时,主体恰好贴合 `dialogBodyBounds`,不增加 Dialog shadow inset。 +4. Linux CSD Dialog 拖到右下边界时,主体 Bounds 不越界,但不为 BoxShadow 预留空间。 +5. 非零 Dialog BoxShadow 下,显式 `HostWidth` / `HostHeight` 仍精确表示主体尺寸。 +6. 请求尺寸超过客户区时,最大主体尺寸等于 `dialogBodyBounds`,不再额外扣 Dialog shadow。 +7. CSD decoration、owner resize 或窗口状态变化后,mask 仍覆盖完整 layer,Dialog 主体重新约束到当前 body bounds。 +8. 非 Linux fixture 保留整层 mask 与原位置断言,防止范围外行为变化。 验证命令: @@ -192,22 +210,26 @@ dotnet test tests/AtomUI.Desktop.Controls.Tests/AtomUI.Desktop.Controls.Tests.cs --framework net10.0 --no-restore \ --filter "FullyQualifiedName~Dialog|FullyQualifiedName~WindowResizeArtifactTests|FullyQualifiedName~WindowingPlatformDetectionTests" +dotnet test tests/AtomUI.Desktop.Controls.Tests/AtomUI.Desktop.Controls.Tests.csproj \ + --framework net10.0 --no-restore + git diff --check ``` Linux Wayland Gallery 手动验证: -- 打开 Modal Overlay,确认 mask 从标题栏下沿开始且没有缝隙。 -- 分别拖到上、下、左、右边界,确认标题栏/header/正文不互相覆盖,完整阴影保持可见。 +- 打开 Modal Overlay,确认 mask 覆盖标题栏、内容和四周可见边框,底边及圆角无缝隙。 +- 确认 mask 不着色窗口外部 compositor shadow。 +- 分别把 Dialog 拖到上、下、左、右边界,确认白色主体可以贴边且不进入标题栏。 +- 确认 Dialog 自身阴影不改变主体尺寸;靠边时允许被窗口 frame clip 裁剪。 - resize、maximize、restore owner 后重复上述检查。 ## 7. 契约与风险 - Public API/theme contract changed:No。 - Observable behavior changed:Yes,仅修正 Linux Overlay Dialog 的错误边界。 -- Rendered result changed:Yes,mask 与 Dialog 不再进入 Linux 标题栏/窗口阴影区域。 +- Rendered result changed:Yes,mask 完整覆盖窗口可见轮廓,Dialog 主体不再为自身阴影留白。 - Files split:No。 - AOT impact:No reflection、dynamic discovery 或新动态 binding。 -- Lifecycle risk:低;平台属性订阅必须进入现有 Presenter disposable owner。 +- Lifecycle risk:低;继续使用现有 Presenter disposable owner。 - Residual risk:当前开发环境不能直接运行 Linux Wayland compositor,最终仍需要 Linux Gallery 手动验证。 -