From 7204c282f1e0950a306957b2213d3be580d809a7 Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 10 Sep 2026 13:15:46 +0800 Subject: [PATCH] =?UTF-8?q?feat(capsule):=20typeless=20=E6=94=BE=E5=A4=A7?= =?UTF-8?q?=E5=88=B0=E5=8E=9F=E7=89=88=201/5=20=E9=9D=A2=E7=A7=AF=E3=80=81?= =?UTF-8?q?=E9=BB=91=E5=BA=95=E7=99=BD=E6=B3=A2=E7=BA=B9=EF=BC=9BSiri=20?= =?UTF-8?q?=E9=A2=84=E8=A7=88=E6=94=B9=E7=94=A8=E7=9C=9F=E5=AE=9E=E5=85=89?= =?UTF-8?q?=E6=95=88=E8=88=9E=E5=8F=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 窗口 174×48 → 206×57,CSS zoom 0.378 → 0.447(面积 = 原版 460×128 的 1/5) - typeless 配色改为黑色胶囊 + 白色波纹(深色主题同调) - VoiceOrbStage 提取为独立组件;设置页 Siri 预览直接渲染缩放 0.5 的真实 SiriGL 光条,与实际胶囊样式一致 --- openless-all/app/src-tauri/src/lib.rs | 10 +- openless-all/app/src/components/Capsule.tsx | 121 +---------------- .../app/src/components/CapsuleStyles.css | 51 ++++---- .../app/src/components/TypelessCapsule.tsx | 3 +- .../app/src/components/VoiceOrbStage.tsx | 123 ++++++++++++++++++ .../app/src/lib/capsuleLayout.test.ts | 4 +- openless-all/app/src/lib/capsuleLayout.ts | 6 +- 7 files changed, 162 insertions(+), 156 deletions(-) create mode 100644 openless-all/app/src/components/VoiceOrbStage.tsx diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 1d37e639..ee24582d 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -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] diff --git a/openless-all/app/src/components/Capsule.tsx b/openless-all/app/src/components/Capsule.tsx index 36f0ecb3..289e0f1f 100644 --- a/openless-all/app/src/components/Capsule.tsx +++ b/openless-all/app/src/components/Capsule.tsx @@ -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 ( -
- {waveAlive && ( - - )} - {isOrb && ( - - )} - {state === 'error' && {message || t('capsule.error')}} -
- ); -} - -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; diff --git a/openless-all/app/src/components/CapsuleStyles.css b/openless-all/app/src/components/CapsuleStyles.css index 63ab99c0..d8fe3f44 100644 --- a/openless-all/app/src/components/CapsuleStyles.css +++ b/openless-all/app/src/components/CapsuleStyles.css @@ -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; } diff --git a/openless-all/app/src/components/TypelessCapsule.tsx b/openless-all/app/src/components/TypelessCapsule.tsx index 9aeede94..b9d75f38 100644 --- a/openless-all/app/src/components/TypelessCapsule.tsx +++ b/openless-all/app/src/components/TypelessCapsule.tsx @@ -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 }) { ) : (
- +
)} diff --git a/openless-all/app/src/components/VoiceOrbStage.tsx b/openless-all/app/src/components/VoiceOrbStage.tsx new file mode 100644 index 00000000..1f903cc1 --- /dev/null +++ b/openless-all/app/src/components/VoiceOrbStage.tsx @@ -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 ( +
+ {waveAlive && ( + + )} + {isOrb && ( + + )} + {state === 'error' && {message || t('capsule.error')}} +
+ ); +} + +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', +}; diff --git a/openless-all/app/src/lib/capsuleLayout.test.ts b/openless-all/app/src/lib/capsuleLayout.test.ts index 5742a811..0919d1c0 100644 --- a/openless-all/app/src/lib/capsuleLayout.test.ts +++ b/openless-all/app/src/lib/capsuleLayout.test.ts @@ -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, diff --git a/openless-all/app/src/lib/capsuleLayout.ts b/openless-all/app/src/lib/capsuleLayout.ts index fb0bcee1..0b0f3558 100644 --- a/openless-all/app/src/lib/capsuleLayout.ts +++ b/openless-all/app/src/lib/capsuleLayout.ts @@ -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;