Merge pull request #182 from Cooper-X-Oak/codex/windows-capsule-geometry-pr

fix(windows): 收紧 Capsule 几何契约
This commit is contained in:
TRIP authored and GitHub committed 2026-05-02 12:11:51 +08:00
commit a2d243f333
5 files changed
+150 -41

No files matched your search

@@ -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。
@@ -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');
}
+55 -20
View File
@@ -450,7 +450,7 @@ fn position_qa_window<R: tauri::Runtime>(window: &tauri::WebviewWindow<R>) -> 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<R: tauri::Runtime>(
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")]
{
let height = if translation_active { 110.0 } else { 52.0 };
(196.0, height)
CapsuleWindowBounds {
width: 220.0,
height: if translation_active { 118.0 } else { 84.0 },
bottom_inset: 12.0,
}
}
#[cfg(not(target_os = "windows"))]
{
let height = if translation_active { 110.0 } else { 42.0 };
(176.0, height)
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")]
{
52.0
}
#[cfg(not(target_os = "windows"))]
{
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_size_matches_visible_pill_when_not_translating() {
let (width, height) = capsule_window_size(false);
fn capsule_window_bounds_leave_room_for_windows_shadow() {
let bounds = capsule_window_bounds(false);
#[cfg(target_os = "windows")]
assert_eq!((width, height), (196.0, 52.0));
assert_eq!((bounds.width, bounds.height, bounds.bottom_inset), (220.0, 84.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, 42.0, 0.0));
}
#[test]
fn capsule_window_size_expands_for_translation_badge() {
let (width, height) = capsule_window_size(true);
fn capsule_window_bounds_expand_for_translation_badge() {
let bounds = capsule_window_bounds(true);
#[cfg(target_os = "windows")]
assert_eq!((width, height), (196.0, 110.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, 110.0));
assert_eq!((bounds.width, bounds.height, bounds.bottom_inset), (176.0, 110.0, 0.0));
}
#[test]
fn capsule_visual_height_matches_frontend_pill() {
#[cfg(target_os = "windows")]
assert_eq!(capsule_visual_height(true), 52.0);
#[cfg(not(target_os = "windows"))]
assert_eq!(capsule_visual_height(true), 42.0);
}
#[test]
+10 -3
View File
@@ -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<boolean>(false);
const hostMetrics = getCapsuleHostMetrics(os, translation);
const [state, setState] = useState<CapsuleState>(isTauri ? 'idle' : 'recording');
const [level, setLevel] = useState<number>(isTauri ? 0 : 0.6);
const [insertedChars, setInsertedChars] = useState<number>(0);
const [message, setMessage] = useState<string | undefined>();
const [translation, setTranslation] = useState<boolean>(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',
}}
+18
View File
@@ -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,