From dc3b561e7880bfc2a34ebb322e7c192695640b1e Mon Sep 17 00:00:00 2001 From: yenharvey Date: Tue, 8 Sep 2026 10:48:19 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=84=E5=8F=A5=E4=B8=AD=20=E2=8C=A5?= =?UTF-8?q?=E2=86=90=20/=20=E2=8C=A5=E2=86=92=20=E6=8C=89=E9=9F=B3?= =?UTF-8?q?=E8=8A=82=E8=B7=B3=E5=85=89=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 光标在末尾发现前面某个音节错了:⌥← 跳到它后面、⌥⌫ 删掉重敲、⌘→ 回末尾,不用一个字母一个字母挪。 边界规则与 ⌥⌫ 相同(全拼按切分、双拼两键、直输段按字母段),往左 ` 跟着前面的音节,往右先越过 `。 --- apps/macos/src/imk/controller.rs | 10 ++++ crates/qingjian-core/src/engine/composing.rs | 54 +++++++++++++++++++ .../qingjian-core/src/engine/tests/lookup.rs | 35 ++++++++++++ docs/design/candidate-ui.md | 1 + docs/user/getting-started/first-input.md | 1 + 5 files changed, 101 insertions(+) diff --git a/apps/macos/src/imk/controller.rs b/apps/macos/src/imk/controller.rs index afc1529e..9f56ab1e 100644 --- a/apps/macos/src/imk/controller.rs +++ b/apps/macos/src/imk/controller.rs @@ -235,6 +235,8 @@ impl QingjianInputController { 125 => Some(sel!(moveDown:)), 123 if command => Some(sel!(moveToLeftEndOfLine:)), 124 if command => Some(sel!(moveToRightEndOfLine:)), + 123 if option => Some(sel!(moveWordLeft:)), + 124 if option => Some(sel!(moveWordRight:)), 123 => Some(sel!(moveLeft:)), 124 => Some(sel!(moveRight:)), 116 => Some(sel!(pageUp:)), @@ -607,6 +609,14 @@ impl QingjianInputController { } else if selector == sel!(moveRight:) { host::with(|h| h.engine.move_cursor_right()); self.refresh(client); + } else if selector == sel!(moveWordLeft:) { + // ⌥←:光标往左跳一个音节 + host::with(|h| h.engine.move_cursor_syllable_left()); + self.refresh(client); + } else if selector == sel!(moveWordRight:) { + // ⌥→:光标往右跳一个音节 + host::with(|h| h.engine.move_cursor_syllable_right()); + self.refresh(client); } else if selector == sel!(moveToBeginningOfLine:) || selector == sel!(moveToLeftEndOfLine:) { host::with(|h| h.engine.move_cursor_home()); diff --git a/crates/qingjian-core/src/engine/composing.rs b/crates/qingjian-core/src/engine/composing.rs index c0ad0035..d99c534e 100644 --- a/crates/qingjian-core/src/engine/composing.rs +++ b/crates/qingjian-core/src/engine/composing.rs @@ -95,6 +95,24 @@ impl Engine { self.composition.delete_before_cursor(len) } + /// 光标往左跳一个音节(壳里 ⌥←),边界与 [`Self::delete_syllable_backward`] 相同。已在开头返回 `false`。 + pub fn move_cursor_syllable_left(&mut self) -> bool { + let cursor = self.composition.cursor(); + let before = &self.composition.text()[..cursor]; + let plain = self.raw_mode() || self.expression_mode() || self.question_mode(); + let len = unit_len_before(before, self.shuangpin.is_some(), plain); + len > 0 && (0..len).all(|_| self.composition.move_left()) + } + + /// 光标往右跳一个音节(壳里 ⌥→):跳过紧跟的 `'`,再跳过一个音节。已在末尾返回 `false`。 + pub fn move_cursor_syllable_right(&mut self) -> bool { + let cursor = self.composition.cursor(); + let after = &self.composition.text()[cursor..]; + let plain = self.raw_mode() || self.expression_mode() || self.question_mode(); + let len = unit_len_after(after, self.shuangpin.is_some(), plain); + len > 0 && (0..len).all(|_| self.composition.move_right()) + } + /// 删掉光标前的全部拼音(壳里 ⌘⌫),光标后的留着。光标在开头时返回 `false`。 pub fn delete_to_start(&mut self) -> bool { let cursor = self.composition.cursor(); @@ -182,6 +200,42 @@ impl Engine { } } +/// 光标后的第一个「单位」占几个字节:先跳过紧跟的 `'`,再算一个音节;规则同 [`unit_len_before`]。 +fn unit_len_after(after: &str, shuangpin: bool, plain: bool) -> usize { + let trimmed = after.trim_start_matches('\''); + let separators = after.len() - trimmed.len(); + let Some(first) = trimmed.chars().next() else { + return separators; + }; + if plain || !first.is_ascii_lowercase() { + let run = if first.is_ascii_alphanumeric() { + trimmed + .chars() + .take_while(char::is_ascii_alphanumeric) + .map(char::len_utf8) + .sum() + } else { + first.len_utf8() + }; + return separators + run; + } + if shuangpin { + let run = trimmed + .chars() + .take_while(|c| c.is_ascii_lowercase() || *c == ';') + .count(); + return separators + run.min(2); + } + let syllable = match segment_longest_prefix(trimmed) { + Ok((segmentations, _)) => segmentations + .first() + .and_then(|s| s.syllables.first()) + .map_or(1, |s| s.text.len()), + Err(_) => 1, + }; + separators + syllable +} + /// 光标前的最后一个「单位」占几个字节:拼音里是一个音节(连同它后面的 `'`),见 [`Engine::delete_syllable_backward`]。 fn unit_len_before(before: &str, shuangpin: bool, plain: bool) -> usize { let trimmed = before.trim_end_matches('\''); diff --git a/crates/qingjian-core/src/engine/tests/lookup.rs b/crates/qingjian-core/src/engine/tests/lookup.rs index 4c683e0d..afa16cb2 100644 --- a/crates/qingjian-core/src/engine/tests/lookup.rs +++ b/crates/qingjian-core/src/engine/tests/lookup.rs @@ -335,3 +335,38 @@ fn option_backspace_deletes_a_syllable_and_command_backspace_deletes_to_the_star assert!(engine.delete_syllable_backward()); assert_eq!(engine.composition().text(), "kd"); } + +#[test] +fn option_arrows_move_the_cursor_by_syllable() { + let mut engine = engine(); + // 光标在末尾发现前面错了:⌥← 两下跳到第二个音节后面,⌥⌫ 删掉它,重敲,⌘→ 回末尾 + engine.set_input("kaifa'xian'xia"); + assert!(engine.move_cursor_syllable_left()); + assert_eq!(engine.composition().cursor(), "kaifa'xian'".len()); + assert!(engine.move_cursor_syllable_left()); + assert_eq!(engine.composition().cursor(), "kaifa'".len()); + assert!(engine.move_cursor_syllable_left()); + assert_eq!(engine.composition().cursor(), "kai".len()); + assert!(engine.delete_syllable_backward()); + assert_eq!(engine.composition().text(), "fa'xian'xia"); + engine.push('x'); + engine.push('i'); + assert_eq!(engine.composition().text(), "xifa'xian'xia"); + // ⌥→:从 xi| 起跳过一个音节到 xifa|,再跳先越过 `'` 再过一个音节 + assert!(engine.move_cursor_syllable_right()); + assert_eq!(engine.composition().cursor(), "xifa".len()); + assert!(engine.move_cursor_syllable_right()); + assert_eq!(engine.composition().cursor(), "xifa'xian".len()); + assert!(engine.move_cursor_syllable_right()); + assert_eq!(engine.composition().cursor(), "xifa'xian'xia".len()); + assert!(!engine.move_cursor_syllable_right()); + engine.move_cursor_home(); + assert!(!engine.move_cursor_syllable_left()); + // 直输段按字母段跳 + engine.set_input("hello,world"); + assert!(engine.move_cursor_syllable_left()); + assert_eq!(engine.composition().cursor(), "hello,".len()); + engine.move_cursor_home(); + assert!(engine.move_cursor_syllable_right()); + assert_eq!(engine.composition().cursor(), "hello".len()); +} diff --git a/docs/design/candidate-ui.md b/docs/design/candidate-ui.md index cf744c44..2196e41c 100644 --- a/docs/design/candidate-ui.md +++ b/docs/design/candidate-ui.md @@ -90,6 +90,7 @@ collection behavior 是 CanJoinAllSpaces + FullScreenAuxiliary + Stationary。 | ⌥⌫ / ⌘⌫ | 删光标前一个音节(全拼按最优切分的最后一个音节连同 `'`,切不动的尾巴整个删;双拼两键一音节;直输段按字母段)/ 删光标前全部拼音(`Engine::delete_syllable_backward` / `delete_to_start`)。不在组句时交给应用,撤销的账不再记(`break_chain`) | | Esc | 清空 | | 上 / 下 | 移动高亮,到页边自动翻页;第一项再按上不动 | +| ⌥← / ⌥→ | 按音节跳光标(边界规则同 ⌥⌫;`'` 跟着前面的音节,`Engine::move_cursor_syllable_left` / `right`)。光标在末尾发现前面的音节错了:⌥← 跳过去、⌥⌫ 删掉重敲、⌘→ 回末尾 | | 左 / 右、Cmd+左 / 右 | 移动拼音光标(不切模式)。光标停在中间时候选只按光标前的拼音算(`ni|hao` 出 你),上屏后剩余拼音留着、光标落到末尾;光标在开头或末尾按整段算 | | PageUp / PageDown、Shift+Tab、翻页键对(配置 `[general] page_keys`,缺省 `[` `]`,可选 `,` `.`) | 翻页。每页几个由 `page_size` 定(1–9) | | 半角标点(`is_ascii_punctuation`,翻页键除外;⇧+数字已被删候选 / 译词键截走) | 组句中敲它进入**英文直输段**:`no-way` `hello,` `dui'ma?` 整段原样显示,之后的可见字符(含数字、大写)都追加,回车原样上屏,空格原样上屏后空格本身也交给应用(`hello, world` 的空格要在),Esc 清掉。表达式 / 问字模式优先。中文模式下就能打带标点的英文句子;代价是没有了「拼音 + 标点 = 上屏候选 + 全角标点」,要先空格再打标点 | diff --git a/docs/user/getting-started/first-input.md b/docs/user/getting-started/first-input.md index 47113b9a..c2e79019 100644 --- a/docs/user/getting-started/first-input.md +++ b/docs/user/getting-started/first-input.md @@ -32,6 +32,7 @@ description: 中英切换、选词、翻页、整句输入、选错了怎么退 | `Esc` | 清空 | | `上` / `下` | 移动高亮,到页边自动翻页 | | `左` / `右` | 移动拼音光标;光标停在中间时候选只按光标前的拼音算 | +| `⌥ + 左` / `⌥ + 右` | 按音节跳光标;`⌘ + 左` / `⌘ + 右` 到开头 / 末尾。前面某个音节敲错了:⌥← 跳到它后面,⌥⌫ 删掉重敲,⌘→ 回末尾 | | `[` `]` | 翻页(可改成 `,` `.`);`PageUp` / `PageDown`、`Shift + Tab` 也翻 | | `Tab` | 有云端整句补全时接受它,没有则翻页 | | 半角标点(`,` `.` `?` `-` 等,翻页键除外) | 进入英文直输段:`hello,` `no-way` 整段原样显示,之后的字符原样追加,空格 / 回车原样上屏(空格本身也打出)。中文模式下也能打 `hello, world?` 这样带标点的英文 |