|
| 1 | +<!-- Updated: 2026-06-08 --> |
| 2 | + |
| 3 | +# 候选数量分档:基础档 + 扩展档(可统一对接的模式驱动) |
| 4 | + |
| 5 | +## 背景与目标 |
| 6 | + |
| 7 | +当前"每页候选数"是**单一全局配置** `ui.candidates_per_page`(默认 7)。五笔用户普遍偏好较少候选以保持候选窗干净,但在以下场景又希望看到更多候选: |
| 8 | + |
| 9 | +- 临时拼音(不确定编码时切拼音找字) |
| 10 | +- 快捷输入(分号/z 等触发的快捷短语) |
| 11 | +- `zzbd` 这类**码表内的快捷短语**(命中后是一批多字短语候选) |
| 12 | +- 纯拼音方案(拼音引擎本身候选量大) |
| 13 | + |
| 14 | +目标:让候选数量**按输入场景自动分档**,普通码表输入用"基础档"(少而干净),上述场景自动切到"扩展档"(多而全)。并且——**新增场景时有统一、低成本的对接方式**,不必每次改动核心判定逻辑。 |
| 15 | + |
| 16 | +## 现状分析:单一配置的贯穿链路 |
| 17 | + |
| 18 | +`candidatesPerPage` 是一个 `int`,从配置经 Coordinator 贯穿到分页、UI 渲染、提交记录三处: |
| 19 | + |
| 20 | +| 位置 | 文件:行 | 作用 | |
| 21 | +|------|---------|------| |
| 22 | +| 配置字段 | `pkg/config/config.go:202` | `CandidatesPerPage int` | |
| 23 | +| 默认值 | `pkg/config/config.go:455` | 默认 7 | |
| 24 | +| 运行时字段 | `internal/coordinator/coordinator.go:227` | `candidatesPerPage int` | |
| 25 | +| 初始化 | `coordinator.go:558-561` | 从 cfg 读,缺省 9 | |
| 26 | +| 热更新 | `handle_config.go:22-23` | 配置变更时重算 | |
| 27 | +| 分页总数 | `handle_candidates.go:511,770` | `totalPages` 计算 | |
| 28 | +| 当前页切片 | `handle_candidates.go:546-547` | `startIdx/endIdx` | |
| 29 | +| 发送 UI | `handle_candidates.go:615` | 传给 `sendCandidates` | |
| 30 | +| 数字键选择 | `handle_key_event.go:720,747` | `pageStart = (page-1)*perPage` | |
| 31 | +| 提交页内索引 | `coordinator.go:968,971` | `index % candidatesPerPage` | |
| 32 | + |
| 33 | +**关键观察**:`candidatesPerPage` 与输入模式完全解耦,所有使用点都是"每次现取"。因此分档逻辑可**完全收敛在 Coordinator 层**,无需改动引擎层或 UI 层——只要把这些使用点统一改成调用一个"取有效值"的方法即可。 |
| 34 | + |
| 35 | +## 设计概览 |
| 36 | + |
| 37 | +``` |
| 38 | + ┌─────────────────────────────┐ |
| 39 | + 各输入模式/候选结果 ──▶│ extendedReasons (bitset) │ |
| 40 | + └──────────────┬──────────────┘ |
| 41 | + │ 只读 |
| 42 | + ┌──────────────▼──────────────┐ |
| 43 | + 分页/切片/选择/提交 ◀─│ effectiveCandidatesPerPage()│ |
| 44 | + └─────────────────────────────┘ |
| 45 | +``` |
| 46 | + |
| 47 | +- 保留 `candidates_per_page`(**基础档**)。 |
| 48 | +- 新增 `candidates_per_page_extended`(**扩展档**,`int`,`0` 表示禁用、始终用基础档)。 |
| 49 | +- 运行时维护一个位标志 `extendedReasons`,记录"当前有哪些原因要求扩展候选"。 |
| 50 | +- 所有使用点改调 `effectiveCandidatesPerPage()`:只要 `extendedReasons != 0` 且扩展档已配置,就返回扩展档,否则基础档。 |
| 51 | + |
| 52 | +## 配置层变更 |
| 53 | + |
| 54 | +### Go 端(`pkg/config/config.go`) |
| 55 | + |
| 56 | +`UIConfig` 紧邻 `CandidatesPerPage` 新增: |
| 57 | + |
| 58 | +```go |
| 59 | +CandidatesPerPage int `yaml:"candidates_per_page" json:"candidates_per_page"` |
| 60 | +// CandidatesPerPageExtended 扩展档每页候选数。在临时拼音/快捷输入/短语/拼音引擎等 |
| 61 | +// 场景下生效。<=0 表示禁用扩展档(始终用基础档,向后兼容);正值有效,上界 |
| 62 | +// clamp 到 15(与基础档上限一致)。判定"是否启用"统一由 >0 决定,故无需下界 clamp。 |
| 63 | +CandidatesPerPageExtended int `yaml:"candidates_per_page_extended,omitempty" json:"candidates_per_page_extended,omitempty"` |
| 64 | +``` |
| 65 | + |
| 66 | +默认值(`config.go:455` 附近):`CandidatesPerPageExtended: 0`(新装默认不启用,保持现有行为;用户主动配置后才分档)。 |
| 67 | + |
| 68 | +> **配置语义**:`0` = 关闭分档(向后兼容,老用户行为不变)。这避免了"新版本静默改变老用户候选数"的意外。 |
| 69 | +
|
| 70 | +### 前端镜像(enum-constraint §前后端镜像要求) |
| 71 | + |
| 72 | +- `wind_setting/frontend/src/api/settings.ts:115` 的 `UIConfig` 接口加 `candidates_per_page_extended: number;` |
| 73 | +- 同文件 `:388` 附近 default 加 `candidates_per_page_extended: 0,` |
| 74 | +- `wind_setting/frontend/src/schemas/appearance.schema.ts:44` 的"每页候选数"slider 下方新增一条 slider(详见下文 UI 段)。 |
| 75 | + |
| 76 | +## 运行时核心:extendedReasons 位标志 |
| 77 | + |
| 78 | +### 类型与字段(`coordinator.go`) |
| 79 | + |
| 80 | +```go |
| 81 | +// ExtendedCandidateReason 标记"当前为何需要扩展候选档"。 |
| 82 | +// 纯运行时内部状态,多个原因可同时成立,故用位标志。 |
| 83 | +// 注意:本类型**不序列化、不进 YAML、不跨进程**,因此不受 docs/design/enum-constraint.md |
| 84 | +// 约束(该约束针对会进 YAML 的有限取值字符串配置)。1<<iota 是 Go bitset 标准惯用法。 |
| 85 | +type ExtendedCandidateReason uint32 |
| 86 | + |
| 87 | +const ( |
| 88 | + ExtendedReasonTempPinyin ExtendedCandidateReason = 1 << iota // 临时拼音模式 |
| 89 | + ExtendedReasonQuickInput // 快捷输入模式 |
| 90 | + ExtendedReasonPinyinEngine // 当前为拼音/混输引擎 |
| 91 | + ExtendedReasonPhraseCands // 当前候选含 PhraseLayer 短语 |
| 92 | + // 未来新增场景:在此追加一行常量,effectiveCandidatesPerPage() 无需改动 |
| 93 | +) |
| 94 | +``` |
| 95 | + |
| 96 | +Coordinator 新增字段(与 `candidatesPerPage` 相邻,`coordinator.go:227` 附近): |
| 97 | + |
| 98 | +```go |
| 99 | +candidatesPerPage int |
| 100 | +candidatesPerPageExtended int |
| 101 | +extendedReasons ExtendedCandidateReason |
| 102 | +``` |
| 103 | + |
| 104 | +### 单一裁判:effectiveCandidatesPerPage() |
| 105 | + |
| 106 | +```go |
| 107 | +// effectiveCandidatesPerPage 返回当前应使用的每页候选数。 |
| 108 | +// 这是分档的唯一裁判,新增扩展场景时**无需改动本函数**—— |
| 109 | +// 只需让对应场景在 extendedReasons 上置/清自己的位。 |
| 110 | +func (c *Coordinator) effectiveCandidatesPerPage() int { |
| 111 | + if c.candidatesPerPageExtended > 0 && c.extendedReasons != 0 { |
| 112 | + return c.candidatesPerPageExtended |
| 113 | + } |
| 114 | + return c.candidatesPerPage |
| 115 | +} |
| 116 | +``` |
| 117 | + |
| 118 | +辅助方法: |
| 119 | + |
| 120 | +```go |
| 121 | +func (c *Coordinator) setExtendedReason(r ExtendedCandidateReason) { c.extendedReasons |= r } |
| 122 | +func (c *Coordinator) clearExtendedReason(r ExtendedCandidateReason) { c.extendedReasons &^= r } |
| 123 | +``` |
| 124 | + |
| 125 | +> **并发**:`set/clear/读取` 全部发生在 `HandleKeyEvent → ... → sendCandidates` 链路内,调用方已持 `c.mu`,无额外加锁需求。与现有 `candidatesPerPage` 的访问约束一致。 |
| 126 | +
|
| 127 | +## 两类登记方式(统一对接契约) |
| 128 | + |
| 129 | +审查中发现:四个 reason 的生命周期**不是同一种**,必须分两类登记,否则会出现"删回普通字但扩展档没收回"的 bug。 |
| 130 | + |
| 131 | +### A. 事件型(有明确激活/退出入口) |
| 132 | + |
| 133 | +`set` 与 `clear` 严格配对,挂在模式的进入/退出处: |
| 134 | + |
| 135 | +| Reason | 置位点 | 清位点 | |
| 136 | +|--------|--------|--------| |
| 137 | +| `ExtendedReasonTempPinyin` | `handle_temp_pinyin.go` 各 `tempPinyinMode = true` 处(:164, :332) | 各 `tempPinyinMode = false` 处(:218, :363)+ `clearState()` | |
| 138 | +| `ExtendedReasonQuickInput` | 快捷输入激活入口(`quickInputMode = true`) | 退出入口 + `clearState()` | |
| 139 | + |
| 140 | +> **兜底**:`clearState()`(`coordinator.go:976` 附近,重置全部输入态)统一 `c.extendedReasons = 0`,保证任何异常退出路径都不会残留事件型位。 |
| 141 | +
|
| 142 | +### B. 派生型(每次候选刷新后重新评估) |
| 143 | + |
| 144 | +无固定生命周期,是"当前查询状态"的派生属性。在 `updateCandidatesEx()` 末尾(`handle_candidates.go`,候选列表确定后)统一调用一次 `refreshDerivedExtendedReasons()` 重算: |
| 145 | + |
| 146 | +```go |
| 147 | +// refreshDerivedExtendedReasons 重算"可从当前查询状态派生"的扩展原因。 |
| 148 | +// 每次候选更新后调用,确保同一 composition 内增删字符时分档实时跟随。 |
| 149 | +func (c *Coordinator) refreshDerivedExtendedReasons() { |
| 150 | + // 引擎型:当前引擎是拼音/混输 → 置位,否则清位 |
| 151 | + if c.engineMgr != nil { |
| 152 | + t := c.engineMgr.GetCurrentType() |
| 153 | + if t == engine.EngineTypePinyin || t == engine.EngineTypeMixed { |
| 154 | + c.setExtendedReason(ExtendedReasonPinyinEngine) |
| 155 | + } else { |
| 156 | + c.clearExtendedReason(ExtendedReasonPinyinEngine) |
| 157 | + } |
| 158 | + } |
| 159 | + // 内容型:候选列表含 PhraseLayer 短语(PhraseTemplate 非空)→ 置位,否则清位 |
| 160 | + hasPhrase := false |
| 161 | + for i := range c.candidates { |
| 162 | + if c.candidates[i].PhraseTemplate != "" { |
| 163 | + hasPhrase = true |
| 164 | + break |
| 165 | + } |
| 166 | + } |
| 167 | + if hasPhrase { |
| 168 | + c.setExtendedReason(ExtendedReasonPhraseCands) |
| 169 | + } else { |
| 170 | + c.clearExtendedReason(ExtendedReasonPhraseCands) |
| 171 | + } |
| 172 | +} |
| 173 | +``` |
| 174 | + |
| 175 | +> 派生型必须 **set/clear 对称重算**(不能只 set),否则用户从 `zzbd`(短语)退格回普通码字时,扩展档不会收回。 |
| 176 | +
|
| 177 | +### 新增场景的对接流程(这就是"统一对接方式") |
| 178 | + |
| 179 | +未来加新模式,只需判断它属于哪一类,二选一: |
| 180 | + |
| 181 | +1. **事件型**(有明确进入/退出): |
| 182 | + - 在 `ExtendedCandidateReason` 追加一个常量; |
| 183 | + - 在激活入口 `setExtendedReason(新常量)`,退出入口 `clearExtendedReason(新常量)`; |
| 184 | + - `clearState()` 已统一清零,无需额外处理。 |
| 185 | +2. **派生型**(从查询/候选状态可判断): |
| 186 | + - 在 `ExtendedCandidateReason` 追加一个常量; |
| 187 | + - 在 `refreshDerivedExtendedReasons()` 加一段 set/clear 对称判断。 |
| 188 | + |
| 189 | +**两种情况都不需要改 `effectiveCandidatesPerPage()`**。这是本设计满足开闭原则的核心。 |
| 190 | + |
| 191 | +## 代码修改点清单 |
| 192 | + |
| 193 | +### 必改:使用点切换到 effectiveCandidatesPerPage() |
| 194 | + |
| 195 | +将下列直接读 `c.candidatesPerPage` 的点替换为 `c.effectiveCandidatesPerPage()`: |
| 196 | + |
| 197 | +- `handle_candidates.go:511` `totalPages` 计算 |
| 198 | +- `handle_candidates.go:546-547` `startIdx/endIdx` 切片 |
| 199 | +- `handle_candidates.go:615` 传 `sendCandidates` 的 perPage 参数 |
| 200 | +- `handle_candidates.go:770` 重算分页 |
| 201 | +- `handle_key_event.go:720,747`(及该文件其余数字键分支)`pageStart` 计算 |
| 202 | +- `coordinator.go:968,971` 提交时 `index % perPage` |
| 203 | + |
| 204 | +> **一致性约束**:同一次按键处理内,分页切片、UI 发送、数字键选择、提交索引必须取**同一个** perPage 值,否则会出现"看到 9 个候选但按 7 翻页"的错位。由于 `effectiveCandidatesPerPage()` 是纯读且同一 composition 内 `extendedReasons` 稳定,同一次按键多次调用结果一致,天然满足。**例外**:派生型在 `updateCandidatesEx()` 末尾才刷新,需确认刷新发生在分页计算(:511)**之前**——见下"待确认"。 |
| 205 | +
|
| 206 | +### 必改:配置与初始化 |
| 207 | + |
| 208 | +- `config.go` 加字段 + 默认值 + `Normalize`/兜底(参照 `MaxCandidateChars` 在 `config.go:625-627` 的越界回退写法) |
| 209 | +- `coordinator.go:558-561` 初始化 `candidatesPerPageExtended` |
| 210 | +- `handle_config.go:22-23` 热更新时同步 `candidatesPerPageExtended` |
| 211 | + |
| 212 | +### 必改:前端三处(见配置层段) |
| 213 | + |
| 214 | +### 文档同步(CLAUDE.md 要求) |
| 215 | + |
| 216 | +- `internal/coordinator/AGENTS.md`:新增导出类型 `ExtendedCandidateReason` 与字段,需补一句说明。 |
| 217 | +- `pkg/config/AGENTS.md`(若存在枚举/字段清单):新增 `CandidatesPerPageExtended`。 |
| 218 | +- 运行 `scripts/lint_agents_md.ps1` 校验引用不悬空。 |
| 219 | + |
| 220 | +## 设置页 UI |
| 221 | + |
| 222 | +在 `appearance.schema.ts` 的"每页候选数"slider(`:44`)正下方新增: |
| 223 | + |
| 224 | +```ts |
| 225 | +{ |
| 226 | + type: "slider", |
| 227 | + key: "ui.candidates_per_page_extended", |
| 228 | + label: "扩展候选数", |
| 229 | + hint: "临时拼音 / 快捷输入 / 短语等场景下的候选数;设为最小值表示与上面相同(关闭分档)", |
| 230 | + min: 0, // 0 = 关闭分档 |
| 231 | + max: 15, |
| 232 | + // 0 时 UI 展示 "跟随基础",其余显示数字 |
| 233 | +} |
| 234 | +``` |
| 235 | + |
| 236 | +> 文案与最小值含义需在 `general.search.ts`(搜索索引)同步登记,保持设置搜索可命中(参照现有 `candidates_per_page` 的登记)。 |
| 237 | +
|
| 238 | +## 边界与测试 |
| 239 | + |
| 240 | +### 单元测试(`internal/coordinator`) |
| 241 | + |
| 242 | +1. `extendedReasons == 0` 且扩展档已配 → 返回基础档。 |
| 243 | +2. 任一 reason 置位 + 扩展档已配 → 返回扩展档。 |
| 244 | +3. 扩展档 `= 0`(禁用)+ reason 置位 → 仍返回基础档。 |
| 245 | +4. 派生型对称性:模拟候选含/不含 `PhraseTemplate`,调用 `refreshDerivedExtendedReasons()` 后位正确置/清。 |
| 246 | +5. 事件型兜底:临时拼音激活置位 → `clearState()` 后 `extendedReasons == 0`。 |
| 247 | +6. 分页一致性:扩展档生效时 `totalPages`、切片、数字键 `pageStart` 用同一 perPage。 |
| 248 | + |
| 249 | +### 配置测试(`pkg/config/enums_test.go` 风格) |
| 250 | + |
| 251 | +- YAML round-trip:`candidates_per_page_extended` 缺省(老配置)→ 读为 0,行为不变。 |
| 252 | +- 边界处理:`<=0` → 禁用(用基础档);`>15` → clamp 到 15。 |
| 253 | + |
| 254 | +## 待实现阶段确认 |
| 255 | + |
| 256 | +1. **刷新时序**:确认 `refreshDerivedExtendedReasons()` 的调用点在 `updateCandidatesEx()` 内、且早于 `handle_candidates.go:511` 的 `totalPages` 计算(同一次按键内)。若分页计算在 `updateCandidatesEx()` 之外的更上层,需把刷新提到分页前。 |
| 257 | +2. **临时拼音融合入口**:临时拼音已"去模式化"为融合模型(拼音+码表候选融合)。`tempPinyinMode` 标志在 `handle_temp_pinyin.go` 仍真实 set/clear(z 触发路径已验证),但需排查是否存在**不置 `tempPinyinMode`** 的其它融合入口;若有,这类场景的扩展需求应改走派生型(按候选 `Source == 拼音` 判断)而非事件型。 |
| 258 | +3. **快捷输入子模式**:快捷输入内部可能再触发临时拼音子模式(`quickInputPinyinDictSwapped`)。两个事件型 reason 可同时置位,bitset 天然支持;确认退出子模式只 `clear` 自己的位,不误清外层 `ExtendedReasonQuickInput`。 |
| 259 | +4. **数字键边界**:`AllowSymbols` 开启时,数字键 1-9 "仅在索引超出当前页可见候选数时进 buffer"(`config.go:385`)。该判断也依赖 perPage,需一并改用 `effectiveCandidatesPerPage()`,避免扩展档下数字键行为错位。 |
| 260 | + |
| 261 | +## 影响面小结 |
| 262 | + |
| 263 | +- **新增**:1 个配置字段(Go + 前端镜像)、1 个运行时枚举类型、3 个 Coordinator 方法、1 条设置项。 |
| 264 | +- **修改**:约 8 处 `candidatesPerPage` 使用点改为方法调用。 |
| 265 | +- **不触碰**:引擎层、UI 渲染层、IPC 协议(`CandidatesPerPage` 仍按实际生效值透传,无需新增协议字段)。 |
| 266 | +- **向后兼容**:扩展档默认 0,老用户行为零变化。 |
0 commit comments