Merge pull request #182 from Cooper-X-Oak/codex/windows-capsule-geometry-pr
fix(windows): 收紧 Capsule 几何契约
This commit is contained in:
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');
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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',
|
||||
}}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user