diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index fdc95d07..cca83683 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -111,6 +111,9 @@ struct Inner { qa_hotkey: Mutex>, /// QA 单独的 session 状态,与 dictation 的 SessionPhase 不冲突。 qa_state: Mutex, + /// 最近一次应用到 capsule 窗口的几何状态。避免录音 level tick 反复触发 + /// resize / reposition。 + capsule_layout: Mutex>, /// QA 用的 ASR 句柄(始终是 Volcengine 流式)。 qa_asr: Mutex>>, /// QA 用的 Recorder 句柄。 @@ -181,6 +184,7 @@ impl Coordinator { translation_modifier_seen: AtomicBool::new(false), qa_hotkey: Mutex::new(None), qa_state: Mutex::new(QaSessionState::default()), + capsule_layout: Mutex::new(None), qa_asr: Mutex::new(None), qa_recorder: Mutex::new(None), }), @@ -2451,6 +2455,7 @@ fn emit_capsule( let show_capsule = inner.prefs.get().show_capsule; if let Some(window) = app.get_webview_window("capsule") { let visible = !matches!(state, CapsuleState::Idle); + maybe_position_capsule_bottom_center(inner, &window, payload.translation); if show_capsule && visible { if cfg!(target_os = "windows") { if !show_capsule_window_no_activate() { @@ -2471,6 +2476,45 @@ fn emit_capsule( let _ = app.emit_to("capsule", "capsule:state", payload); } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct CapsuleLayoutState { + translation_active: bool, + monitor_x: i32, + monitor_y: i32, + monitor_width: u32, + monitor_height: u32, + scale_bits: u64, +} + +fn maybe_position_capsule_bottom_center( + inner: &Arc, + window: &tauri::WebviewWindow, + translation_active: bool, +) { + let Some(monitor) = window.current_monitor().ok().flatten() else { + return; + }; + let next = CapsuleLayoutState { + translation_active, + monitor_x: monitor.position().x, + monitor_y: monitor.position().y, + monitor_width: monitor.size().width, + monitor_height: monitor.size().height, + scale_bits: monitor.scale_factor().to_bits(), + }; + { + let last = inner.capsule_layout.lock(); + if last.as_ref() == Some(&next) { + return; + } + } + if crate::position_capsule_bottom_center(window, translation_active).is_ok() { + let mut last = inner.capsule_layout.lock(); + *last = Some(next); + return; + } +} + // ─────────────────────────── audio bridge ─────────────────────────── struct DeferredAsrBridge { diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 5e81af15..12056a11 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -35,7 +35,7 @@ use std::time::Duration; static QA_WINDOW_POSITIONED: AtomicBool = AtomicBool::new(false); use tauri::menu::{MenuBuilder, MenuItemBuilder}; use tauri::tray::{MouseButton, TrayIconBuilder, TrayIconEvent}; -use tauri::{AppHandle, Emitter, LogicalPosition, Manager, RunEvent, Runtime}; +use tauri::{AppHandle, Emitter, LogicalPosition, LogicalSize, Manager, RunEvent, Runtime}; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { @@ -61,7 +61,7 @@ pub fn run() { // Capsule 启动时定位到屏幕底部居中并隐藏;coordinator 按需显示。 // 与 Swift `CapsuleWindowController.repositionToBottomCenter` 同语义。 if let Some(capsule) = app.get_webview_window("capsule") { - if let Err(e) = position_capsule_bottom_center(&capsule) { + if let Err(e) = position_capsule_bottom_center(&capsule, false) { log::warn!("[capsule] position failed: {e}"); } let _ = capsule.hide(); @@ -436,8 +436,6 @@ const QA_WINDOW_WIDTH: f64 = 380.0; const QA_WINDOW_HEIGHT: f64 = 440.0; /// 胶囊与 QA 窗口的间距,与设计稿一致。 const QA_WINDOW_GAP_TO_CAPSULE: f64 = 8.0; -/// 胶囊高度(与 `position_capsule_bottom_center` 中一致)。 -const CAPSULE_HEIGHT_FOR_QA: f64 = 96.0; /// 给 macOS Dock 留的下边距(与 capsule 同源)。 const DOCK_BOTTOM_PADDING_FOR_QA: f64 = 80.0; @@ -452,10 +450,11 @@ fn position_qa_window(window: &tauri::WebviewWindow) -> ta let size = monitor.size(); let logical_w = size.width as f64 / scale; let logical_h = size.height as f64 / scale; + let capsule_height = capsule_window_size(false).1; let x = ((logical_w - QA_WINDOW_WIDTH) / 2.0).max(0.0); let y = (logical_h - DOCK_BOTTOM_PADDING_FOR_QA - - CAPSULE_HEIGHT_FOR_QA + - capsule_height - QA_WINDOW_GAP_TO_CAPSULE - QA_WINDOW_HEIGHT) .max(0.0); @@ -563,21 +562,75 @@ pub(crate) fn hide_qa_window(app: &AppHandle) { /// 把 capsule 窗口移到屏幕底部居中,与 Swift `CapsuleWindowController.repositionToBottomCenter` 同效。 /// 留 80pt 给 macOS Dock;Windows 任务栏一般在底部 48pt 以内,整体也合适。 -fn position_capsule_bottom_center( +pub(crate) fn position_capsule_bottom_center( window: &tauri::WebviewWindow, + translation_active: bool, ) -> tauri::Result<()> { let monitor = match window.current_monitor()? { Some(m) => m, None => return Ok(()), }; + let (cap_w, cap_h) = capsule_window_size(translation_active); + window.set_size(LogicalSize::new(cap_w, cap_h))?; + let scale = monitor.scale_factor(); let size = monitor.size(); let logical_w = size.width as f64 / scale; let logical_h = size.height as f64 / scale; - let cap_w = 220.0_f64; - let cap_h = 96.0_f64; let x = ((logical_w - cap_w) / 2.0).max(0.0); let y = (logical_h - cap_h - 80.0).max(0.0); window.set_position(LogicalPosition::new(x, y))?; Ok(()) } + +fn capsule_window_size(translation_active: bool) -> (f64, f64) { + #[cfg(target_os = "windows")] + { + let height = if translation_active { 110.0 } else { 52.0 }; + (196.0, height) + } + + #[cfg(not(target_os = "windows"))] + { + let height = if translation_active { 110.0 } else { 42.0 }; + (176.0, height) + } +} + +fn capsule_height_for_qa() -> f64 { + capsule_window_size(false).1 +} + +#[cfg(test)] +mod tests { + use super::{capsule_height_for_qa, capsule_window_size}; + + #[test] + fn capsule_window_size_matches_visible_pill_when_not_translating() { + let (width, height) = capsule_window_size(false); + #[cfg(target_os = "windows")] + assert_eq!((width, height), (196.0, 52.0)); + + #[cfg(not(target_os = "windows"))] + assert_eq!((width, height), (176.0, 42.0)); + } + + #[test] + fn capsule_window_size_expands_for_translation_badge() { + let (width, height) = capsule_window_size(true); + #[cfg(target_os = "windows")] + assert_eq!((width, height), (196.0, 110.0)); + + #[cfg(not(target_os = "windows"))] + assert_eq!((width, height), (176.0, 110.0)); + } + + #[test] + fn qa_anchor_uses_normal_capsule_height_source() { + #[cfg(target_os = "windows")] + assert_eq!(capsule_height_for_qa(), 52.0); + + #[cfg(not(target_os = "windows"))] + assert_eq!(capsule_height_for_qa(), 42.0); + } +} diff --git a/openless-all/app/src-tauri/src/polish.rs b/openless-all/app/src-tauri/src/polish.rs index 0004e1f7..d207bf33 100644 --- a/openless-all/app/src-tauri/src/polish.rs +++ b/openless-all/app/src-tauri/src/polish.rs @@ -411,7 +411,7 @@ fn compose_system_prompt(mode: PolishMode, hotwords: &[String]) -> String { .collect::>() .join("\n"); format!( - "{}\n\n热词(用户提供的正确写法,仅当原始转写明显是其误识别时才纠正,不做机械替换):\n{}", + "{}\n\n热词(用户希望以下写法在输出中保持准确;当转写中出现这些词的同音 / 近形误识别时,优先按上述写法输出,不做无关词的机械替换):\n{}", base, bullets ) } @@ -888,4 +888,14 @@ mod tests { // 防回归:旧版"另外:"标签写法不能再出现在示例输出里。 assert!(!prompt.contains("另外:检查一下当前还有哪些 issues")); } + + #[test] + fn compose_system_prompt_prefers_correct_spelling_for_hotwords() { + let prompt = compose_system_prompt(PolishMode::Light, &["GitHub".into(), "OpenLess".into()]); + + assert!(prompt.contains("用户希望以下写法在输出中保持准确")); + assert!(prompt.contains("同音 / 近形误识别时,优先按上述写法输出")); + assert!(prompt.contains("- GitHub")); + assert!(prompt.contains("- OpenLess")); + } } diff --git a/openless-all/app/src/components/Capsule.tsx b/openless-all/app/src/components/Capsule.tsx index 8e518fef..e7b2044b 100644 --- a/openless-all/app/src/components/Capsule.tsx +++ b/openless-all/app/src/components/Capsule.tsx @@ -323,7 +323,7 @@ export function Capsule() { position: 'absolute', left: '50%', // bottom = 50%(pill 中线)+ pill 半高 21px(capsuleLayout mac=42)+ 8px 间隔。 - // 胶囊窗口高度 110(tauri.conf.json)刚好装下 badge + 间隔 + pill。 + // 只有翻译徽章可见时才需要额外高度;普通录音/转写状态由后端缩到 pill 本体,避免透明死区。 bottom: 'calc(50% + 21px + 8px)', transform: 'translateX(-50%)', pointerEvents: 'none',