diff --git a/.gitignore b/.gitignore
index d11c81f..206022a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -31,3 +31,6 @@ DerivedData/
/settings/dist/
/settings/.build*/
HANDOFF.md
+
+# --- Editor / IDE ---
+.vscode/
diff --git a/README.en.md b/README.en.md
index 524d7cd..7d799c1 100644
--- a/README.en.md
+++ b/README.en.md
@@ -1,8 +1,16 @@
# TriFecta
-> Pick the right candidate by color — a macOS Chinese input method.
+
+
+
-TriFecta is a **Rime / Squirrel**-based macOS Chinese input method. It solves a common pain point of the native input method: **selecting candidates far from the home row (7/8/9)** forces you to lean in and read the candidate numbers, then reach for the number keys. TriFecta uses **three-color grouping + the `~` key** so your fingers stay on `~ 1 2 3` and you locate a candidate purely by color.
+> **Three colors, three picks, one tap to land.** — a macOS Chinese input method that lets you pick precisely by color.
+
+**🌏 Language:** [**中文**](./README.md) | English
+
+TriFecta is a **Rime / Squirrel**-based macOS Chinese input method. It solves a common pain point of native input methods: **when candidate numbers are far from home row (7/8/9), you have to lean in to read the number, then reach for the digit key**. TriFecta uses **three-color grouping + the `~` key** so your fingers stay on `~ 1 2 3` and you locate a candidate purely by color.
+
+TriFecta also ships **three input modes** (trigger / dwell / slider), a **graphical settings panel**, and can be packaged as a **signed + notarized `.pkg`** that auto-registers the input source on install.
---
@@ -14,7 +22,7 @@ A native Chinese candidate bar shows 6–9 candidates at once. When the desired
TriFecta **splits the candidates into 3 groups**, distinguishes them with **red / yellow / green**, and lets you select with the `~` key:
- Press `~` → candidates 1–3 red, 4–6 yellow, 7–9 green;
-- Press `1/2/3` to choose a group → the group's 3 candidates are recolored red/yellow/green **by position**, and the other groups **dim** for focus;
+- Press `1/2/3` to choose a group → that group's 3 candidates are recolored red/yellow/green **by position**, and the other groups **fade away** for focus;
- Press `1/2/3` again to pick the exact candidate (committed).
Your fingers never leave `~ 1 2 3` — no reading numbers, no reaching for 7/8/9.
@@ -23,24 +31,67 @@ Your fingers never leave `~ 1 2 3` — no reading numbers, no reaching for 7/8/9
## Features
-- **Three-color grouping**: candidates split into 3 groups, red/yellow/green, as high-contrast "liquid-glass" pills matching the panel's native glass look.
+### Core: three-color grouping + precise selection
+
+- **Three-color grouping**: candidates split into 3 groups, red/yellow/green, as high-contrast "liquid-glass" pills.
- **`~` three-state toggle**:
1. Blue → press `~` → enter three-color;
- 2. A group selected → press `~` → **back to the "choose a group" step** (stay in three-color, so you can change your pick);
+ 2. A group selected → press `~` → **back to the "choose a group" step** (stay in three-color to change your pick);
3. On "choose a group" → press `~` → exit back to blue.
-- **Precise in-group pick**: after choosing a group, its 3 candidates are colored red/yellow/green by position; other groups dim.
-- **Auto-return to blue**: once a character is committed precisely, it returns to blue single-select; if the remaining candidates are high-frequency and in the top 3, just pick directly, otherwise press `~` again.
+- **Precise in-group pick**: after choosing a group, its 3 candidates are colored red/yellow/green by position; **other groups fade away completely** for stronger contrast.
+- **Auto-return to blue**: once a character is committed precisely, return to blue single-select; if the remaining candidates are high-frequency top-3, pick directly, otherwise press `~` again.
+
+### Three input modes
+
+| Mode | Behavior |
+|---|---|
+| **Trigger** | Press the trigger key (default `~`) to open three-color; `1/2/3` pick a group → `1/2/3` pick the candidate |
+| **Dwell** | As you type pinyin, **no trigger key needed** — the three-color top menu opens automatically; the first 3 candidates are picked with `1` / `~` / `Tab`, and `2/3` enter the 2nd/3rd group |
+| **Slider** | Always shows one group's sub-menu (three colors under candidates 1/2/3); press the trigger key to **slide back one group** (1-3 → 4-6 → 7-9 → page), and `1/2/3` picks the candidate in whichever group is shown; also has a **back key** (default `Tab`) to return to the previous group |
+
+> Switch modes with the **slider in the settings panel** (single thumb — drag to a slot to activate that mode and turn off the others).
+
+### Chinese/English mixing & input comfort
+
+- **`Shift+letter` → uppercase directly**: hold `Shift` and tap a letter to **commit that uppercase letter directly**, without it being treated as an invalid pinyin (no "no candidates" glitch), and without disturbing subsequent Chinese input (temporarily suppresses `Shift`'s CN/EN toggle for a moment).
+- **Single/double quote pairing**: in Chinese mode, quotes **alternate left/right** (press once → left quote, press again → right quote, again → left…), no longer stuck on the left half only.
+
+### Graphical settings panel (TriFectaSettings)
+
+A WeChat-style settings window: **left navigation + right panel**, point-and-click tuning instead of hand-editing `~/Library/Rime/*.yaml`. On save it dispatches a distributed notification to **immediately redeploy** (changes take effect instantly). Hand-editing YAML is still fully supported.
+
+It has 6 pages:
+- **Input**: simplification/traditional & other input options
+- **Appearance**: candidate bar look (font, layout, transparency, colors, …)
+- **Interface**: settings window's own look
+- **Shortcuts**: three-mode slider + per-mode trigger / back / selection keys
+- **Sync**: user data sync
+- **About**: version & credits
+
+### Native look & animation
+
- **Native look**: horizontal capsule bar, semi-circular ends, translucency/glass background, blue capsule highlight, system-like corner radii & spacing.
-- **Punctuation**: common punctuation commits directly (full-width in Chinese, half-width in English/ascii).
- **Nonlinear animation**: clean `easeInEaseOut` state transitions (no flashing on every keystroke).
---
## Install
-### Way A: `.dmg` (recommended)
+### Way A: `.pkg` (recommended — one step)
+
+> **We recommend the `.pkg`** — it's **signed + notarized**, and **auto-registers the input source / deploys the Rime schema data / enables & selects it** on install. Just double-click.
+
+1. Download & open `TriFecta.pkg`;
+2. Follow the wizard and enter your admin password;
+3. After install (postinstall runs `--register-input-source` / `--build` / `--enable-input-source` / `--select-input-source`):
+ - **Squirrel (鼠鬚管)** appears automatically under **System Settings → Keyboard → Input Sources**;
+ - Switch to **Squirrel** with **⌃Control + Space** and type.
-> **Do NOT double-click \`安装.command\` or Squirrel.app** — because the app is ad-hoc signed, macOS shows a “cannot verify… malware” dialog on double-click. Install from the **Terminal** instead.
+> If the input-source list doesn't refresh, **log out and back in** once (HIToolbox cache refresh).
+
+### Way B: `.dmg` (alternative)
+
+> **Do NOT double-click `安装.command` or Squirrel.app** — because the app is ad-hoc signed, macOS shows a "cannot verify… malware" dialog on double-click. Install from the **Terminal** instead.
```bash
# 1) Download this .dmg and open it (mounts to /Volumes/TriFecta)
@@ -49,20 +100,23 @@ cd /Volumes/TriFecta
sudo bash 安装.command
```
-The script automatically: strips the quarantine attribute → copies to \`/Library/Input Methods\` → registers the input source → builds the Rime schema data → enables and selects it. You only type your password once; you’ll see \`✔ 安装完成!\` on success. (Building schema data takes ~10–60 s; the window may look frozen — that’s normal, don’t close it.)
+The script automatically: strips the quarantine attribute → copies to `/Library/Input Methods` → registers the input source → builds the Rime schema data → enables and selects it. You only type your password once; you'll see `✔ 安装完成!` on success. (Building schema data takes ~10–60 s; the window may look frozen — that's normal, don't close it.)
-If it’s still blocked (a \`com.apple.quarantine\` message), clear it once first:
+If it's still blocked (a `com.apple.quarantine` message), clear it once first:
```bash
xattr -dr com.apple.quarantine /Volumes/TriFecta/安装.command 2>/dev/null
cd /Volumes/TriFecta && sudo bash 安装.command
```
After installing:
-1. **System Settings → Keyboard → Input Sources** → click **“+”** → search **“Squirrel”** (or “鼠鬚管”) → add;
+1. **System Settings → Keyboard → Input Sources** → click **"+"** → search **"Squirrel"** (or "鼠鬚管") → add;
2. Switch to **Squirrel** with **⌃Control + Space** (or the top input menu).
+### Way C: build from source (see "Build from source" below)
-### Uninstall (remove this input method)
+---
+
+## Uninstall
Run this in **Terminal** to fully remove it (disable input source → kill the process → delete the app → verify):
@@ -81,7 +135,7 @@ echo ">>> done"
'
```
-### Way B: build from source (see “Build from source” below)
+> To fully clear your usage habits/word dictionary, additionally run `rm -rf ~/Library/Rime`.
---
@@ -91,12 +145,12 @@ echo ">>> done"
|---|---|
| Type pinyin | Blue capsule highlight (native look) |
| Press `~` (left of `1`) | Enter three-color: 1–3 red / 4–6 yellow / 7–9 green |
-| Press `1/2/3` | Choose a group: its 3 candidates colored red/yellow/green by position, others dim |
+| Press `1/2/3` | Choose a group: its 3 candidates colored red/yellow/green by position, **others fade away** |
| Press `1/2/3` again | Pick the exact candidate (committed), auto-return to blue |
-| `~` (a group selected) | Back to the “choose a group” step |
-| `~` (on “choose a group”) | Exit to blue |
-| `Shift` (tap) | Toggle Chinese ↔ Latin (ascii) |
-| `Shift` (hold) | Temporary uppercase, released back to Chinese (never stuck in ascii) |
+| `~` (a group selected) | Back to the "choose a group" step |
+| `~` (on "choose a group") | Exit to blue |
+| `Shift` (hold) + letter | Commit that **uppercase** letter directly (not treated as pinyin, doesn't disturb Chinese) |
+| Single / double quote | Left ↔ right quote alternation |
---
@@ -112,7 +166,7 @@ TriFecta is a customization of [rime/squirrel](https://github.com/rime/squirrel)
### Steps
```bash
# 1. Clone (includes librime / plum / Sparkle submodules)
-git clone --recursive https://github.com/yourname/TriFecta.git
+git clone --recursive https://github.com/thesadbee/TriFecta.git
cd TriFecta
# 2. Use prebuilt librime + Sparkle (skip building librime/Boost)
@@ -127,22 +181,33 @@ make release
Output: `build/Build/Products/Release/Squirrel.app` (this project keeps the input identifier `im.rime.inputmethod.Squirrel`).
+### Package as `.pkg`
+
+After building the source you can package a signed + notarized `.pkg` that includes the auto-register/deploy script `scripts/postinstall`:
+
+```bash
+# Sign the .app with "Developer ID Application", the .pkg with "Developer ID Installer", then notarize
+# See the package/ scripts and the Makefile `package` target
+make package
+```
+
---
## Configuration
-Schemas and appearance are configurable via Rime config (`~/Library/Rime`) and the project's `data/`:
+Schemas and appearance are configurable via Rime config (`~/Library/Rime`), the project's `data/`, **or the settings panel**:
- **Three colors**: `sources/SquirrelView.swift` → `groupColors` (red/yellow/green RGB + alpha).
- **Grouping**: candidates N split into 3 groups (`groupSize = ceil(N/3)`).
+- **Modes / trigger / back keys**: settings panel "Shortcuts" page, or `~/Library/Rime/squirrel.yaml` → `group_colors/*`.
- **Capsule look**: `data/squirrel.yaml` (`candidate_list_layout: linear`, `font_point`, `line_spacing`, `translucency`, ...).
---
## Distribution
-- **Not on the App Store** — distributed via **GitHub Releases** as `.dmg`.
-- Signed locally with **ad-hoc** (`codesign --force --deep --sign -`).
-- To let others install without a right-click “Open”, use **Apple Developer ID signing + notarization** ($99/year account).
+- **Not on the App Store** — distributed via **GitHub Releases** as a signed + notarized **`.pkg`** (or `.dmg`).
+- Sign the `.app` with **Apple Developer ID Application**, the `.pkg` with **Developer ID Installer**, then run **notarization** and `stapler` staple.
+- The `.pkg` uses `scripts/postinstall` to **auto-register the input source / deploy the Rime schema data / enable & select it** — users don't have to add it manually.
---
@@ -156,7 +221,7 @@ TriFecta is built on top of these open-source projects and authors:
- **Sparkle** update framework — [sparkle-project/Sparkle](https://github.com/sparkle-project/Sparkle)
- **Rime schema data** — rime-prelude / rime-luna-pinyin / rime-essay / rime-bopomofo / rime-cangjie / rime-stroke / rime-terra-pinyin / rime-quick / rime-double-pinyin, etc.
-The **TriFecta three-color grouping + `~` precise-selection** feature was designed & implemented with the help of **DeepSeek Harness** (an AI coding agent).
+The **TriFecta three-color grouping + `~` precise selection + three modes + settings panel** features were designed & implemented with the help of **DeepSeek Harness** (an AI coding agent).
## License
diff --git a/README.md b/README.md
index 3e699c8..2ddc0d1 100644
--- a/README.md
+++ b/README.md
@@ -1,10 +1,16 @@
# TriFecta
-> 三连中 —— 用颜色让你从容三选一的 macOS 中文输入法。
+
+
+
+
+> **三色三选,一按即中。** —— 用颜色让你从容三选一的 macOS 中文输入法。
**🌏 Language:** 中文 | [**English**](./README.en.md)
-TriFecta 是一个基于 **Rime(Squirrel)** 的 macOS 中文输入法,针对原生输入法“**取远号候选字要凑近看编号、还要伸手去够 7/8/9**”的痛点,用**三色分组 + `~` 键精准选字**来解决:手指始终不离 `~ 1 2 3` 四个键,凭颜色即可定位候选字。
+TriFecta 是一款基于 **Rime(Squirrel)** 的 macOS 中文输入法。它解决了原生输入法“**候选字编号靠后(7/8/9)要凑近看编号、还得伸手去够数字键**”的痛点——用**三色分组 + `~` 键精准选字**,让你的手指始终不离 `~ 1 2 3` 四个键,**凭颜色即可定位候选字**。
+
+TriFecta 还提供**三种输入模式**(触发 / 常驻 / 滑块)、**图形化设置面板**,并可打包为**已签名 + 公证的 `.pkg`**(安装后自动完成输入源注册)。
---
@@ -25,22 +31,65 @@ TriFecta 把候选字**均分成 3 组**,用**红 / 黄 / 绿**三色区分,
## 功能特性
-- **三色分组**:候选字均分为 3 组,红/黄/绿,高对比度“彩色液态玻璃”观感(与原生 Liquid Glass 背景一致)。
+### 核心:三色分组 + 精准选字
+
+- **三色分组**:候选字均分为 3 组,红 / 黄 / 绿,高对比度“彩色液态玻璃”观感。
- **`~` 三态切换**:
1. 蓝色 → 按 `~` → 开启三色;
- 2. 已选某组 → 按 `~` → **回到“选组”那一步**(不退出三色,便于改选);
- 3. 在“选组”状态 → 按 `~` → 退出三色回蓝色。
-- **组内精准选字**:选中某组后,组内 3 个候选按位置红/黄/绿着色,其它组变暗。
+ 2. 已选某组 → 按 `~` → 回到“选组”那一步(不退出三色,便于改选);
+ 3. 在“选组”状态 → 按 `~` → 退出三色回到蓝色。
+- **组内精准选字**:选中某组后,组内 3 个候选按位置红/黄/绿着色,**其它组完全褪去**(更强的对比聚焦)。
- **选后自动回蓝**:完成一次“精准上字”后自动回到蓝色单点;剩余字若高频在前 1-3 位可直接选,否则再按 `~`。
+
+### 三种输入模式
+
+| 模式 | 行为 |
+|---|---|
+| **触发模式** | 按触发键(默认 `~`)打开三色选字;`1/2/3` 选组 → 再 `1/2/3` 选字 |
+| **常驻模式** | 敲入拼音后**无需触发键**,自动打开三色一级菜单;前 3 个候选可用 `1` / `~` / `Tab` 直接选,`2/3` 进入第 2/3 组 |
+| **滑块模式** | 常驻显示某一组的二级菜单(三色显示在 1/2/3 候选下);按触发键**向后滑动一组**(1-3 → 4-6 → 7-9 → 翻页),滑动到哪组按 `1/2/3` 即选该组对应候选;还提供**回退键**(默认 `Tab`)返回上一组 |
+
+> 三模式通过**设置面板的滑条**一键切换(单滑块拖到某档即激活该模式,其余关闭)。
+
+### 中英混输 / 输入体验
+
+- **中文模式 `Shift+字母` 快速出大写**:按住 `Shift` 敲字母**直接输出对应大写字母**,不进入拼音候选、不产生“非法拼音无候选框”,且不影响后续中文输入(临时屏蔽 `Shift` 的中英切换一小段)。
+- **单 / 双引号互补**:中文下单引号 / 双引号**左右交替**(按一次出左引号,再按出右引号,再按回左引号),不再永远只有左半边。
+
+### 图形化设置面板(TriFectaSettings)
+
+微信输入法风格的设置窗口:**左侧导航 + 右侧面板**,鼠标点按调参,替代手改 `~/Library/Rime/*.yaml`。保存后通过分布式通知让输入法**立即重新部署**(配置即时生效)。手改 YAML 的方式完全保留。
+
+设置面板包含 6 页:
+- **输入**:简繁、开关等输入相关选项
+- **外观**:输入法候选框外观(字体、布局、透明度、颜色等)
+- **界面**:设置窗口自身外观
+- **快捷键**:三模式切换滑条 + 各模式的触发键 / 回退键 / 选词键自定义
+- **同步**:用户数据同步
+- **关于**:版本与致谢
+
+### 原生观感与动画
+
- **原生观感**:横向胶囊候选框、两端半圆、玻璃透明底、蓝色胶囊高亮、圆角/间距贴近系统输入法。
-- **标点**:中文下常用标点全角直接上屏,英文(Shift 切西文)下半角。
- **非线性动画**:状态切换带干净的 `easeInEaseOut` 过渡(不会每次打字都闪)。
---
## 安装
-### 方式 A:`.dmg`(推荐)
+### 方式 A:`.pkg`(推荐,一步到位)
+
+> **推荐用 `.pkg`** —— 它是**已签名 + 公证**的安装包,安装时**自动完成输入源注册 / Rime 方案部署 / 启用选中**,无需手动添加。双击即可安装。
+
+1. 下载并打开 `TriFecta.pkg`;
+2. 按安装向导输入管理员密码;
+3. 安装完成后(postinstall 自动执行 `--register-input-source` / `--build` / `--enable-input-source` / `--select-input-source`):
+ - **系统设置 → 键盘 → 输入法** 中 **Squirrel(鼠鬚管)已自动出现**;
+ - 用 **⌃Control + 空格** 切换到 Squirrel 即可打字。
+
+> 若安装后系统输入法列表未刷新,**注销重登**一次即可(HIToolbox 缓存刷新)。
+
+### 方式 B:`.dmg`(备选)
> **注意:不要双击 `安装.command` 或 Squirrel.app** —— 因为 App 是 ad-hoc 签名,macOS 会在你双击时弹「无法验证是否含恶意软件」拦截。请在**终端**用一行命令安装。
@@ -63,9 +112,13 @@ cd /Volumes/TriFecta && sudo bash 安装.command
1. **系统设置 → 键盘 → 输入法** → 点 **“+”** → 搜索 **“Squirrel”**(或“鼠鬚管”)→ 添加;
2. 用 **⌃Control + 空格**(或顶部输入法菜单)切到 **Squirrel**。
-### 卸载(删除本输入法)
+### 方式 C:源码构建(见下文“从源码构建”)
+
+---
-在**终端**运行以下命令即可干净移除(禁用输入源 → 杀进程 → 删除 App → 验证):
+## 卸载
+
+在**终端**运行以下命令即可**干净移除**(禁用输入源 → 杀进程 → 删除 App → 验证):
```bash
sudo bash -c '
@@ -82,7 +135,7 @@ echo ">>> 完成"
'
```
-### 方式 B:源码构建(见下文“从源码构建”)
+> 卸载后如需彻底清空使用习惯/词库,可再执行 `rm -rf ~/Library/Rime`。
---
@@ -92,12 +145,12 @@ echo ">>> 完成"
|---|---|
| 输入拼音 | 蓝色胶囊高亮(原生观感) |
| 按 `~`(数字1左边) | 开启三色分组:1-3红 / 4-6黄 / 7-9绿 |
-| 按 `1/2/3` | 选中一组:该组 3 个按位置红/黄/绿,其它组变暗 |
+| 按 `1/2/3` | 选中一组:该组 3 个按位置红/黄/绿,**其它组褪去** |
| 再按 `1/2/3` | 选中组内该字(精准上屏),并自动回蓝色 |
| `~`(已选组时) | 回到“选组”那一步 |
| `~`(选组状态) | 退出三色,回到蓝色 |
-| `Shift`(轻按) | 中文 ↔ 西文(ascii)切换 |
-| `Shift`(按住) | 临时大写,松开自动回中文(不卡英文态) |
+| `Shift`(按住)+字母 | 直接输出对应**大写**字母(不进拼音候选,不影响后续中文) |
+| 单 / 双引号 | 左引号 ↔ 右引号 左右交替 |
---
@@ -113,7 +166,7 @@ TriFecta 基于 [rime/squirrel](https://github.com/rime/squirrel)(GPL-3.0)
### 步骤
```bash
# 1. 拉取仓库(含 librime / plum / Sparkle 子模块)
-git clone --recursive https://github.com/yourname/TriFecta.git
+git clone --recursive https://github.com/thesadbee/TriFecta.git
cd TriFecta
# 2. 用预编译 librime + Sparkle 快速就绪(跳过编译 librime/Boost)
@@ -128,22 +181,33 @@ make release
产物:`build/Build/Products/Release/Squirrel.app`(本工程仍沿用输入法标识 `im.rime.inputmethod.Squirrel`)。
+### 打包为 `.pkg`
+
+源码构建后可打包为已签名 + 公证的 `.pkg`(含自动注册 / 部署脚本 `scripts/postinstall`):
+
+```bash
+# 用 Developer ID Application 签名 app + Developer ID Installer 签名 pkg + 公证
+# 详见 package/ 相关脚本与 Makefile package 目标
+make package
+```
+
---
## 配置
-方案与外观可通过 Rime/主题配置调整(`~/Library/Rime` 与工程 `data/`):
+方案与外观可通过 Rime/主题配置或**设置面板**调整(`~/Library/Rime` 与工程 `data/`):
- **三色**:`sources/SquirrelView.swift` 中 `groupColors`(红/黄/绿 RGB + alpha)。
- **分组规则**:候选数 N 均分为 3 组(`groupSize = ceil(N/3)`)。
+- **三模式 / 触发键 / 回退键**:设置面板「快捷键」页,或 `~/Library/Rime/squirrel.yaml` 的 `group_colors/*`。
- **胶囊外观**:`data/squirrel.yaml`(`candidate_list_layout: linear`、`font_point`、`line_spacing`、`translucency` 等)。
---
## 分发
-- **不上架 App Store**,通过 **GitHub Releases** 分发 `.dmg`。
-- 本工程本地用 **ad-hoc 签名**(`codesign --force --deep --sign -`)。
-- 若要让他人无需右键“打开”即可安装,需 **Apple Developer ID 签名 + notarization**($99/年账号)。
+- **不上架 App Store**,通过 **GitHub Releases** 分发已签名 + 公证的 **`.pkg`**(或 `.dmg`)。
+- 工程用 **Apple Developer ID Application** 签名 `.app` + **Developer ID Installer** 签名 `.pkg`,并做 **notarization**(公证)与 `stapler` 装订。
+- `.pkg` 安装时通过 `scripts/postinstall` **自动注册输入源 / 部署 Rime 方案数据 / 启用并选中**,用户无需手动添加。
---
@@ -157,7 +221,7 @@ make release
- **Sparkle** 自动更新框架 —— [sparkle-project/Sparkle](https://github.com/sparkle-project/Sparkle)
- **Rime 语音方案数据** —— rime-prelude / rime-luna-pinyin / rime-essay / rime-bopomofo / rime-cangjie / rime-stroke / rime-terra-pinyin / rime-quick / rime-double-pinyin 等
-**TriFecta 的三色分组 + `~` 精准选字功能**由 **DeepSeek Harness**(AI 编程代理)协助设计与实现。
+**TriFecta 的三色分组 + `~` 精准选字 + 三模式 + 设置面板等功能**由 **DeepSeek Harness**(AI 编程代理)协助设计与实现。
## 许可
diff --git a/data/plum/lua/quote.lua b/data/plum/lua/quote.lua
new file mode 100644
index 0000000..7c5a512
--- /dev/null
+++ b/data/plum/lua/quote.lua
@@ -0,0 +1,51 @@
+-- quote.lua
+-- 单双引号左右交替 + 超时重置(Rime / librime-lua)
+-- 行为:中文模式下按一次'输出左引号,短时间再按输出右引号,再按回左引号;
+-- 超过 RESET_SECONDS 无输入则重置为输出左引号。
+-- 用法(scheme):processors 中加入 - lua_processor@*quote
+local quote = {}
+
+-- 需要记录各自输出状态的引号
+local state = {} -- key_repr -> 是否"已出左(下个应出右)"
+local last = {} -- key_repr -> 上次时间
+local RESET_SECONDS = 2.0
+
+local function map(name)
+ if name == "apostrophe" then
+ return { left = "‘", right = "’", entry = "apostrophe" }
+ end
+ if name == "quotedbl" or name == "quotation" or name == "quotedbl" then
+ return { left = "“", right = "”", entry = "quotedbl" }
+ end
+ return nil
+end
+
+function quote.init(env)
+ -- 可选:从配置读取 reset 秒数
+ local sec = env.engine.schema.config:get_double('quote/auto_reset_seconds')
+ if sec and sec > 0 then RESET_SECONDS = sec end
+end
+
+function quote.func(key, env)
+ if key:release() then
+ return 1 -- kNoop
+ end
+ local repr = key:repr()
+ local m = map(repr) or map(string.lower(repr or ""))
+ if not m then
+ return 1 -- kNoop,继续后续处理器(非引号键)
+ end
+
+ local t = os.clock()
+ local shouldLeft = true
+ if state[m.entry] and (t - (last[m.entry] or -999)) < RESET_SECONDS then
+ shouldLeft = false
+ end
+ last[m.entry] = t
+ state[m.entry] = shouldLeft
+
+ env.engine:commit_text(shouldLeft and m.left or m.right)
+ return 0 -- kAccepted,消费该键
+end
+
+return quote
diff --git a/data/plum/symbols.yaml b/data/plum/symbols.yaml
index 2c541d2..463d9aa 100644
--- a/data/plum/symbols.yaml
+++ b/data/plum/symbols.yaml
@@ -20,8 +20,8 @@ punctuator:
'?' : { commit: ? }
';' : { commit: ; }
':' : { commit: : }
- '''' : { commit: '‘' }
- '"' : { commit: '“' }
+ '''' : { pair: [ '‘', '’' ] }
+ '"' : { pair: [ '“', '”' ] }
'\' : { commit: 、 }
'|' : { commit: · }
'`' : `
@@ -53,8 +53,8 @@ punctuator:
'?' : { commit: ? }
';' : { commit: ; }
':' : { commit: : }
- '''' : { commit: '‘' }
- '"' : { commit: '“' }
+ '''' : { pair: [ '‘', '’' ] }
+ '"' : { pair: [ '“', '”' ] }
'\' : { commit: 、 }
'|' : { commit: · }
'`' : { commit: '`' }
diff --git a/icon.png b/icon.png
new file mode 100644
index 0000000..9372f53
Binary files /dev/null and b/icon.png differ
diff --git a/settings/Sources/TriFectaSettings/AppState.swift b/settings/Sources/TriFectaSettings/AppState.swift
index fdef9ae..bf028d0 100644
--- a/settings/Sources/TriFectaSettings/AppState.swift
+++ b/settings/Sources/TriFectaSettings/AppState.swift
@@ -21,7 +21,6 @@ final class AppState: ObservableObject {
@Published var style = StyleValues.defaults
@Published var groupColors = GroupColorsValues.defaults
@Published var schemaList: [String] = []
- @Published var shiftEnabled = true
@Published var customizedResets: [String: [Int: Int]] = [:]
/// 用户在界面明确切换过的简繁状态(推荐默认未落盘时强制写入用)
private(set) var userResetOverrides: [String: [Int: Int]] = [:]
@@ -39,7 +38,6 @@ final class AppState: ObservableObject {
if style != loaded.style { return true }
if groupColors != loaded.groupColors { return true }
if schemaList != loaded.schemaList { return true }
- if shiftEnabled != loaded.shiftEnabled { return true }
if !userResetOverrides.isEmpty { return true }
return !diffResets(customizedResets, vs: loaded.switchResets).isEmpty
}
@@ -51,7 +49,6 @@ final class AppState: ObservableObject {
style = model.style
groupColors = model.groupColors
schemaList = model.schemaList
- shiftEnabled = model.shiftEnabled
customizedResets = model.switchResets
userResetOverrides = [:]
status = .idle
@@ -66,7 +63,6 @@ final class AppState: ObservableObject {
style = loaded.style
groupColors = loaded.groupColors
schemaList = loaded.schemaList
- shiftEnabled = loaded.shiftEnabled
customizedResets = loaded.switchResets
userResetOverrides = [:]
status = .idle
@@ -80,7 +76,6 @@ final class AppState: ObservableObject {
if style != loaded.style { changeSet.style = style }
if groupColors != loaded.groupColors { changeSet.groupColors = groupColors }
if schemaList != loaded.schemaList { changeSet.schemaList = schemaList }
- if shiftEnabled != loaded.shiftEnabled { changeSet.shiftEnabled = shiftEnabled }
var resetDiff = diffResets(customizedResets, vs: loaded.switchResets)
// 推荐默认(未自定义方案列表):默认仅留推荐方案且默认简体(reset=1)。
// 仅当用户从未显式保存过该开关时推荐才生效;已保存(custom 文件含键)以用户值为准。
diff --git a/settings/Sources/TriFectaSettings/Pages/ShortcutsPage.swift b/settings/Sources/TriFectaSettings/Pages/ShortcutsPage.swift
index 3dfb342..79ddf2c 100644
--- a/settings/Sources/TriFectaSettings/Pages/ShortcutsPage.swift
+++ b/settings/Sources/TriFectaSettings/Pages/ShortcutsPage.swift
@@ -1,30 +1,219 @@
//
-// 快捷键:~ 键三色开关、Shift 切换中英。
+// 快捷键:三色分组选字 —— 模式切换 + 各模式快捷键设置。
//
import SwiftUI
+import AppKit
+import TriFectaSettingsCore
+
+/// 把 mac keyCode 转成用于展示的键名(默认触发键 50 = ` 键,UI 显示为 ~)。
+func keycapDisplay(forKeyCode code: Int) -> String {
+ switch code {
+ case 50: return "~" // ` 键(数字1左侧)
+ case 48: return "⇥ Tab"
+ case 36: return "↩ Return"
+ case 49: return "␣ Space"
+ case 51: return "⌫ Delete"
+ case 53: return "⎋ Esc"
+ case 122: return "F1"
+ case 120: return "F2"
+ case 99: return "F3"
+ case 118: return "F4"
+ case 96: return "F5"
+ case 97: return "F6"
+ case 98: return "F7"
+ case 100: return "F8"
+ case 101: return "F9"
+ case 109: return "F10"
+ case 103: return "F11"
+ case 111: return "F12"
+ case 123: return "←"
+ case 124: return "→"
+ case 125: return "↓"
+ case 126: return "↑"
+ case 116: return "Page Up"
+ case 121: return "Page Down"
+ default:
+ // 字母键(mac keyCode 无法稳定逆映射字符,回退为 Key 数字)
+ return "Key \(code)"
+ }
+}
+
+/// 触发键输入框:显示当前触发键,点击后监听下一次 keyDown 并更新绑定。
+struct TriggerKeyField: View {
+ @Binding var keyCode: Int
+ @State private var isListening = false
+ @State private var monitor: Any?
+
+ var body: some View {
+ Button {
+ beginListening()
+ } label: {
+ HStack(spacing: 6) {
+ KeycapView(text: keycapDisplay(forKeyCode: keyCode), systemImage: "command")
+ Text(isListening ? "请按下按键" : "点击设置")
+ .font(.system(size: 11))
+ .foregroundColor(isListening ? Color.accentColor : .secondary)
+ Image(systemName: isListening ? "record.circle" : "keyboard")
+ .font(.system(size: 11, weight: .semibold))
+ .foregroundColor(isListening ? Color.accentColor : .secondary)
+ }
+ }
+ .buttonStyle(.plain)
+ .onDisappear { stopListening() }
+ }
+
+ private func beginListening() {
+ if isListening { stopListening(); return }
+ isListening = true
+ monitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in
+ let code = Int(event.keyCode)
+ stopListening()
+ keyCode = code
+ return nil // 吞掉该事件,避免同时触发其它按键作用
+ }
+ }
+
+ private func stopListening() {
+ if let monitor = monitor {
+ NSEvent.removeMonitor(monitor)
+ self.monitor = nil
+ }
+ isListening = false
+ }
+}
+
+/// 三模式滑动切换(触发 / 常驻 / 滑块)。单滑块拖到某档即激活该模式、关闭其余,
+/// 直观体现"同时只有一个模式生效"。拖动/点击整个滑条即可切换,无需精准点文字。
+/// 滑条是"切换器"而非"进度条",轨道统一无色;滑块中心对齐每一档模式名称的中间。
+struct ModeSwitcher: View {
+ @Binding var mode: TriColorMode
+ @Environment(\.appTheme) private var theme
+
+ private let trackHeight: CGFloat = 4
+ private let thumbSize: CGFloat = 18
+
+ var body: some View {
+ VStack(spacing: 6) {
+ GeometryReader { geo in
+ let trackWidth = geo.size.width
+ let steps = TriColorMode.allCases.count // 3 档
+ let selectedIndex = TriColorMode.allCases.firstIndex(of: mode) ?? 0
+ // 档位中心对齐三档名称的中间:1/6、3/6、5/6(与下方 label 三等分一致)
+ let centerX = trackWidth * (CGFloat(selectedIndex) + 0.5) / CGFloat(steps)
+
+ ZStack(alignment: .leading) {
+ // 轨道(统一无色/淡色,无"进度"填充)
+ RoundedRectangle(cornerRadius: trackHeight / 2)
+ .fill(Color.primary.opacity(0.14))
+ .frame(height: trackHeight)
+ .frame(maxWidth: .infinity)
+ // 圆形滑块(中心对齐当前档名称)
+ Circle()
+ .fill(Color.white)
+ .frame(width: thumbSize, height: thumbSize)
+ .shadow(color: .black.opacity(0.18), radius: 2, x: 0, y: 1)
+ .overlay(Circle().stroke(Color.primary.opacity(0.15), lineWidth: 0.5))
+ .offset(x: centerX - thumbSize / 2)
+ }
+ .frame(height: thumbSize) // 可点击/拖动区域
+ .contentShape(Rectangle())
+ .gesture(
+ DragGesture(minimumDistance: 0)
+ .onChanged { value in
+ // 把点击/拖动位置映射到各档中心(1/6、3/6、5/6),落到最近档
+ let t = value.location.x / trackWidth
+ let idx = Int((t * CGFloat(steps) - 0.5).rounded())
+ mode = TriColorMode.allCases[min(max(idx, 0), steps - 1)]
+ }
+ )
+ }
+ .frame(height: thumbSize)
+
+ // 三档标签(点击标签也能切换)
+ HStack(spacing: 0) {
+ ForEach(Array(TriColorMode.allCases.enumerated()), id: \.element) { _, m in
+ Text(m.label)
+ .font(.system(size: 11, weight: mode == m ? .semibold : .regular))
+ .foregroundColor(mode == m ? theme.accent : .secondary)
+ .frame(maxWidth: .infinity)
+ .onTapGesture { withAnimation(.easeInOut(duration: 0.15)) { mode = m } }
+ }
+ }
+ }
+ }
+}
+
+extension TriColorMode {
+ var label: String {
+ switch self {
+ case .trigger: return "触发模式"
+ case .dwell: return "常驻模式"
+ case .slider: return "滑块模式"
+ }
+ }
+}
struct ShortcutsPage: View {
@EnvironmentObject private var state: AppState
var body: some View {
PageScroll(title: "快捷键") {
- SettingCard {
- SettingRow("~ 键三色分组选字") {
- HStack(spacing: 8) {
- KeycapView(text: "~", systemImage: "function")
- Toggle("", isOn: $state.groupColors.enabled)
- .toggleStyle(.switch)
- .labelsHidden()
+ // 顶部:三模式滑块切换
+ ModeSwitcher(mode: $state.groupColors.mode)
+
+ // 下方:仅显示当前模式的快捷键设置
+ switch state.groupColors.mode {
+ case .trigger:
+ SettingCard {
+ SettingRow("~ 键三色分组选字") {
+ HStack(spacing: 8) {
+ TriggerKeyField(keyCode: $state.groupColors.triggerKey)
+ Toggle("", isOn: $state.groupColors.enabled)
+ .toggleStyle(.switch)
+ .labelsHidden()
+ }
+ }
+ SettingRow("", subtitle:
+ "触发键默认为数字 1 左侧的 ` 键(显示为 ~)。点击后按下任意键即可替换触发键;" +
+ "候选展开时,触发键将被拦截并进入三色选字,不再执行它原有的功能。",
+ divider: false) {
+ EmptyView()
}
}
- SettingRow("Shift 切换中英", divider: false) {
- HStack(spacing: 8) {
- KeycapView(text: "Shift", systemImage: "arrow.up")
- Toggle("", isOn: $state.shiftEnabled)
+
+ case .dwell:
+ SettingCard {
+ SettingRow("常驻模式触发组内选词") {
+ HStack(spacing: 8) {
+ TriggerKeyField(keyCode: $state.groupColors.dwellSecondKey)
+ Text("第2个")
+ }
+ }
+ SettingRow("第3个候选键") {
+ TriggerKeyField(keyCode: $state.groupColors.dwellThirdKey)
+ }
+ SettingRow("进入第2/3组后仍用 `~/tab 选词", divider: false) {
+ Toggle("", isOn: $state.groupColors.dwellUseDefaultKeysInGroup)
.toggleStyle(.switch)
.labelsHidden()
}
}
+
+ case .slider:
+ SettingCard {
+ SettingRow("滑块模式触发键") {
+ TriggerKeyField(keyCode: $state.groupColors.sliderTriggerKey)
+ }
+ SettingRow("滑块模式回退键") {
+ TriggerKeyField(keyCode: $state.groupColors.sliderBackKey)
+ }
+ SettingRow("", subtitle:
+ "触发键默认为数字 1 左侧的 ` 键(显示为 ~),回退键默认为 Tab。敲入拼音后常驻显示某组二级菜单," +
+ "按触发键向后滑动一组、按回退键返回上一组;滑动到哪组,按 1/2/3 即选该组第 1/2/3 个。",
+ divider: false) {
+ EmptyView()
+ }
+ }
}
}
}
diff --git a/settings/Sources/TriFectaSettingsCLI/main.swift b/settings/Sources/TriFectaSettingsCLI/main.swift
index 53ca5a7..6b2fa33 100644
--- a/settings/Sources/TriFectaSettingsCLI/main.swift
+++ b/settings/Sources/TriFectaSettingsCLI/main.swift
@@ -14,7 +14,6 @@
// schema_list=a,b,c
// input.full_shape=0|1 (默认方案的 full_shape reset)
// input.simplified=0|1 (默认方案的简繁 reset)
-// shortcuts.shift=on|off
//
import Foundation
@@ -52,7 +51,6 @@ func main() {
print("colorSchemes: \(model.colorSchemes.map { "\($0.0)(\($0.1))" }.joined(separator: ", "))")
print("schemaList: \(model.schemaList.joined(separator: ","))")
print("schemas: \(model.schemas.map { $0.id }.joined(separator: ","))")
- print("shiftEnabled: \(model.shiftEnabled)")
for (id, resets) in model.switchResets.sorted(by: { $0.key < $1.key }) {
print("switches[\(id)]: \(resets.sorted { $0.key < $1.key }.map { "@\($0.key)=\($0.value)" }.joined(separator: " "))")
}
@@ -65,7 +63,6 @@ func main() {
var style: StyleValues?
var gc: GroupColorsValues?
var schemaList: [String]?
- var shift: Bool?
var resets: [String: [Int: Int]] = [:]
let model = try RimeModel.effectiveModel(paths: repo.paths)
for arg in args.dropFirst(2) {
@@ -121,15 +118,12 @@ func main() {
var d = resets[schema.id] ?? [:]
d[sw.index] = value == "1" ? 1 : 0
resets[schema.id] = d
- case "shortcuts.shift":
- guard value == "on" || value == "off" else { fail("shift 应为 on/off") }
- shift = value == "on"
default:
fail("未知键:\(key)")
}
}
let changes = ChangeSet(style: style, groupColors: gc, schemaList: schemaList,
- switchResets: resets.isEmpty ? nil : resets, shiftEnabled: shift)
+ switchResets: resets.isEmpty ? nil : resets)
let outcome = try repo.apply(changes, deploy: false)
print("已写入:\(outcome.filesWritten.map { $0.path }.joined(separator: " "))")
print("squirrel.yaml 首次创建:\(outcome.squirrelYamlCreated)")
diff --git a/settings/Sources/TriFectaSettingsCore/EffectiveModel.swift b/settings/Sources/TriFectaSettingsCore/EffectiveModel.swift
index a014dd2..259bddb 100644
--- a/settings/Sources/TriFectaSettingsCore/EffectiveModel.swift
+++ b/settings/Sources/TriFectaSettingsCore/EffectiveModel.swift
@@ -34,17 +34,53 @@ public struct StyleValues: Equatable {
)
}
+/// 三色分组的工作模式。
+public enum TriColorMode: String, Equatable, CaseIterable {
+ case trigger = "trigger" // 触发键打开三色一级菜单
+ case dwell = "dwell" // 常驻:无触发键自动打开一级菜单
+ case slider = "slider" // 滑块:常驻显示某组二级菜单, 触发键平移组
+}
+
public struct GroupColorsValues: Equatable {
public var enabled: Bool
public var red: UInt32
public var yellow: UInt32
public var green: UInt32
+ /// 三色分组的触发键:mac keyCode(数字)。默认 50 = ` 键(UI 显示为 ~)。
+ /// 主程序据此拦截该键在候选展开时进入三色的功能。(触发模式)
+ public var triggerKey: Int
+ /// 当前模式:trigger / dwell / slider。
+ public var mode: TriColorMode
+ /// 常驻模式:第1组第2个候选的选字键(默认 50 = ` 键,UI 显示 ~)。
+ public var dwellSecondKey: Int
+ /// 常驻模式:第1组第3个候选的选字键(默认 48 = Tab)。
+ public var dwellThirdKey: Int
+ /// 常驻模式:进第2/3组二级菜单后,用 1/2/3 选词,还是用 `~/tab(与第1组一致)。
+ public var dwellUseDefaultKeysInGroup: Bool
+ /// 滑块模式:触发键(默认 50 = ` 键,UI 显示 ~)。
+ public var sliderTriggerKey: Int
+ /// 滑块模式:回退键(返回上一组,默认 48 = Tab)。
+ public var sliderBackKey: Int
- public init(enabled: Bool, red: UInt32, yellow: UInt32, green: UInt32) {
+ public init(enabled: Bool, red: UInt32, yellow: UInt32, green: UInt32,
+ triggerKey: Int = 50,
+ mode: TriColorMode = .trigger,
+ dwellSecondKey: Int = 50,
+ dwellThirdKey: Int = 48,
+ dwellUseDefaultKeysInGroup: Bool = false,
+ sliderTriggerKey: Int = 50,
+ sliderBackKey: Int = 48) {
self.enabled = enabled
self.red = red
self.yellow = yellow
self.green = green
+ self.triggerKey = triggerKey
+ self.mode = mode
+ self.dwellSecondKey = dwellSecondKey
+ self.dwellThirdKey = dwellThirdKey
+ self.dwellUseDefaultKeysInGroup = dwellUseDefaultKeysInGroup
+ self.sliderTriggerKey = sliderTriggerKey
+ self.sliderBackKey = sliderBackKey
}
/// 与 sources/SquirrelView.swift 硬编码值一致(0xAABBGGRR:alpha、blue、green、red)
@@ -61,7 +97,6 @@ public struct EffectiveModel {
public let schemaList: [String]
/// 用户是否已自定义方案列表(未自定义时显示推荐默认:简中 + 繁中)
public let schemaListCustomized: Bool
- public let shiftEnabled: Bool
/// schemaID -> switchIndex -> 有效 reset(含 schema 默认)
public let switchResets: [String: [Int: Int]]
/// schemaID -> 用户 custom 文件里显式声明过 reset 的开关索引(区分"用户已保存"与"schema 默认")
@@ -106,6 +141,12 @@ public enum RimeModel {
return hexColorValue(s)
}
+ /// 解析整数值(用于 group_colors/trigger_key 这类 keyCode)。
+ public static func intScalar(_ node: Node?) -> Int? {
+ guard let s = node?.string else { return nil }
+ return Int(s.trimmingCharacters(in: .whitespaces))
+ }
+
public static func hexDump(_ value: UInt32) -> String {
String(format: "0x%08X", value)
}
@@ -153,7 +194,14 @@ public enum RimeModel {
enabled: boolScalar(gc?["enabled"]) ?? defaults.enabled,
red: hexColor(gc?["red"]) ?? defaults.red,
yellow: hexColor(gc?["yellow"]) ?? defaults.yellow,
- green: hexColor(gc?["green"]) ?? defaults.green
+ green: hexColor(gc?["green"]) ?? defaults.green,
+ triggerKey: intScalar(gc?["trigger_key"]) ?? defaults.triggerKey,
+ mode: TriColorMode(rawValue: gc?["mode"]?.string ?? "") ?? defaults.mode,
+ dwellSecondKey: intScalar(gc?["dwell_second_key"]) ?? defaults.dwellSecondKey,
+ dwellThirdKey: intScalar(gc?["dwell_third_key"]) ?? defaults.dwellThirdKey,
+ dwellUseDefaultKeysInGroup: boolScalar(gc?["dwell_use_default_keys_in_group"]) ?? defaults.dwellUseDefaultKeysInGroup,
+ sliderTriggerKey: intScalar(gc?["slider_trigger_key"]) ?? defaults.sliderTriggerKey,
+ sliderBackKey: intScalar(gc?["slider_back_key"]) ?? defaults.sliderBackKey
)
}
@@ -192,22 +240,6 @@ public enum RimeModel {
return false
}
- /// 有效 Shift 切换中英:用户 patch 优先,否则 SharedSupport 默认(inline_ascii = 开启)
- public static func readShiftEnabled(paths: ConfigPaths) throws -> Bool {
- if let patch = try readCustomPatch(paths: paths, fileName: "default.custom.yaml") {
- // Rime 补丁键是扁平路径键(整串为一个 YAML 键);兼容嵌套写法
- if let v = patch["ascii_composer/switch_key/Shift_L"]?.string {
- return v == "inline_ascii"
- }
- if let v = patch["ascii_composer"]?["switch_key"]?["Shift_L"]?.string {
- return v == "inline_ascii"
- }
- }
- let text = try String(contentsOf: paths.sharedDefaultYaml, encoding: .utf8)
- let node = try compose(text)
- return (node["ascii_composer"]?["switch_key"]?["Shift_L"]?.string ?? "inline_ascii") == "inline_ascii"
- }
-
/// 某 schema 开关的有效 reset(用户 .custom.yaml 的 "switches/@N/reset" 优先),
/// 一并返回用户显式声明过的索引(用于区分"用户已保存"与"schema 默认值")。
public static func readSwitchResets(paths: ConfigPaths, schema: SchemaInfo) throws -> (resets: [Int: Int], patchKeys: Set) {
@@ -249,7 +281,6 @@ public enum RimeModel {
schemas: schemas,
schemaList: schemaList,
schemaListCustomized: try isSchemaListCustomized(paths: paths),
- shiftEnabled: try readShiftEnabled(paths: paths),
switchResets: switchResets,
userResetPatchKeys: userResetPatchKeys,
squirrelYamlSource: source,
diff --git a/settings/Sources/TriFectaSettingsCore/SettingsRepository.swift b/settings/Sources/TriFectaSettingsCore/SettingsRepository.swift
index 17ee648..26b4eab 100644
--- a/settings/Sources/TriFectaSettingsCore/SettingsRepository.swift
+++ b/settings/Sources/TriFectaSettingsCore/SettingsRepository.swift
@@ -9,18 +9,15 @@ public struct ChangeSet: Equatable {
public var groupColors: GroupColorsValues?
public var schemaList: [String]?
public var switchResets: [String: [Int: Int]]?
- public var shiftEnabled: Bool?
public init(style: StyleValues? = nil,
groupColors: GroupColorsValues? = nil,
schemaList: [String]? = nil,
- switchResets: [String: [Int: Int]]? = nil,
- shiftEnabled: Bool? = nil) {
+ switchResets: [String: [Int: Int]]? = nil) {
self.style = style
self.groupColors = groupColors
self.schemaList = schemaList
self.switchResets = switchResets
- self.shiftEnabled = shiftEnabled
}
}
@@ -85,6 +82,13 @@ public final class SettingsRepository {
if cur.red != gc.red { vals.append(("red", .hexColor(gc.red))) }
if cur.yellow != gc.yellow { vals.append(("yellow", .hexColor(gc.yellow))) }
if cur.green != gc.green { vals.append(("green", .hexColor(gc.green))) }
+ if cur.triggerKey != gc.triggerKey { vals.append(("trigger_key", .number(String(gc.triggerKey)))) }
+ if cur.mode != gc.mode { vals.append(("mode", .string(gc.mode.rawValue))) }
+ if cur.dwellSecondKey != gc.dwellSecondKey { vals.append(("dwell_second_key", .number(String(gc.dwellSecondKey)))) }
+ if cur.dwellThirdKey != gc.dwellThirdKey { vals.append(("dwell_third_key", .number(String(gc.dwellThirdKey)))) }
+ if cur.dwellUseDefaultKeysInGroup != gc.dwellUseDefaultKeysInGroup { vals.append(("dwell_use_default_keys_in_group", .bool(gc.dwellUseDefaultKeysInGroup))) }
+ if cur.sliderTriggerKey != gc.sliderTriggerKey { vals.append(("slider_trigger_key", .number(String(gc.sliderTriggerKey)))) }
+ if cur.sliderBackKey != gc.sliderBackKey { vals.append(("slider_back_key", .number(String(gc.sliderBackKey)))) }
if !vals.isEmpty {
try editor.setSectionValues(section: "group_colors", values: vals)
}
@@ -95,8 +99,8 @@ public final class SettingsRepository {
written.append(url)
}
- // 2. default.custom.yaml(schema_list / Shift 切换)
- if changes.schemaList != nil || changes.shiftEnabled != nil {
+ // 2. default.custom.yaml(schema_list)
+ if changes.schemaList != nil {
let url = paths.userDefaultCustomYaml
let (text, _) = try readOrCreate(url)
var editor = YamlLineEditor(text: text)
@@ -106,11 +110,6 @@ public final class SettingsRepository {
items: list.map { "- schema: \($0)" }
)
}
- if let shift = changes.shiftEnabled {
- let v = shift ? "inline_ascii" : "noop"
- try editor.setScalar(section: "patch", keyText: "\"ascii_composer/switch_key/Shift_L\"", value: .string(v))
- try editor.setScalar(section: "patch", keyText: "\"ascii_composer/switch_key/Shift_R\"", value: .string(v))
- }
let newText = editor.text
try validateCustomText(newText, fileName: "default.custom.yaml", changes: changes)
try atomicWrite(url, text: newText)
@@ -161,6 +160,13 @@ public final class SettingsRepository {
try assertWritten("group_colors/red", before: before.red, expected: gc.red, after: after.red)
try assertWritten("group_colors/yellow", before: before.yellow, expected: gc.yellow, after: after.yellow)
try assertWritten("group_colors/green", before: before.green, expected: gc.green, after: after.green)
+ try assertWritten("group_colors/trigger_key", before: before.triggerKey, expected: gc.triggerKey, after: after.triggerKey)
+ try assertWritten("group_colors/mode", before: before.mode, expected: gc.mode, after: after.mode)
+ try assertWritten("group_colors/dwell_second_key", before: before.dwellSecondKey, expected: gc.dwellSecondKey, after: after.dwellSecondKey)
+ try assertWritten("group_colors/dwell_third_key", before: before.dwellThirdKey, expected: gc.dwellThirdKey, after: after.dwellThirdKey)
+ try assertWritten("group_colors/dwell_use_default_keys_in_group", before: before.dwellUseDefaultKeysInGroup, expected: gc.dwellUseDefaultKeysInGroup, after: after.dwellUseDefaultKeysInGroup)
+ try assertWritten("group_colors/slider_trigger_key", before: before.sliderTriggerKey, expected: gc.sliderTriggerKey, after: after.sliderTriggerKey)
+ try assertWritten("group_colors/slider_back_key", before: before.sliderBackKey, expected: gc.sliderBackKey, after: after.sliderBackKey)
}
}
@@ -183,13 +189,6 @@ public final class SettingsRepository {
throw YamlLineEditor.EditorError.malformed("写入校验失败:\(fileName) schema_list 与预期不符")
}
}
- if let shift = changes.shiftEnabled {
- let v = shift ? "inline_ascii" : "noop"
- let parsed = node["patch"]?["ascii_composer/switch_key/Shift_L"]?.string
- if parsed != v {
- throw YamlLineEditor.EditorError.malformed("写入校验失败:\(fileName) shift 设置与预期不符")
- }
- }
}
}
diff --git a/settings/Tests/TriFectaSettingsTests/RimeSchemasAndRepositoryTests.swift b/settings/Tests/TriFectaSettingsTests/RimeSchemasAndRepositoryTests.swift
index fb9fa1b..619dfdc 100644
--- a/settings/Tests/TriFectaSettingsTests/RimeSchemasAndRepositoryTests.swift
+++ b/settings/Tests/TriFectaSettingsTests/RimeSchemasAndRepositoryTests.swift
@@ -97,20 +97,18 @@ final class SettingsRepositoryTests: XCTestCase {
XCTAssertEqual(model.groupColors.yellow, GroupColorsValues.defaults.yellow, "未改的颜色保持默认")
}
- func testSchemaListAndShiftApply() throws {
+ func testSchemaListApply() throws {
let model0 = try RimeModel.effectiveModel(paths: paths)
var list = model0.schemaList
XCTAssertEqual(list, RimeModel.recommendedSchemaList, "未自定义时使用推荐默认(简中+繁中各一)")
XCTAssertFalse(model0.schemaListCustomized, "未自定义方案列表")
list = ["cangjie5", "luna_pinyin"]
- _ = try repo.apply(ChangeSet(schemaList: list, shiftEnabled: false), deploy: false)
+ _ = try repo.apply(ChangeSet(schemaList: list), deploy: false)
let model = try RimeModel.effectiveModel(paths: paths)
XCTAssertEqual(model.schemaList, ["cangjie5", "luna_pinyin"])
XCTAssertTrue(model.schemaListCustomized, "保存后视为已自定义")
- XCTAssertFalse(model.shiftEnabled)
let fileText = try String(contentsOf: paths.userDefaultCustomYaml, encoding: .utf8)
XCTAssertTrue(fileText.contains(" schema_list:\n - schema: cangjie5\n"))
- XCTAssertTrue(fileText.contains("\"ascii_composer/switch_key/Shift_L\": noop"))
}
func testSwitchResetPerSchema() throws {
diff --git a/sources/Main.swift b/sources/Main.swift
index 17b5751..b7607d1 100644
--- a/sources/Main.swift
+++ b/sources/Main.swift
@@ -71,8 +71,18 @@ struct SquirrelApp {
return true
case "--build":
SquirrelApplicationDelegate.showMessage(msgText: NSLocalizedString("deploy_update", comment: ""))
+ // 与运行时 setupRime() 保持一致,否则部署会把数据写到当前工作目录
+ //(如 SharedSupport/build),而运行时读的是 ~/Library/Rime,导致切到输入法无法打字。
+ do {
+ try FileManager.default.createDirectory(at: SquirrelApp.userDir, withIntermediateDirectories: true)
+ } catch {
+ print("Error creating user data directory: \(SquirrelApp.userDir.path())")
+ }
var builderTraits = RimeTraits.rimeStructInit()
- builderTraits.setCString("rime.squirrel-builder", to: \.app_name)
+ builderTraits.setCString(Bundle.main.sharedSupportPath!, to: \.shared_data_dir)
+ builderTraits.setCString(SquirrelApp.userDir.path(), to: \.user_data_dir)
+ builderTraits.setCString("Squirrel", to: \.distribution_code_name)
+ builderTraits.setCString("rime.squirrel", to: \.app_name)
rimeAPI.setup(&builderTraits)
rimeAPI.deployer_initialize(nil)
_ = rimeAPI.deploy()
diff --git a/sources/SquirrelInputController.swift b/sources/SquirrelInputController.swift
index 8d35c33..fb48c7e 100644
--- a/sources/SquirrelInputController.swift
+++ b/sources/SquirrelInputController.swift
@@ -24,6 +24,10 @@ final class SquirrelInputController: IMKInputController {
// 三色分组递进选字:`~` 是否开启(切换,而非长按)、当前选中的组号(0/1/2)
private var tildeDown = false
private var tildeGroup: Int?
+ // 滑块模式:当前聚焦的三色组号(0/1/2)。候选变化时重置为 0。
+ private var sliderGroupIndex = 0
+ // 用 Shift+字母 输出大写后,临时屏蔽 Shift 的中英切换一小段,防止松开 Shift 时误切换。
+ private var shiftSupressedUntil: Date = .distantPast
private var chordKeyCodes: [UInt32] = .init(repeating: 0, count: SquirrelInputController.keyRollOver)
private var chordModifiers: [UInt32] = .init(repeating: 0, count: SquirrelInputController.keyRollOver)
private var chordKeyCount: Int = 0
@@ -85,6 +89,11 @@ final class SquirrelInputController: IMKInputController {
if modifiers.contains(flag) {
buffer.append((keycode: rimeKeycode, modifier: rimeModifiers))
} else {
+ let isShiftRelease = (flag == .shift)
+ // 用 Shift+字母 输出大写后,短暂屏蔽 Shift 的中英切换,避免松开时误切中英。
+ if isShiftRelease, Date() < shiftSupressedUntil {
+ continue
+ }
buffer.insert((keycode: rimeKeycode, modifier: rimeModifiers | kReleaseMask.rawValue), at: 0)
}
}
@@ -98,45 +107,10 @@ final class SquirrelInputController: IMKInputController {
case .keyDown:
let keyCode = event.keyCode
let candidateCount = NSApp.squirrelAppDelegate.panel?.candidateCount ?? 0
- // 三色分组 + ~123 递进选字:按住 `~`(keycode 50),先按 1/2/3 选组,再按 1/2/3 选候选
- // group_colors/enabled = false(设置窗口「~ 键三色」关闭)时整段跳过,~ 走默认行为
- if candidateCount > 0, NSApp.squirrelAppDelegate.config?.getBool("group_colors/enabled") ?? true {
- if keyCode == 50 {
- // 三态切换:未开 -> 开三色;已选组 -> 返回选组状态;选组状态 -> 退出
- if !tildeDown {
- tildeDown = true
- tildeGroup = nil
- NSApp.squirrelAppDelegate.panel?.setGroupMode(true)
- NSApp.squirrelAppDelegate.panel?.setSelectedGroup(nil)
- } else if tildeGroup != nil {
- tildeGroup = nil
- NSApp.squirrelAppDelegate.panel?.setSelectedGroup(nil) // 回到选组,不退出三色
- } else {
- tildeDown = false
- NSApp.squirrelAppDelegate.panel?.setGroupMode(false) // 退出三色
- }
- handled = true // 吞掉 `~`,不输入到拼音
- break
- }
- if tildeDown, [18, 19, 20].contains(keyCode) {
- let item = Int(keyCode) - 17 // 18->1, 19->2, 20->3
- if tildeGroup == nil {
- tildeGroup = item - 1 // 当前选中的组号 0/1/2
- NSApp.squirrelAppDelegate.panel?.setSelectedGroup(tildeGroup) // 组内候选按位置红/黄/绿
- } else {
- let groupSize = max(1, Int((Double(candidateCount) / 3.0).rounded(.up)))
- _ = selectCandidate(tildeGroup! * groupSize + (item - 1))
- tildeGroup = nil
- // 完成一次“精准上字”后自动退回蓝色:长词组的剩余候选可能高频出现,用蓝色更省事;需要三色再按 `~`
- tildeDown = false
- NSApp.squirrelAppDelegate.panel?.setGroupMode(false)
- }
- handled = true
- break
- }
- if tildeDown, tildeGroup != nil {
- tildeGroup = nil // 按住 ~ 时按了其它键:取消待选组,放行该键
- }
+ // 三色分组递进选字:交给 handleTriColor 处理,命中则消费该键(跳过后面的 processKey)。
+ if handleTriColor(keyCode: keyCode, candidateCount: candidateCount) {
+ handled = true // 关键:标记已消费,否则 IMK 会把键当字符输入
+ break
}
// Let client apps handle Command shortcuts.
@@ -144,6 +118,18 @@ final class SquirrelInputController: IMKInputController {
break
}
+ // 中文状态按住 Shift+字母:直接输出对应大写字母,不进入 Rime 拼音候选,
+ // 避免非法拼音无候选框、影响后续汉字输入。仅当 Shift 按下且为字母键时。
+ if modifiers.contains(.shift), !modifiers.contains(.control),
+ !modifiers.contains(.option), !modifiers.contains(.command),
+ let ch = event.charactersIgnoringModifiers?.first, ch.isLetter {
+ let upper = String(ch).uppercased()
+ commit(string: upper)
+ shiftSupressedUntil = Date().addingTimeInterval(1.0) // 屏蔽 Shift 切换 1 秒
+ handled = true
+ break
+ }
+
var keyChars = event.charactersIgnoringModifiers
let capitalModifiers = modifiers.isSubset(of: [.shift, .capsLock])
if let code = keyChars?.first,
@@ -172,6 +158,166 @@ final class SquirrelInputController: IMKInputController {
return handled
}
+
+ /// 三色分组递进选字(三模式)。返回 true 表示该键已被三色逻辑消费。
+ /// 命中后调用方应跳过后续 processKey,避免触发键/数字被当作字符输入。
+ private func handleTriColor(keyCode: UInt16, candidateCount: Int) -> Bool {
+ let triEnabled = NSApp.squirrelAppDelegate.config?.getBool("group_colors/enabled") ?? true
+ let triMode = NSApp.squirrelAppDelegate.config?.getString("group_colors/mode") ?? "trigger"
+ let triTriggerKey = Int(NSApp.squirrelAppDelegate.config?.getString("group_colors/trigger_key") ?? "") ?? 50
+ let dwellSecondKey = Int(NSApp.squirrelAppDelegate.config?.getString("group_colors/dwell_second_key") ?? "") ?? 50
+ let dwellThirdKey = Int(NSApp.squirrelAppDelegate.config?.getString("group_colors/dwell_third_key") ?? "") ?? 48
+ let dwellUseDefault = NSApp.squirrelAppDelegate.config?.getBool("group_colors/dwell_use_default_keys_in_group") ?? false
+ let sliderTriggerKey = Int(NSApp.squirrelAppDelegate.config?.getString("group_colors/slider_trigger_key") ?? "") ?? 50
+ let sliderBackKey = Int(NSApp.squirrelAppDelegate.config?.getString("group_colors/slider_back_key") ?? "") ?? 48
+ let groupSize = max(1, Int((Double(candidateCount) / 3.0).rounded(.up)))
+ let groupCount = min(3, (candidateCount + groupSize - 1) / groupSize)
+ let isGroupSel = [18, 19, 20].contains(keyCode) // 1/2/3
+ let itemNum = Int(keyCode) - 17 // 18->1, 19->2, 20->3
+
+ // 仅在有候选时处理三色(数字/触发键才与候选绑定);无候选时放行数字等键,
+ // 避免常驻/滑块模式下无法打出 1/2/3。
+ guard triEnabled, candidateCount > 0 else { return false }
+
+ switch triMode {
+ case "dwell":
+ // ---- 常驻模式:候选出现即开一级菜单,无需触发键 ----
+ if !tildeDown {
+ tildeDown = true
+ tildeGroup = nil
+ NSApp.squirrelAppDelegate.panel?.setGroupMode(true)
+ NSApp.squirrelAppDelegate.panel?.setSelectedGroup(nil)
+ }
+ if tildeGroup == nil {
+ // 一级菜单:前 3 个候选(第1组)用 1 / dwellSecondKey / dwellThirdKey 直接选
+ if keyCode == 18 {
+ _ = selectCandidate(0)
+ finishTriSelect()
+ return true
+ } else if keyCode == dwellSecondKey {
+ _ = selectCandidate(1)
+ finishTriSelect()
+ return true
+ } else if keyCode == dwellThirdKey {
+ _ = selectCandidate(2)
+ finishTriSelect()
+ return true
+ } else if keyCode == 19 || keyCode == 20 {
+ tildeGroup = itemNum - 1
+ NSApp.squirrelAppDelegate.panel?.setSelectedGroup(tildeGroup)
+ return true
+ }
+ } else {
+ // 已进第2/3组二级菜单
+ var consumed = false
+ if dwellUseDefault {
+ if keyCode == 18 {
+ _ = selectCandidate(tildeGroup! * groupSize + 0); consumed = true
+ } else if keyCode == dwellSecondKey {
+ _ = selectCandidate(tildeGroup! * groupSize + 1); consumed = true
+ } else if keyCode == dwellThirdKey {
+ _ = selectCandidate(tildeGroup! * groupSize + 2); consumed = true
+ } else if isGroupSel {
+ _ = selectCandidate(tildeGroup! * groupSize + (itemNum - 1)); consumed = true
+ }
+ } else {
+ if isGroupSel {
+ _ = selectCandidate(tildeGroup! * groupSize + (itemNum - 1)); consumed = true
+ }
+ }
+ if consumed {
+ finishTriSelect()
+ return true
+ }
+ return false
+ }
+ return false
+
+ case "slider":
+ // ---- 滑块模式:常驻显示某组二级菜单,触发键平移组 + 到末尾翻页 ----
+ if keyCode == sliderTriggerKey {
+ if sliderGroupIndex < groupCount - 1 {
+ sliderGroupIndex += 1
+ } else {
+ _ = page(up: false) // 向后翻页(Rime change_page: backward=false = 下一页,= 键)
+ rimeUpdate()
+ sliderGroupIndex = 0
+ }
+ NSApp.squirrelAppDelegate.panel?.setGroupMode(true)
+ NSApp.squirrelAppDelegate.panel?.setSelectedGroup(sliderGroupIndex)
+ return true
+ }
+ // 回退键:滑块返回上一组(到第一组则保持第一组)
+ if keyCode == sliderBackKey {
+ if sliderGroupIndex > 0 {
+ sliderGroupIndex -= 1
+ } else {
+ _ = page(up: true) // 已在第一组:回退则向前翻一页(上一组候选)
+ rimeUpdate()
+ sliderGroupIndex = groupCount - 1
+ }
+ NSApp.squirrelAppDelegate.panel?.setGroupMode(true)
+ NSApp.squirrelAppDelegate.panel?.setSelectedGroup(sliderGroupIndex)
+ return true
+ }
+ if !tildeDown {
+ tildeDown = true
+ NSApp.squirrelAppDelegate.panel?.setGroupMode(true)
+ NSApp.squirrelAppDelegate.panel?.setSelectedGroup(sliderGroupIndex)
+ }
+ if isGroupSel {
+ _ = selectCandidate(sliderGroupIndex * groupSize + (itemNum - 1))
+ // 滑块模式:选字后不退出三色,保持常驻并回到第一组,便于连续选字。
+ tildeDown = true
+ sliderGroupIndex = 0
+ NSApp.squirrelAppDelegate.panel?.setGroupMode(true)
+ NSApp.squirrelAppDelegate.panel?.setSelectedGroup(0)
+ return true
+ }
+ return false
+
+ default:
+ // ---- 触发模式:按触发键进入三色,1/2/3 选组/选字 ----
+ if keyCode == triTriggerKey {
+ if !tildeDown {
+ tildeDown = true
+ tildeGroup = nil
+ NSApp.squirrelAppDelegate.panel?.setGroupMode(true)
+ NSApp.squirrelAppDelegate.panel?.setSelectedGroup(nil)
+ } else if tildeGroup != nil {
+ tildeGroup = nil
+ NSApp.squirrelAppDelegate.panel?.setSelectedGroup(nil)
+ } else {
+ tildeDown = false
+ NSApp.squirrelAppDelegate.panel?.setGroupMode(false)
+ }
+ return true
+ }
+ if tildeDown, isGroupSel {
+ if tildeGroup == nil {
+ tildeGroup = itemNum - 1
+ NSApp.squirrelAppDelegate.panel?.setSelectedGroup(tildeGroup)
+ } else {
+ _ = selectCandidate(tildeGroup! * groupSize + (itemNum - 1))
+ finishTriSelect()
+ }
+ return true
+ }
+ if tildeDown, tildeGroup != nil {
+ tildeGroup = nil
+ }
+ return false
+ }
+ }
+
+ /// 选中候选后:清组、退出三色、回到蓝色。
+ private func finishTriSelect() {
+ tildeGroup = nil
+ tildeDown = false
+ NSApp.squirrelAppDelegate.panel?.setGroupMode(false)
+ }
+
+
func selectCandidate(_ index: Int) -> Bool {
let success = rimeAPI.select_candidate_on_current_page(session, index)
if success {
@@ -338,6 +484,13 @@ final class SquirrelInputController: IMKInputController {
.appendingPathComponent("MacOS", isDirectory: true)
.appendingPathComponent("TriFectaSettings.app", isDirectory: true)
if FileManager.default.fileExists(atPath: settingsURL.path) {
+ // 设置窗口用 WindowGroup + LSUIElement:窗口关闭后进程常驻,
+ // 再次 NSWorkspace.open 只激活旧进程而不重建窗口,导致"保存退出后无法唤起"。
+ // 打开前先结束残留的设置进程,保证每次都是单实例、全新起窗。
+ let settingsBundleID = "im.rime.inputmethod.Squirrel.settings"
+ for app in NSRunningApplication.runningApplications(withBundleIdentifier: settingsBundleID) {
+ app.terminate()
+ }
NSWorkspace.shared.open(settingsURL)
} else {
openRimeFolder()
@@ -517,6 +670,7 @@ private extension SquirrelInputController {
if NSApp.squirrelAppDelegate.panel?.candidateCount == 0 {
tildeDown = false
tildeGroup = nil
+ sliderGroupIndex = 0 // 滑块模式:拼音清空时滑块回到初始组
NSApp.squirrelAppDelegate.panel?.setGroupMode(false)
}
if clearReservedComments {
diff --git a/sources/SquirrelView.swift b/sources/SquirrelView.swift
index 69924c6..c5521f8 100644
--- a/sources/SquirrelView.swift
+++ b/sources/SquirrelView.swift
@@ -46,6 +46,9 @@ final class SquirrelView: NSView {
NSColor(calibratedRed: 0.14, green: 0.74, blue: 0.27, alpha: 0.68), // 绿
]
// 三色分组配色从配置读取:squirrel.yaml 顶层 group_colors(0xAABBGGRR,缺省回退上方硬编码值)
+ // 注意:设置窗口(TriFectaSettings)采用「只写改动的 key」的增量写入,
+ // 因此 group_colors 里可能只包含 red/yellow/green 之一。这里对缺失的色逐个
+ // 回退到硬编码默认值,避免因缺某色而整体不生效,导致"切换颜色后无法应用"。
private func loadGroupColorsFromConfig() {
guard let config = NSApp.squirrelAppDelegate.config else { return }
if config.getBool("group_colors/enabled") == false {
@@ -53,11 +56,15 @@ final class SquirrelView: NSView {
groupColors = []
return
}
- if let red = config.getColor("group_colors/red", inSpace: .sRGB),
- let yellow = config.getColor("group_colors/yellow", inSpace: .sRGB),
- let green = config.getColor("group_colors/green", inSpace: .sRGB) {
- groupColors = [red, yellow, green]
- }
+ let defaults = [
+ NSColor(calibratedRed: 0.94, green: 0.20, blue: 0.16, alpha: 0.68), // 红
+ NSColor(calibratedRed: 0.95, green: 0.72, blue: 0.00, alpha: 0.68), // 黄
+ NSColor(calibratedRed: 0.14, green: 0.74, blue: 0.27, alpha: 0.68), // 绿
+ ]
+ let red = config.getColor("group_colors/red", inSpace: .sRGB) ?? defaults[0]
+ let yellow = config.getColor("group_colors/yellow", inSpace: .sRGB) ?? defaults[1]
+ let green = config.getColor("group_colors/green", inSpace: .sRGB) ?? defaults[2]
+ groupColors = [red, yellow, green]
}
// 是否显示三色分组(按住 `~` 时打开)
var showsGroups = false
@@ -315,14 +322,9 @@ final class SquirrelView: NSView {
}
// 三色分组:仅在 `~` 开启时,用每组颜色填充候选背景
if showsGroups {
- if let selG = selectedGroup {
- // 选中组:其它组变暗,选中组内候选按位置红/黄/绿
- for (g, path) in candidateGroupPaths.enumerated() {
- guard let path, g != selG, g < groupColors.count else { continue }
- let layer = shapeFromPath(path: path)
- layer.fillColor = groupColors[g].withAlphaComponent(0.30).cgColor
- panelLayer.addSublayer(layer)
- }
+ if selectedGroup != nil {
+ // 选中组:其它组完全褪去(不绘制),只保留选中组内候选按位置红/黄/绿,
+ // 避免变淡后的残留色造成视觉杂乱、对比度不足。
for (idx, ip) in candidateItemPaths.enumerated() {
guard let ip, idx < groupColors.count else { continue }
let layer = shapeFromPath(path: ip)