feat(dictation): 落字失败时把话交还给用户,换行不再劈开聊天消息 (#943)

两件事,本质是同一个:说出来的话没落到该去的地方。

**一、换行会把聊天消息从中间劈开发出去。**

macOS 把 U+000A 当 Return 键,而聊天框里 Return 就是「发送」。一条带空行
的两段话逐字上屏时,第一个换行直接把上半句发了出去,下半句留在输入框里。

改成发真实的 Shift+Return:聊天框里是软换行不发送,编辑器、终端、网页输入
框里就是普通换行。

做成设置而不是硬编码——风格市场里有靠换行把一段话拆成多条消息的风格包,
那种效果要的正是真回车。默认 Shift+Return,需要的人切回 Return。

**二、落字失败时,那段话会悄无声息地消失。**

密码框挡着(Secure Input)、辅助功能掉了权限、粘贴被拒——这些时候唯一的
兜底是把文本写进剪贴板。可它既依赖一个默认可关的开关,就算开着,屏幕上也
没有任何东西告诉你文本在那儿。用户看到的只是胶囊一闪而过,然后是个空输入框。

现在屏幕底部会弹一张卡片,里面是**完整**的那段话,一键复制。20 秒后自己
消失,鼠标停在上面就不走。

顺带修正一处数据失真:逐字上屏打到一半断掉时,此前一律报 InsertStatus::
Inserted(屏幕上明明只有半截),history 里的 insertStatus 因此是错的。
This commit is contained in:
bigsong authored and GitHub committed 2026-08-15 22:21:05 +08:00
1 parent 2ca2a48c52
commit f42f3e60f0
23 files changed
+1394 -118

No files matched your search

@@ -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
@@ -338,6 +338,253 @@ fn position_vocab_card<R: tauri::Runtime>(
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<R: tauri::Runtime>(
window: &tauri::WebviewWindow<R>,
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<u64>,
presentation_id: u64,
height: f64,
) -> Result<Option<f64>, 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<Inner>, 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<Inner>,
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>) {
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::<crate::types::InsertFallbackCardPayload>,
);
// 先隐藏再改几何:复原要同时动尺寸和位置,窗口还亮着时改就有概率被合成出
// 一帧「卡片被拉宽、还横着飞过半个屏幕」。
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<VolcengineStreamingASR>),
@@ -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<Option<String>>,
/// 兜底卡片是不是正占着胶囊窗口。与 `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<Option<CapsulePayload>>,
recording_mute: Mutex<SharedRecordingMuteState>,
hotkey: Mutex<Option<HotkeyMonitor>>,
hotkey_status: Mutex<HotkeyStatus>,
@@ -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 {
@@ -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<Inner>,
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<R: tauri::Runtime>(
inner: &Arc<Inner>,
app: &AppHandle<R>,
window: &tauri::WebviewWindow<R>,
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<R: tauri::Runtime>(
#[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() {
@@ -333,6 +333,8 @@ async fn run_streaming_polish(
let (tx, rx) = std::sync::mpsc::channel::<String>();
#[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<String>,
flush_interval: std::time::Duration,
#[cfg(target_os = "macos")] newline_mode: crate::types::MacosNewlineMode,
) -> (String, Option<String>) {
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<String> {
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<Inner>, 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 {
@@ -4161,6 +4199,10 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> 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!(
@@ -4168,7 +4210,15 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> 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,
@@ -4309,9 +4359,46 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> 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<Inner>, 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 配置时明确报错、不回退传统配置。
@@ -4633,6 +4720,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(())
}
@@ -4811,11 +4903,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};
@@ -5498,6 +5591,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();
+79 -7
View File
@@ -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<P>(
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() {
+3
View File
@@ -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,
+47
View File
@@ -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(),
@@ -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<usize, TypeError> {
type_unicode_chunk_with_options(text, MacosNewlineMode::default())
}
pub fn type_unicode_chunk_with_options(
text: &str,
newline_mode: MacosNewlineMode,
) -> Result<usize, TypeError> {
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")]
+26 -1
View File
@@ -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 这个 <style> 元素 —— 纯属
@@ -773,6 +780,9 @@ export function Capsule({ os: forcedOs }: CapsuleProps = {}) {
// 词条建议卡片。走独立事件通道,不进会话状态机 —— 那套状态机身上挂着 Esc 独占、
// Space 贴附、多屏定位一整串逻辑,加一个非会话状态进去只会污染它。
const [suggestions, setSuggestions] = useState<PendingCorrection[]>([]);
// 落字失败兜底卡片。与词条卡片同一套路:独立事件通道,不进会话状态机。
const [insertFallback, setInsertFallback] =
useState<InsertFallbackCardPayload | null>(null);
// 前端 host 与原生窗口保持同一份透明语音 orb 舞台尺寸。
const hostMetrics = getCapsuleHostMetrics(os, translation);
// Windows 端 host 用「host 高 − pill 高」把 pill 垂直居中;Siri 舞台 460×180 与 host
@@ -819,13 +829,21 @@ export function Capsule({ os: forcedOs }: CapsuleProps = {}) {
const suggestHandle = await listen<PendingCorrection[]>('vocab:suggested', event => {
setSuggestions(event.payload ?? []);
});
const fallbackHandle = await listen<InsertFallbackCardPayload | null>(
'insert:fallback',
event => {
setInsertFallback(event.payload ?? null);
},
);
if (cancelled) {
handle();
suggestHandle();
fallbackHandle();
} else {
unlisten = () => {
handle();
suggestHandle();
fallbackHandle();
};
}
})();
@@ -921,6 +939,13 @@ export function Capsule({ os: forcedOs }: CapsuleProps = {}) {
});
}, [warming]);
// 兜底卡片排在最前:它是在会话收尾那一刻弹的,那一帧胶囊还在渲染 Done/Error 终态,
// 而这次会话的结果恰恰是「没落进去」—— 让终态盖在上面等于报了个假的成功。
// 后端那边同步让路:卡片可见时 idle 隐藏不收窗口(见 capsule_focus.rs)。
if (insertFallback) {
return <InsertFallbackCard payload={insertFallback} />;
}
// 卡片优先:它和胶囊共用一个窗口,但时序上不冲突 —— 卡片在改完字之后才弹,那时
// 会话早已收尾;新一轮听写开始时后端会先把卡片收起来(见 begin_session_as)。
if (suggestions.length > 0) {
@@ -0,0 +1,230 @@
// 「这段话没能落进去」的兜底卡片,弹在屏幕右下角。
//
// 为什么需要它:文本没能插进目标 app 时(上屏途中你切走了窗口、密码框挡着、粘贴被拒),
// 此前唯一的兜底是**悄悄**把文本写进剪贴板。那有两个问题——它依赖一个默认可关的开关,
// 而且就算开着,屏幕上也没有任何东西告诉你「你刚说的那段话在剪贴板里」。你看到的只是
// 胶囊一闪而过,然后是空的输入框,或者被守卫截断的半截话。
//
// 所以卡片必须把**完整**的那段话摆出来:切走窗口的人要的是整段,不是屏幕上残留的半截。
//
// 为什么复制走后端:卡片浮在别的 app 上面,按钮刻意 preventDefault 不抢焦点(抢了就把
// 你正在写的地方的光标弄没了),而未聚焦的文档调 navigator.clipboard 会直接抛
// `Document is not focused`。
//
// 为什么 TTL 比词条卡片长一倍:那张卡片只要瞄一眼「记不记这个词」,这张要把一段话读完
// 再决定复不复制。悬停时还会停表——正在读的时候卡片消失是最气人的。
//
// 为什么没有标题:卡片突然出现、里面是你刚说的那段话、下面一个「复制」——这三件事凑在
// 一起,意思已经到了。原先那行「你切走了窗口,这段话没能落进去」是在替用户解释他自己
// 刚做过的动作,读起来像旁白。`payload.reason` 仍然保留,但只进日志,不上屏。
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
copyTextToClipboard,
dismissInsertFallbackCard,
reportInsertFallbackCardHeight,
} from '../lib/ipc';
import {
nextFallbackCardHeightReport,
type FallbackCardHeightReport,
} from '../lib/insertFallbackLayout';
import type { InsertFallbackCardPayload } from '../lib/types';
/// 卡片自己消失的时间。比词条卡片的 10 秒长——这张要读内容。
const TTL_MS = 20_000;
/// 复制成功后按钮停留在「已复制」的时间。
const COPIED_FEEDBACK_MS = 1_600;
/// 正文最多显示几行,再多就在卡片内部滚动。
/// 原生窗口使用 DOM 实测高度,不再重复维护这组布局参数。
const MAX_LINES = 8;
const LINE_HEIGHT = 18;
interface InsertFallbackCardProps {
payload: InsertFallbackCardPayload;
}
export function InsertFallbackCard({ payload }: InsertFallbackCardProps) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
const [copyFailed, setCopyFailed] = useState(false);
const [paused, setPaused] = useState(false);
const timerRef = useRef<number | null>(null);
const cardRootRef = useRef<HTMLDivElement | null>(null);
const lastHeightReportRef = useRef<FallbackCardHeightReport | null>(null);
useLayoutEffect(() => {
const element = cardRootRef.current;
if (!element) return undefined;
let cancelled = false;
const reportHeight = () => {
const report = nextFallbackCardHeightReport(
lastHeightReportRef.current,
payload.presentationId,
element.getBoundingClientRect().height,
);
if (!report) return;
lastHeightReportRef.current = report;
void reportInsertFallbackCardHeight(report.presentationId, report.height).catch(() => {
// IPC 短暂失败后允许后续 ResizeObserver 通知重试。
if (
!cancelled
&& lastHeightReportRef.current?.presentationId === report.presentationId
&& lastHeightReportRef.current.height === report.height
) {
lastHeightReportRef.current = null;
}
});
};
lastHeightReportRef.current = null;
reportHeight();
if (typeof ResizeObserver === 'undefined') return undefined;
const observer = new ResizeObserver(reportHeight);
observer.observe(element);
return () => {
cancelled = true;
observer.disconnect();
};
}, [payload.presentationId]);
// TTL 倒计时。悬停时暂停:鼠标停在卡片上说明人正在读它。
useEffect(() => {
if (paused) return;
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = window.setTimeout(() => {
void dismissInsertFallbackCard();
}, TTL_MS);
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, [paused, payload.text]);
const copy = async () => {
try {
await copyTextToClipboard(payload.text);
setCopied(true);
setCopyFailed(false);
window.setTimeout(() => setCopied(false), COPIED_FEEDBACK_MS);
} catch {
// 复制失败要说出来——这张卡片本身就是「文本别丢了」的最后一道保障,
// 再静默失败一次,用户就真的没有任何途径拿到这段话了。
setCopyFailed(true);
}
};
return (
<div
ref={cardRootRef}
style={{
width: '100%',
alignSelf: 'flex-end',
display: 'flex',
flexDirection: 'column',
justifyContent: 'flex-end',
padding: 12,
// 卡片是唯一要接鼠标的东西——胶囊本体全程 pointerEvents:none。
pointerEvents: 'auto',
boxSizing: 'border-box',
}}
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
>
<div
style={{
borderRadius: 16,
padding: 12,
background: 'var(--ol-capsule-pill-bg)',
backdropFilter: 'blur(20px)',
WebkitBackdropFilter: 'blur(20px)',
border: '1px solid var(--ol-capsule-pill-border)',
boxShadow:
'var(--ol-capsule-pill-shadow), var(--ol-capsule-pill-inset)',
color: 'var(--ol-capsule-btn-ink)',
fontFamily: 'var(--ol-font-sans)',
overflow: 'hidden',
animation: 'capsule-in .28s cubic-bezier(.3,1.1,.4,1) both',
}}
>
<div
style={{
fontSize: 13,
lineHeight: `${LINE_HEIGHT}px`,
maxHeight: LINE_HEIGHT * MAX_LINES,
overflowY: 'auto',
// 让用户能手动选一段——有人只想要其中一句。
userSelect: 'text',
cursor: 'text',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
marginBottom: 10,
}}
>
{payload.text}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<TextButton
primary
label={
copyFailed
? t('insertFallbackCard.copyFailed')
: copied
? t('insertFallbackCard.copied')
: t('insertFallbackCard.copy')
}
onClick={() => void copy()}
/>
<TextButton
label={t('insertFallbackCard.dismiss')}
onClick={() => void dismissInsertFallbackCard()}
/>
</div>
</div>
</div>
);
}
/// 按钮配色与胶囊上那对确认/取消同源;这里是带文字的宽按钮,因为「复制」需要说清楚
/// 自己干了什么,一个图标撑不住。
function TextButton({
label,
primary,
onClick,
}: {
label: string;
primary?: boolean;
onClick: () => void;
}) {
return (
<button
onClick={onClick}
// 卡片浮在别的 app 上面,按下去不能把焦点从用户正在写的地方抢走。
// 这也正是复制必须走后端的原因(navigator.clipboard 要求文档聚焦)。
onMouseDown={event => {
event.preventDefault();
event.stopPropagation();
}}
style={{
flex: primary ? 1 : undefined,
height: 28,
padding: '0 14px',
borderRadius: 999,
fontSize: 12,
fontFamily: 'var(--ol-font-sans)',
background: primary
? 'var(--ol-capsule-btn-bg-confirm)'
: 'var(--ol-capsule-btn-bg)',
color: 'var(--ol-capsule-btn-ink)',
border: '0.8px solid var(--ol-capsule-btn-border)',
boxShadow: '0 1px 2px rgba(0, 0, 0, 0.06)',
cursor: 'default',
transition:
'background 0.16s var(--ol-motion-quick), transform 0.12s var(--ol-motion-quick)',
}}
>
{label}
</button>
);
}
+10
View File
@@ -430,6 +430,12 @@ export const en: typeof zhCN = {
accept: 'Remember',
reject: 'Skip',
},
insertFallbackCard: {
copy: 'Copy',
copied: 'Copied',
copyFailed: 'Copy failed',
dismiss: 'Dismiss',
},
vocab: {
kicker: 'VOCABULARY',
title: 'Vocabulary',
@@ -857,6 +863,10 @@ export const en: typeof zhCN = {
windowsInsertionModeTsf: 'TSF IME (default)',
windowsInsertionModeSendInput: 'SendInput keystroke simulation',
windowsInsertionModePaste: 'Clipboard paste (Ctrl+V, etc.)',
macosNewlineModeLabel: 'Line breaks',
macosNewlineModeDesc: 'Which key a line break sends while typing out text. A plain Return means "send" in chat apps — keep Shift+Return unless your style pack deliberately splits one dictation into several messages.',
macosNewlineModeShiftReturn: 'Shift+Return (newline in chat)',
macosNewlineModeReturn: 'Return (split into messages)',
windowsSendInputNewlineModeLabel: 'SendInput newline simulation',
windowsSendInputNewlineModeDesc: 'How SendInput turns line breaks into keys. Use Shift+Enter for chat boxes; Enter for Notepad / VS Code and most editors.',
windowsSendInputNewlineModeEnter: 'Enter (most editors)',
+10
View File
@@ -432,6 +432,12 @@ export const ja: typeof zhCN = {
accept: '覚える',
reject: '不要',
},
insertFallbackCard: {
copy: 'コピー',
copied: 'コピーしました',
copyFailed: 'コピーに失敗',
dismiss: '閉じる',
},
vocab: {
kicker: 'VOCABULARY',
title: '語彙',
@@ -859,6 +865,10 @@ export const ja: typeof zhCN = {
windowsInsertionModeTsf: 'TSF IME(既定)',
windowsInsertionModeSendInput: 'SendInput キー入力シミュレーション',
windowsInsertionModePaste: 'クリップボード貼り付け(Ctrl+V など)',
macosNewlineModeLabel: '改行の送り方',
macosNewlineModeDesc: '逐次入力時に改行をどのキーで送るか。チャットでは通常の Return は「送信」になります。改行で複数メッセージに分けるスタイルパックを使う場合以外は Shift+Return のままに。',
macosNewlineModeShiftReturn: 'Shift+Return(チャットで改行)',
macosNewlineModeReturn: 'Return(複数メッセージに分割)',
windowsSendInputNewlineModeLabel: 'SendInput 改行シミュレーション',
windowsSendInputNewlineModeDesc: 'SendInput で改行をどのキーとして送るか。チャット入力は Shift+Enter、メモ帳 / VS Code などは Enter。',
windowsSendInputNewlineModeEnter: 'Enter(多くのエディタ)',
+10
View File
@@ -432,6 +432,12 @@ export const ko: typeof zhCN = {
accept: '기억하기',
reject: '건너뛰기',
},
insertFallbackCard: {
copy: '복사',
copied: '복사됨',
copyFailed: '복사 실패',
dismiss: '닫기',
},
vocab: {
kicker: 'VOCABULARY',
title: '어휘',
@@ -859,6 +865,10 @@ export const ko: typeof zhCN = {
windowsInsertionModeTsf: 'TSF 입력기(기본)',
windowsInsertionModeSendInput: 'SendInput 키 입력 시뮬레이션',
windowsInsertionModePaste: '클립보드 붙여넣기(Ctrl+V 등)',
macosNewlineModeLabel: '줄바꿈 처리',
macosNewlineModeDesc: '한 글자씩 입력할 때 줄바꿈을 어느 키로 보낼지. 채팅에서 일반 Return은 전송을 의미합니다. 줄바꿈으로 여러 메시지를 나누는 스타일 팩을 쓰는 게 아니라면 Shift+Return을 유지하세요.',
macosNewlineModeShiftReturn: 'Shift+Return(채팅에서 줄바꿈)',
macosNewlineModeReturn: 'Return(여러 메시지로 분할)',
windowsSendInputNewlineModeLabel: 'SendInput 줄바꿈 시뮬레이션',
windowsSendInputNewlineModeDesc: 'SendInput 모드에서 줄바꿈을 어떤 키로 보낼지. 채팅 입력창은 Shift+Enter, 메모장 / VS Code 등은 Enter.',
windowsSendInputNewlineModeEnter: 'Enter(대부분 편집기)',
+10
View File
@@ -428,6 +428,12 @@ export const zhCN = {
accept: '记住',
reject: '不用',
},
insertFallbackCard: {
copy: '复制',
copied: '已复制',
copyFailed: '复制失败',
dismiss: '关闭',
},
vocab: {
kicker: 'VOCABULARY',
title: '词汇表',
@@ -855,6 +861,10 @@ export const zhCN = {
windowsInsertionModeTsf: 'TSF 输入法(默认)',
windowsInsertionModeSendInput: 'SendInput 逐字模拟',
windowsInsertionModePaste: '剪贴板粘贴(Ctrl+V 等)',
macosNewlineModeLabel: '换行怎么落',
macosNewlineModeDesc: '逐字上屏时换行符发哪个按键。聊天框里真回车等于发送——除非你用的风格包就是靠换行拆成多条消息,否则保持 Shift+Return。',
macosNewlineModeShiftReturn: 'Shift+Return(聊天框换行)',
macosNewlineModeReturn: 'Return(拆成多条消息)',
windowsSendInputNewlineModeLabel: 'SendInput 换行模拟',
windowsSendInputNewlineModeDesc: 'SendInput 模式下如何把换行符模拟成按键。聊天框通常选 Shift+Enter;记事本 / VS Code 等选 Enter。',
windowsSendInputNewlineModeEnter: 'Enter(多数编辑器)',
+10
View File
@@ -430,6 +430,12 @@ export const zhTW: typeof zhCN = {
accept: '記住',
reject: '不用',
},
insertFallbackCard: {
copy: '複製',
copied: '已複製',
copyFailed: '複製失敗',
dismiss: '關閉',
},
vocab: {
kicker: 'VOCABULARY',
title: '詞彙表',
@@ -857,6 +863,10 @@ export const zhTW: typeof zhCN = {
windowsInsertionModeTsf: 'TSF 輸入法(預設)',
windowsInsertionModeSendInput: 'SendInput 逐字模擬',
windowsInsertionModePaste: '剪貼簿貼上(Ctrl+V 等)',
macosNewlineModeLabel: '換行怎麼落',
macosNewlineModeDesc: '逐字上屏時換行符發哪個按鍵。聊天框裡真回車等於發送——除非你用的風格包就是靠換行拆成多條訊息,否則保持 Shift+Return。',
macosNewlineModeShiftReturn: 'Shift+Return(聊天框換行)',
macosNewlineModeReturn: 'Return(拆成多條訊息)',
windowsSendInputNewlineModeLabel: 'SendInput 換行模擬',
windowsSendInputNewlineModeDesc: 'SendInput 模式下如何把換行符模擬成按鍵。聊天框通常選 Shift+Enter;記事本 / VS Code 等選 Enter。',
windowsSendInputNewlineModeEnter: 'Enter(多數編輯器)',
@@ -0,0 +1,32 @@
import { nextFallbackCardHeightReport } from './insertFallbackLayout';
function assert(condition: boolean, message: string) {
if (!condition) throw new Error(message);
}
function assertReport(
actual: ReturnType<typeof nextFallbackCardHeightReport>,
presentationId: number,
height: number,
) {
assert(actual?.presentationId === presentationId, 'presentation id should match');
assert(actual?.height === height, 'height should be rounded up');
}
assertReport(nextFallbackCardHeightReport(null, 7, 181.2), 7, 182);
assert(
nextFallbackCardHeightReport({ presentationId: 7, height: 182 }, 7, 181.2) === null,
'same presentation and height should be deduplicated',
);
assertReport(
nextFallbackCardHeightReport({ presentationId: 7, height: 182 }, 8, 181.2),
8,
182,
);
assert(
nextFallbackCardHeightReport(null, 7, Number.NaN) === null,
'non-finite height should be ignored',
);
assert(nextFallbackCardHeightReport(null, 7, 0) === null, 'non-positive height should be ignored');
console.log('insert fallback layout tests passed');
@@ -0,0 +1,15 @@
export interface FallbackCardHeightReport {
presentationId: number;
height: number;
}
export function nextFallbackCardHeightReport(
previous: FallbackCardHeightReport | null,
presentationId: number,
measuredHeight: number,
): FallbackCardHeightReport | null {
if (!Number.isFinite(measuredHeight) || measuredHeight <= 0) return null;
const height = Math.ceil(measuredHeight);
if (previous?.presentationId === presentationId && previous.height === height) return null;
return { presentationId, height };
}
+3
View File
@@ -68,6 +68,9 @@ export {
acceptPendingCorrection,
rejectPendingCorrection,
dismissVocabSuggestions,
copyTextToClipboard,
dismissInsertFallbackCard,
reportInsertFallbackCardHeight,
removeCorrectionRule,
setCorrectionRuleEnabled,
listVocabPresets,
@@ -60,6 +60,7 @@ export let mockSettings: UserPreferences = {
allowNonTsfInsertionFallback: true,
windowsInsertionMode: "tsf",
windowsSendInputNewlineMode: "enter",
macosNewlineMode: "shiftReturn",
windowsSendInputInsertionOnly: false,
windowsShowOpenlessInKeyboardList: true,
workingLanguages: ["简体中文"],
+27
View File
@@ -74,6 +74,33 @@ export function dismissVocabSuggestions(): Promise<void> {
return invokeOrMock("dismiss_vocab_suggestions", undefined, () => undefined)
}
/**
* 把文字放进剪贴板。
*
* 走后端而不是 `navigator.clipboard`:兜底卡片浮在别的 app 上面、按钮刻意不抢焦点,
* 而未聚焦的文档调那个 API 会抛 `Document is not focused`。
*/
export function copyTextToClipboard(text: string): Promise<void> {
return invokeOrMock("copy_text_to_clipboard", { text }, () => undefined)
}
/** 落字失败兜底卡片关掉了(用户点关闭 / TTL 到时)。 */
export function dismissInsertFallbackCard(): Promise<void> {
return invokeOrMock("dismiss_insert_fallback_card", undefined, () => undefined)
}
/** 把浏览器真实折行后的卡片高度同步给原生共享窗口。 */
export function reportInsertFallbackCardHeight(
presentationId: number,
height: number,
): Promise<void> {
return invokeOrMock(
"report_insert_fallback_card_height",
{ presentationId, height },
() => undefined,
)
}
export function removeCorrectionRule(id: string): Promise<void> {
return invokeOrMock("remove_correction_rule", { id }, () => undefined)
}
@@ -53,6 +53,7 @@ const previousPrefs: UserPreferences = {
allowNonTsfInsertionFallback: true,
windowsInsertionMode: 'tsf',
windowsSendInputNewlineMode: 'enter',
macosNewlineMode: 'shiftReturn',
windowsSendInputInsertionOnly: false,
windowsShowOpenlessInKeyboardList: true,
workingLanguages: ['简体中文'],
+16
View File
@@ -121,6 +121,17 @@ export interface PendingCorrection {
replacement: string;
}
/** 为什么这段话没落进目标 app。只用于后端日志,卡片本身不渲染它。 */
export type InsertFallbackReason = 'partialStream' | 'insertFailed';
/** 落字失败兜底卡片的内容。`text` 始终是完整的那段话,即便屏幕上只落了半截。 */
export interface InsertFallbackCardPayload {
text: string;
reason: InsertFallbackReason;
/** 本次展示代次;尺寸回报必须原样携带,后端据此忽略旧卡片的迟到 IPC。 */
presentationId: number;
}
export interface VocabPreset {
id: string;
name: string;
@@ -224,6 +235,10 @@ export type WindowsInsertionMode = 'tsf' | 'sendInput' | 'paste';
/** Windows SendInput 路径的换行模拟方式。 */
export type WindowsSendInputNewlineMode = 'enter' | 'shiftEnter' | 'crlf';
/** macOS 逐字上屏时换行符怎么发。`return` 在聊天框里等于发送 —— 靠换行拆多条消息的
* 风格包要的就是这个。 */
export type MacosNewlineMode = 'shiftReturn' | 'return';
export type WindowsImeInstallState =
| 'installed'
| 'notInstalled'
@@ -365,6 +380,7 @@ export interface UserPreferences {
windowsInsertionMode: WindowsInsertionMode;
/** Windows SendInput 路径的换行模拟方式。 */
windowsSendInputNewlineMode: WindowsSendInputNewlineMode;
macosNewlineMode: MacosNewlineMode;
/** 旧版兼容:`true` 等价于 `windowsInsertionMode === 'sendInput'`。 */
windowsSendInputInsertionOnly: boolean;
/** Windows:SendInput 模式下是否在系统键盘列表(Win+Space)中显示 OpenLess。 */
@@ -24,6 +24,7 @@ import type {
UserPreferences,
WindowsInsertionMode,
WindowsSendInputNewlineMode,
MacosNewlineMode,
} from '../../lib/types';
import { useHotkeySettings } from '../../state/HotkeySettingsContext';
import { SelectLite } from '../../components/ui/SelectLite';
@@ -178,6 +179,8 @@ export function RecordingInputSection() {
});
const onWindowsSendInputNewlineModeChange = (windowsSendInputNewlineMode: WindowsSendInputNewlineMode) =>
savePrefs({ ...prefs, windowsSendInputNewlineMode });
const onMacosNewlineModeChange = (macosNewlineMode: MacosNewlineMode) =>
savePrefs({ ...prefs, macosNewlineMode });
const onWindowsShowOpenlessInKeyboardListChange = (
windowsShowOpenlessInKeyboardList: boolean,
) => void saveKeyboardListAffectingPrefs({ ...prefs, windowsShowOpenlessInKeyboardList });
@@ -517,6 +520,23 @@ export function RecordingInputSection() {
/>
</SettingRow>
)}
{capability.adapter === 'macEventTap' && prefs.streamingInsert && (
<SettingRow
label={t('settings.recording.macosNewlineModeLabel')}
desc={t('settings.recording.macosNewlineModeDesc')}
>
<SelectLite
value={prefs.macosNewlineMode ?? 'shiftReturn'}
onChange={next => onMacosNewlineModeChange(next as MacosNewlineMode)}
options={[
{ value: 'shiftReturn', label: t('settings.recording.macosNewlineModeShiftReturn') },
{ value: 'return', label: t('settings.recording.macosNewlineModeReturn') },
]}
ariaLabel={t('settings.recording.macosNewlineModeLabel')}
style={{ ...inputStyle, maxWidth: 260 }}
/>
</SettingRow>
)}
{capability.adapter === 'windowsLowLevel'
&& (prefs.windowsInsertionMode === 'sendInput' || prefs.windowsSendInputInsertionOnly) && (
<SettingRow