Skip to content

Commit a2d243f

Browse files
authored
Merge pull request #182 from Cooper-X-Oak/codex/windows-capsule-geometry-pr
fix(windows): 收紧 Capsule 几何契约
2 parents 13d9d29 + 9fd726e commit a2d243f

5 files changed

Lines changed: 150 additions & 41 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Issue #142 Placeholder / 占位
2+
3+
## 中文摘要
4+
5+
本 PR 是 issue #142 的 draft 占位,专门跟踪 Windows Capsule 变形、失真与尺寸错位问题。
6+
当前只保留问题边界、几何证据和后续修复准入条件,不引入业务逻辑改动。
7+
8+
## Scope / 范围
9+
10+
- Capsule native window bounds
11+
- visual pill metrics
12+
- badge position
13+
- Windows DPI / transparent window clipping
14+
15+
## Evidence / 证据入口
16+
17+
- `openless-all/app/src-tauri/src/lib.rs`
18+
- `openless-all/app/src/components/Capsule.tsx`
19+
- `openless-all/app/src/lib/capsuleLayout.ts`
20+
21+
## Merge Rule / 合并规则
22+
23+
- 仅当 issue #142 的几何对齐与 Windows smoke 验证完成后才允许从 draft 转为 ready。

openless-all/app/scripts/windows-ui-config.test.mjs

Lines changed: 44 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -17,34 +17,20 @@ const config = JSON.parse(raw);
1717
const capsuleWindow = config.app.windows.find((window) => window.label === 'capsule');
1818
const libRs = await readFile(new URL('../src-tauri/src/lib.rs', import.meta.url), 'utf-8');
1919
const coordinatorRs = await readFile(new URL('../src-tauri/src/coordinator.rs', import.meta.url), 'utf-8');
20+
const capsuleTsx = await readFile(new URL('../src/components/Capsule.tsx', import.meta.url), 'utf-8');
21+
const capsuleLayoutTs = await readFile(new URL('../src/lib/capsuleLayout.ts', import.meta.url), 'utf-8');
2022

2123
if (!capsuleWindow) {
2224
throw new Error('capsule window config missing');
2325
}
24-
2526
assertEqual(capsuleWindow.width, 220, 'windows capsule config keeps translation-capable width baseline');
2627
assertEqual(capsuleWindow.height, 110, 'windows capsule config keeps translation-capable height baseline');
2728
assertEqual(capsuleWindow.transparent, true, 'capsule window should keep transparent visuals');
2829
assertEqual(capsuleWindow.alwaysOnTop, true, 'capsule window should stay above the focused app while recording');
29-
assertMatch(
30-
libRs,
31-
/#\[cfg\(target_os = "windows"\)\][\s\S]*?\(196\.0, height\)/,
32-
'windows runtime capsule width should collapse to the visible pill',
33-
);
34-
assertMatch(
35-
libRs,
36-
/let height = if translation_active \{ 110\.0 \} else \{ 52\.0 \};/,
37-
'windows runtime capsule height should shrink outside translation mode',
38-
);
39-
assertMatch(
40-
libRs,
41-
/window\.set_size\(LogicalSize::new\(cap_w, cap_h\)\)\?/,
42-
'capsule positioning should resync runtime size with the computed layout',
43-
);
4430
assertMatch(
4531
coordinatorRs,
46-
/let visible = matches!\(\s*state,\s*CapsuleState::Recording \| CapsuleState::Transcribing \| CapsuleState::Polishing\s*\);/m,
47-
'capsule should only stay visible during active recording or processing states',
32+
/let visible = !matches!\(state,\s*CapsuleState::Idle\);/,
33+
'capsule should stay visible until the unified idle hide path runs',
4834
);
4935
assertMatch(
5036
coordinatorRs,
@@ -61,3 +47,43 @@ assertMatch(
6147
/SetWindowPos\([\s\S]*?HWND_NOTOPMOST[\s\S]*?SWP_HIDEWINDOW/m,
6248
'windows capsule hide helper should drop topmost participation when inactive',
6349
);
50+
51+
if (!/export function getCapsuleHostMetrics\(\s*os: OS,\s*translationActive: boolean,\s*\): CapsuleHostMetrics/.test(capsuleLayoutTs)) {
52+
throw new Error('capsule layout should define explicit host metrics separate from the visible pill metrics');
53+
}
54+
55+
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)) {
56+
throw new Error('windows capsule host metrics should leave room for shadow and badge geometry');
57+
}
58+
59+
if (!/const hostMetrics = getCapsuleHostMetrics\(os,\s*translation\);/.test(capsuleTsx)) {
60+
throw new Error('capsule should derive host metrics from the shared layout contract');
61+
}
62+
63+
if (!/justifyContent:\s*os === 'win' \? 'flex-end' : 'center'/.test(capsuleTsx)) {
64+
throw new Error('windows capsule host should anchor the pill to the bottom instead of centering it inside the larger native host window');
65+
}
66+
67+
if (!/paddingBottom:\s*os === 'win' \? hostMetrics\.bottomInset : 0/.test(capsuleTsx)) {
68+
throw new Error('windows capsule host should respect the shared bottom inset');
69+
}
70+
71+
if (!/bottom:\s*`\$\{hostMetrics\.bottomInset \+ metrics\.height \+ hostMetrics\.badgeGap\}px`/.test(capsuleTsx)) {
72+
throw new Error('windows translation badge should anchor from the shared host inset instead of a fixed center-based offset');
73+
}
74+
75+
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)) {
76+
throw new Error('windows runtime capsule bounds should leave room for the native shadow while keeping a fixed visual pill');
77+
}
78+
79+
if (!/#\[cfg\(target_os = "windows"\)\]\s*\{\s*52\.0\s*\}/.test(libRs)) {
80+
throw new Error('windows capsule visual pill height should stay at 52px');
81+
}
82+
83+
if (!/window\.set_size\(LogicalSize::new\(bounds\.width, bounds\.height\)\)\?/.test(libRs)) {
84+
throw new Error('capsule positioning should resync runtime size with the computed layout');
85+
}
86+
87+
if (!/let _ = window\.hide\(\);/.test(coordinatorRs)) {
88+
throw new Error('capsule should be hidden once it leaves active states');
89+
}

openless-all/app/src-tauri/src/lib.rs

Lines changed: 55 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -450,7 +450,7 @@ fn position_qa_window<R: tauri::Runtime>(window: &tauri::WebviewWindow<R>) -> ta
450450
let size = monitor.size();
451451
let logical_w = size.width as f64 / scale;
452452
let logical_h = size.height as f64 / scale;
453-
let capsule_height = capsule_window_size(false).1;
453+
let capsule_height = capsule_height_for_qa();
454454
let x = ((logical_w - QA_WINDOW_WIDTH) / 2.0).max(0.0);
455455
let y = (logical_h
456456
- DOCK_BOTTOM_PADDING_FOR_QA
@@ -614,59 +614,94 @@ pub(crate) fn position_capsule_bottom_center<R: tauri::Runtime>(
614614
Some(m) => m,
615615
None => return Ok(()),
616616
};
617-
let (cap_w, cap_h) = capsule_window_size(translation_active);
618-
window.set_size(LogicalSize::new(cap_w, cap_h))?;
617+
let bounds = capsule_window_bounds(translation_active);
618+
window.set_size(LogicalSize::new(bounds.width, bounds.height))?;
619619

620620
let scale = monitor.scale_factor();
621621
let size = monitor.size();
622622
let logical_w = size.width as f64 / scale;
623623
let logical_h = size.height as f64 / scale;
624-
let x = ((logical_w - cap_w) / 2.0).max(0.0);
625-
let y = (logical_h - cap_h - 80.0).max(0.0);
624+
let x = ((logical_w - bounds.width) / 2.0).max(0.0);
625+
let y = (logical_h - capsule_visual_height(translation_active) - 80.0 - bounds.bottom_inset)
626+
.max(0.0);
626627
window.set_position(LogicalPosition::new(x, y))?;
627628
Ok(())
628629
}
629630

630-
fn capsule_window_size(translation_active: bool) -> (f64, f64) {
631+
#[derive(Clone, Copy, Debug, PartialEq)]
632+
struct CapsuleWindowBounds {
633+
width: f64,
634+
height: f64,
635+
bottom_inset: f64,
636+
}
637+
638+
fn capsule_window_bounds(translation_active: bool) -> CapsuleWindowBounds {
639+
#[cfg(target_os = "windows")]
640+
{
641+
CapsuleWindowBounds {
642+
width: 220.0,
643+
height: if translation_active { 118.0 } else { 84.0 },
644+
bottom_inset: 12.0,
645+
}
646+
}
647+
648+
#[cfg(not(target_os = "windows"))]
649+
{
650+
CapsuleWindowBounds {
651+
width: 176.0,
652+
height: if translation_active { 110.0 } else { 42.0 },
653+
bottom_inset: 0.0,
654+
}
655+
}
656+
}
657+
658+
fn capsule_visual_height(_translation_active: bool) -> f64 {
631659
#[cfg(target_os = "windows")]
632660
{
633-
let height = if translation_active { 110.0 } else { 52.0 };
634-
(196.0, height)
661+
52.0
635662
}
636663

637664
#[cfg(not(target_os = "windows"))]
638665
{
639-
let height = if translation_active { 110.0 } else { 42.0 };
640-
(176.0, height)
666+
42.0
641667
}
642668
}
643669

644670
fn capsule_height_for_qa() -> f64 {
645-
capsule_window_size(false).1
671+
capsule_visual_height(false)
646672
}
647673

648674
#[cfg(test)]
649675
mod tests {
650-
use super::{capsule_height_for_qa, capsule_window_size};
676+
use super::{capsule_height_for_qa, capsule_visual_height, capsule_window_bounds};
677+
678+
#[test]
679+
fn capsule_window_bounds_leave_room_for_windows_shadow() {
680+
let bounds = capsule_window_bounds(false);
681+
#[cfg(target_os = "windows")]
682+
assert_eq!((bounds.width, bounds.height, bounds.bottom_inset), (220.0, 84.0, 12.0));
683+
684+
#[cfg(not(target_os = "windows"))]
685+
assert_eq!((bounds.width, bounds.height, bounds.bottom_inset), (176.0, 42.0, 0.0));
686+
}
651687

652688
#[test]
653-
fn capsule_window_size_matches_visible_pill_when_not_translating() {
654-
let (width, height) = capsule_window_size(false);
689+
fn capsule_window_bounds_expand_for_translation_badge() {
690+
let bounds = capsule_window_bounds(true);
655691
#[cfg(target_os = "windows")]
656-
assert_eq!((width, height), (196.0, 52.0));
692+
assert_eq!((bounds.width, bounds.height, bounds.bottom_inset), (220.0, 118.0, 12.0));
657693

658694
#[cfg(not(target_os = "windows"))]
659-
assert_eq!((width, height), (176.0, 42.0));
695+
assert_eq!((bounds.width, bounds.height, bounds.bottom_inset), (176.0, 110.0, 0.0));
660696
}
661697

662698
#[test]
663-
fn capsule_window_size_expands_for_translation_badge() {
664-
let (width, height) = capsule_window_size(true);
699+
fn capsule_visual_height_matches_frontend_pill() {
665700
#[cfg(target_os = "windows")]
666-
assert_eq!((width, height), (196.0, 110.0));
701+
assert_eq!(capsule_visual_height(true), 52.0);
667702

668703
#[cfg(not(target_os = "windows"))]
669-
assert_eq!((width, height), (176.0, 110.0));
704+
assert_eq!(capsule_visual_height(true), 42.0);
670705
}
671706

672707
#[test]

openless-all/app/src/components/Capsule.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
22
import { useTranslation } from 'react-i18next';
33
import { detectOS, type OS } from './WindowChrome';
44
import {
5+
getCapsuleHostMetrics,
56
getCapsuleMessageLayout,
67
getCapsulePillMetrics,
78
} from '../lib/capsuleLayout';
@@ -259,11 +260,13 @@ function Pill({ os, state, level, insertedChars, message, onCancel, onConfirm }:
259260
export function Capsule() {
260261
const { t } = useTranslation();
261262
const os = detectOS();
263+
const metrics = getCapsulePillMetrics(os);
264+
const [translation, setTranslation] = useState<boolean>(false);
265+
const hostMetrics = getCapsuleHostMetrics(os, translation);
262266
const [state, setState] = useState<CapsuleState>(isTauri ? 'idle' : 'recording');
263267
const [level, setLevel] = useState<number>(isTauri ? 0 : 0.6);
264268
const [insertedChars, setInsertedChars] = useState<number>(0);
265269
const [message, setMessage] = useState<string | undefined>();
266-
const [translation, setTranslation] = useState<boolean>(false);
267270

268271
useEffect(() => {
269272
if (!isTauri) return;
@@ -309,7 +312,11 @@ export function Capsule() {
309312
position: 'relative',
310313
display: 'flex',
311314
alignItems: 'center',
312-
justifyContent: 'center',
315+
justifyContent: os === 'win' ? 'flex-end' : 'center',
316+
paddingTop: os === 'win'
317+
? Math.max(0, hostMetrics.height - metrics.height - hostMetrics.bottomInset)
318+
: 0,
319+
paddingBottom: os === 'win' ? hostMetrics.bottomInset : 0,
313320
background: 'transparent',
314321
animation: os === 'win' ? 'none' : 'capsule-in .22s cubic-bezier(.2,.9,.3,1.1)',
315322
}}
@@ -324,7 +331,7 @@ export function Capsule() {
324331
left: '50%',
325332
// bottom = 50%(pill 中线)+ pill 半高 21px(capsuleLayout mac=42)+ 8px 间隔。
326333
// 只有翻译徽章可见时才需要额外高度;普通录音/转写状态由后端缩到 pill 本体,避免透明死区。
327-
bottom: 'calc(50% + 21px + 8px)',
334+
bottom: `${hostMetrics.bottomInset + metrics.height + hostMetrics.badgeGap}px`,
328335
transform: 'translateX(-50%)',
329336
pointerEvents: 'none',
330337
}}

openless-all/app/src/lib/capsuleLayout.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,13 @@ export interface CapsulePillMetrics {
88
textWidth: number;
99
}
1010

11+
export interface CapsuleHostMetrics {
12+
width: number;
13+
height: number;
14+
bottomInset: number;
15+
badgeGap: number;
16+
}
17+
1118
export interface CapsuleMessageLayout {
1219
allowWrap: boolean;
1320
lineClamp: number;
@@ -21,6 +28,17 @@ export function getCapsulePillMetrics(os: OS): CapsulePillMetrics {
2128
return { width: 176, height: 42, textWidth: 84 };
2229
}
2330

31+
export function getCapsuleHostMetrics(
32+
os: OS,
33+
translationActive: boolean,
34+
): CapsuleHostMetrics {
35+
if (os === 'win') {
36+
return { width: 220, height: translationActive ? 118 : 84, bottomInset: 12, badgeGap: 8 };
37+
}
38+
39+
return { width: 176, height: translationActive ? 110 : 42, bottomInset: 0, badgeGap: 8 };
40+
}
41+
2442
export function getCapsuleMessageLayout(
2543
os: OS,
2644
kind: CapsuleMessageKind,

0 commit comments

Comments
 (0)