diff --git a/openless-all/app/src-tauri/src/commands/dictionary.rs b/openless-all/app/src-tauri/src/commands/dictionary.rs index 5cbe9ce5..07b5dc0d 100644 --- a/openless-all/app/src-tauri/src/commands/dictionary.rs +++ b/openless-all/app/src-tauri/src/commands/dictionary.rs @@ -66,6 +66,35 @@ 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(); +} + +/// 前端按真实折行结果回报卡片高度;presentation_id 用来忽略旧组件迟到的 ResizeObserver。 +#[tauri::command] +pub fn report_insert_fallback_card_height( + coord: CoordinatorState<'_>, + presentation_id: u64, + height: f64, +) -> Result<(), String> { + coord.report_insert_fallback_card_height(presentation_id, height) +} + #[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 fea8c78f..b89b3c21 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -338,6 +338,253 @@ fn position_vocab_card( window.set_position(tauri::LogicalPosition::new(x, y)) } +/// 兜底卡片的窗口宽度(逻辑点)。比词条卡片宽一点 —— 这张要放一整段话。 +const FALLBACK_CARD_WIDTH: f64 = 360.0; +/// Webview 首次渲染前的安全高度。真实高度由卡片 DOM 测量后通过 IPC 回报。 +const FALLBACK_CARD_INITIAL_HEIGHT: f64 = 260.0; +/// 尺寸 IPC 的原生安全边界,不表达任何 CSS 布局规则。 +const FALLBACK_CARD_MIN_HEIGHT: f64 = 96.0; +const FALLBACK_CARD_MAX_HEIGHT: f64 = 320.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)) +} + +fn validated_fallback_card_height( + active_presentation_id: Option, + presentation_id: u64, + height: f64, +) -> Result, String> { + if !height.is_finite() { + return Err("fallback card height must be finite".into()); + } + if active_presentation_id != Some(presentation_id) { + return Ok(None); + } + Ok(Some( + height + .ceil() + .clamp(FALLBACK_CARD_MIN_HEIGHT, FALLBACK_CARD_MAX_HEIGHT), + )) +} + +/// 文本没能落到目标 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 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; + } + let Some(window) = app.get_webview_window("capsule") else { + return; + }; + let presentation_id = inner + .insert_fallback_presentation_id + .fetch_add(1, Ordering::SeqCst) + .wrapping_add(1); + let payload = crate::types::InsertFallbackCardPayload { + text, + reason: reason.to_string(), + presentation_id, + }; + inner.insert_fallback_deferred_capsule.lock().take(); + inner + .insert_fallback_card_visible + .store(true, Ordering::SeqCst); + #[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, + FALLBACK_CARD_INITIAL_HEIGHT, + )) { + log::warn!("[fallback-card] resize failed: {e}"); + } + if let Err(e) = position_fallback_card( + &window, + FALLBACK_CARD_WIDTH, + FALLBACK_CARD_INITIAL_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() + ); + }); +} + +fn report_insert_fallback_card_height( + inner: &Arc, + presentation_id: u64, + height: f64, +) -> Result<(), String> { + let active_presentation_id = inner + .insert_fallback_card_visible + .load(Ordering::SeqCst) + .then(|| { + inner + .insert_fallback_presentation_id + .load(Ordering::SeqCst) + }); + let Some(height) = + validated_fallback_card_height(active_presentation_id, presentation_id, height)? + else { + return Ok(()); + }; + let Some(app) = inner.app.lock().clone() else { + return Ok(()); + }; + let app_for_main = app.clone(); + let inner_for_main = Arc::clone(inner); + app.run_on_main_thread(move || { + if !inner_for_main + .insert_fallback_card_visible + .load(Ordering::SeqCst) + || inner_for_main + .insert_fallback_presentation_id + .load(Ordering::SeqCst) + != presentation_id + { + return; + } + let Some(window) = app_for_main.get_webview_window("capsule") else { + return; + }; + if let Err(e) = window.set_size(tauri::LogicalSize::new(FALLBACK_CARD_WIDTH, height)) { + log::warn!("[fallback-card] measured resize failed: {e}"); + } + if let Err(e) = position_fallback_card(&window, FALLBACK_CARD_WIDTH, height) { + log::warn!("[fallback-card] measured position failed: {e}"); + } + }) + .map_err(|e| e.to_string()) +} + +/// 收起兜底卡片:把窗口完整还给胶囊。 +/// +/// 与 [`hide_vocab_suggestion_card`] 同款:**没有卡片时必须原样返回**,否则每次听写 +/// 开始都会去 hide 那个窗口,和 `emit_capsule` 的 show 抢。 +pub(crate) fn hide_insert_fallback_card(inner: &Arc) { + inner.insert_fallback_text.lock().take(); + let _event_guard = inner.capsule_event_lock.lock(); + if !inner + .insert_fallback_card_visible + .swap(false, Ordering::SeqCst) + { + return; + } + let deferred_capsule = inner.insert_fallback_deferred_capsule.lock().take(); + 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(); + if let Some(payload) = deferred_capsule { + // 卡片期间 QA / Selection Polish 仍会推进胶囊状态,只是不能碰共享窗口。 + // 卡片释放后把最新状态一次性应用回来;若最新是 Idle,该 helper 会正常隐藏。 + apply_capsule_window_payload(&inner, &app, &window, &payload, false, true); + return; + } + // 卡片期间没有任何胶囊事件:恢复默认隐藏态。 + // 穿透必须还回去,否则胶囊会一直挡着屏幕那一块。 + #[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 +1012,27 @@ 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, + /// 每次展示递增;前端尺寸回报必须携带当前代次,旧卡片的迟到 IPC 才不能缩放新卡片。 + insert_fallback_presentation_id: AtomicU64, + /// 卡片占用共享窗口期间收到的最新胶囊状态。事件仍下发给 webview,但原生窗口变化 + /// 延后;卡片关闭时用这份 payload 恢复仍在进行的 QA / Selection Polish。 + insert_fallback_deferred_capsule: Mutex>, recording_mute: Mutex, hotkey: Mutex>, hotkey_status: Mutex, @@ -1027,6 +1295,10 @@ 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), + insert_fallback_presentation_id: AtomicU64::new(0), + insert_fallback_deferred_capsule: Mutex::new(None), recording_mute: Mutex::new(SharedRecordingMuteState::new()), hotkey: Mutex::new(None), hotkey_status: Mutex::new(HotkeyStatus::default()), @@ -1152,6 +1424,10 @@ 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), + insert_fallback_presentation_id: AtomicU64::new(0), + insert_fallback_deferred_capsule: Mutex::new(None), recording_mute: Mutex::new(SharedRecordingMuteState::new()), hotkey: Mutex::new(None), hotkey_status: Mutex::new(HotkeyStatus::default()), @@ -1929,6 +2205,19 @@ impl Coordinator { hide_vocab_suggestion_card(&self.inner); } + /// 落字失败兜底卡片自己关掉了(用户点关闭 / TTL 到时)。 + pub fn dismiss_insert_fallback_card(&self) { + hide_insert_fallback_card(&self.inner); + } + + pub fn report_insert_fallback_card_height( + &self, + presentation_id: u64, + height: f64, + ) -> Result<(), String> { + report_insert_fallback_card_height(&self.inner, presentation_id, height) + } + /// 用户关掉了「光标上下文」开关 —— 立刻停掉一切还在跑的观察,别等它自己超时。 /// /// 置空即解除:`EditWatcher` 的 `Drop` 会把停止 flag 置位,观察线程在下一次 @@ -3331,6 +3620,40 @@ fn resolve_ark_endpoint_with_policy( #[cfg(test)] mod tests { + #[test] + fn fallback_card_height_report_rejects_non_finite_values() { + assert!(super::validated_fallback_card_height(Some(7), 7, f64::NAN).is_err()); + assert!(super::validated_fallback_card_height(Some(7), 7, f64::INFINITY).is_err()); + } + + #[test] + fn fallback_card_height_report_ignores_stale_presentations() { + assert_eq!( + super::validated_fallback_card_height(Some(8), 7, 180.0).unwrap(), + None + ); + assert_eq!( + super::validated_fallback_card_height(None, 7, 180.0).unwrap(), + None + ); + } + + #[test] + fn fallback_card_height_report_clamps_to_native_safety_bounds() { + assert_eq!( + super::validated_fallback_card_height(Some(7), 7, 40.0).unwrap(), + Some(96.0) + ); + assert_eq!( + super::validated_fallback_card_height(Some(7), 7, 500.0).unwrap(), + Some(320.0) + ); + assert_eq!( + super::validated_fallback_card_height(Some(7), 7, 181.2).unwrap(), + Some(182.0) + ); + } + /// 造一条词典条目。传给 `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 c0aed97b..2986ed76 100644 --- a/openless-all/app/src-tauri/src/coordinator/capsule_focus.rs +++ b/openless-all/app/src-tauri/src/coordinator/capsule_focus.rs @@ -413,6 +413,141 @@ fn emit_capsule_with_context( ) } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum CapsuleWindowAction { + PreserveFallbackCard, + ShowCapsule, + HideCapsule, +} + +fn capsule_window_action( + fallback_card_active: bool, + show_capsule: bool, + state: CapsuleState, +) -> CapsuleWindowAction { + if fallback_card_active { + CapsuleWindowAction::PreserveFallbackCard + } else if show_capsule && !matches!(state, CapsuleState::Idle) { + CapsuleWindowAction::ShowCapsule + } else { + CapsuleWindowAction::HideCapsule + } +} + +fn defer_capsule_payload_if_fallback_active( + inner: &Arc, + payload: &CapsulePayload, +) -> bool { + let active = inner + .insert_fallback_card_visible + .load(Ordering::SeqCst); + if active { + *inner.insert_fallback_deferred_capsule.lock() = Some(payload.clone()); + } + active +} + +/// 把一帧胶囊状态应用到共享原生窗口。 +/// +/// 兜底卡片是可交互的恢复界面,显示期间必须拥有全部原生窗口属性。胶囊事件仍会抵达 +/// webview 并推进代次,但定位、尺寸、鼠标穿透和显隐要等卡片释放窗口后再恢复。 +pub(super) fn apply_capsule_window_payload( + inner: &Arc, + app: &AppHandle, + window: &tauri::WebviewWindow, + payload: &CapsulePayload, + fallback_card_active: bool, + reassert_spaces: bool, +) { + // Selection Polish 没有独立显示开关,因为这是它唯一的反馈。 + let prefs_snapshot = inner.prefs.get(); + let show_capsule = payload.selection_polish || prefs_snapshot.show_capsule; + let classic_style = matches!(prefs_snapshot.capsule_style, CapsuleStyle::Classic); + inner.capsule_style.store( + if classic_style { 1 } else { 0 }, + Ordering::Relaxed, + ); + + // Linux 通过 fcitx 辅助区显示状态,不操作胶囊窗口。 + #[cfg(target_os = "linux")] + { + let _ = ( + app, + window, + payload, + fallback_card_active, + reassert_spaces, + show_capsule, + classic_style, + ); + return; + } + + #[cfg(not(target_os = "linux"))] + { + let action = capsule_window_action(fallback_card_active, show_capsule, payload.state); + if action == CapsuleWindowAction::PreserveFallbackCard { + log::debug!( + "[capsule] native window update deferred: insert fallback card owns the window" + ); + return; + } + + maybe_position_capsule_bottom_center(inner, window, payload.translation); + + #[cfg(not(mobile))] + { + let interactive = classic_style + && action == CapsuleWindowAction::ShowCapsule + && !payload.selection_polish + && matches!( + payload.state, + CapsuleState::Recording + | CapsuleState::Transcribing + | CapsuleState::Polishing + ); + let want_passthrough = !interactive; + if inner + .capsule_cursor_passthrough + .swap(want_passthrough, Ordering::SeqCst) + != want_passthrough + { + if let Err(e) = window.set_ignore_cursor_events(want_passthrough) { + log::warn!("[capsule] set_ignore_cursor_events failed: {e}"); + } + } + } + + match action { + CapsuleWindowAction::PreserveFallbackCard => unreachable!(), + CapsuleWindowAction::ShowCapsule => { + if !CAPSULE_FIRST_SHOW_LOGGED.swap(true, Ordering::SeqCst) { + log::info!( + "[capsule] first show this session: show_capsule=true visible=true state={}", + capsule_state_log_name(payload.state) + ); + } + show_capsule_window_for_recording(app, window, reassert_spaces); + #[cfg(target_os = "macos")] + crate::restore_main_window_key_if_active(app); + } + CapsuleWindowAction::HideCapsule => { + if !show_capsule + && !matches!(payload.state, CapsuleState::Idle) + && !CAPSULE_SUPPRESSED_BY_TOGGLE_LOGGED.swap(true, Ordering::SeqCst) + { + log::info!( + "[capsule] suppressed by user toggle: show_capsule=false visible=true state={}", + capsule_state_log_name(payload.state) + ); + } + hide_capsule_window_if_present(); + let _ = window.hide(); + } + } + } +} + /// `capsule_event_lock` 已由调用方持有的内部实现。自动隐藏路径必须能在验证 epoch /// 后、发出 Idle 前一直持锁,才能保证旧 timer 不会盖掉刚到的新 payload。 fn emit_capsule_with_context_locked( @@ -473,6 +608,7 @@ fn emit_capsule_with_context_locked( _ => CapsuleStyle::Siri, }, }; + defer_capsule_payload_if_fallback_active(inner, &payload); #[cfg(target_os = "android")] crate::android::notify_capsule_state(&payload); @@ -598,6 +734,7 @@ fn emit_capsule_with_context_locked( } else { None }; + let payload_for_window = payload.clone(); let _ = app.run_on_main_thread(move || { let Some(window) = app_for_main.get_webview_window("capsule") else { // #470 诊断 v2:比 A/B/C 更靠前的暗点 A0 —— capsule webview 句柄取不到 @@ -610,104 +747,21 @@ fn emit_capsule_with_context_locked( } return; }; - // `show_capsule` 是原有“录音胶囊”偏好;Selection Polish 没有独立开关,且它的 - // 无选区/失败提示是这条无界面工作流的唯一反馈,所以始终展示轻量提示。 - let prefs_snapshot = inner_for_main.prefs.get(); - let show_capsule = selection_polish || prefs_snapshot.show_capsule; - // 把胶囊样式同步进 Inner 原子缓存:emit_capsule(音频回调线程)从这里读 - // payload.capsuleStyle,避免在音频线程碰偏好锁。主线程每帧克隆 prefs 本就有 - //(show_capsule 同源),多读一个字段零额外代价。 - let classic_style = matches!(prefs_snapshot.capsule_style, CapsuleStyle::Classic); - inner_for_main.capsule_style.store( - if classic_style { 1 } else { 0 }, - Ordering::Relaxed, + let fallback_card_active = + defer_capsule_payload_if_fallback_active(&inner_for_main, &payload_for_window); + apply_capsule_window_payload( + &inner_for_main, + &app_for_main, + &window, + &payload_for_window, + fallback_card_active, + payload_for_deferred_emit.is_some(), ); - // Linux: 不操作胶囊窗口(不 show/hide,不 reposition)。 - // 文字通过 fcitx5 插件直接 commit,用户始终在目标 app 中。 - #[cfg(target_os = "linux")] - { - return; - } - #[cfg(not(target_os = "linux"))] - { - - // 三平台统一:Done / Cancelled / Error 状态保留 ~1.5s toast - // (schedule_capsule_idle 之后会回 Idle 隐藏)。 - // Windows 上 linger 的真实问题(截图选中 / 死区 / 拖拽卡顿)由 #140 加的 - // `hide_capsule_window_if_present()` Win32 hard-hide 在 visible=false 分支 - // 处理,不依赖把 Done/Cancelled/Error 打成 invisible。详见 PR #140 评论。 - maybe_position_capsule_bottom_center(&inner_for_main, &window, translation); - // 经典药丸(Openless 默认风格)的 ✕/✓ 按钮需要接收点击:录音/转写/润色期间 - // 关掉鼠标穿透(按钮可点,代价是窗口底部 460×180 区域在这几秒内拦截点击—— - // 与 1.3.x 经典胶囊同款取舍);终态 toast、隐藏、Siri 光效、选区润色提示一律 - // 保持穿透,不遮挡底层 app。set_ignore_cursor_events 只在值变化时调用一次。 - // Android 没有胶囊窗口,tauri 的 set_ignore_cursor_events 在其上不可用。 - #[cfg(not(mobile))] - { - let interactive = classic_style - && visible - && !selection_polish - && matches!( - state, - CapsuleState::Recording | CapsuleState::Transcribing | CapsuleState::Polishing - ); - let want_passthrough = !interactive; - if inner_for_main - .capsule_cursor_passthrough - .swap(want_passthrough, Ordering::SeqCst) - != want_passthrough - { - if let Err(e) = window.set_ignore_cursor_events(want_passthrough) { - log::warn!("[capsule] set_ignore_cursor_events failed: {e}"); - } - } - } - if show_capsule && visible { - // 用户报"看不到胶囊"时第一时间能在 log 里确认:胶囊路径有跑、show_capsule - // 开关是 true、当前进入 visible 帧 —— 排除 prefs 没存住 / emit_capsule 没触 - // 发 / state 一直 Idle 这几类常见 root cause。issue #470。 - if !CAPSULE_FIRST_SHOW_LOGGED.swap(true, Ordering::SeqCst) { - log::info!( - "[capsule] first show this session: show_capsule=true visible=true state={}", - capsule_state_log_name(state) - ); - } - // 入场帧(隐藏→可见)强制重注册 Space 贴附:macOS 26 观测到系统会在运行中 - // 把窗口从「全 Space 贴附」剥离(2026-07-31 实测:胶囊被钉死单个 Space, - // 其它桌面上听写全程不可见),而 setCollectionBehavior 写入相同值是 no-op, - // 之后每帧重写 273 都救不回来。只在入场帧做一次 0→273 的翻转即可恢复注册, - // 30Hz 的 level 帧不做(每帧翻转会让 WindowServer 反复重排窗口)。 - show_capsule_window_for_recording( - &app_for_main, - &window, - payload_for_deferred_emit.is_some(), - ); - // macOS/Windows 优先走 no-activate show,避免录音胶囊抢走当前工作 app 焦点。 - // 若 fallback 到 show(),OpenLess 已是前台 app 时再把 key window 还给 main。 - #[cfg(target_os = "macos")] - crate::restore_main_window_key_if_active(&app_for_main); - } else { - // show_capsule 开关被用户关掉但本次确实想显示(visible=true)的情况: - // 一次性 info log,让用户报"胶囊没显示"时能在日志里一眼看到根因 —— 维护者 - // 不必再让用户"去打开设置确认"。issue #470。 - if !show_capsule - && visible - && !CAPSULE_SUPPRESSED_BY_TOGGLE_LOGGED.swap(true, Ordering::SeqCst) - { - log::info!( - "[capsule] suppressed by user toggle: show_capsule=false visible=true state={}", - capsule_state_log_name(state) - ); - } - hide_capsule_window_if_present(); - let _ = window.hide(); - } // 入场帧:窗口刚 show(或本次用户关了胶囊显示走了 hide 分支),此刻再把 state 发给 // capsule 前端 —— 前端起播 capsule-in 时窗口已可见,入场动画从头完整播放。 if let Some(payload) = payload_for_deferred_emit.as_ref() { let _ = app_for_main.emit_to("capsule", "capsule:state", payload); } - } }); // 非入场帧(含 Linux、录音中的 level 更新、离场/终态)保持即时同步 emit,最低延迟; @@ -853,7 +907,80 @@ pub(super) fn maybe_position_capsule_bottom_center( #[cfg(test)] mod tests { use super::*; - use crate::types::CapsuleState; + use crate::types::{CapsulePayload, CapsuleState, CapsuleStyle}; + + fn payload(state: CapsuleState) -> CapsulePayload { + CapsulePayload { + state, + level: 0.0, + elapsed_ms: 0, + message: None, + inserted_chars: None, + translation: false, + operating: false, + warming: false, + selection_polish: false, + capsule_style: CapsuleStyle::Siri, + } + } + + #[test] + fn fallback_card_owns_native_window_until_dismissed() { + for state in [ + CapsuleState::Idle, + CapsuleState::Recording, + CapsuleState::Polishing, + CapsuleState::Done, + ] { + assert_eq!( + capsule_window_action(true, true, state), + CapsuleWindowAction::PreserveFallbackCard + ); + } + } + + #[test] + fn capsule_window_action_follows_visibility_without_fallback_card() { + assert_eq!( + capsule_window_action(false, true, CapsuleState::Recording), + CapsuleWindowAction::ShowCapsule + ); + assert_eq!( + capsule_window_action(false, true, CapsuleState::Idle), + CapsuleWindowAction::HideCapsule + ); + assert_eq!( + capsule_window_action(false, false, CapsuleState::Recording), + CapsuleWindowAction::HideCapsule + ); + } + + #[test] + fn fallback_card_keeps_only_the_latest_deferred_capsule_payload() { + let coordinator = Coordinator::new(); + coordinator + .inner + .insert_fallback_card_visible + .store(true, Ordering::SeqCst); + + assert!(defer_capsule_payload_if_fallback_active( + &coordinator.inner, + &payload(CapsuleState::Recording), + )); + assert!(defer_capsule_payload_if_fallback_active( + &coordinator.inner, + &payload(CapsuleState::Idle), + )); + assert_eq!( + coordinator + .inner + .insert_fallback_deferred_capsule + .lock() + .as_ref() + .map(|payload| payload.state), + Some(CapsuleState::Idle), + ); + } #[test] fn esc_exclusive_flag_matches_capsule_and_phase() { diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index 45ab0841..ed8c9576 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 e2c4cbf7..5076622b 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 { @@ -405,15 +417,36 @@ fn should_restore_clipboard(current_text: Option<&str>, inserted_text: &str) -> matches!(current_text, Some(current) if current == inserted_text) } -#[cfg(target_os = "macos")] -fn simulate_paste() -> Result<(), String> { - if !matches!( - crate::permissions::check_accessibility(), - crate::permissions::PermissionStatus::Granted - ) { +#[cfg(any(target_os = "macos", test))] +fn simulate_macos_paste_with

( + accessibility_granted: bool, + secure_input_active: bool, + post_cmd_v: P, +) -> Result<(), String> +where + P: FnOnce() -> Result<(), String>, +{ + if !accessibility_granted { return Err("accessibility permission is not granted".into()); } - macos::post_cmd_v() + if secure_input_active { + return Err("secure input is active".into()); + } + // CoreGraphics 只能确认合成事件已经构造并投递,无法确认前台应用接受了 Cmd+V + // 或真的插入了文本。 + post_cmd_v() +} + +#[cfg(target_os = "macos")] +fn simulate_paste() -> Result<(), String> { + simulate_macos_paste_with( + matches!( + crate::permissions::check_accessibility(), + crate::permissions::PermissionStatus::Granted + ), + crate::unicode_keystroke::is_secure_input_enabled(), + macos::post_cmd_v, + ) } /// 把 `PasteShortcut` 拆成 `(modifiers, primary)`,顺序决定按下/释放顺序。 @@ -770,6 +803,45 @@ mod tests { )); } + #[test] + fn macos_paste_preflight_rejects_missing_accessibility_before_posting() { + let mut posted = false; + let result = simulate_macos_paste_with(false, false, || { + posted = true; + Ok(()) + }); + + assert_eq!( + result.unwrap_err(), + "accessibility permission is not granted" + ); + assert!(!posted); + } + + #[test] + fn macos_paste_preflight_rejects_secure_input_before_posting() { + let mut posted = false; + let result = simulate_macos_paste_with(true, true, || { + posted = true; + Ok(()) + }); + + assert_eq!(result.unwrap_err(), "secure input is active"); + assert!(!posted); + } + + #[test] + fn macos_paste_preflight_posts_when_guards_are_clear() { + let mut posted = false; + let result = simulate_macos_paste_with(true, false, || { + posted = true; + Ok(()) + }); + + assert_eq!(result, Ok(())); + assert!(posted); + } + #[test] #[cfg(target_os = "windows")] fn delayed_terminal_paste_must_see_dictated_text_before_clipboard_restore() { diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 2056f344..78996046 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -350,6 +350,9 @@ 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, + commands::report_insert_fallback_card_height, 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 d842b4bb..45f4f8d3 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,28 @@ 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, + /// 本次卡片展示的代次。尺寸测量 IPC 必须回传它,防止旧卡片迟到的报告缩放新卡片。 + pub presentation_id: u64, +} + +/// 逐字上屏打到一半断了(Secure Input 中途打开、合成按键被拒)。 +pub const INSERT_FALLBACK_REASON_PARTIAL_STREAM: &str = "partialStream"; +/// 插入没能完成(Secure Input、辅助功能掉权限、粘贴被拒等)。 +pub const INSERT_FALLBACK_REASON_INSERT_FAILED: &str = "insertFailed"; + /// 卡片自动消失的时间。 /// /// 到点就当没发生 —— 不记任何东西。用户下次改同一个词还会再问,这正是不要拒绝名单 @@ -910,6 +949,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 +1324,8 @@ struct UserPreferencesWire { alias = "windowsSendinputNewlineMode" )] windows_sendinput_newline_mode: WindowsSendInputNewlineMode, + #[serde(default)] + macos_newline_mode: MacosNewlineMode, #[serde( default, rename = "windowsSendInputInsertionOnly", @@ -1448,6 +1492,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 +1635,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 +2461,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 d868d7ce..447de6a4 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 db2f5941..5d2d2218 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 这个