From cad8536884dfcd125acf32a1424860fc2929a6fc Mon Sep 17 00:00:00 2001 From: Cooper-X-Oak Date: Sat, 2 May 2026 16:08:52 +0800 Subject: [PATCH 1/2] fix(windows): split capsule host and restore full viewport --- ...ows-capsule-platform-split-design.zh-CN.md | 74 ++++ ...ndows-capsule-platform-split-plan.zh-CN.md | 22 + .../app/scripts/windows-ui-config.test.mjs | 6 +- openless-all/app/src-tauri/src/lib.rs | 169 ++++++-- openless-all/app/src-tauri/tauri.conf.json | 2 +- openless-all/app/src/App.tsx | 47 +-- openless-all/app/src/components/Capsule.tsx | 389 +----------------- .../app/src/components/SharedCapsule.tsx | 359 ++++++++++++++++ .../app/src/components/WindowsCapsule.tsx | 326 +++++++++++++++ .../app/src/components/useCapsuleState.ts | 62 +++ openless-all/app/src/lib/capsuleLayout.ts | 2 +- 11 files changed, 986 insertions(+), 472 deletions(-) create mode 100644 docs/2026-05-02-windows-capsule-platform-split-design.zh-CN.md create mode 100644 docs/2026-05-02-windows-capsule-platform-split-plan.zh-CN.md create mode 100644 openless-all/app/src/components/SharedCapsule.tsx create mode 100644 openless-all/app/src/components/WindowsCapsule.tsx create mode 100644 openless-all/app/src/components/useCapsuleState.ts diff --git a/docs/2026-05-02-windows-capsule-platform-split-design.zh-CN.md b/docs/2026-05-02-windows-capsule-platform-split-design.zh-CN.md new file mode 100644 index 00000000..406326e7 --- /dev/null +++ b/docs/2026-05-02-windows-capsule-platform-split-design.zh-CN.md @@ -0,0 +1,74 @@ +# Windows Capsule 平台分离设计 + +日期:2026-05-02 + +## 目标 + +同一个产品目标: + +- 录音胶囊在出现、处理中、结束时都稳定可读 +- 用户只看到胶囊本体,不看到宿主矩形 +- 状态切换不裁切、不变形、不撞边 + +不同 OS 使用不同承载手段: + +- macOS / 非 Windows:保留现有通用胶囊实现 +- Windows:单独使用 Windows 原生承载思路和独立组件 + +## 设计决策 + +### 决策 1:`Capsule.tsx` 只做平台路由 + +- `win -> WindowsCapsule` +- 其他平台 -> `SharedCapsule` + +`Capsule.tsx` 不再负责布局、状态订阅或视觉分支。 + +### 决策 2:状态数据共享,视觉承载分离 + +共享层只保留: + +- `CapsulePayload` +- `CapsuleState` +- Tauri 事件订阅 +- `cancel_dictation` / `stop_dictation` + +平台层各自负责: + +- 宿主尺寸 +- 可视 pill 尺寸 +- processing / error / done 布局 +- 状态切换动画和裁切策略 + +### 决策 3:Windows 只显示 pill,不显示宿主矩形 + +Windows 宿主窗口继续存在,但视觉上应完全透明。 + +Windows 组件只允许渲染: + +- 左右动作按钮 +- 中间 processing / error / done 内容 +- 可选 translation badge + +不允许继续暴露一层白色矩形宿主框。 + +## 文件边界 + +- `openless-all/app/src/components/Capsule.tsx` + - 平台路由入口 +- `openless-all/app/src/components/WindowsCapsule.tsx` + - Windows 独立组件 +- `openless-all/app/src/components/SharedCapsule.tsx` + - 非 Windows 胶囊实现 +- `openless-all/app/src/components/useCapsuleState.ts` + - 通用状态数据 hook +- `openless-all/app/src/lib/capsuleLayout.ts` + - 平台尺寸数据 + +## 本轮验收标准 + +- `Capsule.tsx` 只剩平台路由 +- Windows 独立使用 `WindowsCapsule.tsx` +- Windows `thinking / error / done` 不再复用通用外观层 +- `npm run build` 通过 +- `cargo check --manifest-path openless-all/app/src-tauri/Cargo.toml` 通过 diff --git a/docs/2026-05-02-windows-capsule-platform-split-plan.zh-CN.md b/docs/2026-05-02-windows-capsule-platform-split-plan.zh-CN.md new file mode 100644 index 00000000..262fbfc4 --- /dev/null +++ b/docs/2026-05-02-windows-capsule-platform-split-plan.zh-CN.md @@ -0,0 +1,22 @@ +# Windows Capsule 平台分离实施清单 + +日期:2026-05-02 + +## 步骤 + +1. 新增通用状态 hook + - 抽出 capsule 事件订阅和动作命令 +2. 拆出非 Windows 组件 + - 把现有非 Windows 渲染迁到 `SharedCapsule.tsx` +3. 接入 Windows 独立组件 + - `Capsule.tsx` 只做 `win -> WindowsCapsule` +4. 验证 + - `npm run build` + - `cargo check --manifest-path openless-all/app/src-tauri/Cargo.toml` + - Windows 启动并前置主窗口 + +## 本轮不做 + +- helper-window lifecycle 重构 +- QA panel 交互修复 +- 业务状态流改写 diff --git a/openless-all/app/scripts/windows-ui-config.test.mjs b/openless-all/app/scripts/windows-ui-config.test.mjs index 633a7583..1a60c825 100644 --- a/openless-all/app/scripts/windows-ui-config.test.mjs +++ b/openless-all/app/scripts/windows-ui-config.test.mjs @@ -33,9 +33,13 @@ 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 preserve platform-native titlebars by default'); assertEqual(mainWindow.visible, false, 'windows main window should stay hidden until the intended first show point'); +if (!/fn configure_main_window_for_platform\(window: &tauri::WebviewWindow\)[\s\S]*?#\[cfg\(target_os = "windows"\)\][\s\S]*?window\.set_decorations\(false\)/.test(libRs)) { + throw new Error('windows runtime should own the frameless shell switch instead of the shared config layer'); +} + 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/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 03c69213..58936878 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -23,9 +23,9 @@ mod recorder; mod selection; mod types; +use std::sync::atomic::{AtomicBool, Ordering}; #[cfg(target_os = "macos")] use std::sync::mpsc; -use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; #[cfg(target_os = "macos")] use std::time::Duration; @@ -61,6 +61,8 @@ pub fn run() { // Capsule 启动时定位到屏幕底部居中并隐藏;coordinator 按需显示。 // 与 Swift `CapsuleWindowController.repositionToBottomCenter` 同语义。 if let Some(capsule) = app.get_webview_window("capsule") { + #[cfg(target_os = "windows")] + configure_capsule_window_for_platform(&capsule, false); if let Err(e) = position_capsule_bottom_center(&capsule, false) { log::warn!("[capsule] position failed: {e}"); } @@ -88,33 +90,8 @@ pub fn run() { // decorations 留给运行时分平台决定:macOS 默认 true 用系统红黄绿; // Windows 这里关掉 native chrome 让 React 端 WinTitleBar 接管。 if let Some(main) = app.get_webview_window("main") { - #[cfg(target_os = "macos")] - { - use window_vibrancy::{ - apply_vibrancy, NSVisualEffectMaterial, NSVisualEffectState, - }; - if let Err(e) = apply_vibrancy( - &main, - NSVisualEffectMaterial::HudWindow, - Some(NSVisualEffectState::Active), - Some(20.0), - ) { - log::warn!("[main] vibrancy failed: {e}"); - } - } + configure_main_window_for_platform(&main); #[cfg(target_os = "windows")] - { - use window_vibrancy::apply_mica; - // The window starts hidden so Windows native chrome can be disabled before - // the first show; doing this after the native frame is visible is unreliable. - if let Err(e) = main.set_decorations(false) { - log::warn!("[main] disable native decorations failed: {e}"); - } - if let Err(e) = apply_mica(&main, None) { - log::warn!("[main] mica failed: {e}"); - } - apply_windows_rounded_frame(&main); - } if let Err(e) = main.show() { log::warn!("[main] initial show failed: {e}"); } @@ -167,9 +144,6 @@ pub fn run() { coordinator.start_hotkey_listener(); // 同步启动 QA hotkey listener。和 dictation hotkey 平行,互不抢状态。 coordinator.start_qa_hotkey_listener(); - if std::env::var("OPENLESS_SHOW_MAIN_ON_START").ok().as_deref() == Some("1") { - show_main_window(app.handle()); - } Ok(()) }) @@ -225,7 +199,11 @@ pub fn run() { hide_main_window(app); } #[cfg(target_os = "windows")] - if matches!(event, tauri::WindowEvent::Resized(_) | tauri::WindowEvent::ScaleFactorChanged { .. }) { + if matches!( + event, + tauri::WindowEvent::Resized(_) + | tauri::WindowEvent::ScaleFactorChanged { .. } + ) { if let Some(main) = app.get_webview_window("main") { apply_windows_rounded_frame(&main); } @@ -439,6 +417,103 @@ fn hide_main_window(app: &AppHandle) { activate_menu_bar_mode(app); } +fn configure_main_window_for_platform(window: &tauri::WebviewWindow) { + #[cfg(target_os = "macos")] + { + use window_vibrancy::{apply_vibrancy, NSVisualEffectMaterial, NSVisualEffectState}; + if let Err(e) = apply_vibrancy( + window, + NSVisualEffectMaterial::HudWindow, + Some(NSVisualEffectState::Active), + Some(20.0), + ) { + log::warn!("[main] vibrancy failed: {e}"); + } + } + + #[cfg(target_os = "windows")] + use window_vibrancy::apply_mica; + if let Err(e) = window.set_decorations(false) { + log::warn!("[main] disable native decorations failed: {e}"); + } + if let Err(e) = apply_mica(window, None) { + log::warn!("[main] mica failed: {e}"); + } + apply_windows_rounded_frame(window); +} + +#[cfg(target_os = "windows")] +fn configure_capsule_window_for_platform( + window: &tauri::WebviewWindow, + _translation_active: bool, +) { + use raw_window_handle::{HasWindowHandle, RawWindowHandle}; + use windows::Win32::Graphics::Dwm::{DwmSetWindowAttribute, DWMWA_BORDER_COLOR}; + use windows::Win32::UI::WindowsAndMessaging::WS_EX_TOOLWINDOW; + use windows::Win32::{ + Foundation::HWND, + UI::WindowsAndMessaging::{ + GetWindowLongW, SetWindowLongW, SetWindowPos, GWL_EXSTYLE, GWL_STYLE, SWP_FRAMECHANGED, + SWP_NOMOVE, SWP_NOSIZE, SWP_NOZORDER, WS_BORDER, WS_CAPTION, WS_DLGFRAME, WS_POPUP, + WS_THICKFRAME, + }, + }; + + let handle = match window.window_handle().map(|h| h.as_raw()) { + Ok(RawWindowHandle::Win32(handle)) => handle, + Ok(other) => { + log::warn!("[capsule] unexpected raw window handle: {other:?}"); + return; + } + Err(e) => { + log::warn!("[capsule] read raw window handle failed: {e}"); + return; + } + }; + let hwnd = HWND(handle.hwnd.get() as *mut core::ffi::c_void); + + unsafe { + let style = GetWindowLongW(hwnd, GWL_STYLE); + let desired_style = (style + & !(WS_CAPTION.0 as i32 + | WS_THICKFRAME.0 as i32 + | WS_BORDER.0 as i32 + | WS_DLGFRAME.0 as i32)) + | WS_POPUP.0 as i32; + if style != desired_style { + SetWindowLongW(hwnd, GWL_STYLE, desired_style); + if let Err(e) = SetWindowPos( + hwnd, + HWND::default(), + 0, + 0, + 0, + 0, + SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED, + ) { + log::warn!("[capsule] refresh native frame after style update failed: {e}"); + } + } + + let ex_style = GetWindowLongW(hwnd, GWL_EXSTYLE); + let desired_ex_style = ex_style | WS_EX_TOOLWINDOW.0 as i32; + if ex_style != desired_ex_style { + SetWindowLongW(hwnd, GWL_EXSTYLE, desired_ex_style); + } + + // Remove the default DWM light border so only the pill's own stroke remains. + let border_color_none: u32 = 0xFFFFFFFE; + if let Err(e) = DwmSetWindowAttribute( + hwnd, + DWMWA_BORDER_COLOR, + &border_color_none as *const _ as *const core::ffi::c_void, + std::mem::size_of_val(&border_color_none) as u32, + ) { + log::warn!("[capsule] remove DWM border color failed: {e}"); + } + } +} + #[cfg(target_os = "macos")] fn activate_window_mode(app: &AppHandle) { let _ = app.set_activation_policy(tauri::ActivationPolicy::Regular); @@ -572,9 +647,7 @@ fn position_qa_window(window: &tauri::WebviewWindow) -> ta /// fallback 仍能从原 app 拿到选区)。 pub(crate) fn show_qa_window(app: &AppHandle, content_kind: &str) { let Some(window) = app.get_webview_window("qa") else { - log::info!( - "[qa] show 跳过:qa 窗口不存在 (content_kind={content_kind})" - ); + log::info!("[qa] show 跳过:qa 窗口不存在 (content_kind={content_kind})"); return; }; // 仅首次 show 时居中;之后保留用户拖动后的位置。 @@ -719,6 +792,8 @@ pub(crate) fn position_capsule_bottom_center( }; let bounds = capsule_window_bounds(translation_active); window.set_size(LogicalSize::new(bounds.width, bounds.height))?; + #[cfg(target_os = "windows")] + configure_capsule_window_for_platform(window, translation_active); let scale = monitor.scale_factor(); let size = monitor.size(); @@ -742,9 +817,9 @@ fn capsule_window_bounds(translation_active: bool) -> CapsuleWindowBounds { #[cfg(target_os = "windows")] { CapsuleWindowBounds { - width: 220.0, - height: if translation_active { 118.0 } else { 84.0 }, - bottom_inset: 12.0, + width: 234.0, + height: if translation_active { 102.0 } else { 62.0 }, + bottom_inset: 0.0, } } @@ -782,20 +857,32 @@ mod tests { fn capsule_window_bounds_leave_room_for_windows_shadow() { let bounds = capsule_window_bounds(false); #[cfg(target_os = "windows")] - assert_eq!((bounds.width, bounds.height, bounds.bottom_inset), (220.0, 84.0, 12.0)); + assert_eq!( + (bounds.width, bounds.height, bounds.bottom_inset), + (234.0, 62.0, 0.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), + (176.0, 42.0, 0.0) + ); } #[test] fn capsule_window_bounds_expand_for_translation_badge() { let bounds = capsule_window_bounds(true); #[cfg(target_os = "windows")] - assert_eq!((bounds.width, bounds.height, bounds.bottom_inset), (220.0, 118.0, 12.0)); + assert_eq!( + (bounds.width, bounds.height, bounds.bottom_inset), + (234.0, 102.0, 0.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), + (176.0, 110.0, 0.0) + ); } #[test] 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, diff --git a/openless-all/app/src/App.tsx b/openless-all/app/src/App.tsx index b1baac39..39318cfd 100644 --- a/openless-all/app/src/App.tsx +++ b/openless-all/app/src/App.tsx @@ -6,7 +6,6 @@ import { detectOS } from './components/WindowChrome'; import { checkAccessibilityPermission, checkMicrophonePermission, - getHotkeyStatus, handleWindowHotkeyEvent, isTauri, } from './lib/ipc'; @@ -29,12 +28,11 @@ export function App({ isCapsule, isQa }: AppProps) { } const os = detectOS(); - // Windows 启动不应被权限探测阻塞首屏。 - const [gate, setGate] = useState(isTauri ? 'checking' : 'ready'); + const [gate, setGate] = useState(isTauri && os !== 'win' ? 'checking' : 'ready'); useEffect(() => { if (!isTauri) return; - if (os === 'win' && gate === 'checking') return; + if (os === 'win') return; let cancelled = false; requestAnimationFrame(() => { if (cancelled) return; @@ -50,48 +48,11 @@ export function App({ isCapsule, isQa }: AppProps) { return () => { cancelled = true; }; - }, [gate, os]); + }, [os]); useEffect(() => { - if (!isTauri) return; + if (!isTauri || os === 'win') return; let cancelled = false; - - if (os === 'win') { - // 超时保护:50 次 × 200ms = 10s。hotkey hook 永远 starting(被反作弊 / EDR - // / UAC 拦)时不让 UI 死锁灰屏,过 10s 强 setGate('ready') 让用户进 - // Permissions 页看 hotkey_status.lastError 处理。详见 issue #163。 - const POLL_INTERVAL_MS = 200; - const POLL_MAX_ATTEMPTS = 50; - const pollHotkeyStatus = async () => { - let attempts = 0; - while (!cancelled && attempts < POLL_MAX_ATTEMPTS) { - attempts += 1; - const status = await getHotkeyStatus(); - if (cancelled) return; - if (status.state !== 'starting') { - setGate('ready'); - return; - } - await new Promise(resolve => window.setTimeout(resolve, POLL_INTERVAL_MS)); - } - if (!cancelled) { - console.warn( - `[startup] hotkey gate timed out after ${POLL_MAX_ATTEMPTS * POLL_INTERVAL_MS}ms; forcing ready so user can reach Permissions page` - ); - setGate('ready'); - } - }; - void pollHotkeyStatus().catch(error => { - console.warn('[startup] hotkey status polling failed', error); - if (!cancelled) { - setGate('ready'); - } - }); - return () => { - cancelled = true; - }; - } - (async () => { const [a, m] = await Promise.all([ checkAccessibilityPermission(), diff --git a/openless-all/app/src/components/Capsule.tsx b/openless-all/app/src/components/Capsule.tsx index 03120f28..0953f9b8 100644 --- a/openless-all/app/src/components/Capsule.tsx +++ b/openless-all/app/src/components/Capsule.tsx @@ -1,389 +1,8 @@ -import { useEffect, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { detectOS, type OS } from './WindowChrome'; -import { - getCapsuleHostMetrics, - getCapsuleMessageLayout, - getCapsulePillMetrics, -} from '../lib/capsuleLayout'; -import { invokeOrMock, isTauri } from '../lib/ipc'; -import type { CapsulePayload, CapsuleState } from '../lib/types'; - -interface AudioBarsProps { - level: number; -} - -function AudioBars({ level }: AudioBarsProps) { - const envelope = [0.55, 0.85, 1.0, 0.85, 0.55]; - const base = 4; - const max = 18; - const voice = Math.min(1, Math.max(0, level)); - return ( -
- {envelope.map((env, i) => ( - - ))} -
- ); -} - -function ProcessingDots() { - return ( -
- {[0, 1, 2].map(i => ( - - ))} -
- ); -} - -interface CenterTextProps { - os: OS; - kind: 'default' | 'processing' | 'error'; - text: string; - color?: string; -} - -function CenterText({ os, kind, text, color = 'var(--ol-ink-3)' }: CenterTextProps) { - const metrics = getCapsulePillMetrics(os); - const layout = getCapsuleMessageLayout(os, kind); - return ( - - {text} - - ); -} - -interface CircleButtonProps { - variant: 'cancel' | 'confirm'; - enabled: boolean; - onClick: () => void; -} - -function CircleButton({ variant, enabled, onClick }: CircleButtonProps) { - const { t } = useTranslation(); - const isCancel = variant === 'cancel'; - return ( - - ); -} - -interface PillProps { - os: OS; - state: CapsuleState; - level: number; - insertedChars: number; - message?: string; - onCancel: () => void; - onConfirm: () => void; -} - -function Pill({ os, state, level, insertedChars, message, onCancel, onConfirm }: PillProps) { - const { t } = useTranslation(); - const metrics = getCapsulePillMetrics(os); - const processingLayout = getCapsuleMessageLayout(os, 'processing'); - const enabled = state === 'recording'; - - let center: JSX.Element; - switch (state) { - case 'recording': - center = ; - break; - case 'transcribing': - case 'polishing': - center = ( -
- - - {t('capsule.thinking')} - -
- ); - break; - case 'done': - center = ; - break; - case 'cancelled': - center = ; - break; - case 'error': - center = ; - break; - default: - center = ; - } - - const ambient = state === 'recording' ? Math.min(1, Math.max(0, level)) : 0; - const scale = os === 'win' ? 1 : 1 + ambient * 0.018; - const shadowAlpha = 0.20 + ambient * 0.10; - const dropShadow = os === 'win' - ? `drop-shadow(0 12px 24px rgba(0, 0, 0, ${(0.15 + ambient * 0.06).toFixed(3)}))` - : 'none'; - - return ( -
- -
- {center} -
- -
- ); -} +import { detectOS } from './WindowChrome'; +import { SharedCapsule } from './SharedCapsule'; +import { WindowsCapsule } from './WindowsCapsule'; export function Capsule() { - const { t } = useTranslation(); const os = detectOS(); - const metrics = getCapsulePillMetrics(os); - const [translation, setTranslation] = useState(false); - const hostMetrics = getCapsuleHostMetrics(os, translation); - const [state, setState] = useState(isTauri ? 'idle' : 'recording'); - const [level, setLevel] = useState(isTauri ? 0 : 0.6); - const [insertedChars, setInsertedChars] = useState(0); - const [message, setMessage] = useState(); - - useEffect(() => { - if (!isTauri) return; - let unlisten: (() => void) | undefined; - let cancelled = false; - (async () => { - const { listen } = await import('@tauri-apps/api/event'); - const handle = await listen('capsule:state', event => { - const p = event.payload; - setState(p.state); - setLevel(p.level ?? 0); - setMessage(p.message ?? undefined); - if (p.insertedChars != null) setInsertedChars(p.insertedChars); - setTranslation(p.translation === true); - }); - if (cancelled) handle(); - else unlisten = handle; - })(); - return () => { - cancelled = true; - if (unlisten) unlisten(); - }; - }, []); - - - const onCancel = () => { - void invokeOrMock('cancel_dictation', undefined, () => undefined); - }; - - const onConfirm = () => { - void invokeOrMock('stop_dictation', undefined, () => undefined); - }; - - if (state === 'idle') { - return
; - } - - return ( -
- {/* "正在翻译" 徽章 — 嵌套两层: - 外层只负责"绝对定位 + 水平居中(translateX(-50%))",不参与动画; - 内层只负责"垂直位移 + 渐变透明度"——这样不会跟 translateX(-50%) 冲突, - 也不存在 keyframe 与 inline transform 互相覆盖导致的视觉跳变。 */} -
-
- - {t('capsule.translating')} -
-
- - -
- ); + return os === 'win' ? : ; } diff --git a/openless-all/app/src/components/SharedCapsule.tsx b/openless-all/app/src/components/SharedCapsule.tsx new file mode 100644 index 00000000..d9ed2908 --- /dev/null +++ b/openless-all/app/src/components/SharedCapsule.tsx @@ -0,0 +1,359 @@ +import { useTranslation } from 'react-i18next'; +import { detectOS, type OS } from './WindowChrome'; +import { + getCapsuleHostMetrics, + getCapsuleMessageLayout, + getCapsulePillMetrics, +} from '../lib/capsuleLayout'; +import type { CapsuleState } from '../lib/types'; +import { useCapsuleState } from './useCapsuleState'; + +function AudioBars({ level }: { level: number }) { + const envelope = [0.55, 0.85, 1.0, 0.85, 0.55]; + const base = 4; + const max = 18; + const voice = Math.min(1, Math.max(0, level)); + return ( +
+ {envelope.map((env, i) => ( + + ))} +
+ ); +} + +function ProcessingDots() { + return ( +
+ {[0, 1, 2].map(i => ( + + ))} +
+ ); +} + +function CenterText({ + os, + kind, + text, + color = 'var(--ol-ink-3)', +}: { + os: OS; + kind: 'default' | 'processing' | 'error'; + text: string; + color?: string; +}) { + const metrics = getCapsulePillMetrics(os); + const layout = getCapsuleMessageLayout(os, kind); + return ( + + {text} + + ); +} + +function CircleButton({ + variant, + enabled, + onClick, +}: { + variant: 'cancel' | 'confirm'; + enabled: boolean; + onClick: () => void; +}) { + const { t } = useTranslation(); + const isCancel = variant === 'cancel'; + return ( + + ); +} + +function SharedPill({ + os, + state, + level, + insertedChars, + message, + onCancel, + onConfirm, +}: { + os: OS; + state: CapsuleState; + level: number; + insertedChars: number; + message?: string; + onCancel: () => void; + onConfirm: () => void; +}) { + const { t } = useTranslation(); + const metrics = getCapsulePillMetrics(os); + const processingLayout = getCapsuleMessageLayout(os, 'processing'); + const enabled = state === 'recording'; + + let center: JSX.Element; + switch (state) { + case 'recording': + center = ; + break; + case 'transcribing': + case 'polishing': + center = ( +
+ + + {t('capsule.thinking')} + +
+ ); + break; + case 'done': + center = ; + break; + case 'cancelled': + center = ; + break; + case 'error': + center = ; + break; + default: + center = ; + } + + const ambient = state === 'recording' ? Math.min(1, Math.max(0, level)) : 0; + const scale = 1 + ambient * 0.018; + const shadowAlpha = 0.20 + ambient * 0.10; + + return ( +
+ +
+ {center} +
+ +
+ ); +} + +export function SharedCapsule() { + const os = detectOS(); + const { t } = useTranslation(); + const { + state, + level, + insertedChars, + message, + translation, + onCancel, + onConfirm, + } = useCapsuleState(); + + if (state === 'idle') { + return
; + } + + const metrics = getCapsulePillMetrics(os); + const hostMetrics = getCapsuleHostMetrics(os, translation); + + return ( +
+
+
+ + {t('capsule.translating')} +
+
+ + +
+ ); +} diff --git a/openless-all/app/src/components/WindowsCapsule.tsx b/openless-all/app/src/components/WindowsCapsule.tsx new file mode 100644 index 00000000..3c8653de --- /dev/null +++ b/openless-all/app/src/components/WindowsCapsule.tsx @@ -0,0 +1,326 @@ +import { useTranslation } from 'react-i18next'; +import { + getCapsuleHostMetrics, + getCapsulePillMetrics, +} from '../lib/capsuleLayout'; +import type { CapsuleState } from '../lib/types'; +import { useCapsuleState } from './useCapsuleState'; + +interface AudioBarsProps { + level: number; +} + +function AudioBars({ level }: AudioBarsProps) { + const envelope = [0.55, 0.85, 1.0, 0.85, 0.55]; + const base = 4; + const max = 18; + const voice = Math.min(1, Math.max(0, level)); + return ( +
+ {envelope.map((env, i) => ( + + ))} +
+ ); +} + +function ProcessingDots() { + return ( +
+ {[0, 1, 2].map(i => ( + + ))} +
+ ); +} + +function WindowsCapsuleButton({ + type, + enabled, + onClick, +}: { + type: 'cancel' | 'confirm'; + enabled: boolean; + onClick: () => void; +}) { + const { t } = useTranslation(); + const isCancel = type === 'cancel'; + return ( + + ); +} + +function WindowsProcessingCenter({ text }: { text: string }) { + const metrics = getCapsulePillMetrics('win'); + return ( +
+ + + {text} + +
+ ); +} + +function WindowsCenterText({ + text, + color = 'var(--ol-ink-2)', +}: { + text: string; + color?: string; +}) { + const metrics = getCapsulePillMetrics('win'); + return ( + + {text} + + ); +} + +function WindowsCapsulePill({ + state, + level, + insertedChars, + message, + onCancel, + onConfirm, +}: { + state: CapsuleState; + level: number; + insertedChars: number; + message?: string; + onCancel: () => void; + onConfirm: () => void; +}) { + const { t } = useTranslation(); + const metrics = getCapsulePillMetrics('win'); + const active = state === 'recording'; + + let center: JSX.Element; + switch (state) { + case 'recording': + center = ; + break; + case 'transcribing': + case 'polishing': + center = ; + break; + case 'done': + center = ; + break; + case 'cancelled': + center = ; + break; + case 'error': + center = ; + break; + default: + center = ; + } + + return ( +
+
+
+ +
+ {center} +
+ +
+
+ ); +} + +export function WindowsCapsule() { + const { t } = useTranslation(); + const { + state, + level, + insertedChars, + message, + translation, + onCancel, + onConfirm, + } = useCapsuleState(); + const metrics = getCapsulePillMetrics('win'); + const hostMetrics = getCapsuleHostMetrics('win', translation); + + if (state === 'idle') { + return
; + } + + return ( +
+ {translation && ( +
+ + {t('capsule.translating')} +
+ )} + + +
+ ); +} diff --git a/openless-all/app/src/components/useCapsuleState.ts b/openless-all/app/src/components/useCapsuleState.ts new file mode 100644 index 00000000..b93fd3d8 --- /dev/null +++ b/openless-all/app/src/components/useCapsuleState.ts @@ -0,0 +1,62 @@ +import { useEffect, useState } from 'react'; +import { invokeOrMock, isTauri } from '../lib/ipc'; +import type { CapsulePayload, CapsuleState } from '../lib/types'; + +export interface CapsuleController { + state: CapsuleState; + level: number; + insertedChars: number; + message?: string; + translation: boolean; + onCancel: () => void; + onConfirm: () => void; +} + +export function useCapsuleState(): CapsuleController { + 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; + let unlisten: (() => void) | undefined; + let cancelled = false; + (async () => { + const { listen } = await import('@tauri-apps/api/event'); + const handle = await listen('capsule:state', event => { + const payload = event.payload; + setState(payload.state); + setLevel(payload.level ?? 0); + setMessage(payload.message ?? undefined); + if (payload.insertedChars != null) setInsertedChars(payload.insertedChars); + setTranslation(payload.translation === true); + }); + if (cancelled) handle(); + else unlisten = handle; + })(); + return () => { + cancelled = true; + if (unlisten) unlisten(); + }; + }, []); + + const onCancel = () => { + void invokeOrMock('cancel_dictation', undefined, () => undefined); + }; + + const onConfirm = () => { + void invokeOrMock('stop_dictation', undefined, () => undefined); + }; + + return { + state, + level, + insertedChars, + message, + translation, + onCancel, + onConfirm, + }; +} diff --git a/openless-all/app/src/lib/capsuleLayout.ts b/openless-all/app/src/lib/capsuleLayout.ts index 65c03330..db96c6de 100644 --- a/openless-all/app/src/lib/capsuleLayout.ts +++ b/openless-all/app/src/lib/capsuleLayout.ts @@ -33,7 +33,7 @@ export function getCapsuleHostMetrics( translationActive: boolean, ): CapsuleHostMetrics { if (os === 'win') { - return { width: 220, height: translationActive ? 118 : 84, bottomInset: 12, badgeGap: 8 }; + return { width: 196, height: translationActive ? 92 : 52, bottomInset: 0, badgeGap: 8 }; } return { width: 176, height: translationActive ? 110 : 42, bottomInset: 0, badgeGap: 8 }; From c5cdc8dabf0f17268988bd220a537d4589c6abd0 Mon Sep 17 00:00:00 2001 From: Cooper-X-Oak Date: Sat, 2 May 2026 17:26:49 +0800 Subject: [PATCH 2/2] fix(windows): remove capsule titlebar on translation host --- openless-all/app/src-tauri/src/lib.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 58936878..dc5a9d85 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -472,6 +472,10 @@ fn configure_capsule_window_for_platform( }; let hwnd = HWND(handle.hwnd.get() as *mut core::ffi::c_void); + if let Err(e) = window.set_decorations(false) { + log::warn!("[capsule] disable native decorations failed: {e}"); + } + unsafe { let style = GetWindowLongW(hwnd, GWL_STYLE); let desired_style = (style