diff --git a/docs/windows-ui-tracking/issue-142-capsule-geometry.md b/docs/windows-ui-tracking/issue-142-capsule-geometry.md new file mode 100644 index 00000000..f524e225 --- /dev/null +++ b/docs/windows-ui-tracking/issue-142-capsule-geometry.md @@ -0,0 +1,23 @@ +# Issue #142 Placeholder / 占位 + +## 中文摘要 + +本 PR 是 issue #142 的 draft 占位,专门跟踪 Windows Capsule 变形、失真与尺寸错位问题。 +当前只保留问题边界、几何证据和后续修复准入条件,不引入业务逻辑改动。 + +## Scope / 范围 + +- Capsule native window bounds +- visual pill metrics +- badge position +- Windows DPI / transparent window clipping + +## Evidence / 证据入口 + +- `openless-all/app/src-tauri/src/lib.rs` +- `openless-all/app/src/components/Capsule.tsx` +- `openless-all/app/src/lib/capsuleLayout.ts` + +## Merge Rule / 合并规则 + +- 仅当 issue #142 的几何对齐与 Windows smoke 验证完成后才允许从 draft 转为 ready。 diff --git a/openless-all/app/scripts/windows-ui-config.test.mjs b/openless-all/app/scripts/windows-ui-config.test.mjs index 5e2bcc00..a2224f4b 100644 --- a/openless-all/app/scripts/windows-ui-config.test.mjs +++ b/openless-all/app/scripts/windows-ui-config.test.mjs @@ -17,34 +17,20 @@ const config = JSON.parse(raw); const capsuleWindow = config.app.windows.find((window) => window.label === 'capsule'); const libRs = await readFile(new URL('../src-tauri/src/lib.rs', import.meta.url), 'utf-8'); const coordinatorRs = await readFile(new URL('../src-tauri/src/coordinator.rs', import.meta.url), 'utf-8'); +const capsuleTsx = await readFile(new URL('../src/components/Capsule.tsx', import.meta.url), 'utf-8'); +const capsuleLayoutTs = await readFile(new URL('../src/lib/capsuleLayout.ts', import.meta.url), 'utf-8'); if (!capsuleWindow) { throw new Error('capsule window config missing'); } - assertEqual(capsuleWindow.width, 220, 'windows capsule config keeps translation-capable width baseline'); 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'); -assertMatch( - libRs, - /#\[cfg\(target_os = "windows"\)\][\s\S]*?\(196\.0, height\)/, - 'windows runtime capsule width should collapse to the visible pill', -); -assertMatch( - libRs, - /let height = if translation_active \{ 110\.0 \} else \{ 52\.0 \};/, - 'windows runtime capsule height should shrink outside translation mode', -); -assertMatch( - libRs, - /window\.set_size\(LogicalSize::new\(cap_w, cap_h\)\)\?/, - 'capsule positioning should resync runtime size with the computed layout', -); assertMatch( coordinatorRs, - /let visible = matches!\(\s*state,\s*CapsuleState::Recording \| CapsuleState::Transcribing \| CapsuleState::Polishing\s*\);/m, - 'capsule should only stay visible during active recording or processing states', + /let visible = !matches!\(state,\s*CapsuleState::Idle\);/, + 'capsule should stay visible until the unified idle hide path runs', ); assertMatch( coordinatorRs, @@ -61,3 +47,43 @@ assertMatch( /SetWindowPos\([\s\S]*?HWND_NOTOPMOST[\s\S]*?SWP_HIDEWINDOW/m, 'windows capsule hide helper should drop topmost participation when inactive', ); + +if (!/export function getCapsuleHostMetrics\(\s*os: OS,\s*translationActive: boolean,\s*\): CapsuleHostMetrics/.test(capsuleLayoutTs)) { + throw new Error('capsule layout should define explicit host metrics separate from the visible pill metrics'); +} + +if (!/if \(os === 'win'\)\s*\{[\s\S]*?width: 220,[\s\S]*?height: translationActive \? 118 : 84,[\s\S]*?bottomInset: 12,[\s\S]*?badgeGap: 8[\s\S]*?\}/.test(capsuleLayoutTs)) { + throw new Error('windows capsule host metrics should leave room for shadow and badge geometry'); +} + +if (!/const hostMetrics = getCapsuleHostMetrics\(os,\s*translation\);/.test(capsuleTsx)) { + throw new Error('capsule should derive host metrics from the shared layout contract'); +} + +if (!/justifyContent:\s*os === 'win' \? 'flex-end' : 'center'/.test(capsuleTsx)) { + throw new Error('windows capsule host should anchor the pill to the bottom instead of centering it inside the larger native host window'); +} + +if (!/paddingBottom:\s*os === 'win' \? hostMetrics\.bottomInset : 0/.test(capsuleTsx)) { + throw new Error('windows capsule host should respect the shared bottom inset'); +} + +if (!/bottom:\s*`\$\{hostMetrics\.bottomInset \+ metrics\.height \+ hostMetrics\.badgeGap\}px`/.test(capsuleTsx)) { + throw new Error('windows translation badge should anchor from the shared host inset instead of a fixed center-based offset'); +} + +if (!/#\[cfg\(target_os = "windows"\)\][\s\S]*?width: 220\.0[\s\S]*?height: if translation_active \{ 118\.0 \} else \{ 84\.0 \}[\s\S]*?bottom_inset: 12\.0,/.test(libRs)) { + throw new Error('windows runtime capsule bounds should leave room for the native shadow while keeping a fixed visual pill'); +} + +if (!/#\[cfg\(target_os = "windows"\)\]\s*\{\s*52\.0\s*\}/.test(libRs)) { + throw new Error('windows capsule visual pill height should stay at 52px'); +} + +if (!/window\.set_size\(LogicalSize::new\(bounds\.width, bounds\.height\)\)\?/.test(libRs)) { + throw new Error('capsule positioning should resync runtime size with the computed layout'); +} + +if (!/let _ = window\.hide\(\);/.test(coordinatorRs)) { + throw new Error('capsule should be hidden once it leaves active states'); +} diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 2baeddd3..b15723ea 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -450,7 +450,7 @@ 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 capsule_height = capsule_height_for_qa(); let x = ((logical_w - QA_WINDOW_WIDTH) / 2.0).max(0.0); let y = (logical_h - DOCK_BOTTOM_PADDING_FOR_QA @@ -614,59 +614,94 @@ pub(crate) fn position_capsule_bottom_center( 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 bounds = capsule_window_bounds(translation_active); + window.set_size(LogicalSize::new(bounds.width, bounds.height))?; 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 x = ((logical_w - cap_w) / 2.0).max(0.0); - let y = (logical_h - cap_h - 80.0).max(0.0); + let x = ((logical_w - bounds.width) / 2.0).max(0.0); + let y = (logical_h - capsule_visual_height(translation_active) - 80.0 - bounds.bottom_inset) + .max(0.0); window.set_position(LogicalPosition::new(x, y))?; Ok(()) } -fn capsule_window_size(translation_active: bool) -> (f64, f64) { +#[derive(Clone, Copy, Debug, PartialEq)] +struct CapsuleWindowBounds { + width: f64, + height: f64, + bottom_inset: f64, +} + +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, + } + } + + #[cfg(not(target_os = "windows"))] + { + CapsuleWindowBounds { + width: 176.0, + height: if translation_active { 110.0 } else { 42.0 }, + bottom_inset: 0.0, + } + } +} + +fn capsule_visual_height(_translation_active: bool) -> f64 { #[cfg(target_os = "windows")] { - let height = if translation_active { 110.0 } else { 52.0 }; - (196.0, height) + 52.0 } #[cfg(not(target_os = "windows"))] { - let height = if translation_active { 110.0 } else { 42.0 }; - (176.0, height) + 42.0 } } fn capsule_height_for_qa() -> f64 { - capsule_window_size(false).1 + capsule_visual_height(false) } #[cfg(test)] mod tests { - use super::{capsule_height_for_qa, capsule_window_size}; + use super::{capsule_height_for_qa, capsule_visual_height, capsule_window_bounds}; + + #[test] + 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)); + + #[cfg(not(target_os = "windows"))] + assert_eq!((bounds.width, bounds.height, bounds.bottom_inset), (176.0, 42.0, 0.0)); + } #[test] - fn capsule_window_size_matches_visible_pill_when_not_translating() { - let (width, height) = capsule_window_size(false); + fn capsule_window_bounds_expand_for_translation_badge() { + let bounds = capsule_window_bounds(true); #[cfg(target_os = "windows")] - assert_eq!((width, height), (196.0, 52.0)); + assert_eq!((bounds.width, bounds.height, bounds.bottom_inset), (220.0, 118.0, 12.0)); #[cfg(not(target_os = "windows"))] - assert_eq!((width, height), (176.0, 42.0)); + assert_eq!((bounds.width, bounds.height, bounds.bottom_inset), (176.0, 110.0, 0.0)); } #[test] - fn capsule_window_size_expands_for_translation_badge() { - let (width, height) = capsule_window_size(true); + fn capsule_visual_height_matches_frontend_pill() { #[cfg(target_os = "windows")] - assert_eq!((width, height), (196.0, 110.0)); + assert_eq!(capsule_visual_height(true), 52.0); #[cfg(not(target_os = "windows"))] - assert_eq!((width, height), (176.0, 110.0)); + assert_eq!(capsule_visual_height(true), 42.0); } #[test] diff --git a/openless-all/app/src/components/Capsule.tsx b/openless-all/app/src/components/Capsule.tsx index e7b2044b..03120f28 100644 --- a/openless-all/app/src/components/Capsule.tsx +++ b/openless-all/app/src/components/Capsule.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { detectOS, type OS } from './WindowChrome'; import { + getCapsuleHostMetrics, getCapsuleMessageLayout, getCapsulePillMetrics, } from '../lib/capsuleLayout'; @@ -259,11 +260,13 @@ function Pill({ os, state, level, insertedChars, message, onCancel, onConfirm }: 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(); - const [translation, setTranslation] = useState(false); useEffect(() => { if (!isTauri) return; @@ -309,7 +312,11 @@ export function Capsule() { position: 'relative', display: 'flex', alignItems: 'center', - justifyContent: 'center', + justifyContent: os === 'win' ? 'flex-end' : 'center', + paddingTop: os === 'win' + ? Math.max(0, hostMetrics.height - metrics.height - hostMetrics.bottomInset) + : 0, + paddingBottom: os === 'win' ? hostMetrics.bottomInset : 0, background: 'transparent', animation: os === 'win' ? 'none' : 'capsule-in .22s cubic-bezier(.2,.9,.3,1.1)', }} @@ -324,7 +331,7 @@ export function Capsule() { left: '50%', // bottom = 50%(pill 中线)+ pill 半高 21px(capsuleLayout mac=42)+ 8px 间隔。 // 只有翻译徽章可见时才需要额外高度;普通录音/转写状态由后端缩到 pill 本体,避免透明死区。 - bottom: 'calc(50% + 21px + 8px)', + bottom: `${hostMetrics.bottomInset + metrics.height + hostMetrics.badgeGap}px`, transform: 'translateX(-50%)', pointerEvents: 'none', }} diff --git a/openless-all/app/src/lib/capsuleLayout.ts b/openless-all/app/src/lib/capsuleLayout.ts index 7a614c3f..65c03330 100644 --- a/openless-all/app/src/lib/capsuleLayout.ts +++ b/openless-all/app/src/lib/capsuleLayout.ts @@ -8,6 +8,13 @@ export interface CapsulePillMetrics { textWidth: number; } +export interface CapsuleHostMetrics { + width: number; + height: number; + bottomInset: number; + badgeGap: number; +} + export interface CapsuleMessageLayout { allowWrap: boolean; lineClamp: number; @@ -21,6 +28,17 @@ export function getCapsulePillMetrics(os: OS): CapsulePillMetrics { return { width: 176, height: 42, textWidth: 84 }; } +export function getCapsuleHostMetrics( + os: OS, + translationActive: boolean, +): CapsuleHostMetrics { + if (os === 'win') { + return { width: 220, height: translationActive ? 118 : 84, bottomInset: 12, badgeGap: 8 }; + } + + return { width: 176, height: translationActive ? 110 : 42, bottomInset: 0, badgeGap: 8 }; +} + export function getCapsuleMessageLayout( os: OS, kind: CapsuleMessageKind,