Skip to content

Commit e6694dd

Browse files
committed
refactor(toolbar): L1 几何单一真相源,删按钮坐标线性公式
工具栏按钮矩形原本被算三遍且互不引用(渲染 buildToolbarTree / 命中 HitTest / 定位 GetButtonBounds),改任一常量需同步三处。L1 统一为一次 buildToolbarTree+Layout 派生: - HitTest/GetButtonBounds/GetToolbarSize 删除各自的线性公式,统一查 computeGeometry() - GetButtonBounds = 按钮 View 的 Rect()(content 矩形) - HitTest = viewOuterRect(margin 盒,LayoutRow 使其首尾相接、平铺整条满高) - GetToolbarSize = root 尺寸 - 窗口尺寸魔数 116/30 收口为常量 toolbarBaseWidth/Height(不动 ScaleIntForDPI 缩放源) - 新增 TestToolbarGeometry_SingleSource 守护命中带/content 矩形/尺寸与旧公式逐像素等价 零视觉/交互变化(真机验证按钮点击/拖动/菜单/tooltip 定位均正常)。设计见 docs/design/theme-toolbar-geometry.md。
1 parent 38c0df9 commit e6694dd

6 files changed

Lines changed: 204 additions & 58 deletions

File tree

docs/design/AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
| `macos-imkit-plan.md` | **PR-A 实战手册**: macOS IMKit `.app` 工程目录结构 / Swift 类骨架 / Info.plist 模板 / 6 个开发里程碑 / 验证步骤 / 风险清单 |
2626
| `enum-constraint.md` | 枚举与魔法字符串约束 SSOT (跨模块共享) |
2727
| `theme-schema-v3.md` | **主题 Schema v3(当前权威设计语义)**:结构归一(删 layout 并入 views / 删 candidate_window 颜色组并入 colors 扁平 token)+ 亮暗统一(`LightDark<T>` 贯穿颜色与图片)+ base 单链继承 + Fill(色·渐变·图)/font 分组;含 v2.6→v3 迁移映射与编辑器原型(`WindInputThemeEditor/src/lib/theme3`)作参考实现 |
28+
| `theme-toolbar-geometry.md` | 工具栏几何重构:L1 几何单一真相源(HitTest/GetButtonBounds/GetToolbarSize 统一查 Layout 派生的 Rect,删线性公式)+ L2 盒模型化(几何进 schema、measure 生效、补 ResolveToolbarViews)规划 |
2829
| `theme-v3-freeze-report.md` | **主题 v3 冻结契约(当前权威)**:冻结字段面 + 求值语义快照 + 不兼容旧主题(不合法整体回退 default)+ golden 逐字节验收证据。历史演进/阶段文档(architecture/P2/P4-P8、v26-freeze)已移入 `archive/` |
2930
| `settings-incremental-save.md` | 设置端全局保存重构:快照 diff + 按 key 最小化提交(`Config.Set`)。根治「全局保存整份覆盖、formData 不含 stats 导致 null 冲掉 track_english」的 bug;独立段(stats/dict)天然隔离 |
3031

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
<!-- Parent: AGENTS.md -->
2+
<!-- Generated: 2026-06-04 | Updated: 2026-06-04 -->
3+
4+
# 工具栏几何重构(L1 几何单一真相源 + L2 盒模型化)
5+
6+
## 背景
7+
8+
输入法浮动工具栏(`internal/ui` 的 toolbar 系列,仅 Windows)当前几何还停留在
9+
"线性公式手算按钮坐标 + 几何与鼠标命中各算一套"的状态。候选窗早已迁到盒模型 View
10+
引擎(`viewbox.go``Layout`/measure/arrange),工具栏渲染虽也接了 `Layout`/`PaintTree`
11+
但用 `FixedW/FixedH` 把 measure 架空,且命中/边界查询仍走独立公式。
12+
13+
本次按颜色已迁 `toolbar_*` token(v3)之后的延续工作,分两步:**L1 几何单一真相源**
14+
(解耦,零视觉变化)、**L2 盒模型化**(几何进 schema,measure 真正生效)。
15+
16+
## 问题:按钮矩形被算了三遍(+窗口尺寸第四处)
17+
18+
| # | 位置 | 用途 | 形式 |
19+
|---|------|------|------|
20+
| 1 | `viewbox_toolbar.go buildToolbarTree` | 渲染布局 | View `FixedW + Margin` 隐式编码 x |
21+
| 2 | `toolbar_renderer.go HitTest` | 鼠标命中 | 独立线性累加 `gripW + n×buttonW` |
22+
| 3 | `toolbar_renderer.go GetButtonBounds` | tooltip/菜单定位 | 独立线性累加 + padding |
23+
| 4 | `toolbar_window.go:219/547` | Win32 窗口尺寸 | `ScaleIntForDPI(116/30)` 字面量 |
24+
25+
改任一常量(如 `buttonWidth`)需同步改 1/2/3 三处——这是耦合。#4 用的是不同缩放源
26+
`ScaleIntForDPI` vs 渲染的 `GetDPIScale`),属另一议题,L1 不动缩放基准。
27+
28+
## L1:几何单一真相源(已实现,零视觉变化)
29+
30+
**核心**:一切按钮矩形从同一次 `buildToolbarTree + Layout` 派生,删除 #2/#3 的线性公式。
31+
32+
仿候选窗 `renderTree → RenderResult.Rects → hitRects` 范式,但工具栏只 5 个 View、
33+
几何与状态无关(mode 文字变化不影响布局,因 `FixedW` 固定),故采用**无状态按需布局**
34+
不引入缓存(无缓存失效 / DPI 过期风险):
35+
36+
```
37+
computeGeometry() → 用零 state/零色 buildToolbarTree + Layout,提取:
38+
- size = root.Rect().Size() (GetToolbarSize)
39+
- bounds = 各按钮 View 的 Rect()(content 矩形) (GetButtonBounds)
40+
- hits = 各按钮 View 的 margin 盒(content 外扩 Margin)(HitTest)
41+
```
42+
43+
**等价性证明(faithful 关键)**
44+
-`HitTest` 命中语义 = 按 x 分段、忽略 y、按钮间无间隙 = 每个子 View 的 **margin 盒**
45+
`LayoutRow` 使 margin 盒首尾相接 → 平铺整条、满高。逐按钮核对:grip `[0,10)`
46+
mode `[10,36)`、width `[36,62)`、punct `[62,88)`、settings `[88,114)`(scale=1),与旧带界一致。
47+
-`GetButtonBounds` = content 矩形 = View `Rect()`(如 settings `Min.X=90`、宽 `buttonW-2pad=22`)。
48+
-`GetToolbarSize` = `(116,30)×scale` = root `Rect().Size()`(root `FixedW/FixedH` 即此值)。
49+
50+
`viewOuterRect(v)` = `v.Rect()` 外扩 `v.Margin`(Margin 是 View 自带数据,非重算公式)。
51+
52+
**附带**`toolbar_window.go` 的窗口尺寸字面量 `116/30` 换成包内常量
53+
`toolbarBaseWidth/toolbarBaseHeight`(消魔数,不改 `ScaleIntForDPI` 缩放源)。
54+
55+
**不变**:渲染(`Render`)与矢量符号后处理已用 `tt.X.Rect()`,本就单源,无需改。
56+
57+
## L2:盒模型化(规划,L1 绿后再开)
58+
59+
L1 解耦后,几何收口到 `buildToolbarTree`,可安全地让 measure 真正生效、几何进主题 schema:
60+
61+
1. **schema 补几何字段**`ToolbarViews` 增按钮 padding / gap / grip 宽 / 圆角等;默认值=当前常量(零回归)。
62+
2. **`FixedW`**:按钮尺寸由内容 measure + padding 决定,root 尺寸由 measure 汇总;`GetToolbarSize` 自然反映。
63+
3. **`ResolveToolbarViews`**`other_views.go` 当前唯一缺失槽位;走统一 `resolveViewNode`
64+
消费目前被忽略的 `ToolbarButtonNode.Border` 等字段。
65+
4. 协调 `GetDPIScale` vs `ScaleIntForDPI`,统一窗口尺寸到 `GetToolbarSize`#4 收口)。
66+
67+
## L3 愿景(远期,不在本次)
68+
69+
按钮内容动态化:每个状态指定开/关对应显示效果(文字 / 符号 / 图片),支持悬停特效。
70+
当前无视觉 hover(仅记录用于 tooltip)。
71+
72+
## 测试策略
73+
74+
- `TestBuildToolbarTree_Geometry`(既有):整条 116×30、按钮框高 26、mode 选色、settings `Min.X=90`——L1 后仍逐项绿(几何数值不变)。
75+
- 新增 `TestToolbarHitTest_SingleSource`:① 各按钮带中心点 `HitTest` 返回对应 kind;② `GetButtonBounds` == `buildToolbarTree+Layout` 的对应 `Rect()`;③ `GetToolbarSize` == root 尺寸;④ 命中带平铺无缝(相邻带界相接)。
76+
- `TestResolveToolbarViews_BaseAndMode`(既有):颜色不变。
77+
78+
## 风险与回滚
79+
80+
- L1 行为零变化由等价性证明 + 测试守护;如真机命中异常,回滚仅涉及 `toolbar_renderer.go` 三方法。
81+
- 缩放源差异(`GetDPIScale`/`ScaleIntForDPI`)刻意留到 L2,避免 L1 夹带 DPI 回归。

wind_input/internal/ui/AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@
6565
| `viewbox_tooltip.go` | **Tooltip View 化(P4-B)**`buildTooltipTree`(多行 LayoutColumn + `\t` 列对齐 LayoutRow,每列 `FixedW` 取最大宽对齐、缺列补空占位 cell;行数上限 20 / 单列截断 / 多列末列截断逻辑预处理)+ `(*TooltipWindow).resolveTooltipColors`(views.tooltip token > `Palette.Tooltip` > 默认)。`tooltip.go:render` 改走此路径 + `newSharedDrawContext`(取代旧 gg 直绘 + `getTooltipColors`);`truncateLineToWidth` 形参改 `TextMeasurer` 以可单测 |
6666
| `toolbar_window.go` | 工具栏 Win32 窗口创建和消息循环 |
6767
| `toolbar_window_event.go` | 工具栏鼠标事件(拖拽、按钮点击) |
68-
| `toolbar_renderer.go` | 工具栏渲染:`Render` 走盒模型 View 引擎(见 viewbox_toolbar.go),整条背景/边框/按钮框/mode 文字走 View,grip/全半角/标点/齿轮矢量符号后处理(`paintGrip`/`paintWidthSymbol`/`paintPunctSymbols`/`paintGear`,定位用 View rect);含 HitTest/GetButtonBounds/RenderTooltip(按钮悬停提示,仍 gg 直绘) |
68+
| `toolbar_renderer.go` | 工具栏渲染:`Render` 走盒模型 View 引擎(见 viewbox_toolbar.go),整条背景/边框/按钮框/mode 文字走 View,grip/全半角/标点/齿轮矢量符号后处理(`paintGrip`/`paintWidthSymbol`/`paintPunctSymbols`/`paintGear`,定位用 View rect)**L1 几何单一真相源**`HitTest`/`GetButtonBounds`/`GetToolbarSize` 不再各算线性公式,统一查 `computeGeometry()`(一次 `buildToolbarTree`+`Layout` 派生)——`GetButtonBounds`=按钮 View 的 `Rect()`(content),`HitTest`=`viewOuterRect`(margin 盒,平铺整条满高),`GetToolbarSize`=root 尺寸;设计见 `docs/design/theme-toolbar-geometry.md`。含 `RenderTooltip`(按钮悬停提示,仍 gg 直绘) |
6969
| `viewbox_toolbar.go` | **工具栏 View 化(P4-C)**`buildToolbarTree`(整条 LayoutRow:grip 占位 + 4 按钮框,按钮 Stretch 撑高 + margin,mode 是带背景文本叶子,width/punct/settings 是无 Text 框)返回 `toolbarTree`(各按钮 View 引用);`(*ToolbarRenderer).resolveToolbarViews`(button base 默认 FullWidthOff* + mode 中/英 token 覆盖,映射 `Palette.Toolbar`)。几何 hardcode×scale 与现状逐像素一致 |
7070
| `toolbar_shellhook.go` | 工具栏 Shell Hook 集成:`RegisterShellHookWindow` + 动态注册 `SHELLHOOK` 消息;拦截 `HSHELL_WINDOWENTERFULLSCREEN=53`/`HSHELL_WINDOWEXITFULLSCREEN=54` 通过 `ToolbarCallback.OnForegroundFullscreenChange` 派发 |
7171
| `popup_menu.go` | `PopupMenu`:自定义弹出菜单窗口,支持子菜单、勾选状态、主题;`Show`/`Hide`/`Destroy`;键盘导航通过全局低级键盘钩子(`WH_KEYBOARD_LL`)实现;子菜单共享父菜单渲染资源(`newPopupMenuShared`|

wind_input/internal/ui/toolbar_renderer.go

Lines changed: 57 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -164,73 +164,77 @@ func (r *ToolbarRenderer) paintGear(dc *gg.Context, rect image.Rectangle, scale
164164
dc.Fill()
165165
}
166166

167-
// HitTest determines which part of the toolbar was clicked
168-
func (r *ToolbarRenderer) HitTest(x, y, width, height int) ToolbarHitResult {
169-
scale := GetDPIScale()
170-
171-
// Check grip area
172-
gripW := int(gripWidth * scale)
173-
if x < gripW {
174-
return HitGrip
175-
}
176-
177-
// Check buttons
178-
buttonW := int(buttonWidth * scale)
179-
buttonX := gripW
167+
// toolbarHit 是一个按钮的命中带(kind + margin 盒;LayoutRow 使各 margin 盒首尾相接,平铺整条满高)。
168+
type toolbarHit struct {
169+
kind ToolbarHitResult
170+
rect image.Rectangle
171+
}
180172

181-
// Mode button
182-
if x >= buttonX && x < buttonX+buttonW {
183-
return HitModeButton
184-
}
185-
buttonX += buttonW
173+
// toolbarGeometry 是工具栏几何的单一真相源:一次 buildToolbarTree+Layout 的派生结果。
174+
type toolbarGeometry struct {
175+
size image.Point // 整条尺寸(GetToolbarSize)
176+
bounds map[ToolbarHitResult]image.Rectangle // 各按钮 content 矩形(GetButtonBounds)
177+
hits []toolbarHit // 各按钮 margin 盒,按 x 顺序(HitTest)
178+
}
186179

187-
// Width button
188-
if x >= buttonX && x < buttonX+buttonW {
189-
return HitWidthButton
190-
}
191-
buttonX += buttonW
180+
// viewOuterRect 返回 View 的 margin 盒(content 矩形外扩自身 Margin)。
181+
func viewOuterRect(v *View) image.Rectangle {
182+
r := v.Rect()
183+
return image.Rect(
184+
r.Min.X-v.Margin.Left, r.Min.Y-v.Margin.Top,
185+
r.Max.X+v.Margin.Right, r.Max.Y+v.Margin.Bottom,
186+
)
187+
}
192188

193-
// Punctuation button
194-
if x >= buttonX && x < buttonX+buttonW {
195-
return HitPunctButton
189+
// computeGeometry 用零 state/零色构建工具栏 View 树并 Layout,派生几何——命中/边界/尺寸的唯一来源。
190+
// 几何与 state/颜色无关(按钮 FixedW 固定、mode 文字不影响布局),故按需计算、无需缓存。
191+
func (r *ToolbarRenderer) computeGeometry() toolbarGeometry {
192+
scale := GetDPIScale()
193+
tt := buildToolbarTree(ToolbarState{}, theme.ResolvedToolbarViews{}, scale)
194+
Layout(tt.root, 0, 0, r.TextDrawer())
195+
return toolbarGeometry{
196+
size: tt.root.Rect().Size(),
197+
bounds: map[ToolbarHitResult]image.Rectangle{
198+
HitGrip: tt.grip.Rect(),
199+
HitModeButton: tt.mode.Rect(),
200+
HitWidthButton: tt.width.Rect(),
201+
HitPunctButton: tt.punct.Rect(),
202+
HitSettingsButton: tt.settings.Rect(),
203+
},
204+
hits: []toolbarHit{
205+
{HitGrip, viewOuterRect(tt.grip)},
206+
{HitModeButton, viewOuterRect(tt.mode)},
207+
{HitWidthButton, viewOuterRect(tt.width)},
208+
{HitPunctButton, viewOuterRect(tt.punct)},
209+
{HitSettingsButton, viewOuterRect(tt.settings)},
210+
},
196211
}
197-
buttonX += buttonW
212+
}
198213

199-
// Settings button
200-
if x >= buttonX && x < buttonX+buttonW {
201-
return HitSettingsButton
214+
// HitTest determines which part of the toolbar was clicked(查 Layout 派生的命中带,无独立公式)。
215+
func (r *ToolbarRenderer) HitTest(x, y, width, height int) ToolbarHitResult {
216+
pt := image.Pt(x, y)
217+
for _, h := range r.computeGeometry().hits {
218+
if pt.In(h.rect) {
219+
return h.kind
220+
}
202221
}
203-
204222
return HitNone
205223
}
206224

207-
// GetButtonBounds returns the bounds of a specific button
225+
// GetButtonBounds returns the bounds of a specific button(查 Layout 派生的 content 矩形)。
208226
func (r *ToolbarRenderer) GetButtonBounds(button ToolbarHitResult) (x, y, w, h int) {
209-
scale := GetDPIScale()
210-
height := int(toolbarBaseHeight * scale)
211-
gripW := int(gripWidth * scale)
212-
buttonW := int(buttonWidth * scale)
213-
padding := int(buttonPadding * scale)
214-
215-
switch button {
216-
case HitGrip:
217-
return 0, 0, gripW, height
218-
case HitModeButton:
219-
return gripW + padding, padding, buttonW - padding*2, height - padding*2
220-
case HitWidthButton:
221-
return gripW + buttonW + padding, padding, buttonW - padding*2, height - padding*2
222-
case HitPunctButton:
223-
return gripW + buttonW*2 + padding, padding, buttonW - padding*2, height - padding*2
224-
case HitSettingsButton:
225-
return gripW + buttonW*3 + padding, padding, buttonW - padding*2, height - padding*2
227+
rect, ok := r.computeGeometry().bounds[button]
228+
if !ok {
229+
return 0, 0, 0, 0
226230
}
227-
return 0, 0, 0, 0
231+
return rect.Min.X, rect.Min.Y, rect.Dx(), rect.Dy()
228232
}
229233

230-
// GetToolbarSize returns the toolbar size
234+
// GetToolbarSize returns the toolbar size(Layout 后 root 尺寸)。
231235
func (r *ToolbarRenderer) GetToolbarSize() (width, height int) {
232-
scale := GetDPIScale()
233-
return int(toolbarBaseWidth * scale), int(toolbarBaseHeight * scale)
236+
sz := r.computeGeometry().size
237+
return sz.X, sz.Y
234238
}
235239

236240
// CreateModeIndicatorColor returns the color for mode indicator

wind_input/internal/ui/toolbar_window.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -216,8 +216,8 @@ func (w *ToolbarWindow) Create() error {
216216
style := uint32(WS_POPUP)
217217

218218
// Initial size - match toolbarBaseWidth/Height in toolbar_renderer.go
219-
w.width = ScaleIntForDPI(116)
220-
w.height = ScaleIntForDPI(30)
219+
w.width = ScaleIntForDPI(toolbarBaseWidth)
220+
w.height = ScaleIntForDPI(toolbarBaseHeight)
221221

222222
hwnd, _, err := procCreateWindowExW.Call(
223223
uintptr(exStyle),
@@ -544,8 +544,8 @@ func (w *ToolbarWindow) Destroy() {
544544
func (w *ToolbarWindow) handleDPIChanged() {
545545
// Recalculate toolbar size with new DPI
546546
w.mu.Lock()
547-
w.width = ScaleIntForDPI(116)
548-
w.height = ScaleIntForDPI(30)
547+
w.width = ScaleIntForDPI(toolbarBaseWidth)
548+
w.height = ScaleIntForDPI(toolbarBaseHeight)
549549
w.mu.Unlock()
550550

551551
// Re-render with the new DPI scale

wind_input/internal/ui/viewbox_toolbar_test.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,3 +75,63 @@ func TestBuildToolbarTree_Geometry(t *testing.T) {
7575
t.Errorf("settings 框左缘应 90, got %d", tt2.settings.Rect().Min.X)
7676
}
7777
}
78+
79+
// TestToolbarGeometry_SingleSource 守护 L1:命中/边界/尺寸均从一次 buildToolbarTree+Layout 派生,
80+
// 且与旧线性公式逐项等价(scale=1:grip=10, button=26, pad=2, H=30)。
81+
func TestToolbarGeometry_SingleSource(t *testing.T) {
82+
m := fixedMeasurer{charW: 10}
83+
tt := buildToolbarTree(ToolbarState{ChineseMode: true}, theme.ResolvedToolbarViews{}, 1.0)
84+
Layout(tt.root, 0, 0, m)
85+
86+
// content 矩形(GetButtonBounds 语义)= 旧 GetButtonBounds 公式
87+
type rectWant struct {
88+
name string
89+
v *View
90+
minX, minY, w, h int
91+
}
92+
for _, c := range []rectWant{
93+
{"grip", tt.grip, 0, 0, 10, 30},
94+
{"mode", tt.mode, 12, 2, 22, 26},
95+
{"width", tt.width, 38, 2, 22, 26},
96+
{"punct", tt.punct, 64, 2, 22, 26},
97+
{"settings", tt.settings, 90, 2, 22, 26},
98+
} {
99+
r := c.v.Rect()
100+
if r.Min.X != c.minX || r.Min.Y != c.minY || r.Dx() != c.w || r.Dy() != c.h {
101+
t.Errorf("%s content=(%d,%d,%d,%d), 期望(%d,%d,%d,%d)",
102+
c.name, r.Min.X, r.Min.Y, r.Dx(), r.Dy(), c.minX, c.minY, c.w, c.h)
103+
}
104+
}
105+
106+
// 命中带(HitTest 语义)= margin 盒:与旧 x-分段一致、满高 [0,30)、首尾相接平铺
107+
type bandWant struct {
108+
name string
109+
v *View
110+
minX, maxX int
111+
}
112+
prevMax := 0
113+
for _, b := range []bandWant{
114+
{"grip", tt.grip, 0, 10},
115+
{"mode", tt.mode, 10, 36},
116+
{"width", tt.width, 36, 62},
117+
{"punct", tt.punct, 62, 88},
118+
{"settings", tt.settings, 88, 114},
119+
} {
120+
hb := viewOuterRect(b.v)
121+
if hb.Min.X != b.minX || hb.Max.X != b.maxX {
122+
t.Errorf("%s 命中带 x=[%d,%d), 期望[%d,%d)", b.name, hb.Min.X, hb.Max.X, b.minX, b.maxX)
123+
}
124+
if hb.Min.Y != 0 || hb.Max.Y != 30 {
125+
t.Errorf("%s 命中带应满高[0,30), got[%d,%d)", b.name, hb.Min.Y, hb.Max.Y)
126+
}
127+
if hb.Min.X != prevMax {
128+
t.Errorf("%s 命中带应与前带相接:期望 Min.X=%d, got %d", b.name, prevMax, hb.Min.X)
129+
}
130+
prevMax = hb.Max.X
131+
}
132+
133+
// 整条尺寸(GetToolbarSize 语义)
134+
if sz := tt.root.Rect().Size(); sz.X != 116 || sz.Y != 30 {
135+
t.Errorf("整条尺寸应 116x30, got %dx%d", sz.X, sz.Y)
136+
}
137+
}

0 commit comments

Comments
 (0)