feat(capsule): typeless 放大到原版 1/5 面积、黑底白波纹;Siri 预览改用真实光效舞台
- 窗口 174×48 → 206×57,CSS zoom 0.378 → 0.447(面积 = 原版 460×128 的 1/5) - typeless 配色改为黑色胶囊 + 白色波纹(深色主题同调) - VoiceOrbStage 提取为独立组件;设置页 Siri 预览直接渲染缩放 0.5 的真实 SiriGL 光条,与实际胶囊样式一致
This commit is contained in:
1 parent
105fb99dc0
commit
7204c282f1
7 files changed
+162
-156
No files matched your search
@@ -3332,16 +3332,16 @@ fn capsule_window_bounds(translation_active: bool) -> CapsuleWindowBounds {
|
||||
|
||||
fn capsule_window_bounds_for_style(style: types::CapsuleStyle) -> CapsuleWindowBounds {
|
||||
CapsuleWindowBounds {
|
||||
// typeless 窗口面积是旧尺寸(460×128)的 1/7;前端用 CSS zoom 同步缩放内容,
|
||||
// typeless 窗口面积是原尺寸(460×128)的 1/5;前端用 CSS zoom 同步缩放内容,
|
||||
// 见 CapsuleStyles.css 与 src/lib/capsuleLayout.ts。
|
||||
width: match style {
|
||||
types::CapsuleStyle::Typeless => 174.0,
|
||||
types::CapsuleStyle::Typeless => 206.0,
|
||||
types::CapsuleStyle::Siri | types::CapsuleStyle::Classic => 460.0,
|
||||
},
|
||||
height: match style {
|
||||
types::CapsuleStyle::Siri => 180.0,
|
||||
types::CapsuleStyle::Classic => 100.0,
|
||||
types::CapsuleStyle::Typeless => 48.0,
|
||||
types::CapsuleStyle::Typeless => 57.0,
|
||||
},
|
||||
bottom_inset: 0.0,
|
||||
}
|
||||
@@ -3599,9 +3599,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typeless_capsule_window_is_one_seventh_of_the_old_area() {
|
||||
fn typeless_capsule_window_is_one_fifth_of_the_old_area() {
|
||||
let bounds = capsule_window_bounds_for_style(CapsuleStyle::Typeless);
|
||||
assert_eq!((bounds.width, bounds.height), (174.0, 48.0));
|
||||
assert_eq!((bounds.width, bounds.height), (206.0, 57.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -6,12 +6,12 @@ import {
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { detectOS, type OS } from './WindowChrome';
|
||||
import { SiriGL, warmUpSiriShaders } from './SiriGL';
|
||||
import { warmUpSiriShaders } from './SiriGL';
|
||||
import { VoiceOrbStage } from './VoiceOrbStage';
|
||||
import { TypelessCapsule } from './TypelessCapsule';
|
||||
import { getSettings } from '../lib/ipc/settings';
|
||||
import { cancelDictation, stopDictation } from '../lib/ipc/dictation';
|
||||
@@ -79,123 +79,6 @@ if (typeof document !== 'undefined' && !document.getElementById('capsule-keyfram
|
||||
document.head.appendChild(tag);
|
||||
}
|
||||
|
||||
interface VoiceOrbStageProps {
|
||||
os: OS;
|
||||
state: CapsuleState;
|
||||
level: number;
|
||||
/** 预备态:录音光条渲染成「待命」呼吸形态,不接真实电平。见 CapsulePayload.warming。 */
|
||||
warming?: boolean;
|
||||
/** 预备→就绪的平均耗时(ms),驱动展开动画的预测节奏。见 SiriGL warmupMs。 */
|
||||
warmupMs?: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 纯光效舞台(siri-glsl 完整克隆,无壳无按钮无底):
|
||||
* - recording:彩虹光谱声波横贯舞台,振幅随真实麦克风电平起伏;
|
||||
* - transcribing / polishing:波形从两端向中间收缩汇聚,流体圆点环淡入加速转动;
|
||||
* - done / cancelled:转速回落标准,六点合并成中央一颗圆,由外层 capsule-out 淡出;
|
||||
* - error:冻结光效 + 浮一行发光红字说明原因(唯一保留的文字信息)。
|
||||
* 刻意没有任何垫底/暗晕(用户拍板):白底界面上宁可对比度弱,也不要黑色遮挡。
|
||||
*/
|
||||
function VoiceOrbStage({ os, state, level, warming, warmupMs, message }: VoiceOrbStageProps) {
|
||||
const { t } = useTranslation();
|
||||
const metrics = useMemo(() => getCapsulePillMetrics(os), [os]);
|
||||
|
||||
// done / cancelled / error 冻结最后形态淡出,不再切换 phase。
|
||||
const lastPhaseRef = useRef<'wave' | 'orb'>('wave');
|
||||
let phase = lastPhaseRef.current;
|
||||
if (state === 'recording') phase = 'wave';
|
||||
else if (state === 'transcribing' || state === 'polishing') phase = 'orb';
|
||||
lastPhaseRef.current = phase;
|
||||
const isOrb = phase === 'orb';
|
||||
|
||||
// 性能:波形淡出彻底结束(.55s delay + .6s duration)后卸载它的绘制循环 ——
|
||||
// 思考期间不再为一块不可见的 canvas 每帧跑 fragment。回到录音态立即重挂
|
||||
//(shader 编译已被驱动缓存,重建近零耗时)。
|
||||
const [waveAlive, setWaveAlive] = useState(true);
|
||||
useEffect(() => {
|
||||
if (!isOrb) {
|
||||
setWaveAlive(true);
|
||||
return undefined;
|
||||
}
|
||||
const timer = setTimeout(() => setWaveAlive(false), 1300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [isOrb]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: metrics.width,
|
||||
height: metrics.height,
|
||||
boxSizing: metrics.boxSizing,
|
||||
fontFamily: 'var(--ol-font-sans)',
|
||||
position: 'relative',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
{waveAlive && (
|
||||
<SiriGL
|
||||
mode="wave"
|
||||
level={level}
|
||||
resolved={!isOrb}
|
||||
warming={warming}
|
||||
warmupMs={warmupMs}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
// 收缩汇聚进行时波形保持可见,收成中央光点后再淡出,与圆点环的淡入交叠。
|
||||
opacity: isOrb ? 0 : 1,
|
||||
transition: isOrb ? 'opacity .6s ease-out .55s' : 'opacity .25s ease-out',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isOrb && (
|
||||
<SiriGL
|
||||
mode="orb"
|
||||
// 思考中(LLM 接收)加速转动;插入/取消/出错时回落到标准速度,
|
||||
// 同时六点合并成中央一颗圆,随外层淡出一起消失。
|
||||
speed={state === 'transcribing' || state === 'polishing' ? 1.5 : 1.0}
|
||||
merging={state !== 'transcribing' && state !== 'polishing'}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
top: '50%',
|
||||
width: 170,
|
||||
height: 170,
|
||||
marginLeft: -85,
|
||||
marginTop: -85,
|
||||
animation: 'siri-orb-in .7s ease-out .3s both',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{state === 'error' && <span style={errorGlowTextStyle}>{message || t('capsule.error')}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const errorGlowTextStyle: CSSProperties = {
|
||||
position: 'absolute',
|
||||
bottom: 24,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
maxWidth: 400,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.4,
|
||||
textAlign: 'center',
|
||||
color: 'var(--ol-err)',
|
||||
padding: '6px 12px',
|
||||
background: 'var(--ol-capsule-pill-bg)',
|
||||
border: '1px solid var(--ol-capsule-pill-border)',
|
||||
borderRadius: 12,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
};
|
||||
|
||||
interface SelectionPolishNoticeProps {
|
||||
state: CapsuleState;
|
||||
message?: string;
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
.ol-typeless-capsule-wrap {
|
||||
--ol-typeless-bg: #fafafa;
|
||||
--ol-typeless-border: #d4d4d8;
|
||||
--ol-typeless-ink: #18181b;
|
||||
--ol-typeless-button-bg: #e4e4e7;
|
||||
--ol-typeless-button-hover: #d4d4d8;
|
||||
--ol-typeless-confirm-bg: #27272a;
|
||||
--ol-typeless-confirm-ink: #ffffff;
|
||||
--ol-typeless-bg: #18181b;
|
||||
--ol-typeless-border: #52525b;
|
||||
--ol-typeless-ink: #fafafa;
|
||||
--ol-typeless-button-bg: #3f3f46;
|
||||
--ol-typeless-button-hover: #52525b;
|
||||
--ol-typeless-confirm-bg: #fafafa;
|
||||
--ol-typeless-confirm-ink: #18181b;
|
||||
position: relative;
|
||||
font-family: var(--ol-font-sans);
|
||||
/* 整体缩到 1/7 面积(线性 1/√7≈0.378),与 174×48 的胶囊窗口配套;
|
||||
/* 整体缩放使药丸面积为原尺寸的 1/5(线性 1/√5≈0.447),与 206×57 的胶囊窗口配套;
|
||||
尺寸基准见 src-tauri/src/lib.rs 的 capsule_window_bounds_for_style。 */
|
||||
zoom: 0.378;
|
||||
zoom: 0.447;
|
||||
}
|
||||
|
||||
/* 黑色胶囊配白色波纹。 */
|
||||
.ol-typeless-capsule .ol-capsule-waveform > span {
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.ol-typeless-capsule {
|
||||
@@ -212,28 +217,22 @@
|
||||
width: 60px;
|
||||
transform: scale(0.7);
|
||||
}
|
||||
.ol-siri-capsule-preview .ol-capsule-waveform {
|
||||
width: 160px;
|
||||
gap: 5px;
|
||||
filter: drop-shadow(0 0 6px #8b5cf680);
|
||||
/* Siri 预览 = 缩小的真实光效舞台(460×180 → ×0.5 = 230×90,正好放进 256×96 预览框)。 */
|
||||
.ol-siri-capsule-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.ol-siri-capsule-preview .ol-capsule-waveform > span {
|
||||
width: 8px;
|
||||
background: #a580ff;
|
||||
}
|
||||
.ol-siri-capsule-preview .ol-capsule-waveform > span:nth-child(-n + 4) {
|
||||
background: #65a5ff;
|
||||
}
|
||||
.ol-siri-capsule-preview .ol-capsule-waveform > span:nth-child(n + 8) {
|
||||
background: #fa84b3;
|
||||
.ol-siri-capsule-preview > div {
|
||||
transform: scale(0.5);
|
||||
}
|
||||
|
||||
[data-ol-theme='dark'] .ol-typeless-capsule-wrap {
|
||||
--ol-typeless-bg: #202024;
|
||||
--ol-typeless-border: #505056;
|
||||
--ol-typeless-bg: #101014;
|
||||
--ol-typeless-border: #3f3f46;
|
||||
--ol-typeless-ink: #fafafa;
|
||||
--ol-typeless-button-bg: #414148;
|
||||
--ol-typeless-button-hover: #55555e;
|
||||
--ol-typeless-button-bg: #27272a;
|
||||
--ol-typeless-button-hover: #3f3f46;
|
||||
--ol-typeless-confirm-bg: #fafafa;
|
||||
--ol-typeless-confirm-ink: #18181b;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { cancelDictation, stopDictation } from '../lib/ipc/dictation';
|
||||
import type { CapsuleState, CapsuleStyle } from '../lib/types';
|
||||
import { Icon } from './Icon';
|
||||
import { VoiceOrbStage } from './VoiceOrbStage';
|
||||
import './CapsuleStyles.css';
|
||||
|
||||
interface TypelessCapsuleProps {
|
||||
@@ -122,7 +123,7 @@ export function CapsuleStylePreview({ style }: { style: CapsuleStyle }) {
|
||||
</div>
|
||||
) : (
|
||||
<div className="ol-siri-capsule-preview">
|
||||
<CapsuleWaveform level={0.2} />
|
||||
<VoiceOrbStage os="mac" state="recording" level={0.2} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SiriGL } from './SiriGL';
|
||||
import { getCapsulePillMetrics } from '../lib/capsuleLayout';
|
||||
import type { CapsuleState } from '../lib/types';
|
||||
import type { OS } from './WindowChrome';
|
||||
|
||||
export interface VoiceOrbStageProps {
|
||||
os: OS;
|
||||
state: CapsuleState;
|
||||
level: number;
|
||||
/** 预备态:录音光条渲染成「待命」呼吸形态,不接真实电平。见 CapsulePayload.warming。 */
|
||||
warming?: boolean;
|
||||
/** 预备→就绪的平均耗时(ms),驱动展开动画的预测节奏。见 SiriGL warmupMs。 */
|
||||
warmupMs?: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 纯光效舞台(siri-glsl 完整克隆,无壳无按钮无底):
|
||||
* - recording:彩虹光谱声波横贯舞台,振幅随真实麦克风电平起伏;
|
||||
* - transcribing / polishing:波形从两端向中间收缩汇聚,流体圆点环淡入加速转动;
|
||||
* - done / cancelled:转速回落标准,六点合并成中央一颗圆,由外层 capsule-out 淡出;
|
||||
* - error:冻结光效 + 浮一行发光红字说明原因(唯一保留的文字信息)。
|
||||
* 刻意没有任何垫底/暗晕(用户拍板):白底界面上宁可对比度弱,也不要黑色遮挡。
|
||||
*/
|
||||
export function VoiceOrbStage({ os, state, level, warming, warmupMs, message }: VoiceOrbStageProps) {
|
||||
const { t } = useTranslation();
|
||||
const metrics = useMemo(() => getCapsulePillMetrics(os), [os]);
|
||||
|
||||
// done / cancelled / error 冻结最后形态淡出,不再切换 phase。
|
||||
const lastPhaseRef = useRef<'wave' | 'orb'>('wave');
|
||||
let phase = lastPhaseRef.current;
|
||||
if (state === 'recording') phase = 'wave';
|
||||
else if (state === 'transcribing' || state === 'polishing') phase = 'orb';
|
||||
lastPhaseRef.current = phase;
|
||||
const isOrb = phase === 'orb';
|
||||
|
||||
// 性能:波形淡出彻底结束(.55s delay + .6s duration)后卸载它的绘制循环 ——
|
||||
// 思考期间不再为一块不可见的 canvas 每帧跑 fragment。回到录音态立即重挂
|
||||
//(shader 编译已被驱动缓存,重建近零耗时)。
|
||||
const [waveAlive, setWaveAlive] = useState(true);
|
||||
useEffect(() => {
|
||||
if (!isOrb) {
|
||||
setWaveAlive(true);
|
||||
return undefined;
|
||||
}
|
||||
const timer = setTimeout(() => setWaveAlive(false), 1300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [isOrb]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: metrics.width,
|
||||
height: metrics.height,
|
||||
boxSizing: metrics.boxSizing,
|
||||
fontFamily: 'var(--ol-font-sans)',
|
||||
position: 'relative',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
{waveAlive && (
|
||||
<SiriGL
|
||||
mode="wave"
|
||||
level={level}
|
||||
resolved={!isOrb}
|
||||
warming={warming}
|
||||
warmupMs={warmupMs}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
// 收缩汇聚进行时波形保持可见,收成中央光点后再淡出,与圆点环的淡入交叠。
|
||||
opacity: isOrb ? 0 : 1,
|
||||
transition: isOrb ? 'opacity .6s ease-out .55s' : 'opacity .25s ease-out',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isOrb && (
|
||||
<SiriGL
|
||||
mode="orb"
|
||||
// 思考中(LLM 接收)加速转动;插入/取消/出错时回落到标准速度,
|
||||
// 同时六点合并成中央一颗圆,随外层淡出一起消失。
|
||||
speed={state === 'transcribing' || state === 'polishing' ? 1.5 : 1.0}
|
||||
merging={state !== 'transcribing' && state !== 'polishing'}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
top: '50%',
|
||||
width: 170,
|
||||
height: 170,
|
||||
marginLeft: -85,
|
||||
marginTop: -85,
|
||||
animation: 'siri-orb-in .7s ease-out .3s both',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{state === 'error' && <span style={errorGlowTextStyle}>{message || t('capsule.error')}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const errorGlowTextStyle: CSSProperties = {
|
||||
position: 'absolute',
|
||||
bottom: 24,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
maxWidth: 400,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.4,
|
||||
textAlign: 'center',
|
||||
color: 'var(--ol-err)',
|
||||
padding: '6px 12px',
|
||||
background: 'var(--ol-capsule-pill-bg)',
|
||||
border: '1px solid var(--ol-capsule-pill-border)',
|
||||
borderRadius: 12,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
};
|
||||
@@ -86,8 +86,8 @@ for (const os of ['mac', 'win', 'linux'] as const) {
|
||||
const classic = getCapsuleHostMetrics(os, false, 'classic');
|
||||
const typeless = getCapsuleHostMetrics(os, true, 'typeless');
|
||||
assertEqual(classic.height, 100, `${os}: classic uses the compact native window`);
|
||||
assertEqual(typeless.height, 48, `${os}: typeless window is 1/7 of the old 460x128 area`);
|
||||
assertEqual(typeless.width, 174, `${os}: typeless window keeps the 1/7 stage width`);
|
||||
assertEqual(typeless.height, 57, `${os}: typeless window is 1/5 of the old 460x128 area`);
|
||||
assertEqual(typeless.width, 206, `${os}: typeless window keeps the 1/5 stage width`);
|
||||
assertEqual(
|
||||
typeless.bottomInset,
|
||||
0,
|
||||
|
||||
@@ -31,10 +31,10 @@ const VOICE_ORB_STAGE_WIDTH = 460;
|
||||
const VOICE_ORB_STAGE_HEIGHT = 180;
|
||||
const VOICE_ORB_TEXT_WIDTH = 400;
|
||||
|
||||
// typeless 窗口面积是旧尺寸(460×128)的 1/7;内容由 CapsuleStyles.css 的 zoom 缩放,
|
||||
// typeless 窗口面积是原尺寸(460×128)的 1/5;内容由 CapsuleStyles.css 的 zoom 缩放,
|
||||
// 与 src-tauri/src/lib.rs 的 capsule_window_bounds_for_style 保持一致。
|
||||
const TYPELESS_STAGE_WIDTH = 174;
|
||||
const TYPELESS_STAGE_HEIGHT = 48;
|
||||
const TYPELESS_STAGE_WIDTH = 206;
|
||||
const TYPELESS_STAGE_HEIGHT = 57;
|
||||
|
||||
export function parseCapsuleStyle(value: unknown): CapsuleStyle | undefined {
|
||||
return value === 'siri' || value === 'classic' || value === 'typeless' ? value : undefined;
|
||||
|
||||
Reference in new issue
Block a user