From f89a2858773de4707b2b83db3625161e17c7c795 Mon Sep 17 00:00:00 2001 From: baiqing Date: Sun, 3 May 2026 09:29:59 +0800 Subject: [PATCH 1/7] fix(macos): restore native window chrome and capsule focus --- .../app/scripts/windows-ui-config.test.mjs | 14 +++- openless-all/app/src-tauri/src/coordinator.rs | 75 +++++++++++++------ openless-all/app/src-tauri/src/lib.rs | 18 +++-- openless-all/app/src-tauri/tauri.conf.json | 2 +- 4 files changed, 81 insertions(+), 28 deletions(-) diff --git a/openless-all/app/scripts/windows-ui-config.test.mjs b/openless-all/app/scripts/windows-ui-config.test.mjs index 633a7583..b6fcae40 100644 --- a/openless-all/app/scripts/windows-ui-config.test.mjs +++ b/openless-all/app/scripts/windows-ui-config.test.mjs @@ -33,9 +33,21 @@ assertEqual(capsuleWindow.width, 220, 'windows capsule config keeps translation- assertEqual(capsuleWindow.height, 110, 'windows capsule config keeps translation-capable height baseline'); assertEqual(capsuleWindow.transparent, true, 'capsule window should keep transparent visuals'); assertEqual(capsuleWindow.alwaysOnTop, true, 'capsule window should stay above the focused app while recording'); -assertEqual(mainWindow.decorations, false, 'windows main window should use only custom titlebar'); +assertEqual(mainWindow.decorations, true, 'shared main window config should keep native macOS traffic lights'); assertEqual(mainWindow.visible, false, 'windows main window should stay hidden until the intended first show point'); +assertMatch( + libRs, + /#\[cfg\(target_os = "windows"\)\][\s\S]*?main\.set_decorations\(false\)/, + 'windows runtime should disable native chrome before the first show', +); + +assertMatch( + coordinatorRs, + /#\[cfg\(target_os = "macos"\)\][\s\S]*?orderFrontRegardless/, + 'macOS capsule should show without taking the key window', +); + if (!/function WindowsResizeHandles\(\)/.test(windowChromeTsx)) { throw new Error('windows frameless shell should expose explicit resize handles'); } diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 42eea2e8..36f035ba 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -8,7 +8,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc; use std::sync::Arc; -use std::time::Instant; +use std::time::{Duration, Instant}; use chrono::Utc; use parking_lot::Mutex; @@ -2520,24 +2520,58 @@ fn restore_focus_target_if_possible(_target: Option) -> bool { true } +#[cfg(target_os = "macos")] +fn show_capsule_window_no_activate( + app: &AppHandle, + window: &tauri::WebviewWindow, +) -> bool { + let window = window.clone(); + let (tx, rx) = mpsc::channel(); + let _ = app.run_on_main_thread(move || { + use objc2::msg_send; + use objc2::runtime::AnyObject; + + let ok = match window.ns_window() { + Ok(handle) => { + let ns = handle as *mut AnyObject; + if ns.is_null() { + false + } else { + unsafe { + let _: () = msg_send![ns, orderFrontRegardless]; + } + true + } + } + Err(e) => { + log::warn!("[capsule] ns_window unavailable for no-activate show: {e}"); + false + } + }; + let _ = tx.send(ok); + }); + rx.recv_timeout(Duration::from_millis(800)).unwrap_or(false) +} + #[cfg(target_os = "windows")] -fn show_capsule_window_no_activate() -> bool { - use std::iter::once; - use windows::core::PCWSTR; +fn show_capsule_window_no_activate( + _app: &AppHandle, + window: &tauri::WebviewWindow, +) -> bool { + use raw_window_handle::{HasWindowHandle, RawWindowHandle}; use windows::Win32::Foundation::HWND; use windows::Win32::UI::WindowsAndMessaging::{ - FindWindowW, SetWindowPos, ShowWindow, HWND_TOPMOST, SWP_NOACTIVATE, SWP_NOMOVE, - SWP_NOSIZE, SWP_SHOWWINDOW, SW_SHOWNOACTIVATE, + SetWindowPos, ShowWindow, HWND_TOPMOST, SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOSIZE, + SWP_SHOWWINDOW, SW_SHOWNOACTIVATE, }; - let title: Vec = "OpenLess Capsule".encode_utf16().chain(once(0)).collect(); - let hwnd = match unsafe { FindWindowW(PCWSTR::null(), PCWSTR(title.as_ptr())) } { - Ok(hwnd) => hwnd, - Err(_) => return false, + let Ok(handle) = window.window_handle() else { + return false; }; - if hwnd == HWND::default() || hwnd.0.is_null() { + let RawWindowHandle::Win32(raw) = handle.as_raw() else { return false; - } + }; + let hwnd = HWND(raw.hwnd.get() as *mut _); let _ = unsafe { ShowWindow(hwnd, SW_SHOWNOACTIVATE) }; let _ = unsafe { @@ -2554,8 +2588,11 @@ fn show_capsule_window_no_activate() -> bool { true } -#[cfg(not(target_os = "windows"))] -fn show_capsule_window_no_activate() -> bool { +#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] +fn show_capsule_window_no_activate( + _app: &AppHandle, + _window: &tauri::WebviewWindow, +) -> bool { false } @@ -2624,15 +2661,11 @@ fn emit_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() { - let _ = window.show(); - } - } else { + if !show_capsule_window_no_activate(&app, &window) { let _ = window.show(); } - // 胶囊 show() 在 macOS 会调 makeKeyAndOrderFront: 抢走主窗口焦点。 - // 若 OpenLess 已是前台 app,用 makeKeyWindow 还原主窗口(不激活 NSApp)。 + // macOS/Windows 优先走 no-activate show,避免录音胶囊抢走主窗口点击焦点。 + // 若 fallback 到 show(),OpenLess 已是前台 app 时再把 key window 还给 main。 #[cfg(target_os = "macos")] crate::restore_main_window_key_if_active(&app); } else { diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 03c69213..fa478965 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -483,7 +483,8 @@ fn activate_app(_app: &AppHandle) {} /// 不调 NSApp.activate,不抢其他 app 焦点,符合 CLAUDE.md 约束。 #[cfg(target_os = "macos")] pub(crate) fn restore_main_window_key_if_active(app: &AppHandle) { - let _ = app.run_on_main_thread(|| { + let main = app.get_webview_window("main"); + let _ = app.run_on_main_thread(move || { use objc2::msg_send; use objc2::runtime::{AnyClass, AnyObject, Bool}; unsafe { @@ -498,11 +499,18 @@ pub(crate) fn restore_main_window_key_if_active(app: &AppHandle) if !is_active.as_bool() { return; } - let main_win: *mut AnyObject = msg_send![ns_app, mainWindow]; - if main_win.is_null() { + let Some(main) = main else { return; - } - let _: () = msg_send![main_win, makeKeyWindow]; + }; + match main.ns_window() { + Ok(handle) => { + let main_win = handle as *mut AnyObject; + if !main_win.is_null() { + let _: () = msg_send![main_win, makeKeyWindow]; + } + } + Err(e) => log::warn!("[main] ns_window unavailable for key restore: {e}"), + }; } }); } diff --git a/openless-all/app/src-tauri/tauri.conf.json b/openless-all/app/src-tauri/tauri.conf.json index fa959dd5..ac9c091a 100644 --- a/openless-all/app/src-tauri/tauri.conf.json +++ b/openless-all/app/src-tauri/tauri.conf.json @@ -21,7 +21,7 @@ "minWidth": 980, "minHeight": 640, "resizable": true, - "decorations": false, + "decorations": true, "transparent": true, "shadow": true, "hiddenTitle": true, From 420c5425e6351c48bc029ab9e2b069a878288551 Mon Sep 17 00:00:00 2001 From: baiqing Date: Sun, 3 May 2026 09:43:19 +0800 Subject: [PATCH 2/7] fix(macos): tune window chrome motion --- .../app/scripts/windows-ui-config.test.mjs | 17 +++++ openless-all/app/src-tauri/Cargo.toml | 2 +- openless-all/app/src-tauri/src/lib.rs | 56 ++++++++++++++++ .../app/src/components/AutoUpdate.tsx | 2 +- openless-all/app/src/components/Capsule.tsx | 19 +++--- .../app/src/components/FloatingShell.tsx | 65 ++++++++++--------- .../app/src/components/Onboarding.tsx | 2 +- .../app/src/components/SettingsModal.tsx | 25 +++---- .../app/src/components/WindowChrome.tsx | 13 ++-- .../app/src/components/ui/SwitchLite.tsx | 2 +- openless-all/app/src/pages/History.tsx | 4 +- openless-all/app/src/pages/Overview.tsx | 2 +- openless-all/app/src/pages/QaPanel.tsx | 19 +++--- openless-all/app/src/pages/SelectionAsk.tsx | 4 +- openless-all/app/src/pages/Settings.tsx | 14 ++-- openless-all/app/src/pages/Style.tsx | 10 +-- openless-all/app/src/pages/Translation.tsx | 2 +- openless-all/app/src/pages/Vocab.tsx | 10 +-- openless-all/app/src/pages/_atoms.tsx | 2 +- openless-all/app/src/styles/global.css | 13 ++++ openless-all/app/src/styles/tokens.css | 6 ++ 21 files changed, 196 insertions(+), 93 deletions(-) diff --git a/openless-all/app/scripts/windows-ui-config.test.mjs b/openless-all/app/scripts/windows-ui-config.test.mjs index b6fcae40..a747b00b 100644 --- a/openless-all/app/scripts/windows-ui-config.test.mjs +++ b/openless-all/app/scripts/windows-ui-config.test.mjs @@ -22,6 +22,7 @@ const capsuleTsx = await readFile(new URL('../src/components/Capsule.tsx', impor const capsuleLayoutTs = await readFile(new URL('../src/lib/capsuleLayout.ts', import.meta.url), 'utf-8'); const windowChromeTsx = await readFile(new URL('../src/components/WindowChrome.tsx', import.meta.url), 'utf-8'); const floatingShellTsx = await readFile(new URL('../src/components/FloatingShell.tsx', import.meta.url), 'utf-8'); +const tokensCss = await readFile(new URL('../src/styles/tokens.css', import.meta.url), 'utf-8'); if (!capsuleWindow) { throw new Error('capsule window config missing'); @@ -52,6 +53,22 @@ if (!/function WindowsResizeHandles\(\)/.test(windowChromeTsx)) { throw new Error('windows frameless shell should expose explicit resize handles'); } +assertMatch( + windowChromeTsx, + /const MAC_TITLEBAR_HEIGHT = 30;/, + 'macOS titlebar spacer should stay visually compact around the native traffic lights', +); +assertMatch( + libRs, + /fn tune_macos_main_window_controls[\s\S]*?standardWindowButton[\s\S]*?TRAFFIC_LIGHT_TOP_INSET/, + 'macOS main window should explicitly center native traffic lights in the compact top band', +); +assertMatch( + tokensCss, + /--ol-motion-spring:[\s\S]*?--ol-motion-soft:[\s\S]*?--ol-motion-quick:/, + 'shared motion tokens should drive shell animations and transitions', +); + if (!/startResizeDragging\(direction\)/.test(windowChromeTsx)) { throw new Error('windows resize handles should delegate edge dragging to Tauri'); } diff --git a/openless-all/app/src-tauri/Cargo.toml b/openless-all/app/src-tauri/Cargo.toml index 3942cd74..ebccea08 100644 --- a/openless-all/app/src-tauri/Cargo.toml +++ b/openless-all/app/src-tauri/Cargo.toml @@ -50,7 +50,7 @@ core-foundation = "0.10" core-graphics = "0.24" objc2 = "0.5" objc2-foundation = "0.2" -objc2-app-kit = "0.2" +objc2-app-kit = { version = "0.2", features = ["NSButton", "NSWindow"] } [target.'cfg(target_os = "windows")'.dependencies] raw-window-handle = "0.6" diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index fa478965..8880062a 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -101,6 +101,7 @@ pub fn run() { ) { log::warn!("[main] vibrancy failed: {e}"); } + tune_macos_main_window_controls(&main); } #[cfg(target_os = "windows")] { @@ -230,6 +231,16 @@ pub fn run() { apply_windows_rounded_frame(&main); } } + #[cfg(target_os = "macos")] + if matches!( + event, + tauri::WindowEvent::Resized(_) + | tauri::WindowEvent::ScaleFactorChanged { .. } + ) { + if let Some(main) = app.get_webview_window("main") { + tune_macos_main_window_controls(&main); + } + } } } RunEvent::Exit => { @@ -241,6 +252,51 @@ pub fn run() { }); } +/// macOS 主窗口保留原生 traffic lights,但 React 壳层把标题区压低到 30pt。 +/// 系统默认按钮会落在偏下的位置;这里只微调三个标准按钮的位置,让它们 +/// 在顶部视觉带里垂直居中,避免和左侧 OpenLess 品牌区抢空间。 +#[cfg(target_os = "macos")] +fn tune_macos_main_window_controls(window: &tauri::WebviewWindow) { + use objc2::msg_send; + use objc2::runtime::AnyObject; + use objc2_app_kit::NSWindowButton; + use objc2_foundation::{NSPoint, NSRect}; + + const TRAFFIC_LIGHT_LEFT: f64 = 14.0; + const TRAFFIC_LIGHT_GAP: f64 = 20.0; + const TRAFFIC_LIGHT_TOP_INSET: f64 = 9.0; + + let Ok(handle) = window.ns_window() else { + return; + }; + let ns_window = handle as *mut AnyObject; + if ns_window.is_null() { + return; + } + + unsafe { + let window_frame: NSRect = msg_send![ns_window, frame]; + for (index, button_kind) in [ + NSWindowButton::NSWindowCloseButton, + NSWindowButton::NSWindowMiniaturizeButton, + NSWindowButton::NSWindowZoomButton, + ] + .into_iter() + .enumerate() + { + let button: *mut AnyObject = msg_send![ns_window, standardWindowButton: button_kind]; + if button.is_null() { + continue; + } + let frame: NSRect = msg_send![button, frame]; + let x = TRAFFIC_LIGHT_LEFT + TRAFFIC_LIGHT_GAP * index as f64; + let y = window_frame.size.height - frame.size.height - TRAFFIC_LIGHT_TOP_INSET; + let origin = NSPoint::new(x, y); + let _: () = msg_send![button, setFrameOrigin: origin]; + } + } +} + #[cfg(target_os = "windows")] fn apply_windows_rounded_frame(window: &tauri::WebviewWindow) { use raw_window_handle::{HasWindowHandle, RawWindowHandle}; diff --git a/openless-all/app/src/components/AutoUpdate.tsx b/openless-all/app/src/components/AutoUpdate.tsx index 167103a1..40f96d85 100644 --- a/openless-all/app/src/components/AutoUpdate.tsx +++ b/openless-all/app/src/components/AutoUpdate.tsx @@ -173,7 +173,7 @@ export function UpdateDialog({ {(downloading || installing || status === 'downloaded') && (
-
+
{installing diff --git a/openless-all/app/src/components/Capsule.tsx b/openless-all/app/src/components/Capsule.tsx index 03120f28..ff9cac52 100644 --- a/openless-all/app/src/components/Capsule.tsx +++ b/openless-all/app/src/components/Capsule.tsx @@ -39,7 +39,7 @@ function AudioBars({ level }: AudioBarsProps) { borderRadius: 999, background: 'var(--ol-blue)', opacity: 0.82, - transition: 'height 0.06s linear', + transition: 'height 0.08s var(--ol-motion-quick)', }} /> ))} @@ -59,7 +59,7 @@ function ProcessingDots() { borderRadius: 999, background: 'var(--ol-blue)', opacity: 0.85, - animation: `cap-dot 0.9s linear ${i * 0.3}s infinite`, + animation: `cap-dot 0.9s var(--ol-motion-soft) ${i * 0.3}s infinite`, }} /> ))} @@ -130,7 +130,7 @@ function CircleButton({ variant, enabled, onClick }: CircleButtonProps) { flexShrink: 0, padding: 0, boxShadow: '0 1px 2px rgba(0, 0, 0, 0.06)', - transition: 'opacity 0.15s ease-out, background 0.12s ease-out, transform 0.08s ease-out', + transition: 'opacity 0.18s var(--ol-motion-soft), background 0.16s var(--ol-motion-quick), transform 0.12s var(--ol-motion-quick)', }} > {isCancel ? ( @@ -243,7 +243,7 @@ function Pill({ os, state, level, insertedChars, message, onCancel, onConfirm }: fontFamily: 'var(--ol-font-sans)', transform: `scale(${scale.toFixed(4)})`, transformOrigin: 'center', - transition: 'transform 0.06s linear, box-shadow 0.06s linear', + transition: 'transform 0.08s var(--ol-motion-quick), box-shadow 0.08s var(--ol-motion-quick)', willChange: 'transform, box-shadow', filter: dropShadow, }} @@ -318,7 +318,7 @@ export function Capsule() { : 0, paddingBottom: os === 'win' ? hostMetrics.bottomInset : 0, background: 'transparent', - animation: os === 'win' ? 'none' : 'capsule-in .22s cubic-bezier(.2,.9,.3,1.1)', + animation: os === 'win' ? 'none' : 'capsule-in .28s var(--ol-motion-spring)', }} > {/* "正在翻译" 徽章 — 嵌套两层: @@ -357,8 +357,9 @@ export function Capsule() { opacity: translation ? 1 : 0, transform: translation ? 'translateY(0) scale(1)' : 'translateY(40px) scale(.88)', transformOrigin: 'center bottom', - transition: 'opacity .24s ease-out, transform .34s cubic-bezier(.2,.9,.3,1.1)', - willChange: 'opacity, transform', + transition: 'opacity .24s var(--ol-motion-soft), transform .34s var(--ol-motion-spring), filter .24s var(--ol-motion-soft)', + filter: translation ? 'blur(0)' : 'blur(4px)', + willChange: 'opacity, transform, filter', }} > @@ -376,8 +377,8 @@ export function Capsule() { />
@@ -379,9 +382,9 @@ function ProviderSetupPrompt({ onLater, onOpenSettings }: { onLater: () => void; justifyContent: 'center', padding: 28, background: 'rgba(15,17,22,0.28)', - backdropFilter: 'blur(2px)', - WebkitBackdropFilter: 'blur(2px)', - animation: 'ol-prompt-fade 0.18s ease-out', + backdropFilter: 'blur(6px) saturate(140%)', + WebkitBackdropFilter: 'blur(6px) saturate(140%)', + animation: 'ol-prompt-fade 0.2s var(--ol-motion-soft)', }} >
void; border: '0.5px solid rgba(0,0,0,.08)', boxShadow: '0 24px 70px -24px rgba(15,17,22,.38), 0 0 0 0.5px rgba(0,0,0,.06)', padding: 20, - animation: 'ol-prompt-pop 0.22s cubic-bezier(.2,.9,.3,1.1)', + animation: 'ol-prompt-pop 0.26s var(--ol-motion-spring)', }} >
@@ -430,7 +433,7 @@ function ProviderSetupPrompt({ onLater, onOpenSettings }: { onLater: () => void; fontSize: 12.5, fontWeight: 500, cursor: 'default', - transition: 'background 0.12s ease-out, border-color 0.12s ease-out', + transition: 'background 0.16s var(--ol-motion-quick), border-color 0.16s var(--ol-motion-quick)', }} > {t('shell.providerPrompt.later')} @@ -448,7 +451,7 @@ function ProviderSetupPrompt({ onLater, onOpenSettings }: { onLater: () => void; fontSize: 12.5, fontWeight: 500, cursor: 'default', - transition: 'background 0.12s ease-out, transform 0.08s ease-out', + transition: 'background 0.16s var(--ol-motion-quick), transform 0.12s var(--ol-motion-quick)', }} > {t('shell.providerPrompt.openSettings')} @@ -472,9 +475,9 @@ function HotkeyModeMigrationPrompt({ onLater, onOpenSettings }: { onLater: () => justifyContent: 'center', padding: 28, background: 'rgba(15,17,22,0.28)', - backdropFilter: 'blur(2px)', - WebkitBackdropFilter: 'blur(2px)', - animation: 'ol-prompt-fade 0.18s ease-out', + backdropFilter: 'blur(6px) saturate(140%)', + WebkitBackdropFilter: 'blur(6px) saturate(140%)', + animation: 'ol-prompt-fade 0.2s var(--ol-motion-soft)', }} >
border: '0.5px solid rgba(0,0,0,.08)', boxShadow: '0 24px 70px -24px rgba(15,17,22,.38), 0 0 0 0.5px rgba(0,0,0,.06)', padding: 20, - animation: 'ol-prompt-pop 0.22s cubic-bezier(.2,.9,.3,1.1)', + animation: 'ol-prompt-pop 0.26s var(--ol-motion-spring)', }} >
@@ -523,7 +526,7 @@ function HotkeyModeMigrationPrompt({ onLater, onOpenSettings }: { onLater: () => fontSize: 12.5, fontWeight: 500, cursor: 'default', - transition: 'background 0.12s ease-out, border-color 0.12s ease-out', + transition: 'background 0.16s var(--ol-motion-quick), border-color 0.16s var(--ol-motion-quick)', }} > {t('shell.hotkeyModePrompt.later')} @@ -541,7 +544,7 @@ function HotkeyModeMigrationPrompt({ onLater, onOpenSettings }: { onLater: () => fontSize: 12.5, fontWeight: 500, cursor: 'default', - transition: 'background 0.12s ease-out, transform 0.08s ease-out', + transition: 'background 0.16s var(--ol-motion-quick), transform 0.12s var(--ol-motion-quick)', }} > {t('shell.hotkeyModePrompt.openSettings')} @@ -579,7 +582,7 @@ function FooterIcon({ name, tip, active, onClick }: FooterIconProps) { color: active ? 'var(--ol-ink)' : hover ? 'var(--ol-ink-2)' : 'var(--ol-ink-4)', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', cursor: 'default', - transition: 'background 0.12s ease-out, color 0.12s ease-out', + transition: 'background 0.16s var(--ol-motion-quick), color 0.16s var(--ol-motion-quick)', }}> @@ -610,11 +613,11 @@ function FooterIconWithPopover({ padding: 12, borderRadius: 12, background: 'rgba(255,255,255,0.96)', - backdropFilter: 'blur(20px) saturate(180%)', - WebkitBackdropFilter: 'blur(20px) saturate(180%)', + backdropFilter: 'blur(var(--ol-glass-blur)) saturate(180%)', + WebkitBackdropFilter: 'blur(var(--ol-glass-blur)) saturate(180%)', border: '0.5px solid rgba(0,0,0,0.08)', boxShadow: '0 18px 50px -22px rgba(15,17,22,0.32), 0 0 0 0.5px rgba(0,0,0,0.05)', - animation: 'ol-popover-pop 0.18s cubic-bezier(0.32, 0.72, 0, 1) both', + animation: 'ol-popover-pop 0.22s var(--ol-motion-spring) both', transformOrigin: 'bottom left', }} > @@ -693,7 +696,7 @@ function FooterAutoUpdateButton() { cursor: 'default', padding: 0, opacity: u.checking || u.busy ? 0.7 : 1, - transition: 'opacity 0.12s ease-out', + transition: 'opacity 0.16s var(--ol-motion-soft)', }} > {u.checking ? t('settings.about.checkingUpdate') : t('settings.about.checkUpdateBtn')} diff --git a/openless-all/app/src/components/Onboarding.tsx b/openless-all/app/src/components/Onboarding.tsx index 1f272707..978c3033 100644 --- a/openless-all/app/src/components/Onboarding.tsx +++ b/openless-all/app/src/components/Onboarding.tsx @@ -247,7 +247,7 @@ function PermissionStep({ index, title, desc, status, actionLabel, onAction, dis color: granted ? 'var(--ol-ink-3)' : '#fff', cursor: disabled ? 'not-allowed' : 'default', opacity: disabled && !granted ? 0.6 : 1, - transition: 'background 0.15s ease-out, color 0.15s ease-out, opacity 0.15s ease-out, transform 0.08s ease-out', + transition: 'background 0.16s var(--ol-motion-quick), color 0.16s var(--ol-motion-quick), opacity 0.18s var(--ol-motion-soft), transform 0.12s var(--ol-motion-quick)', }} > {actionLabel} diff --git a/openless-all/app/src/components/SettingsModal.tsx b/openless-all/app/src/components/SettingsModal.tsx index 20911c5d..dcf04656 100644 --- a/openless-all/app/src/components/SettingsModal.tsx +++ b/openless-all/app/src/components/SettingsModal.tsx @@ -67,12 +67,12 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett style={{ position: 'absolute', inset: 0, background: 'rgba(15,17,22,0.32)', - backdropFilter: 'blur(2px)', - WebkitBackdropFilter: 'blur(2px)', + backdropFilter: 'blur(8px) saturate(140%)', + WebkitBackdropFilter: 'blur(8px) saturate(140%)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 28, zIndex: 50, - animation: 'ol-modal-fade .18s ease-out', + animation: 'ol-modal-fade .2s var(--ol-motion-soft)', }}>
@@ -121,7 +121,7 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett fontFamily: 'inherit', fontSize: 13, fontWeight: active ? 600 : 500, boxShadow: active ? '0 1px 2px rgba(0,0,0,.05), 0 0 0 0.5px rgba(0,0,0,.06)' : 'none', cursor: 'default', textAlign: 'left', - transition: 'background 0.12s ease-out, color 0.12s ease-out', + transition: 'background 0.16s var(--ol-motion-quick), color 0.16s var(--ol-motion-quick)', }}> @@ -146,7 +146,7 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett background: 'transparent', color: 'var(--ol-ink-3)', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', cursor: 'default', - transition: 'background 0.12s ease-out', + transition: 'background 0.16s var(--ol-motion-quick)', }} onMouseEnter={e => (e.currentTarget.style.background = 'rgba(0,0,0,0.05)')} onMouseLeave={e => (e.currentTarget.style.background = 'transparent')} @@ -173,10 +173,13 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett
@@ -234,7 +237,7 @@ function PersonalizeSection() { fontWeight: selected ? 600 : 500, cursor: 'default', boxShadow: selected ? '0 1px 2px rgba(0,0,0,.06), 0 0 0 0.5px rgba(0,0,0,.06)' : 'none', - transition: 'background 0.12s ease-out, color 0.12s ease-out, box-shadow 0.12s ease-out', + transition: 'background 0.16s var(--ol-motion-quick), color 0.16s var(--ol-motion-quick), box-shadow 0.18s var(--ol-motion-soft)', padding: '0 12px', }} > @@ -302,7 +305,7 @@ const btnGhost: CSSProperties = { border: '0.5px solid var(--ol-line-strong)', background: '#fff', color: 'var(--ol-ink-2)', cursor: 'default', fontFamily: 'inherit', - transition: 'background 0.12s ease-out, border-color 0.12s ease-out', + transition: 'background 0.16s var(--ol-motion-quick), border-color 0.16s var(--ol-motion-quick)', }; // 真正可用的语言切换器 —— 用原生 onTargetChange(e.target.value)} + style={selectStyle} + > + + {SUPPORTED_LANGUAGES.map(lang => ( + + ))} + +
+ +
+
+ {enabled ? t('translation.statusEnabled') : t('translation.statusDisabled')} +
+
+ {enabled ? prefs.translationTargetLanguage : t('translation.statusDisabled')} +
+
+ Shift +
+
-
+ + + {/* 2. 工作语言 */} + +
{t('translation.working.title')}
+
{t('translation.working.desc')}
+
{SUPPORTED_LANGUAGES.map(lang => { const checked = prefs.workingLanguages.includes(lang); return ( @@ -86,95 +117,144 @@ export function Translation() {
- {/* 2. 翻译目标语言 */} - -
-
{t('translation.target.title')}
- - {enabled ? t('translation.statusEnabled') : t('translation.statusDisabled')} - -
-
- {t('translation.target.desc')} -
- -
- {/* 3. 使用方法 */} -
{t('translation.howto.title')}
-
    -
  1. {t('translation.howto.step1', { trigger: triggerLabel })}
  2. -
  3. {t('translation.howto.step2')}
  4. -
  5. {t('translation.howto.step3')}
  6. -
  7. {t('translation.howto.step4')}
  8. -
  9. {t('translation.howto.step5')}
  10. -
- -
-
{t('translation.howto.indicatorTitle')}
- {t('translation.howto.indicatorDesc')} -
- -
-
{t('translation.howto.fallbackTitle')}
- {t('translation.howto.fallbackDesc')} +
{t('translation.howto.title')}
+
+ {howtoSteps.map((step, index) => ( +
+ {String(index + 1).padStart(2, '0')} + {step} +
+ ))}
+ +
+ + +
); } + +const settingsHeaderStyle: CSSProperties = { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: 12, + marginBottom: 5, +}; + +const settingsTitleStyle: CSSProperties = { + fontSize: 13, + fontWeight: 650, + color: 'var(--ol-ink)', +}; + +const settingsDescStyle: CSSProperties = { + fontSize: 11.5, + color: 'var(--ol-ink-4)', + marginBottom: 14, + lineHeight: 1.58, +}; + +const selectStyle: CSSProperties = { + width: '100%', + maxWidth: 380, + height: 34, + padding: '0 11px', + fontSize: 13, + border: '0.5px solid var(--ol-line-strong)', + borderRadius: 9, + background: '#fff', + color: 'var(--ol-ink)', + fontFamily: 'inherit', + cursor: 'default', + boxShadow: '0 1px 0 rgba(255,255,255,0.9) inset', +}; + +const stepRowStyle: CSSProperties = { + display: 'grid', + gridTemplateColumns: '34px minmax(0, 1fr)', + gap: 10, + alignItems: 'start', + padding: '9px 10px', + borderRadius: 11, + background: 'rgba(0,0,0,0.025)', + color: 'var(--ol-ink-2)', + fontSize: 12.5, + lineHeight: 1.55, +}; + +const stepNumberStyle: CSSProperties = { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + width: 24, + height: 24, + borderRadius: 999, + background: 'rgba(255,255,255,0.85)', + color: 'var(--ol-ink-4)', + fontFamily: 'var(--ol-font-mono)', + fontSize: 10.5, + fontWeight: 600, + boxShadow: '0 1px 2px rgba(0,0,0,0.04), 0 0 0 0.5px var(--ol-line)', +}; + +function languageChipStyle(checked: boolean): CSSProperties { + return { + padding: '6px 12px', + fontSize: 12.5, + fontWeight: checked ? 650 : 500, + border: checked ? '0.5px solid rgba(37,99,235,0.35)' : '0.5px solid var(--ol-line)', + borderRadius: 999, + background: checked ? 'var(--ol-blue)' : 'rgba(255,255,255,0.72)', + color: checked ? '#fff' : 'var(--ol-ink-2)', + cursor: 'default', + fontFamily: 'inherit', + boxShadow: checked ? '0 8px 18px -12px rgba(37,99,235,0.55)' : '0 1px 0 rgba(255,255,255,0.8) inset', + transition: 'background 0.16s var(--ol-motion-quick), color 0.16s var(--ol-motion-quick), border-color 0.16s var(--ol-motion-quick), box-shadow 0.18s var(--ol-motion-soft)', + }; +} + +function translationStatusStyle(enabled: boolean): CSSProperties { + return { + minHeight: 108, + borderRadius: 14, + padding: 14, + background: enabled + ? 'linear-gradient(135deg, rgba(37,99,235,0.12), rgba(37,99,235,0.04))' + : 'linear-gradient(135deg, rgba(0,0,0,0.045), rgba(255,255,255,0.55))', + border: enabled ? '0.5px solid rgba(37,99,235,0.16)' : '0.5px solid var(--ol-line)', + boxShadow: '0 1px 0 rgba(255,255,255,0.85) inset', + }; +} + +function InfoTile({ tone, title, body }: { tone: 'blue' | 'neutral'; title: string; body: string }) { + const blue = tone === 'blue'; + return ( + +
+ +
{title}
+
+
{body}
+
+ ); +} From f06f305774a951541594376a12309da767607da7 Mon Sep 17 00:00:00 2001 From: baiqing Date: Sun, 3 May 2026 13:29:53 +0800 Subject: [PATCH 5/7] fix(macos): align with 1.2.11 window/capsule UI + tab cross-fade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 红绿灯 - trafficLightPosition x=14 y=20(Apple Notes / Safari 视觉), MAC_TITLEBAR_HEIGHT 30→28,paddingTop 30→28,让红绿灯在 titlebar 内对称居中。 胶囊几何回退到 1.2.11 - macOS / Linux 上 capsule_window_bounds 固定 220×110,capsule_visual_height 改回 96,emit_capsule 不再随 translation_active 改 size + 位置。修两个 回归:(a) 录音胶囊 pill 左右被裁;(b) 按 Shift 后窗口高度从 42→110 导致整体下移。 - 单元测试断言同步更新。 emit_capsule macOS show 路径回退到 1.2.11 - show_capsule_window_no_activate macOS 实现删除(合进 cfg(not(windows)) stub 返回 false),fallback 走 window.show() + restore_main_window_key_if_active。 orderFrontRegardless 路径在 webview 未完整初始化时偶发不可见。 Translation 设置页 + i18n 回退到 1.2.12 - 撤回 d7d274f 整页重排,回到 1.2.11/1.2.12 的简洁布局。 tab 切换 cross-fade with blur - FloatingShell.tsx 加 displayTab + tabPhase;点新 tab 触发旧页 ol-page-fadeout(opacity 1→0、blur 0→8px、180ms),结束后挂载 新页走现有 ol-page-slide。修 Windows 徽章 bottom 公式:保留 hostMetrics 路径(pill 不在 win host 中线,calc(50%) 不对)。 Capsule.tsx 动画化简 - 徽章/capsule-in 撤回 motion-soft / motion-spring 别名 + filter blur, 回到 1.2.11 的 ease-out / cubic-bezier。 --- openless-all/app/src-tauri/src/coordinator.rs | 43 +-- openless-all/app/src-tauri/src/lib.rs | 17 +- openless-all/app/src-tauri/tauri.conf.json | 1 + openless-all/app/src/components/Capsule.tsx | 23 +- .../app/src/components/FloatingShell.tsx | 34 ++- .../app/src/components/WindowChrome.tsx | 2 +- openless-all/app/src/i18n/en.ts | 6 +- openless-all/app/src/i18n/zh-CN.ts | 10 +- openless-all/app/src/lib/capsuleLayout.ts | 5 +- openless-all/app/src/pages/Translation.tsx | 286 +++++++----------- 10 files changed, 173 insertions(+), 254 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 36f035ba..6e6bb4f2 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -8,7 +8,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc; use std::sync::Arc; -use std::time::{Duration, Instant}; +use std::time::Instant; use chrono::Utc; use parking_lot::Mutex; @@ -2505,7 +2505,7 @@ fn restore_focus_target_if_possible(target: Option) -> bool { let _ = unsafe { ShowWindow(hwnd, SW_RESTORE) }; } let _ = unsafe { SetForegroundWindow(hwnd) }; - std::thread::sleep(Duration::from_millis(60)); + std::thread::sleep(std::time::Duration::from_millis(60)); let foreground = unsafe { GetForegroundWindow() }; if foreground != hwnd { @@ -2520,39 +2520,6 @@ fn restore_focus_target_if_possible(_target: Option) -> bool { true } -#[cfg(target_os = "macos")] -fn show_capsule_window_no_activate( - app: &AppHandle, - window: &tauri::WebviewWindow, -) -> bool { - let window = window.clone(); - let (tx, rx) = mpsc::channel(); - let _ = app.run_on_main_thread(move || { - use objc2::msg_send; - use objc2::runtime::AnyObject; - - let ok = match window.ns_window() { - Ok(handle) => { - let ns = handle as *mut AnyObject; - if ns.is_null() { - false - } else { - unsafe { - let _: () = msg_send![ns, orderFrontRegardless]; - } - true - } - } - Err(e) => { - log::warn!("[capsule] ns_window unavailable for no-activate show: {e}"); - false - } - }; - let _ = tx.send(ok); - }); - rx.recv_timeout(Duration::from_millis(800)).unwrap_or(false) -} - #[cfg(target_os = "windows")] fn show_capsule_window_no_activate( _app: &AppHandle, @@ -2588,7 +2555,11 @@ fn show_capsule_window_no_activate( true } -#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] +// macOS / Linux 上不走 no-activate 路径:胶囊由 emit_capsule 的 fallback +// `window.show()` 直接显示,再用 restore_main_window_key_if_active 把焦点还给 +// 主窗口。这是 1.2.11 的实现 — 单独走 orderFrontRegardless 会让胶囊在 webview +// 未完整初始化时偶发不可见。 +#[cfg(not(target_os = "windows"))] fn show_capsule_window_no_activate( _app: &AppHandle, _window: &tauri::WebviewWindow, diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index fa478965..e94fded7 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -758,9 +758,12 @@ fn capsule_window_bounds(translation_active: bool) -> CapsuleWindowBounds { #[cfg(not(target_os = "windows"))] { + // macOS / Linux:固定 220×110,与 1.2.11 行为一致 — 录音 / 翻译徽章 + // 共用同一个窗口尺寸,避免按 Shift 后窗口高度变化导致胶囊整体下移。 + let _ = translation_active; CapsuleWindowBounds { - width: 176.0, - height: if translation_active { 110.0 } else { 42.0 }, + width: 220.0, + height: 110.0, bottom_inset: 0.0, } } @@ -774,7 +777,7 @@ fn capsule_visual_height(_translation_active: bool) -> f64 { #[cfg(not(target_os = "windows"))] { - 42.0 + 96.0 } } @@ -793,7 +796,7 @@ mod tests { assert_eq!((bounds.width, bounds.height, bounds.bottom_inset), (220.0, 84.0, 12.0)); #[cfg(not(target_os = "windows"))] - assert_eq!((bounds.width, bounds.height, bounds.bottom_inset), (176.0, 42.0, 0.0)); + assert_eq!((bounds.width, bounds.height, bounds.bottom_inset), (220.0, 110.0, 0.0)); } #[test] @@ -803,7 +806,7 @@ mod tests { assert_eq!((bounds.width, bounds.height, bounds.bottom_inset), (220.0, 118.0, 12.0)); #[cfg(not(target_os = "windows"))] - assert_eq!((bounds.width, bounds.height, bounds.bottom_inset), (176.0, 110.0, 0.0)); + assert_eq!((bounds.width, bounds.height, bounds.bottom_inset), (220.0, 110.0, 0.0)); } #[test] @@ -812,7 +815,7 @@ mod tests { assert_eq!(capsule_visual_height(true), 52.0); #[cfg(not(target_os = "windows"))] - assert_eq!(capsule_visual_height(true), 42.0); + assert_eq!(capsule_visual_height(true), 96.0); } #[test] @@ -821,6 +824,6 @@ mod tests { assert_eq!(capsule_height_for_qa(), 52.0); #[cfg(not(target_os = "windows"))] - assert_eq!(capsule_height_for_qa(), 42.0); + assert_eq!(capsule_height_for_qa(), 96.0); } } diff --git a/openless-all/app/src-tauri/tauri.conf.json b/openless-all/app/src-tauri/tauri.conf.json index ac9c091a..fead7540 100644 --- a/openless-all/app/src-tauri/tauri.conf.json +++ b/openless-all/app/src-tauri/tauri.conf.json @@ -26,6 +26,7 @@ "shadow": true, "hiddenTitle": true, "titleBarStyle": "Overlay", + "trafficLightPosition": { "x": 14, "y": 20 }, "visible": false, "acceptFirstMouse": true }, diff --git a/openless-all/app/src/components/Capsule.tsx b/openless-all/app/src/components/Capsule.tsx index ff9cac52..c81a7d39 100644 --- a/openless-all/app/src/components/Capsule.tsx +++ b/openless-all/app/src/components/Capsule.tsx @@ -261,12 +261,12 @@ export function Capsule() { const { t } = useTranslation(); const os = detectOS(); const metrics = getCapsulePillMetrics(os); - const [translation, setTranslation] = useState(false); - const hostMetrics = getCapsuleHostMetrics(os, translation); + const hostMetrics = getCapsuleHostMetrics(os, false); const [state, setState] = useState(isTauri ? 'idle' : 'recording'); const [level, setLevel] = useState(isTauri ? 0 : 0.6); const [insertedChars, setInsertedChars] = useState(0); const [message, setMessage] = useState(); + const [translation, setTranslation] = useState(false); useEffect(() => { if (!isTauri) return; @@ -318,7 +318,7 @@ export function Capsule() { : 0, paddingBottom: os === 'win' ? hostMetrics.bottomInset : 0, background: 'transparent', - animation: os === 'win' ? 'none' : 'capsule-in .28s var(--ol-motion-spring)', + animation: os === 'win' ? 'none' : 'capsule-in .22s cubic-bezier(.2,.9,.3,1.1)', }} > {/* "正在翻译" 徽章 — 嵌套两层: @@ -329,9 +329,11 @@ export function Capsule() { style={{ position: 'absolute', left: '50%', - // bottom = 50%(pill 中线)+ pill 半高 21px(capsuleLayout mac=42)+ 8px 间隔。 - // 只有翻译徽章可见时才需要额外高度;普通录音/转写状态由后端缩到 pill 本体,避免透明死区。 - bottom: `${hostMetrics.bottomInset + metrics.height + hostMetrics.badgeGap}px`, + // macOS / Linux:胶囊窗口 220×110、pill 居中,badge 锚到 pill 中线上方 21+8。 + // Windows:pill 不居中(带 12pt 阴影 inset),用 hostMetrics 量到底部 inset + pill 高 + gap。 + bottom: os === 'win' + ? `${hostMetrics.bottomInset + metrics.height + hostMetrics.badgeGap}px` + : 'calc(50% + 21px + 8px)', transform: 'translateX(-50%)', pointerEvents: 'none', }} @@ -357,9 +359,8 @@ export function Capsule() { opacity: translation ? 1 : 0, transform: translation ? 'translateY(0) scale(1)' : 'translateY(40px) scale(.88)', transformOrigin: 'center bottom', - transition: 'opacity .24s var(--ol-motion-soft), transform .34s var(--ol-motion-spring), filter .24s var(--ol-motion-soft)', - filter: translation ? 'blur(0)' : 'blur(4px)', - willChange: 'opacity, transform, filter', + transition: 'opacity .24s ease-out, transform .34s cubic-bezier(.2,.9,.3,1.1)', + willChange: 'opacity, transform', }} > @@ -377,8 +378,8 @@ export function Capsule() { />