Skip to content
Merged
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。
62 changes: 44 additions & 18 deletions openless-all/app/scripts/windows-ui-config.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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');
}
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 @@ -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);
Comment on lines +625 to +626
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve non-Windows bottom clearance when translating

The new y position uses capsule_visual_height(translation_active), but on non-Windows that function is constant (42.0) while capsule_window_bounds(true) grows host height to 110.0. This means translated state keeps the same top coordinate as non-translated state and drops the larger host 68px lower, so the window bottom no longer respects the prior 80pt clearance behavior on macOS/Linux. Non-Windows positioning should still account for the expanded host height.

Useful? React with 👍 / 👎.

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',
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Use cross-axis alignment for Windows pill anchoring

In this flex container, flex-direction is the default row, so switching justifyContent to flex-end moves the pill horizontally, not vertically. On Windows that pushes the 196px pill to the right side of the 220px host while the translation badge is still centered via left: '50%', creating a visible horizontal misalignment. If the goal is bottom anchoring, this needs cross-axis alignment (alignItems or a column direction) instead of main-axis justification.

Useful? React with 👍 / 👎.

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