Skip to content

Commit e637b4f

Browse files
claude-code-bestglm-5.2
andcommitted
feat(effort): 波纹 v4 — 平滑波 + 全色环旋转 + 淡入淡出 + 宽度自适应
- 波函数改 (sin+1)/2:消除 max(0,sin) 平直暗带(约 6 行宽) - 主色相连续旋转(0.03°/ms,12s/圈全色环):蓝→紫→品红→红→橙→黄→绿→青 - 文字 overlay 同步色相旋转(rotateHue 应用到 Faster/▲/档位名/分隔线/副标签) - 淡入淡出动画:fadeColor/fadeCells + fade 状态机 ~300ms 进出过渡 - 副标签固定 ultracode 段下方,不跟随光标移动 - 顶部/底部各加一行纯波纹行,视觉一致 - 宽度自适应终端列数:窄则 72,宽则铺满(computeSegment/computeRippleSourceX) - 快捷键改 plain Text,不参与波纹背景渲染 - 新增 18 测试(fadeColor/fadeCells/rotateHue/getHueShiftAtTime) Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
1 parent 4903f54 commit e637b4f

4 files changed

Lines changed: 514 additions & 62 deletions

File tree

src/components/EffortPanel/EffortPanel.tsx

Lines changed: 143 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import * as React from 'react';
2-
import { BaseText, Box, Text } from '@anthropic/ink';
2+
import { BaseText, Box, Text, useTerminalSize } from '@anthropic/ink';
33
import { useKeybindings } from '../../keybindings/useKeybinding.js';
44
import { type EffortValue, getDisplayedEffortLevel, getEffortEnvOverride } from '../../utils/effort.js';
55
import {
@@ -22,30 +22,70 @@ import {
2222
applyOverlaysToCells,
2323
cellsToSegments,
2424
computeRippleCells,
25+
fadeCells,
26+
getHueShiftAtTime,
27+
rotateHue,
2528
} from './rippleAnimation.js';
2629

27-
// 每档固定宽度,Ink Box 自动对齐。PANEL_WIDTH = SEGMENT * 6。
28-
const SEGMENT = 12;
29-
const PANEL_WIDTH = SEGMENT * PANEL_POSITIONS.length;
30+
/**
31+
* 每档最小宽度(足够装下 'ultracode' 9 字符 + 居中留白)。
32+
* 当终端窄时使用此值,保证最低可读性。
33+
*/
34+
const MIN_SEGMENT = 12;
35+
3036
const SUBLABEL_ULTRACODE = 'xhigh + workflows';
3137

3238
// 颜色:与项目主题对齐(suggestion=Medium blue #5769F7)。
3339
const COLOR_LABEL_SELECTED = '#5769F7'; // 选中档位(suggestion)
34-
const COLOR_LABEL_DEFAULT = '#8a8a8a'; // 未选中档位(subtle gray
40+
const COLOR_LABEL_DEFAULT = '#7a8eff'; // 未选中档位(淡紫蓝,与波纹背景协调
3541
const COLOR_OVERLAY = '#5769F7'; // Faster / Smarter / ▲ 等 overlay 文字
3642

37-
// 波纹震源坐标(相对波纹区域坐标系,y=0 是档位名行)
38-
// ultracode 字符在 SEGMENT*5=60 起始段内居中(9 字符 in 12 列 → 偏移 1.5 → 1),
39-
// 中心列 ≈ 60 + 1 + 4 = 65。
40-
const RIPPLE_SOURCE_X = SEGMENT * 5 + 5;
43+
// 淡入淡出每帧步长:60ms 间隔下 5 帧达到目标 ≈ 300ms 动画时长
44+
const FADE_STEP = 0.2;
45+
46+
// 波纹震源 y 坐标(相对波纹区域坐标系,y=0 是档位名行)。
4147
const RIPPLE_SOURCE_Y = 0;
4248

49+
/**
50+
* 根据终端宽度计算每档实际宽度(SEGMENT)。
51+
*
52+
* 规则:
53+
* - 留出 paddingX={1} 的左右各 1 列 → 可用宽度 = columns - 2
54+
* - 若可用宽度 <= MIN_SEGMENT * 6(72),用 MIN_SEGMENT(保持当前窄布局)
55+
* - 否则铺满:floor(可用宽度 / 6)
56+
*
57+
* 即"窄则不变,宽则铺满"。最小宽度保证 'ultracode' 9 字符能正常显示。
58+
*/
59+
function computeSegment(terminalColumns: number): number {
60+
const available = terminalColumns - 2; // paddingX={1} 两侧
61+
const minNeeded = MIN_SEGMENT * PANEL_POSITIONS.length;
62+
if (available <= minNeeded) return MIN_SEGMENT;
63+
return Math.floor(available / PANEL_POSITIONS.length);
64+
}
65+
66+
/**
67+
* 计算波纹震源 x 坐标(ultracode 段内 'ultracode' 标签的中心列)。
68+
*
69+
* 'ultracode' 是 9 字符,在 SEGMENT 列内居中:
70+
* offset = floor((SEGMENT - 9) / 2)
71+
* labelCenter = SEGMENT * 5 + offset + 4 (4 是 9 字符串的中心偏移)
72+
*
73+
* SEGMENT=12 → 60 + 1 + 4 = 65(与历史值一致)
74+
* SEGMENT=20 → 100 + 5 + 4 = 109
75+
*/
76+
function computeRippleSourceX(segment: number): number {
77+
const LABEL_LEN = 9; // 'ultracode'
78+
const offset = Math.max(0, Math.floor((segment - LABEL_LEN) / 2));
79+
const labelCenter = Math.floor(LABEL_LEN / 2); // 4
80+
return segment * (PANEL_POSITIONS.length - 1) + offset + labelCenter;
81+
}
82+
4383
/**
4484
* 计算某段 idx 内居中文字的起始列。
45-
* 'ultracode' = 9 字符 in SEGMENT=12 → offset = floor((12-9)/2) = 1
85+
* 动态 segment:textLen 字符在 segment 列内居中
4686
*/
47-
function segmentTextStartX(idx: number, textLen: number): number {
48-
return SEGMENT * idx + Math.max(0, Math.floor((SEGMENT - textLen) / 2));
87+
function segmentTextStartX(idx: number, textLen: number, segment: number): number {
88+
return segment * idx + Math.max(0, Math.floor((segment - textLen) / 2));
4989
}
5090

5191
type Props = {
@@ -56,6 +96,13 @@ type Props = {
5696
export function EffortPanel({ appStateEffort, onDone }: Props): React.ReactNode {
5797
const setAppState = useSetAppState();
5898
const model = useMainLoopModel();
99+
const { columns } = useTerminalSize();
100+
101+
// 自适应宽度:根据终端列数计算每档宽度。
102+
// 终端变化(resize)时 columns 改变 → 重新计算 → 重渲染。
103+
const segment = React.useMemo(() => computeSegment(columns), [columns]);
104+
const panelWidth = segment * PANEL_POSITIONS.length;
105+
const rippleSourceX = React.useMemo(() => computeRippleSourceX(segment), [segment]);
59106

60107
const envOverride = getEffortEnvOverride();
61108
const displayed = getDisplayedEffortLevel(model, appStateEffort);
@@ -64,8 +111,23 @@ export function EffortPanel({ appStateEffort, onDone }: Props): React.ReactNode
64111
const [cursor, setCursor] = React.useState<PanelPosition>(initialCursor);
65112
const [done, setDone] = React.useState(false);
66113

67-
const rippleActive = cursor === 'ultracode';
68-
const [rippleRef, time] = useRippleFrame(rippleActive);
114+
const isOnUltracode = cursor === 'ultracode';
115+
const [fade, setFade] = React.useState(0);
116+
// 仍在波纹模式:cursor 在 ultracode,或退出动画未结束(fade > 0)
117+
const showingRipple = isOnUltracode || fade > 0.001;
118+
const [rippleRef, time] = useRippleFrame(showingRipple);
119+
120+
// 淡入淡出驱动:每 tick(time 推进)朝目标步进 FADE_STEP。
121+
// 退出动画完成后 fade 归零,showingRipple 变 false,时钟停止订阅。
122+
React.useEffect(() => {
123+
if (!showingRipple) return;
124+
const target = isOnUltracode ? 1 : 0;
125+
setFade(prev => {
126+
if (prev === target) return prev;
127+
const next = target > prev ? prev + FADE_STEP : prev - FADE_STEP;
128+
return target > prev ? Math.min(target, next) : Math.max(target, next);
129+
});
130+
}, [time, isOnUltracode, showingRipple]);
69131

70132
const handleConfirm = React.useCallback(() => {
71133
if (done) return;
@@ -102,32 +164,42 @@ export function EffortPanel({ appStateEffort, onDone }: Props): React.ReactNode
102164
const envRaw = process.env.CLAUDE_CODE_EFFORT_LEVEL;
103165

104166
// 波纹行 cells 计算:返回该行所有 cell(含 overlay 文字)
167+
// fade 控制背景颜色亮度(0 → 全 transparent,1 → 完整波纹)。
168+
// 文字 overlay 也乘以 fade,让进入/退出动画整体淡入淡出。
105169
const renderRippleRow = React.useCallback(
106170
(relY: number, overlays: Overlay[]): Segment[] => {
107171
const cells = computeRippleCells({
108172
y: relY + RIPPLE_SOURCE_Y,
109-
width: PANEL_WIDTH,
173+
width: panelWidth,
110174
time,
111-
sourceX: RIPPLE_SOURCE_X,
175+
sourceX: rippleSourceX,
112176
sourceY: RIPPLE_SOURCE_Y,
113177
});
114178
const overlayed = applyOverlaysToCells(cells, overlays);
115-
return cellsToSegments(overlayed);
179+
const faded = fadeCells(overlayed, fade);
180+
return cellsToSegments(faded);
116181
},
117-
[time],
182+
[time, fade, panelWidth, rippleSourceX],
118183
);
119184

120185
return (
121-
<Box ref={rippleRef} flexDirection="column" paddingX={1} width={PANEL_WIDTH + 2}>
186+
<Box ref={rippleRef} flexDirection="column" paddingX={1} width={panelWidth + 2}>
122187
<Text bold color="suggestion">
123188
Effort
124189
</Text>
125190
{envActive && <Text color="warning">{`⚠ CLAUDE_CODE_EFFORT_LEVEL=${envRaw} overrides this session`}</Text>}
126-
{rippleActive ? (
127-
<RippleContent renderRow={renderRippleRow} cursor={cursor} />
191+
{showingRipple ? (
192+
<RippleContent
193+
renderRow={renderRippleRow}
194+
cursor={cursor}
195+
fade={fade}
196+
segment={segment}
197+
panelWidth={panelWidth}
198+
time={time}
199+
/>
128200
) : (
129201
<>
130-
<PlainContent cursor={cursor} />
202+
<PlainContent cursor={cursor} segment={segment} panelWidth={panelWidth} />
131203
<Box marginTop={1}>
132204
<Text color="subtle">←/→ adjust · Enter confirm · Esc cancel</Text>
133205
</Box>
@@ -139,17 +211,25 @@ export function EffortPanel({ appStateEffort, onDone }: Props): React.ReactNode
139211

140212
// ---- 普通模式(无波纹)----
141213

142-
function PlainContent({ cursor }: { cursor: PanelPosition }): React.ReactNode {
214+
function PlainContent({
215+
cursor,
216+
segment,
217+
panelWidth,
218+
}: {
219+
cursor: PanelPosition;
220+
segment: number;
221+
panelWidth: number;
222+
}): React.ReactNode {
143223
return (
144224
<>
145225
<Box marginTop={1} flexDirection="row" justifyContent="space-between">
146226
<Text color="suggestion">Faster</Text>
147227
<Text color="suggestion">Smarter</Text>
148228
</Box>
149-
<Text color="subtle">{'─'.repeat(PANEL_WIDTH)}</Text>
229+
<Text color="subtle">{'─'.repeat(panelWidth)}</Text>
150230
<Box flexDirection="row">
151231
{PANEL_POSITIONS.map(p => (
152-
<Box key={`cursor-${p}`} width={SEGMENT} justifyContent="center">
232+
<Box key={`cursor-${p}`} width={segment} justifyContent="center">
153233
<Text bold color={cursor === p ? 'suggestion' : 'subtle'}>
154234
{cursor === p ? '▲' : ' '}
155235
</Text>
@@ -158,16 +238,16 @@ function PlainContent({ cursor }: { cursor: PanelPosition }): React.ReactNode {
158238
</Box>
159239
<Box flexDirection="row">
160240
{PANEL_POSITIONS.map(p => (
161-
<Box key={`label-${p}`} width={SEGMENT} justifyContent="center">
241+
<Box key={`label-${p}`} width={segment} justifyContent="center">
162242
<Text bold={cursor === p} color={cursor === p ? 'suggestion' : 'subtle'}>
163243
{p}
164244
</Text>
165245
</Box>
166246
))}
167247
</Box>
168248
<Box flexDirection="row">
169-
<Box width={SEGMENT * (PANEL_POSITIONS.length - 1)} />
170-
<Box width={SEGMENT} justifyContent="center">
249+
<Box width={segment * (PANEL_POSITIONS.length - 1)} />
250+
<Box width={segment} justifyContent="center">
171251
<Text color="subtle">{SUBLABEL_ULTRACODE}</Text>
172252
</Box>
173253
</Box>
@@ -193,58 +273,73 @@ function PlainContent({ cursor }: { cursor: PanelPosition }): React.ReactNode {
193273
type RippleContentProps = {
194274
renderRow: (relY: number, overlays: Overlay[]) => Segment[];
195275
cursor: PanelPosition;
276+
fade: number;
277+
segment: number;
278+
panelWidth: number;
279+
time: number;
196280
};
197281

198-
function RippleContent({ renderRow, cursor }: RippleContentProps): React.ReactNode {
199-
const cursorIdx = PANEL_POSITIONS.indexOf('ultracode');
282+
function RippleContent({ renderRow, cursor, segment, panelWidth, time }: RippleContentProps): React.ReactNode {
283+
// 光标索引跟随 cursor(退出动画期间 cursor 已移到别处,
284+
// 让 ▲ overlay 跟着移走,ultracode 段恢复普通背景色)。
285+
const cursorIdx = PANEL_POSITIONS.indexOf(cursor);
286+
// 副标签固定在 ultracode 段下方,不跟随光标移动。
287+
const ultracodeIdx = PANEL_POSITIONS.length - 1;
288+
289+
// 文字颜色跟随波浪色相旋转:取当前 time 的 hueShift,
290+
// 应用到所有 overlay 颜色,让文字与背景色环保持同步。
291+
const hueShift = getHueShiftAtTime(time);
292+
const overlayColor = rotateHue(COLOR_OVERLAY, hueShift);
293+
const labelSelectedColor = rotateHue(COLOR_LABEL_SELECTED, hueShift);
294+
const labelDefaultColor = rotateHue(COLOR_LABEL_DEFAULT, hueShift);
200295

201-
const fasterOverlay: Overlay = { text: 'Faster', x: 0, color: COLOR_OVERLAY };
296+
const fasterOverlay: Overlay = { text: 'Faster', x: 0, color: overlayColor };
202297
const smarterOverlay: Overlay = {
203298
text: 'Smarter',
204-
x: PANEL_WIDTH - 'Smarter'.length,
205-
color: COLOR_OVERLAY,
299+
x: panelWidth - 'Smarter'.length,
300+
color: overlayColor,
206301
};
207302
const separatorOverlay: Overlay = {
208-
text: '─'.repeat(PANEL_WIDTH),
303+
text: '─'.repeat(panelWidth),
209304
x: 0,
210-
color: COLOR_LABEL_DEFAULT,
305+
color: labelDefaultColor,
211306
};
212307
const cursorOverlay: Overlay = {
213308
text: '▲',
214-
x: segmentTextStartX(cursorIdx, 1),
215-
color: COLOR_OVERLAY,
309+
x: segmentTextStartX(cursorIdx, 1, segment),
310+
color: overlayColor,
216311
};
217312
const labelOverlays: Overlay[] = PANEL_POSITIONS.map((p, idx) => ({
218313
text: p,
219-
x: segmentTextStartX(idx, p.length),
220-
color: p === cursor ? COLOR_LABEL_SELECTED : COLOR_LABEL_DEFAULT,
314+
x: segmentTextStartX(idx, p.length, segment),
315+
color: p === cursor ? labelSelectedColor : labelDefaultColor,
221316
}));
222317
const sublabelOverlay: Overlay = {
223318
text: SUBLABEL_ULTRACODE,
224-
x: segmentTextStartX(cursorIdx, SUBLABEL_ULTRACODE.length),
225-
color: COLOR_LABEL_DEFAULT,
226-
};
227-
const hintOverlay: Overlay = {
228-
text: '←/→ adjust · Enter confirm · Esc cancel',
229-
x: 0,
230-
color: COLOR_LABEL_DEFAULT,
319+
x: segmentTextStartX(ultracodeIdx, SUBLABEL_ULTRACODE.length, segment),
320+
color: labelDefaultColor,
231321
};
232322

233323
// 各行 y 坐标(相对震源 RIPPLE_SOURCE_Y = 档位名行)
324+
// y=-4: 顶部纯波纹行(视觉一致,无 overlay)
234325
// y=-3: Faster/Smarter
235326
// y=-2: 分隔线
236327
// y=-1: ▲
237328
// y=0: 档位名(震源)
238329
// y=1: 副标签
239-
// y=2: 快捷键行(y 方向延伸覆盖到底部)
330+
// y=2: 底部纯波纹行(视觉一致,无 overlay)
331+
//
332+
// 快捷键行:plain Text,不参与波纹渲染(无背景动画),紧贴底部波纹行。
240333
return (
241334
<>
335+
<RippleRow segments={renderRow(-4, [])} />
242336
<RippleRow segments={renderRow(-3, [fasterOverlay, smarterOverlay])} />
243337
<RippleRow segments={renderRow(-2, [separatorOverlay])} />
244338
<RippleRow segments={renderRow(-1, [cursorOverlay])} />
245339
<RippleRow segments={renderRow(0, labelOverlays)} />
246340
<RippleRow segments={renderRow(1, [sublabelOverlay])} />
247-
<RippleRow segments={renderRow(2, [hintOverlay])} />
341+
<RippleRow segments={renderRow(2, [])} />
342+
<Text color={COLOR_LABEL_DEFAULT}>←/→ adjust · Enter confirm · Esc cancel</Text>
248343
</>
249344
);
250345
}

0 commit comments

Comments
 (0)