Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions docs/windows-ui-tracking/issue-142-capsule-geometry.md
Original file line number Diff line number Diff line change
@@ -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。
55 changes: 51 additions & 4 deletions openless-all/app/scripts/windows-ui-config.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,57 @@ function assertEqual(actual, expected, name) {

const raw = await readFile(new URL('../src-tauri/tauri.conf.json', import.meta.url), 'utf-8');
const config = JSON.parse(raw);
const mainWindow = config.app.windows.find((window) => window.label === 'main');
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 (!mainWindow) {
throw new Error('main window config missing');
if (!capsuleWindow) {
throw new Error('capsule window config missing');
}

assertEqual(mainWindow.decorations, false, 'windows main window should use only custom titlebar');
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');

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');
}
75 changes: 55 additions & 20 deletions openless-all/app/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -570,59 +570,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")]
{
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]
Expand Down
13 changes: 10 additions & 3 deletions openless-all/app/src/components/Capsule.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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)',
}}
Expand All @@ -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',
}}
Expand Down
18 changes: 18 additions & 0 deletions openless-all/app/src/lib/capsuleLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand Down
Loading