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 icon +

-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 中文输入法。 +

+ TriFecta icon +

+ +> **三色三选,一按即中。** —— 用颜色让你从容三选一的 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)