From fec15451ed1a879fd8c538a7441a8fc397b25882 Mon Sep 17 00:00:00 2001 From: jisongniu <529058747@qq.com> Date: Wed, 12 Aug 2026 18:43:40 +0800 Subject: [PATCH] =?UTF-8?q?feat(dictation):=20=E8=90=BD=E5=AD=97=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E6=97=B6=E6=8A=8A=E8=AF=9D=E4=BA=A4=E8=BF=98=E7=BB=99?= =?UTF-8?q?=E7=94=A8=E6=88=B7=EF=BC=8C=E6=8D=A2=E8=A1=8C=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E5=8A=88=E5=BC=80=E8=81=8A=E5=A4=A9=E6=B6=88=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两件事,本质是同一个:说出来的话没落到该去的地方。 **一、换行会把聊天消息从中间劈开发出去。** macOS 把 U+000A 当 Return 键,而聊天框里 Return 就是「发送」。一条带空行 的两段话逐字上屏时,第一个换行直接把上半句发了出去,下半句留在输入框里。 改成发真实的 Shift+Return:聊天框里是软换行不发送,编辑器、终端、网页输入 框里就是普通换行。 做成设置而不是硬编码——风格市场里有靠换行把一段话拆成多条消息的风格包, 那种效果要的正是真回车。默认 Shift+Return,需要的人切回 Return。 **二、落字失败时,那段话会悄无声息地消失。** 密码框挡着(Secure Input)、辅助功能掉了权限、粘贴被拒——这些时候唯一的 兜底是把文本写进剪贴板。可它既依赖一个默认可关的开关,就算开着,屏幕上也 没有任何东西告诉你文本在那儿。用户看到的只是胶囊一闪而过,然后是个空输入框。 现在屏幕底部会弹一张卡片,里面是**完整**的那段话,一键复制。20 秒后自己 消失,鼠标停在上面就不走。 顺带修正一处数据失真:逐字上屏打到一半断掉时,此前一律报 InsertStatus:: Inserted(屏幕上明明只有半截),history 里的 insertStatus 因此是错的。 --- .../app/src-tauri/src/commands/dictionary.rs | 19 ++ openless-all/app/src-tauri/src/coordinator.rs | 231 ++++++++++++++++++ .../src/coordinator/capsule_focus.rs | 19 +- .../src-tauri/src/coordinator/dictation.rs | 121 ++++++++- openless-all/app/src-tauri/src/insertion.rs | 12 + openless-all/app/src-tauri/src/lib.rs | 2 + openless-all/app/src-tauri/src/types.rs | 45 ++++ .../app/src-tauri/src/unicode_keystroke.rs | 168 ++++++++++++- openless-all/app/src/components/Capsule.tsx | 27 +- .../app/src/components/InsertFallbackCard.tsx | 185 ++++++++++++++ openless-all/app/src/i18n/en.ts | 10 + openless-all/app/src/i18n/ja.ts | 10 + openless-all/app/src/i18n/ko.ts | 10 + openless-all/app/src/i18n/zh-CN.ts | 10 + openless-all/app/src/i18n/zh-TW.ts | 10 + openless-all/app/src/lib/ipc/index.ts | 2 + openless-all/app/src/lib/ipc/mock-data.ts | 1 + openless-all/app/src/lib/ipc/vocab.ts | 15 ++ openless-all/app/src/lib/stylePrefs.test.ts | 1 + openless-all/app/src/lib/types.ts | 14 ++ .../pages/settings/RecordingInputSection.tsx | 20 ++ 21 files changed, 911 insertions(+), 21 deletions(-) create mode 100644 openless-all/app/src/components/InsertFallbackCard.tsx diff --git a/openless-all/app/src-tauri/src/commands/dictionary.rs b/openless-all/app/src-tauri/src/commands/dictionary.rs index 5cbe9ce59..c4375e5cd 100644 --- a/openless-all/app/src-tauri/src/commands/dictionary.rs +++ b/openless-all/app/src-tauri/src/commands/dictionary.rs @@ -66,6 +66,25 @@ pub fn dismiss_vocab_suggestions(coord: CoordinatorState<'_>) { coord.dismiss_vocab_suggestions(); } +/// 落字失败兜底卡片上点了「复制」。 +/// +/// **走后端而不是前端的 `navigator.clipboard`**:卡片浮在别的 app 上面,按钮刻意 +/// `preventDefault` 不抢焦点(抢了就把用户正在写的地方的光标弄没了),而未聚焦的 +/// 文档调 `navigator.clipboard.writeText` 会直接抛 `Document is not focused`。 +#[tauri::command] +pub fn copy_text_to_clipboard(text: String) -> Result<(), String> { + if text.is_empty() { + return Ok(()); + } + crate::insertion::copy_text_to_clipboard(&text) +} + +/// 兜底卡片自己关掉了(用户点关闭 / TTL 到时)。 +#[tauri::command] +pub fn dismiss_insert_fallback_card(coord: CoordinatorState<'_>) { + coord.dismiss_insert_fallback_card(); +} + #[tauri::command] pub fn remove_correction_rule(coord: CoordinatorState<'_>, id: String) -> Result<(), String> { coord diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index fea8c78fa..b474ecde5 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -338,6 +338,185 @@ fn position_vocab_card( window.set_position(tauri::LogicalPosition::new(x, y)) } +/// 兜底卡片的窗口宽度(逻辑点)。比词条卡片宽一点 —— 这张要放一整段话。 +const FALLBACK_CARD_WIDTH: f64 = 360.0; +/// 按钮行 + 内边距 + 留给投影的外边距。卡片没有标题行(见 `InsertFallbackCard.tsx`)。 +const FALLBACK_CARD_CHROME_HEIGHT: f64 = 89.0; +/// 正文一行的高度,与 `InsertFallbackCard.tsx` 的 `line-height` 对齐。 +const FALLBACK_CARD_LINE_HEIGHT: f64 = 18.0; +/// 正文最多显示几行,再多就在卡片内部滚动。 +/// +/// **必须与 `InsertFallbackCard.tsx` 里正文的 `max-height` 对齐**:窗口按这个上限算高度, +/// CSS 若允许长得更高,超出的部分就落在窗口外面被裁掉;反过来窗口会空出一块,而卡片 +/// 显示期间窗口是不穿透鼠标的,那块空白会拦住底下的点击。 +const FALLBACK_CARD_MAX_LINES: f64 = 8.0; +/// 正文按每行多少个字符折行估算(CJK 在 360pt 宽、13px 字号下的粗略值)。 +/// 只用来估窗口高度,真正的折行由浏览器做。 +const FALLBACK_CARD_CHARS_PER_LINE: f64 = 22.0; + +/// 把兜底卡片摆到屏幕**水平居中、偏下**的位置。 +/// +/// 与词条卡片的右下角不同:那张是「瞄一眼就完事」的建议,躲在角落里不打扰人正好; +/// 这张是用户切走窗口后要**读完再决定复不复制**的内容,藏在角落容易整个错过。 +/// 底部居中是录音胶囊本来就在的那条视线,用户的眼睛已经习惯往那儿看。 +/// +/// 垂直方向沿用胶囊那套「距底 80pt 给 Dock 留位」,卡片比胶囊高,往上长。 +fn position_fallback_card( + window: &tauri::WebviewWindow, + width: f64, + height: f64, +) -> tauri::Result<()> { + let Some(monitor) = window.current_monitor()? else { + return Ok(()); + }; + let scale = monitor.scale_factor(); + let size = monitor.size(); + let pos = monitor.position(); + let (mon_w, mon_h) = (size.width as f64 / scale, size.height as f64 / scale); + let (mon_x, mon_y) = (pos.x as f64 / scale, pos.y as f64 / scale); + let x = mon_x + (mon_w - width) / 2.0; + let y = mon_y + mon_h - height - 80.0; + window.set_position(tauri::LogicalPosition::new(x, y)) +} + +/// 兜底卡片的窗口高度:内容有多少估多少,封顶在 [`FALLBACK_CARD_MAX_LINES`]。 +fn fallback_card_height(text: &str) -> f64 { + let chars = text.chars().count() as f64; + let estimated_lines = (chars / FALLBACK_CARD_CHARS_PER_LINE).ceil().max(1.0); + // 显式换行也要占行 —— 一段两行的话估出来不能只有一行的高度。 + let newlines = text.matches('\n').count() as f64; + let lines = (estimated_lines + newlines).min(FALLBACK_CARD_MAX_LINES); + FALLBACK_CARD_CHROME_HEIGHT + FALLBACK_CARD_LINE_HEIGHT * lines +} + +/// 文本没能落到目标 app 时,把它连同一个复制按钮弹出来。 +/// +/// 为什么需要这张卡片:这些场景下唯一的兜底是「把文本写进剪贴板」,而它既依赖一个 +/// 默认可关的开关,用户也**根本不知道文本在剪贴板里** —— 没有任何提示。屏幕上要么 +/// 什么都没有,要么只有半截。 +/// +/// 窗口机制整套照搬 [`show_vocab_suggestion_card`](复用胶囊窗口、关穿透、缩尺寸、 +/// 右下角定位),理由见那里。多的一件事是 `insert_fallback_card_visible`:这张卡片 +/// 在会话收尾那一刻弹出,而收尾自己安排了一次 `schedule_capsule_idle` → `hide()`, +/// 必须让那次 hide 认得出卡片并让路。 +pub(crate) fn show_insert_fallback_card(inner: &Arc, text: String, reason: &'static str) { + if text.trim().is_empty() { + return; + } + let Some(app) = inner.app.lock().clone() else { + return; + }; + let height = fallback_card_height(&text); + let payload = crate::types::InsertFallbackCardPayload { + text, + reason: reason.to_string(), + }; + let app_for_main = app.clone(); + let inner_for_main = Arc::clone(inner); + let _ = app.run_on_main_thread(move || { + let app = app_for_main; + let inner = inner_for_main; + // 与词条卡片同一道闸、同一理由:听写不在 Idle 就绝不碰这个窗口,否则等于把 + // 正在进行的那次听写的胶囊弄没了。收尾路径是先把 phase 置回 Idle 再走到这里的。 + if inner.state.lock().phase != crate::coordinator_state::SessionPhase::Idle { + log::debug!("[fallback-card] suppressed: a dictation session is in flight"); + inner.insert_fallback_text.lock().take(); + return; + } + inner + .insert_fallback_card_visible + .store(true, Ordering::SeqCst); + let Some(window) = app.get_webview_window("capsule") else { + return; + }; + #[cfg(not(mobile))] + if let Err(e) = window.set_ignore_cursor_events(false) { + log::warn!("[fallback-card] set_ignore_cursor_events(false) failed: {e}"); + } + // 穿透状态有缓存(`capsule_cursor_passthrough`,emit_capsule 靠它跳过重复调用)。 + // 直接碰了窗口就必须同步它,否则缓存与窗口真实状态分家,下次 emit_capsule + // 会以为「没变化」而跳过该调的那一次 —— 表现是胶囊之后一直挡着屏幕不放。 + #[cfg(not(mobile))] + inner + .capsule_cursor_passthrough + .store(false, Ordering::SeqCst); + if let Err(e) = window.set_size(tauri::LogicalSize::new(FALLBACK_CARD_WIDTH, height)) { + log::warn!("[fallback-card] resize failed: {e}"); + } + if let Err(e) = position_fallback_card(&window, FALLBACK_CARD_WIDTH, height) { + log::warn!("[fallback-card] position failed: {e}"); + } + // 位置同理:`maybe_position_capsule_bottom_center` 的去重缓存只记「显示器 + + // 翻译态」,卡片这一挪它一无所知。不清掉的话下一次录音会判定「没变化」→ + // 跳过重新定位 → 胶囊留在卡片挪过去的右下角。 + *inner.capsule_layout.lock() = None; + let _ = app.emit_to("capsule", "insert:fallback", &payload); + show_capsule_window_for_recording(&app, &window, true); + #[cfg(target_os = "macos")] + crate::restore_main_window_key_if_active(&app); + log::info!( + "[fallback-card] shown: reason={reason} chars={}", + payload.text.chars().count() + ); + }); +} + +/// 收起兜底卡片:把窗口完整还给胶囊。 +/// +/// 与 [`hide_vocab_suggestion_card`] 同款:**没有卡片时必须原样返回**,否则每次听写 +/// 开始都会去 hide 那个窗口,和 `emit_capsule` 的 show 抢。 +pub(crate) fn hide_insert_fallback_card(inner: &Arc) { + inner.insert_fallback_text.lock().take(); + if !inner + .insert_fallback_card_visible + .swap(false, Ordering::SeqCst) + { + return; + } + let Some(app) = inner.app.lock().clone() else { + return; + }; + let app_for_main = app.clone(); + let inner_for_main = Arc::clone(inner); + let _ = app.run_on_main_thread(move || { + let app = app_for_main; + let inner = inner_for_main; + let Some(window) = app.get_webview_window("capsule") else { + return; + }; + let _ = app.emit_to( + "capsule", + "insert:fallback", + None::, + ); + // 先隐藏再改几何:复原要同时动尺寸和位置,窗口还亮着时改就有概率被合成出 + // 一帧「卡片被拉宽、还横着飞过半个屏幕」。 + let _ = window.hide(); + // 穿透必须还回去,否则胶囊会一直挡着屏幕那一块。 + #[cfg(not(mobile))] + if let Err(e) = window.set_ignore_cursor_events(true) { + log::warn!("[fallback-card] restoring cursor passthrough failed: {e}"); + } + #[cfg(not(mobile))] + inner + .capsule_cursor_passthrough + .store(true, Ordering::SeqCst); + // 尺寸也必须还回去 —— 卡片把窗口缩到过自己的大小,不复原的话下一次胶囊 + // 就挤在一个卡片大小的窗口里,等于看不见。 + let bounds = crate::capsule_window_bounds(false); + if let Err(e) = window.set_size(tauri::LogicalSize::new(bounds.width, bounds.height)) { + log::warn!("[fallback-card] restoring capsule size failed: {e}"); + } + // 位置一样要还 —— 卡片把窗口挪到了右下角,胶囊的位置是底部居中。只还尺寸 + // 不还位置,下一次录音胶囊就出现在右下角(词条卡片在真机上踩过这个 bug)。 + // 清缓存和这次重定位两件都要做,理由见 `hide_vocab_suggestion_card`。 + *inner.capsule_layout.lock() = None; + if let Err(e) = crate::position_capsule_bottom_center(&window, false) { + log::warn!("[fallback-card] restoring capsule position failed: {e}"); + } + }); +} + #[derive(Clone)] enum ActiveAsr { Volcengine(Arc), @@ -765,6 +944,22 @@ struct Inner { /// 门控 `hide_vocab_suggestion_card`:没有卡片时它必须什么都不做,否则每次听写 /// 开始都会去 hide 胶囊窗口,和 `emit_capsule` 的 show 抢同一个窗口。 vocab_card_visible: AtomicBool, + /// 「流式上屏被焦点守卫拦下」的信号,值是那次的**完整**文本。 + /// + /// 只有那条路径会往里放东西——它是唯一一处「屏幕上的内容 ≠ 完整结果」的场景: + /// `polished` 按约定只保留真打出去的半截,而切走窗口的用户要的是整段。收尾处 + /// (`maybe_show_insert_fallback_card`) 取走它,据此把 `InsertStatus` 从 `Inserted` + /// 纠正成 `CopiedFallback`,并决定卡片弹什么内容、标题怎么写。 + /// + /// **取走即消费**,不是「卡片当前内容」的镜像——卡片内容随事件发给前端,后端不留。 + /// 会话被取消时这里可能有残留,下一轮 `begin_session_as` 的 hide 会清掉。 + insert_fallback_text: Mutex>, + /// 兜底卡片是不是正占着胶囊窗口。与 `vocab_card_visible` 同一职责、同一理由。 + /// + /// 还多担一件事:这张卡片是在**会话收尾那一刻**弹的,而收尾会安排一次 + /// `schedule_capsule_idle` → `window.hide()`。可见时那次 hide 必须让路, + /// 否则卡片刚出现就被自己这轮会话的收尾干掉。 + insert_fallback_card_visible: AtomicBool, recording_mute: Mutex, hotkey: Mutex>, hotkey_status: Mutex, @@ -1027,6 +1222,8 @@ impl Coordinator { edit_watch_generation: std::sync::atomic::AtomicU64::new(0), pending_corrections: Mutex::new(Vec::new()), vocab_card_visible: AtomicBool::new(false), + insert_fallback_text: Mutex::new(None), + insert_fallback_card_visible: AtomicBool::new(false), recording_mute: Mutex::new(SharedRecordingMuteState::new()), hotkey: Mutex::new(None), hotkey_status: Mutex::new(HotkeyStatus::default()), @@ -1152,6 +1349,8 @@ impl Coordinator { edit_watch_generation: std::sync::atomic::AtomicU64::new(0), pending_corrections: Mutex::new(Vec::new()), vocab_card_visible: AtomicBool::new(false), + insert_fallback_text: Mutex::new(None), + insert_fallback_card_visible: AtomicBool::new(false), recording_mute: Mutex::new(SharedRecordingMuteState::new()), hotkey: Mutex::new(None), hotkey_status: Mutex::new(HotkeyStatus::default()), @@ -1929,6 +2128,11 @@ impl Coordinator { hide_vocab_suggestion_card(&self.inner); } + /// 落字失败兜底卡片自己关掉了(用户点关闭 / TTL 到时)。 + pub fn dismiss_insert_fallback_card(&self) { + hide_insert_fallback_card(&self.inner); + } + /// 用户关掉了「光标上下文」开关 —— 立刻停掉一切还在跑的观察,别等它自己超时。 /// /// 置空即解除:`EditWatcher` 的 `Drop` 会把停止 flag 置位,观察线程在下一次 @@ -3331,6 +3535,33 @@ fn resolve_ark_endpoint_with_policy( #[cfg(test)] mod tests { + /// 兜底卡片的窗口高度必须随内容增长,并封顶在 `FALLBACK_CARD_MAX_LINES`。 + /// + /// 两头都会出事:算矮了正文被裁在窗口外看不见;算高了窗口空出一块,而卡片显示期间 + /// 窗口**不穿透鼠标**,那块空白会拦住用户点底下的东西。 + #[test] + fn fallback_card_height_grows_with_text_and_stops_at_the_cap() { + let short = super::fallback_card_height("好的"); + let medium = super::fallback_card_height(&"字".repeat(100)); + let huge = super::fallback_card_height(&"字".repeat(10_000)); + + assert!(short < medium, "内容变多,卡片要变高"); + assert!(medium <= huge); + let cap = super::FALLBACK_CARD_CHROME_HEIGHT + + super::FALLBACK_CARD_LINE_HEIGHT * super::FALLBACK_CARD_MAX_LINES; + assert_eq!(huge, cap, "再长也不能超过封顶高度"); + assert!(short >= super::FALLBACK_CARD_CHROME_HEIGHT + super::FALLBACK_CARD_LINE_HEIGHT); + } + + /// 显式换行要占行——两段话估出来的高度不能跟一行一样。 + #[test] + fn fallback_card_height_counts_explicit_newlines() { + let one_line = super::fallback_card_height("上半句"); + let two_paragraphs = super::fallback_card_height("上半句\n\n下半句"); + + assert!(two_paragraphs > one_line); + } + /// 造一条词典条目。传给 `prioritize_vocab_for_asr` 时必须是词典的原始顺序 /// (最近添加在前)。 fn vocab_entry(phrase: &str, hits: u64) -> crate::types::DictionaryEntry { diff --git a/openless-all/app/src-tauri/src/coordinator/capsule_focus.rs b/openless-all/app/src-tauri/src/coordinator/capsule_focus.rs index c0aed97bd..4f633d893 100644 --- a/openless-all/app/src-tauri/src/coordinator/capsule_focus.rs +++ b/openless-all/app/src-tauri/src/coordinator/capsule_focus.rs @@ -699,8 +699,17 @@ fn emit_capsule_with_context_locked( capsule_state_log_name(state) ); } - hide_capsule_window_if_present(); - let _ = window.hide(); + // 兜底卡片占着这个窗口时不能隐藏 —— 它正是在会话收尾(会 emit 一帧 + // invisible)的那一刻弹出来的,隐藏等于让它一闪而过。 + if inner_for_main + .insert_fallback_card_visible + .load(Ordering::SeqCst) + { + log::debug!("[capsule] hide skipped: insert fallback card owns the window"); + } else { + hide_capsule_window_if_present(); + let _ = window.hide(); + } } // 入场帧:窗口刚 show(或本次用户关了胶囊显示走了 hide 分支),此刻再把 state 发给 // capsule 前端 —— 前端起播 capsule-in 时窗口已可见,入场动画从头完整播放。 @@ -742,8 +751,12 @@ pub(super) fn hide_capsule_if_all_sessions_idle(inner: &Arc) { let dictation_idle = inner.state.lock().phase == SessionPhase::Idle; let qa_idle = inner.qa_state.lock().phase == QaPhase::Idle; let selection_polish_active = inner.selection_polish_capsule_active.load(Ordering::SeqCst); + // 兜底卡片是在会话收尾那一刻弹的,而收尾自己就安排了一次 idle 隐藏 —— 不让路的话 + // 卡片刚出现就被自己这轮会话的尾巴收掉。卡片有自己的关闭路径(用户点关闭、TTL 到时、 + // 新一轮听写开始),不归这里管。 + let fallback_card_active = inner.insert_fallback_card_visible.load(Ordering::SeqCst); let observed_epoch = inner.capsule_event_epoch.load(Ordering::SeqCst); - if !dictation_idle || !qa_idle || selection_polish_active { + if !dictation_idle || !qa_idle || selection_polish_active || fallback_card_active { return; } diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index 45ab08417..ed8c9576e 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -333,6 +333,8 @@ async fn run_streaming_polish( let (tx, rx) = std::sync::mpsc::channel::(); #[cfg(target_os = "windows")] let sendinput_options = windows_sendinput_options_from_prefs(&inner.prefs.get()); + #[cfg(target_os = "macos")] + let macos_newline_mode = inner.prefs.get().macos_newline_mode; let typer_handle = tokio::task::spawn_blocking(move || { #[cfg(target_os = "windows")] { @@ -344,7 +346,12 @@ async fn run_streaming_polish( } #[cfg(not(target_os = "windows"))] { - drain_streaming_insert_deltas(rx, STREAMING_INSERT_FLUSH_INTERVAL) + drain_streaming_insert_deltas( + rx, + STREAMING_INSERT_FLUSH_INTERVAL, + #[cfg(target_os = "macos")] + macos_newline_mode, + ) } }); @@ -428,6 +435,13 @@ async fn run_streaming_polish( return (text, Some(reason), false); } } + // 上屏打到一半就断了(Secure Input 中途打开、SendInput / enigo 拒绝): + // 把**完整**文本留给兜底卡片。下面的 final_text 遵守「与屏幕一致」的约定 + // (屏幕上只有半截就只记半截),而用户要拿回的是整段话。 + // 这个字段同时是收尾处「这次上屏没落全」的信号,用来决定弹不弹卡片。 + if typer_failure.is_some() { + *inner.insert_fallback_text.lock() = Some(text.clone()); + } // 先确定 final_text —— typer 中途失败时屏幕只有 typed_text 这一段, // history 记完整 polish 反而会让用户复盘困惑。让 history / clipboard / // 后续逻辑统统用 final_text,三处保持一致。 @@ -529,8 +543,30 @@ fn windows_insertion_allows_streaming(_mode: crate::types::WindowsInsertionMode) fn drain_streaming_insert_deltas( rx: std::sync::mpsc::Receiver, flush_interval: std::time::Duration, + #[cfg(target_os = "macos")] newline_mode: crate::types::MacosNewlineMode, ) -> (String, Option) { - drain_streaming_insert_deltas_with(rx, flush_interval, flush_streaming_insert_buffer) + #[cfg(target_os = "macos")] + { + drain_streaming_insert_deltas_with(rx, flush_interval, move |pending, typed| { + flush_streaming_insert_buffer_with_newline_mode(pending, typed, newline_mode) + }) + } + #[cfg(not(target_os = "macos"))] + { + drain_streaming_insert_deltas_with(rx, flush_interval, flush_streaming_insert_buffer) + } +} + +/// macOS:把用户选的换行模式带进逐字上屏。 +#[cfg(target_os = "macos")] +fn flush_streaming_insert_buffer_with_newline_mode( + pending: &mut String, + typed_text: &mut String, + newline_mode: crate::types::MacosNewlineMode, +) -> Option { + flush_streaming_insert_buffer_with(pending, typed_text, move |text| { + crate::unicode_keystroke::type_unicode_chunk_with_options(text, newline_mode) + }) } #[cfg(target_os = "windows")] @@ -1829,6 +1865,8 @@ pub(super) async fn begin_session_as(inner: &Arc, voice_agent: bool) -> R // 词条建议卡片同样让位:它和录音胶囊共用一个窗口,不收起来就会挡住听写反馈。 // 用户开口说下一句时,上一句的建议已经不是他关心的事了。 super::hide_vocab_suggestion_card(inner); + // 落字失败兜底卡片同理 —— 同一个窗口,而且用户既然又开口了,上一句他已经处置完了。 + super::hide_insert_fallback_card(inner); #[cfg(target_os = "windows")] { if inner.prefs.get().windows_insertion_mode == crate::types::WindowsInsertionMode::Tsf { @@ -4157,6 +4195,10 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { let prefs = inner.prefs.get(); let allow_non_tsf_insertion_fallback = prefs.allow_non_tsf_insertion_fallback; let windows_insertion_mode = prefs.windows_insertion_mode; + // 逐字上屏中途断了(Secure Input 打开、SendInput / enigo 拒绝)时, + // `run_streaming_polish` 会把完整文本放进这个字段 —— 它是「这次没落全」的信号, + // 下面据此纠正 status 并弹兜底卡片。 + let streaming_insert_incomplete = inner.insert_fallback_text.lock().is_some(); // 流式路径下,字符已经通过 Unicode keystroke 落到光标处,跳过 inserter.insert。 let status = if already_streamed { log::info!( @@ -4164,7 +4206,15 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { polished.chars().count(), polish_error ); - InsertStatus::Inserted + // 打到一半断掉的那次不算插入成功 —— 屏幕上只有半截。此前这里一律报 + // Inserted,连 history 的 insertStatus 都是失真的。 + // 用 CopiedFallback 而非 Failed:语义上最接近「没落进目标,但文本还在」, + // 而兜底卡片正是那个「还在哪儿」的答案。 + if streaming_insert_incomplete { + InsertStatus::CopiedFallback + } else { + InsertStatus::Inserted + } } else { insert_final_text( inner, @@ -4305,9 +4355,46 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { } schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); + // 必须放在 phase 回到 Idle 之后:卡片要占胶囊窗口,而 + // `show_insert_fallback_card` 有一道「听写进行中绝不碰那个窗口」的闸。 + maybe_show_insert_fallback_card(inner, status, &polished); + Ok(()) } +/// 文本是否没能落到目标 app —— 兜底卡片的唯一判据。 +/// +/// `Inserted` / `PasteSent` 是成功语义。`CopiedFallback` 说明只写了剪贴板、没插进去, +/// `Failed` 连剪贴板都没写成 —— 这两种情况用户屏幕上都看不到自己刚说的话。 +pub(super) fn insert_delivery_failed(status: InsertStatus) -> bool { + matches!( + status, + InsertStatus::CopiedFallback | InsertStatus::Failed + ) +} + +/// 落字失败时把完整的那段话弹出来。 +/// +/// 在此之前,这些场景的唯一兜底是悄悄写剪贴板:既依赖一个默认可关的开关,用户也 +/// **根本不知道文本在剪贴板里**。屏幕上要么什么都没有,要么只有半截。 +fn maybe_show_insert_fallback_card(inner: &Arc, status: InsertStatus, polished: &str) { + // 正常落字路径不该留下残留,取走即可(跨会话残留会让下一次弹出上一句话)。 + let streamed_full_text = inner.insert_fallback_text.lock().take(); + if !insert_delivery_failed(status) { + return; + } + // 逐字上屏打到一半断掉时 `polished` 只是屏幕上那半截,完整文本在上面那个字段里。 + // 一次性插入失败的场景(Secure Input、粘贴被拒等)`polished` 本身就是完整的。 + let (text, reason) = match streamed_full_text { + Some(full) => (full, crate::types::INSERT_FALLBACK_REASON_PARTIAL_STREAM), + None => ( + polished.to_string(), + crate::types::INSERT_FALLBACK_REASON_INSERT_FAILED, + ), + }; + show_insert_fallback_card(inner, text, reason); +} + /// 多模态(Omni)听写收尾(issue #902):录音 PCM → WAV → omni 一次调用 → /// 修正规则 → 一次性插入 → 历史。与两段式管线完全隔离: /// 不复用 ASR 构建/静默重试/流式插入,缺 omni 配置时明确报错、不回退传统配置。 @@ -4629,6 +4716,11 @@ async fn finish_dictation_multimodal( Some(now + std::time::Duration::from_millis(POST_SESSION_COOLDOWN_MS)); } schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); + + // 多模态管线与两段式完全隔离,但「文本没落进目标 app」这件事对用户是一样的, + // 兜底卡片也必须在这条路径上生效。同样要在 phase 回 Idle 之后调。 + maybe_show_insert_fallback_card(inner, status, &polished); + Ok(()) } @@ -4807,11 +4899,12 @@ fn eligible_polish_context_turns( #[cfg(test)] mod tests { use super::{ - accept_silent_retry_transcript, append_typed_prefix, batch_asr_chunk_limit_ms, - build_transcribe_failed_session, default_done_message, drain_streaming_insert_deltas_with, - eligible_polish_context_turns, finalize_polished_text, flush_streaming_insert_buffer_with, - append_cursor_context_to_multimodal_prompt, pcm_duration_ms, pcm_from_wav_bytes, - should_arm_edit_watch, should_read_cursor_context, streaming_insert_eligible, + accept_silent_retry_transcript, append_cursor_context_to_multimodal_prompt, + append_typed_prefix, batch_asr_chunk_limit_ms, build_transcribe_failed_session, + default_done_message, drain_streaming_insert_deltas_with, eligible_polish_context_turns, + finalize_polished_text, flush_streaming_insert_buffer_with, pcm_duration_ms, + pcm_from_wav_bytes, should_arm_edit_watch, should_read_cursor_context, + insert_delivery_failed, streaming_insert_eligible, }; #[cfg(target_os = "macos")] use super::{macos_keyless_dictation_provider, MacosKeylessDictationProvider}; @@ -5494,6 +5587,18 @@ mod tests { assert_eq!(failure, None); } + /// 兜底卡片只在文本真没落进目标 app 时弹。 + /// + /// `PasteSent` 尤其不能算失败 —— 那是 Windows / Linux 上的**成功**语义(粘贴按键 + /// 已发出),错判会让每次正常听写都弹一张卡片。 + #[test] + fn fallback_card_fires_only_when_text_did_not_reach_the_app() { + assert!(insert_delivery_failed(InsertStatus::CopiedFallback)); + assert!(insert_delivery_failed(InsertStatus::Failed)); + assert!(!insert_delivery_failed(InsertStatus::Inserted)); + assert!(!insert_delivery_failed(InsertStatus::PasteSent)); + } + #[test] fn flush_streaming_insert_buffer_keeps_partial_unicode_prefix() { let mut pending = "a你🙂b".to_string(); diff --git a/openless-all/app/src-tauri/src/insertion.rs b/openless-all/app/src-tauri/src/insertion.rs index e2c4cbf76..5da83b961 100644 --- a/openless-all/app/src-tauri/src/insertion.rs +++ b/openless-all/app/src-tauri/src/insertion.rs @@ -20,6 +20,18 @@ use crate::types::{InsertStatus, PasteShortcut}; #[cfg(not(any(target_os = "android", target_os = "ios")))] const CLIPBOARD_RESTORE_DELAY: Duration = Duration::from_millis(750); +/// 把一段文字放进剪贴板。供落字失败兜底卡片的「复制」按钮使用。 +/// +/// 单独开这个入口而不是让前端调 `navigator.clipboard`:卡片浮在别的 app 上、按钮 +/// 不抢焦点,未聚焦文档里那个 API 会抛 `Document is not focused`。 +pub fn copy_text_to_clipboard(text: &str) -> Result<(), String> { + if copy_to_clipboard(text) { + Ok(()) + } else { + Err("clipboard write failed".to_string()) + } +} + pub struct TextInserter; impl TextInserter { diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 2056f3445..81610f470 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -350,6 +350,8 @@ macro_rules! app_invoke_handler_desktop { commands::accept_pending_correction, commands::reject_pending_correction, commands::dismiss_vocab_suggestions, + commands::copy_text_to_clipboard, + commands::dismiss_insert_fallback_card, restart_app, reset_accessibility_permission_and_restart_app, log_client_error, diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index d842b4bb2..099c05191 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -130,6 +130,23 @@ pub enum WindowsSendInputNewlineMode { CrLf, } +/// macOS 逐字上屏时换行符怎么发。仅流式插入路径生效。 +/// +/// 默认 `ShiftReturn`:macOS 把 U+000A 当 Return 键,而聊天框里 Return 就是「发送」—— +/// 一条带空行的两段话会被从中间劈开发出去。Shift+Return 在聊天框是软换行,在编辑器 / +/// 终端 / 网页输入框里就是普通换行。 +/// +/// 保留 `Return` 是因为风格市场里有靠换行发多条消息的风格包,那种效果需要真回车。 +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "camelCase")] +pub enum MacosNewlineMode { + /// Shift+Return:聊天框软换行,不发送。 + #[default] + ShiftReturn, + /// Return:聊天框里等于发送 —— 想要「一段话拆成多条消息」的风格包用这个。 + Return, +} + /// Auto-update 渠道。决定后台 AutoUpdateGate 拉哪条 manifest。 /// `Stable` = `latest-android-{arch}.json`(或桌面 plugin-updater 正式版 endpoints)。 /// `Beta` = `latest-android-{arch}-beta.json`(或桌面 beta endpoints)。 @@ -375,6 +392,26 @@ pub struct PendingCorrection { /// 卡片撑得比屏幕还高没有意义。 pub const MAX_PENDING_CORRECTIONS: usize = 5; +/// 落字失败兜底卡片的内容。 +/// +/// 文本没能落到目标 app 时(焦点在上屏途中离开、Secure Input、插入失败),把**完整** +/// 的那段话连同复制入口摆到用户面前。此前这些场景唯一的兜底是悄悄写剪贴板 —— 既依赖 +/// 一个默认可关的开关,用户也不知道文本在那儿。 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct InsertFallbackCardPayload { + /// 完整文本。焦点中途离开时屏幕上只有半截,这里给的是整段。 + pub text: String, + /// 为什么没落进去。**只进日志,不上屏** —— 卡片没有标题行。见 + /// `INSERT_FALLBACK_REASON_*`。 + pub reason: String, +} + +/// 逐字上屏打到一半断了(Secure Input 中途打开、合成按键被拒)。 +pub const INSERT_FALLBACK_REASON_PARTIAL_STREAM: &str = "partialStream"; +/// 插入没能完成(Secure Input、辅助功能掉权限、粘贴被拒等)。 +pub const INSERT_FALLBACK_REASON_INSERT_FAILED: &str = "insertFailed"; + /// 卡片自动消失的时间。 /// /// 到点就当没发生 —— 不记任何东西。用户下次改同一个词还会再问,这正是不要拒绝名单 @@ -910,6 +947,9 @@ pub struct UserPreferences { /// Windows SendInput 路径的换行模拟方式。 #[serde(default, rename = "windowsSendInputNewlineMode")] pub windows_sendinput_newline_mode: WindowsSendInputNewlineMode, + /// macOS 逐字上屏的换行模拟方式。 + #[serde(default)] + pub macos_newline_mode: MacosNewlineMode, /// 旧版 wire 兼容:`true` 等价于 `windows_insertion_mode = SendInput`。 #[serde( default, @@ -1282,6 +1322,8 @@ struct UserPreferencesWire { alias = "windowsSendinputNewlineMode" )] windows_sendinput_newline_mode: WindowsSendInputNewlineMode, + #[serde(default)] + macos_newline_mode: MacosNewlineMode, #[serde( default, rename = "windowsSendInputInsertionOnly", @@ -1448,6 +1490,7 @@ impl Default for UserPreferencesWire { allow_non_tsf_insertion_fallback: prefs.allow_non_tsf_insertion_fallback, windows_insertion_mode: prefs.windows_insertion_mode, windows_sendinput_newline_mode: prefs.windows_sendinput_newline_mode, + macos_newline_mode: prefs.macos_newline_mode, windows_sendinput_insertion_only: prefs.windows_sendinput_insertion_only, windows_show_openless_in_keyboard_list: prefs.windows_show_openless_in_keyboard_list, working_languages: prefs.working_languages, @@ -1590,6 +1633,7 @@ impl<'de> Deserialize<'de> for UserPreferences { wire.windows_sendinput_insertion_only, ), windows_sendinput_newline_mode: wire.windows_sendinput_newline_mode, + macos_newline_mode: wire.macos_newline_mode, windows_sendinput_insertion_only: resolve_windows_sendinput_insertion_only_legacy( wire.windows_insertion_mode, wire.windows_sendinput_insertion_only, @@ -2415,6 +2459,7 @@ impl Default for UserPreferences { allow_non_tsf_insertion_fallback: true, windows_insertion_mode: WindowsInsertionMode::default(), windows_sendinput_newline_mode: WindowsSendInputNewlineMode::default(), + macos_newline_mode: MacosNewlineMode::default(), windows_sendinput_insertion_only: false, windows_show_openless_in_keyboard_list: true, working_languages: default_working_languages(), diff --git a/openless-all/app/src-tauri/src/unicode_keystroke.rs b/openless-all/app/src-tauri/src/unicode_keystroke.rs index d868d7cea..447de6a42 100644 --- a/openless-all/app/src-tauri/src/unicode_keystroke.rs +++ b/openless-all/app/src-tauri/src/unicode_keystroke.rs @@ -91,12 +91,51 @@ pub enum TisError { #[cfg(target_os = "macos")] mod macos_impl { use super::{TisError, TypeError}; + use crate::types::MacosNewlineMode; use std::ffi::c_void; use std::time::Duration; use tauri::{AppHandle, Runtime}; const INTER_KEYSTROKE_DELAY: Duration = Duration::from_millis(1); + /// 逐字上屏时单个 char 的发送方式。 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub(super) enum MacKeystroke { + /// 换行:发真实的 Shift+Return 按键(聊天框软换行)。 + ShiftReturn, + /// 换行:发真实的 Return 按键(聊天框里等于发送)。 + Return, + /// CR:不发任何键。`\r\n` 里它只是 `\n` 的前缀,发了会变成两个换行; + /// 而 LLM 输出中不存在「单独的 `\r` 表示换行」的老 Mac 格式。吞掉最稳, + /// 也让跨 delta 边界被拆开的 `\r` / `\n` 各自都不会多打一个换行。 + Swallow, + /// 普通字符:`CGEventKeyboardSetUnicodeString`。 + Unicode, + } + + /// **换行必须走真实按键,不能当普通 Unicode 字符发。** + /// + /// macOS 的文本输入系统看到 U+000A 就当作 Return —— 在微信 / Slack / Telegram + /// 这类聊天框里等价于「发送」。曾经有一条带空行的两段话被逐字上屏,第一个 `\n` + /// 直接把上半句发了出去,下半句留在了输入框里。 + /// + /// 默认发 Shift+Return:在聊天框是「软换行」(不发送),在编辑器 / 终端 / 网页 + /// textarea 里就是普通换行 —— 两边都对。Windows 侧早有同款结论(见 + /// `WindowsSendInputNewlineMode::ShiftEnter`,设置文案直接写着「聊天框选它」)。 + /// + /// 用户可以在设置里改成 `Return`:风格市场上有靠换行把一段话拆成多条消息的风格包, + /// 那种效果要的正是真回车。 + pub(super) fn classify_mac_keystroke(ch: char, mode: MacosNewlineMode) -> MacKeystroke { + match ch { + '\n' => match mode { + MacosNewlineMode::ShiftReturn => MacKeystroke::ShiftReturn, + MacosNewlineMode::Return => MacKeystroke::Return, + }, + '\r' => MacKeystroke::Swallow, + _ => MacKeystroke::Unicode, + } + } + /// 之前激活的 input source 引用 token。携带 raw ptr 的 usize 表示,所有解引用都 /// 通过 `restore_input_source` 调度到主线程执行;手动 `Send + Sync`。 pub struct PreviousInputSource { @@ -106,6 +145,13 @@ mod macos_impl { unsafe impl Sync for PreviousInputSource {} pub fn type_unicode_chunk(text: &str) -> Result { + type_unicode_chunk_with_options(text, MacosNewlineMode::default()) + } + + pub fn type_unicode_chunk_with_options( + text: &str, + newline_mode: MacosNewlineMode, + ) -> Result { if text.is_empty() { return Ok(0); } @@ -114,7 +160,17 @@ mod macos_impl { } let mut typed_chars = 0; for ch in text.chars() { - if let Err(e) = send_one_codepoint(ch) { + let sent = match classify_mac_keystroke(ch, newline_mode) { + MacKeystroke::ShiftReturn => send_shift_return(), + MacKeystroke::Return => send_return(), + // 吞掉的 char 也要计数:调用方(`flush_streaming_insert_buffer_with`) + // 拿 `typed_chars` 和 `delta.chars().count()` 比对,少一个就判定 + // 「部分失败」并丢弃后续所有 delta。计数的语义是「这个 char 已处理」, + // 不是「屏幕上多了一个字符」。 + MacKeystroke::Swallow => Ok(()), + MacKeystroke::Unicode => send_one_codepoint(ch), + }; + if let Err(e) = sent { return Err(partial_or_original(typed_chars, e)); } typed_chars += 1; @@ -137,14 +193,39 @@ mod macos_impl { fn send_one_codepoint(ch: char) -> Result<(), TypeError> { let mut buf = [0u16; 2]; let utf16 = ch.encode_utf16(&mut buf); - let len = utf16.len(); + // 虚拟键码 0 + Unicode string 覆写:字符本身由 unicode string 决定,keycode 不参与。 + // flags 显式清零 —— 用户按着 Shift 时不清会被映射成大写。 + post_key_event(0, 0, Some(utf16)) + } + + /// 发一次 Shift+Return。用真实的 Return 虚拟键码(`kVK_Return`)而不是 U+000A, + /// 详见 [`classify_mac_keystroke`]。 + fn send_shift_return() -> Result<(), TypeError> { + post_key_event(KEY_RETURN, KCG_EVENT_FLAG_MASK_SHIFT, None) + } + + /// 发一次不带修饰键的 Return。聊天框里这等于「发送」——只有用户在设置里明确选了 + /// [`MacosNewlineMode::Return`] 才会走到这里。 + fn send_return() -> Result<(), TypeError> { + post_key_event(KEY_RETURN, 0, None) + } + + /// 构造并 post 一对 down/up 键盘事件,负责全部 CF 资源的释放。 + /// + /// `unicode` 为 `Some` 时用 `CGEventKeyboardSetUnicodeString` 覆写字符内容 + /// (此时 `virtual_key` 无意义);为 `None` 时就是按下 `virtual_key` 这个物理键。 + fn post_key_event( + virtual_key: CGKeyCode, + flags: CGEventFlags, + unicode: Option<&[u16]>, + ) -> Result<(), TypeError> { unsafe { let src = CGEventSourceCreate(KCG_EVENT_SOURCE_STATE_HID_SYSTEM_STATE); if src.is_null() { return Err(TypeError::SourceAllocFailed); } - let down = CGEventCreateKeyboardEvent(src, 0, true); - let up = CGEventCreateKeyboardEvent(src, 0, false); + let down = CGEventCreateKeyboardEvent(src, virtual_key, true); + let up = CGEventCreateKeyboardEvent(src, virtual_key, false); if down.is_null() || up.is_null() { if !down.is_null() { CFRelease(down as _); @@ -155,10 +236,12 @@ mod macos_impl { CFRelease(src as _); return Err(TypeError::EventAllocFailed); } - CGEventSetFlags(down, 0); - CGEventSetFlags(up, 0); - CGEventKeyboardSetUnicodeString(down, len, utf16.as_ptr()); - CGEventKeyboardSetUnicodeString(up, len, utf16.as_ptr()); + CGEventSetFlags(down, flags); + CGEventSetFlags(up, flags); + if let Some(utf16) = unicode { + CGEventKeyboardSetUnicodeString(down, utf16.len(), utf16.as_ptr()); + CGEventKeyboardSetUnicodeString(up, utf16.len(), utf16.as_ptr()); + } CGEventPost(KCG_HID_EVENT_TAP, down); CGEventPost(KCG_HID_EVENT_TAP, up); CFRelease(down as _); @@ -267,6 +350,9 @@ mod macos_impl { const KCG_HID_EVENT_TAP: CGEventTapLocation = 0; const KCG_EVENT_SOURCE_STATE_HID_SYSTEM_STATE: CGEventSourceStateID = 1; const K_CF_STRING_ENCODING_ASCII: CFStringEncoding = 0x0600; + const KCG_EVENT_FLAG_MASK_SHIFT: CGEventFlags = 0x00020000; + /// US/ANSI 键盘上 Return 的虚拟键码(`kVK_Return`)。 + const KEY_RETURN: CGKeyCode = 36; #[repr(C)] struct OpaqueCGEvent(c_void); @@ -580,6 +666,70 @@ mod linux_impl { mod tests { use super::TypeError; + /// 默认模式下换行走 Shift+Return —— macOS 把 U+000A 当 Return,聊天框里等于 + /// 「发送」,一条带空行的两段话会被从中间劈开发出去。 + #[test] + #[cfg(target_os = "macos")] + fn newline_defaults_to_shift_return() { + use super::macos_impl::{classify_mac_keystroke, MacKeystroke}; + use crate::types::MacosNewlineMode; + + let mode = MacosNewlineMode::default(); + assert_eq!(mode, MacosNewlineMode::ShiftReturn, "默认必须是不发送的那个"); + assert_eq!( + classify_mac_keystroke('\n', mode), + MacKeystroke::ShiftReturn + ); + // `\r` 吞掉:CRLF 里它只是 LF 的前缀,发出去会变成两个换行。 + assert_eq!(classify_mac_keystroke('\r', mode), MacKeystroke::Swallow); + for ch in ['a', '中', ',', ' ', '\t', '😀'] { + assert_eq!( + classify_mac_keystroke(ch, mode), + MacKeystroke::Unicode, + "{ch:?} 应当走普通 Unicode 路径" + ); + } + } + + /// 选了 Return 模式就得发真回车 —— 风格市场里有靠换行把一段话拆成多条消息的 + /// 风格包,那种效果要的正是「回车 = 发送」。 + #[test] + #[cfg(target_os = "macos")] + fn return_mode_sends_a_plain_return_for_style_packs_that_want_it() { + use super::macos_impl::{classify_mac_keystroke, MacKeystroke}; + use crate::types::MacosNewlineMode; + + assert_eq!( + classify_mac_keystroke('\n', MacosNewlineMode::Return), + MacKeystroke::Return + ); + // 换行模式只影响换行,别的字符一律照旧。 + assert_eq!( + classify_mac_keystroke('中', MacosNewlineMode::Return), + MacKeystroke::Unicode + ); + } + + /// 计数契约:`type_unicode_chunk` 返回的 typed_chars 必须等于输入的 char 数, + /// 连被吞掉的 `\r` 也要算 —— 调用方拿它跟 `delta.chars().count()` 比对, + /// 少一个就判定「部分失败」并丢弃后面所有 delta。 + #[test] + #[cfg(target_os = "macos")] + fn every_char_counts_toward_typed_chars_including_swallowed_ones() { + use super::macos_impl::classify_mac_keystroke; + use crate::types::MacosNewlineMode; + + for mode in [MacosNewlineMode::ShiftReturn, MacosNewlineMode::Return] { + let text = "上半句\r\n\r\n下半句"; + // 每个 char 都会被分类成某一种处理方式,没有漏网的。 + let counted = text + .chars() + .map(|ch| classify_mac_keystroke(ch, mode)) + .count(); + assert_eq!(counted, text.chars().count(), "{mode:?} 下计数必须守恒"); + } + } + #[test] fn type_error_partial_reports_typed_chars() { let err = TypeError::Partial { @@ -696,7 +846,7 @@ pub fn expected_sendinput_typed_chars(text: &str) -> usize { #[allow(unused_imports)] pub use macos_impl::{ is_secure_input_enabled, restore_input_source, switch_to_ascii, type_unicode_chunk, - PreviousInputSource, + type_unicode_chunk_with_options, PreviousInputSource, }; #[cfg(target_os = "windows")] diff --git a/openless-all/app/src/components/Capsule.tsx b/openless-all/app/src/components/Capsule.tsx index db2f59410..5d2d2218a 100644 --- a/openless-all/app/src/components/Capsule.tsx +++ b/openless-all/app/src/components/Capsule.tsx @@ -18,8 +18,15 @@ import { getCapsulePillMetrics, } from '../lib/capsuleLayout'; import { isTauri } from '../lib/ipc'; -import type { CapsulePayload, CapsuleState, CapsuleStyle, PendingCorrection } from '../lib/types'; +import type { + CapsulePayload, + CapsuleState, + CapsuleStyle, + InsertFallbackCardPayload, + PendingCorrection, +} from '../lib/types'; import { VocabSuggestionCard } from './VocabSuggestionCard'; +import { InsertFallbackCard } from './InsertFallbackCard'; // 胶囊 keyframes 注入一次到 document.head,而不是放在组件 JSX 里。否则录音时音量 // 每帧(~60Hz)setLevel 都会让 React 重新创建/reconcile 这个