mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 12:55:51 +00:00
refactor(effort): 波纹动画从字符密度改为颜色渐变
按原版风格把波纹背景从 INTENSITY_CHARS 密度字符('·∙░▒▓')改为 suggestion 系颜色渐变(transparent → 暗深紫蓝 → suggestion → 高光): rippleAnimation.ts: - 删除 pickChar / INTENSITY_CHARS / WAVE_PEAK_CHARS / mergeLayers - 新增 intensityToColor(intensity) → 'transparent' | '#xxxxxx' - 新增 computeRippleCells 返回 Cell[](每位置 char+color) - 新增 applyOverlaysToCells(cells, overlays) 替代 mergeLayers - 新增 cellsToSegments(cells) 合并相邻同色段(减少 Text 节点) EffortPanel.tsx: - RippleContent 用 cells→segments→tokens 渲染 - 空格段用 BaseText backgroundColor 染色块(纯色块视觉) - 文字段用 Text color 染色(亮色突出) - tokens 按空格/文字二次拆分,避免混合段渲染歧义 测试: 29 个 rippleAnimation 测试覆盖 intensityToColor 边界、 computeRippleCells 长度/震源/衰减、applyOverlaysToCells 覆盖/截断/ 防御式拷贝、cellsToSegments 合并逻辑。 Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import * as React from 'react';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import { BaseText, Box, Text } from '@anthropic/ink';
|
||||
import { useKeybindings } from '../../keybindings/useKeybinding.js';
|
||||
import { type EffortValue, getDisplayedEffortLevel, getEffortEnvOverride } from '../../utils/effort.js';
|
||||
import {
|
||||
@@ -15,13 +15,25 @@ import { executeEffort } from '../../commands/effort/effort.js';
|
||||
import { useMainLoopModel } from '../../hooks/useMainLoopModel.js';
|
||||
import { useSetAppState } from '../../state/AppState.js';
|
||||
import { useRippleFrame } from './useRippleFrame.js';
|
||||
import { type Overlay, computeRippleLine, mergeLayers } from './rippleAnimation.js';
|
||||
import {
|
||||
TRANSPARENT,
|
||||
type Overlay,
|
||||
type Segment,
|
||||
applyOverlaysToCells,
|
||||
cellsToSegments,
|
||||
computeRippleCells,
|
||||
} from './rippleAnimation.js';
|
||||
|
||||
// 每档固定宽度,Ink Box 自动对齐。PANEL_WIDTH = SEGMENT * 6。
|
||||
const SEGMENT = 12;
|
||||
const PANEL_WIDTH = SEGMENT * PANEL_POSITIONS.length;
|
||||
const SUBLABEL_ULTRACODE = 'xhigh + workflows';
|
||||
|
||||
// 颜色:与项目主题对齐(suggestion=Medium blue #5769F7)。
|
||||
const COLOR_LABEL_SELECTED = '#5769F7'; // 选中档位(suggestion)
|
||||
const COLOR_LABEL_DEFAULT = '#8a8a8a'; // 未选中档位(subtle gray)
|
||||
const COLOR_OVERLAY = '#5769F7'; // Faster / Smarter / ▲ 等 overlay 文字
|
||||
|
||||
// 波纹震源坐标(相对波纹区域坐标系,y=0 是档位名行)。
|
||||
// ultracode 字符在 SEGMENT*5=60 起始段内居中(9 字符 in 12 列 → 偏移 1.5 → 1),
|
||||
// 中心列 ≈ 60 + 1 + 4 = 65。
|
||||
@@ -89,17 +101,18 @@ export function EffortPanel({ appStateEffort, onDone }: Props): React.ReactNode
|
||||
const envActive = envOverride !== null && envOverride !== undefined;
|
||||
const envRaw = process.env.CLAUDE_CODE_EFFORT_LEVEL;
|
||||
|
||||
// 波纹行渲染:返回 merge 后的单字符串。
|
||||
const renderRippleLine = React.useCallback(
|
||||
(relY: number, overlays: Overlay[]): string => {
|
||||
const ripple = computeRippleLine({
|
||||
// 波纹行 cells 计算:返回该行所有 cell(含 overlay 文字)
|
||||
const renderRippleRow = React.useCallback(
|
||||
(relY: number, overlays: Overlay[]): Segment[] => {
|
||||
const cells = computeRippleCells({
|
||||
y: relY + RIPPLE_SOURCE_Y,
|
||||
width: PANEL_WIDTH,
|
||||
time,
|
||||
sourceX: RIPPLE_SOURCE_X,
|
||||
sourceY: RIPPLE_SOURCE_Y,
|
||||
});
|
||||
return mergeLayers(ripple, overlays);
|
||||
const overlayed = applyOverlaysToCells(cells, overlays);
|
||||
return cellsToSegments(overlayed);
|
||||
},
|
||||
[time],
|
||||
);
|
||||
@@ -110,11 +123,7 @@ export function EffortPanel({ appStateEffort, onDone }: Props): React.ReactNode
|
||||
Effort
|
||||
</Text>
|
||||
{envActive && <Text color="warning">{`⚠ CLAUDE_CODE_EFFORT_LEVEL=${envRaw} overrides this session`}</Text>}
|
||||
{rippleActive ? (
|
||||
<RippleContent time={time} renderLine={renderRippleLine} cursor={cursor} />
|
||||
) : (
|
||||
<PlainContent cursor={cursor} />
|
||||
)}
|
||||
{rippleActive ? <RippleContent renderRow={renderRippleRow} cursor={cursor} /> : <PlainContent cursor={cursor} />}
|
||||
<Box marginTop={1}>
|
||||
<Text color="subtle">←/→ adjust · Enter confirm · Esc cancel</Text>
|
||||
</Box>
|
||||
@@ -161,44 +170,53 @@ function PlainContent({ cursor }: { cursor: PanelPosition }): React.ReactNode {
|
||||
}
|
||||
|
||||
// ---- 波纹模式(cursor === 'ultracode')----
|
||||
//
|
||||
// 渲染策略:
|
||||
// - 每行先 computeRippleCells 算出强度→颜色的 cell 数组(背景为空格 + 颜色)
|
||||
// - applyOverlaysToCells 把文字 overlay(Faster/▲/档位名/副标签)写入对应 cell
|
||||
// - cellsToSegments 合并相邻同色段
|
||||
// - 渲染层遍历 segments:每个段判断是"空格波纹段"还是"文字段"
|
||||
// - 空格段:用 backgroundColor 把空格染成色块(pure color block)
|
||||
// - 文字段:用 color 染色文字(背景保持终端默认,让文字最清晰)
|
||||
// - 混合段(既有空格又有文字,少见):拆为前后两个 Text
|
||||
//
|
||||
// 注意:Segment 内可能同时有空格和非空格字符(如 " Faster " 居中文字)。
|
||||
// 这种段用 color 渲染时,空格部分不显示色块——视觉上"色块断裂"。
|
||||
// 解决:渲染时把 segment 按字符类型二次拆分(runs of whitespace vs non-whitespace)。
|
||||
|
||||
type RippleContentProps = {
|
||||
time: number;
|
||||
renderLine: (relY: number, overlays: Overlay[]) => string;
|
||||
renderRow: (relY: number, overlays: Overlay[]) => Segment[];
|
||||
cursor: PanelPosition;
|
||||
};
|
||||
|
||||
function RippleContent({ renderLine }: RippleContentProps): React.ReactNode {
|
||||
// 各档位名 overlay(基于段中心对齐)
|
||||
const labelOverlays: Overlay[] = PANEL_POSITIONS.map((p, idx) => ({
|
||||
text: p,
|
||||
x: segmentTextStartX(idx, p.length),
|
||||
}));
|
||||
|
||||
// ▲ overlay:放在 ultracode 段中心
|
||||
function RippleContent({ renderRow, cursor }: RippleContentProps): React.ReactNode {
|
||||
const cursorIdx = PANEL_POSITIONS.indexOf('ultracode');
|
||||
const cursorOverlay: Overlay = {
|
||||
text: '▲',
|
||||
x: segmentTextStartX(cursorIdx, 1),
|
||||
};
|
||||
|
||||
// 副标签 overlay:放在 ultracode 段中心
|
||||
const sublabelOverlay: Overlay = {
|
||||
text: SUBLABEL_ULTRACODE,
|
||||
x: segmentTextStartX(cursorIdx, SUBLABEL_ULTRACODE.length),
|
||||
};
|
||||
|
||||
// Faster / Smarter overlay
|
||||
const fasterOverlay: Overlay = { text: 'Faster', x: 0 };
|
||||
const fasterOverlay: Overlay = { text: 'Faster', x: 0, color: COLOR_OVERLAY };
|
||||
const smarterOverlay: Overlay = {
|
||||
text: 'Smarter',
|
||||
x: PANEL_WIDTH - 'Smarter'.length,
|
||||
color: COLOR_OVERLAY,
|
||||
};
|
||||
|
||||
// 分隔线 overlay
|
||||
const separatorOverlay: Overlay = {
|
||||
text: '─'.repeat(PANEL_WIDTH),
|
||||
x: 0,
|
||||
color: COLOR_LABEL_DEFAULT,
|
||||
};
|
||||
const cursorOverlay: Overlay = {
|
||||
text: '▲',
|
||||
x: segmentTextStartX(cursorIdx, 1),
|
||||
color: COLOR_OVERLAY,
|
||||
};
|
||||
const labelOverlays: Overlay[] = PANEL_POSITIONS.map((p, idx) => ({
|
||||
text: p,
|
||||
x: segmentTextStartX(idx, p.length),
|
||||
color: p === cursor ? COLOR_LABEL_SELECTED : COLOR_LABEL_DEFAULT,
|
||||
}));
|
||||
const sublabelOverlay: Overlay = {
|
||||
text: SUBLABEL_ULTRACODE,
|
||||
x: segmentTextStartX(cursorIdx, SUBLABEL_ULTRACODE.length),
|
||||
color: COLOR_LABEL_DEFAULT,
|
||||
};
|
||||
|
||||
// 各行 y 坐标(相对震源 RIPPLE_SOURCE_Y = 档位名行)
|
||||
@@ -207,21 +225,76 @@ function RippleContent({ renderLine }: RippleContentProps): React.ReactNode {
|
||||
// y=-1: ▲
|
||||
// y=0: 档位名(震源)
|
||||
// y=1: 副标签
|
||||
const fasterLine = renderLine(-3, [fasterOverlay, smarterOverlay]);
|
||||
const separatorLine = renderLine(-2, [separatorOverlay]);
|
||||
const cursorLine = renderLine(-1, [cursorOverlay]);
|
||||
const labelLine = renderLine(0, labelOverlays);
|
||||
const sublabelLine = renderLine(1, [sublabelOverlay]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text color="subtle">{fasterLine}</Text>
|
||||
<Text color="subtle">{separatorLine}</Text>
|
||||
<Text color="subtle">{cursorLine}</Text>
|
||||
<Text color="suggestion" bold>
|
||||
{labelLine}
|
||||
</Text>
|
||||
<Text color="subtle">{sublabelLine}</Text>
|
||||
<RippleRow segments={renderRow(-3, [fasterOverlay, smarterOverlay])} />
|
||||
<RippleRow segments={renderRow(-2, [separatorOverlay])} />
|
||||
<RippleRow segments={renderRow(-1, [cursorOverlay])} />
|
||||
<RippleRow segments={renderRow(0, labelOverlays)} />
|
||||
<RippleRow segments={renderRow(1, [sublabelOverlay])} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染一行波纹 segments。
|
||||
*
|
||||
* 每个 segment 可能含空格 + 文字混合(如 " Faster "):
|
||||
* - 空格部分用 backgroundColor 染色块(波纹颜色)
|
||||
* - 文字部分用 color 染色(亮色,背景保持终端默认)
|
||||
*
|
||||
* 简化策略:遍历 segment 字符,按"是否为空格"二次拆分为 token。
|
||||
* 相邻同类型 token 合并,避免 React key 爆炸。
|
||||
*/
|
||||
function RippleRow({ segments }: { segments: Segment[] }): React.ReactNode {
|
||||
const tokens: Array<{ text: string; kind: 'space' | 'text'; color: string }> = [];
|
||||
for (const seg of segments) {
|
||||
// 拆分 seg.text 为空格段和非空格段
|
||||
let buf = '';
|
||||
let bufIsSpace: boolean | null = null;
|
||||
const flush = (): void => {
|
||||
if (buf === '' || bufIsSpace === null) return;
|
||||
tokens.push({
|
||||
text: buf,
|
||||
kind: bufIsSpace ? 'space' : 'text',
|
||||
color: seg.color,
|
||||
});
|
||||
buf = '';
|
||||
bufIsSpace = null;
|
||||
};
|
||||
for (const ch of seg.text) {
|
||||
const isSpace = ch === ' ';
|
||||
if (bufIsSpace === null) {
|
||||
buf = ch;
|
||||
bufIsSpace = isSpace;
|
||||
} else if (isSpace === bufIsSpace) {
|
||||
buf += ch;
|
||||
} else {
|
||||
flush();
|
||||
buf = ch;
|
||||
bufIsSpace = isSpace;
|
||||
}
|
||||
}
|
||||
flush();
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="row">
|
||||
{tokens.map((tok, i) =>
|
||||
tok.kind === 'space' ? (
|
||||
tok.color === TRANSPARENT ? (
|
||||
<BaseText key={i}>{tok.text}</BaseText>
|
||||
) : (
|
||||
<BaseText key={i} backgroundColor={tok.color as `#${string}`}>
|
||||
{tok.text}
|
||||
</BaseText>
|
||||
)
|
||||
) : (
|
||||
<Text key={i} color={tok.color as `#${string}`} bold>
|
||||
{tok.text}
|
||||
</Text>
|
||||
),
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,169 +1,287 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
type Cell,
|
||||
type Overlay,
|
||||
computeRippleLine,
|
||||
mergeLayers,
|
||||
pickChar,
|
||||
TRANSPARENT,
|
||||
applyOverlaysToCells,
|
||||
cellsToSegments,
|
||||
computeRippleCells,
|
||||
intensityToColor,
|
||||
} from '../rippleAnimation.js'
|
||||
|
||||
describe('pickChar', () => {
|
||||
test('intensity=0 → 空格', () => {
|
||||
expect(pickChar(0)).toBe(' ')
|
||||
describe('intensityToColor', () => {
|
||||
test('intensity=0 → transparent', () => {
|
||||
expect(intensityToColor(0)).toBe(TRANSPARENT)
|
||||
})
|
||||
|
||||
test('intensity < 0 钳到 0 → 空格', () => {
|
||||
expect(pickChar(-0.5)).toBe(' ')
|
||||
test('intensity < 0 钳到 0 → transparent', () => {
|
||||
expect(intensityToColor(-0.5)).toBe(TRANSPARENT)
|
||||
})
|
||||
|
||||
test('intensity > 1 钳到 1 → 最高强度字符', () => {
|
||||
expect(pickChar(1.5)).toBe(pickChar(1))
|
||||
test('intensity ≤ 0.1 → transparent(边缘自然消失)', () => {
|
||||
expect(intensityToColor(0.05)).toBe(TRANSPARENT)
|
||||
expect(intensityToColor(0.1)).toBe(TRANSPARENT)
|
||||
})
|
||||
|
||||
test('intensity 单调递增 → 字符视觉密度递增(按字符串长度近似)', () => {
|
||||
// 字符密度:' ' < '·' < '∙' < '░' < '▒' < '▓'
|
||||
const samples = [0, 0.15, 0.35, 0.55, 0.75, 0.95]
|
||||
const chars = samples.map(pickChar)
|
||||
expect(chars[0]).toBe(' ')
|
||||
// 至少 4 种不同字符(说明分级生效)
|
||||
const unique = new Set(chars)
|
||||
expect(unique.size).toBeGreaterThanOrEqual(4)
|
||||
test('intensity > 0.1 → 非透明颜色字符串', () => {
|
||||
const c = intensityToColor(0.5)
|
||||
expect(c).not.toBe(TRANSPARENT)
|
||||
expect(typeof c).toBe('string')
|
||||
// 紫蓝色调(#hex 格式)
|
||||
expect(c).toMatch(/^#[0-9a-fA-F]{6}$/)
|
||||
})
|
||||
|
||||
test('波峰位置(intensity ≥ 0.95)出现 WAVE_PEAK_CHARS 循环字符之一', () => {
|
||||
const wavePeakChars = ['~', '◌', '○', '◑', '●']
|
||||
// 不同 time 偏移应触达不同波峰字符(基于 time / 80 的相位)
|
||||
const seen = new Set<string>()
|
||||
for (let t = 0; t < 400; t += 40) {
|
||||
seen.add(pickChar(1, t))
|
||||
}
|
||||
// 至少出现 2 种 WAVE_PEAK_CHARS(说明循环生效)
|
||||
const hits = [...seen].filter(c => wavePeakChars.includes(c))
|
||||
expect(hits.length).toBeGreaterThanOrEqual(2)
|
||||
test('intensity > 1 钳到 1 → 最高强度颜色', () => {
|
||||
expect(intensityToColor(1.5)).toBe(intensityToColor(1))
|
||||
})
|
||||
|
||||
test('intensity 单调递增 → 颜色档位递增(至少 3 档)', () => {
|
||||
const samples = [0.2, 0.4, 0.6, 0.8, 1.0]
|
||||
const colors = samples.map(intensityToColor)
|
||||
const unique = new Set(colors)
|
||||
expect(unique.size).toBeGreaterThanOrEqual(3)
|
||||
})
|
||||
|
||||
test('intensity=1 → 高光档(不是 suggestion)', () => {
|
||||
// 最高档应为 #8aa0ff(高光),区别于 #5769F7(suggestion)
|
||||
expect(intensityToColor(1)).toBe('#8aa0ff')
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeRippleLine', () => {
|
||||
test('返回字符串长度等于 width', () => {
|
||||
const line = computeRippleLine({
|
||||
describe('computeRippleCells', () => {
|
||||
test('返回数组长度等于 width', () => {
|
||||
const cells = computeRippleCells({
|
||||
y: 2,
|
||||
width: 30,
|
||||
time: 100,
|
||||
sourceX: 25,
|
||||
sourceY: 2,
|
||||
})
|
||||
expect(line.length).toBe(30)
|
||||
expect(cells.length).toBe(30)
|
||||
})
|
||||
|
||||
test('width=0 → 空字符串', () => {
|
||||
test('每个 cell 的 char 是空格', () => {
|
||||
const cells = computeRippleCells({
|
||||
y: 0,
|
||||
width: 10,
|
||||
time: 0,
|
||||
sourceX: 5,
|
||||
sourceY: 0,
|
||||
})
|
||||
for (const cell of cells) {
|
||||
expect(cell.char).toBe(' ')
|
||||
}
|
||||
})
|
||||
|
||||
test('每个 cell 的 color 是合法字符串', () => {
|
||||
const cells = computeRippleCells({
|
||||
y: 0,
|
||||
width: 10,
|
||||
time: 0,
|
||||
sourceX: 5,
|
||||
sourceY: 0,
|
||||
})
|
||||
for (const cell of cells) {
|
||||
expect(typeof cell.color).toBe('string')
|
||||
expect(
|
||||
cell.color === TRANSPARENT || /^#[0-9a-fA-F]{6}$/.test(cell.color),
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test('width=0 → 空数组', () => {
|
||||
expect(
|
||||
computeRippleLine({ y: 0, width: 0, time: 0, sourceX: 0, sourceY: 0 }),
|
||||
).toBe('')
|
||||
computeRippleCells({ y: 0, width: 0, time: 0, sourceX: 0, sourceY: 0 }),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test('震源点 (sourceX, sourceY) 处字符非空(dist=0,falloff=1)', () => {
|
||||
const line = computeRippleLine({
|
||||
test('width<0 → 空数组', () => {
|
||||
expect(
|
||||
computeRippleCells({ y: 0, width: -5, time: 0, sourceX: 0, sourceY: 0 }),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test('震源点处颜色非 transparent(dist=0,falloff=1)', () => {
|
||||
const cells = computeRippleCells({
|
||||
y: 5,
|
||||
width: 11,
|
||||
time: 0,
|
||||
sourceX: 5,
|
||||
sourceY: 5,
|
||||
})
|
||||
// 震源在 (5,5),y=5 行的第 5 列字符应非空格
|
||||
expect(line[5]).not.toBe(' ')
|
||||
// 震源在 (5,5),y=5 行的第 5 列 cell 应非 transparent
|
||||
expect(cells[5].color).not.toBe(TRANSPARENT)
|
||||
})
|
||||
|
||||
test('远离震源的字符强度衰减(远端更可能是空格)', () => {
|
||||
// 震源在左端,远端 30 列外,time=0 时强度低
|
||||
const line = computeRippleLine({
|
||||
test('远离震源的 cell 更可能是 transparent(远端衰减)', () => {
|
||||
// 震源在左端,远端 50 列外,强度低 → 大概率 transparent
|
||||
const cells = computeRippleCells({
|
||||
y: 0,
|
||||
width: 40,
|
||||
width: 50,
|
||||
time: 0,
|
||||
sourceX: 0,
|
||||
sourceY: 0,
|
||||
})
|
||||
// 第 0 列字符密度 >= 第 39 列(不一定严格,但近端更密)
|
||||
const nearDensity = line.charCodeAt(0)
|
||||
const farDensity = line.charCodeAt(39)
|
||||
// 用 charCode 近似:震源附近字符 charCode 应大于等于远端
|
||||
// 注:空格 charCode=32,其他字符 charCode 通常更大
|
||||
expect(nearDensity).toBeGreaterThanOrEqual(farDensity)
|
||||
// 远端第 49 列应为 transparent(falloff = max(0, 1-49/40) = 0)
|
||||
expect(cells[49].color).toBe(TRANSPARENT)
|
||||
})
|
||||
|
||||
test('time 推进时字符变化(动画效果)', () => {
|
||||
const t0 = computeRippleLine({
|
||||
test('time 推进时颜色分布变化(动画效果)', () => {
|
||||
const t0 = computeRippleCells({
|
||||
y: 2,
|
||||
width: 30,
|
||||
time: 0,
|
||||
sourceX: 25,
|
||||
sourceY: 2,
|
||||
})
|
||||
const t1 = computeRippleLine({
|
||||
const t1 = computeRippleCells({
|
||||
y: 2,
|
||||
width: 30,
|
||||
time: 500,
|
||||
sourceX: 25,
|
||||
sourceY: 2,
|
||||
})
|
||||
expect(t0).not.toBe(t1)
|
||||
// 至少有一个位置颜色不同
|
||||
const diffs = t0.filter((c, i) => c.color !== t1[i].color)
|
||||
expect(diffs.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergeLayers', () => {
|
||||
test('无 overlay 时原样返回 ripple', () => {
|
||||
const ripple = '·∙░▒▓░∙·'
|
||||
expect(mergeLayers(ripple, [])).toBe(ripple)
|
||||
describe('applyOverlaysToCells', () => {
|
||||
function makeCells(colors: string[]): Cell[] {
|
||||
return colors.map(c => ({ char: ' ', color: c }))
|
||||
}
|
||||
|
||||
test('无 overlay 时原样返回(但为新数组)', () => {
|
||||
const cells = makeCells(['#111', '#222', '#333'])
|
||||
const out = applyOverlaysToCells(cells, [])
|
||||
expect(out).toEqual(cells)
|
||||
expect(out).not.toBe(cells) // 防御式拷贝
|
||||
})
|
||||
|
||||
test('overlay 文字字符覆盖对应位置', () => {
|
||||
const ripple = '········'
|
||||
const overlays: Overlay[] = [{ text: 'hi', x: 2 }]
|
||||
expect(mergeLayers(ripple, overlays)).toBe('··hi····')
|
||||
test('overlay 替换 char 但保留底层 color(color 未指定时)', () => {
|
||||
const cells = makeCells([
|
||||
TRANSPARENT,
|
||||
TRANSPARENT,
|
||||
TRANSPARENT,
|
||||
TRANSPARENT,
|
||||
])
|
||||
const overlays: Overlay[] = [{ text: 'hi', x: 1 }]
|
||||
const out = applyOverlaysToCells(cells, overlays)
|
||||
expect(out[1].char).toBe('h')
|
||||
expect(out[2].char).toBe('i')
|
||||
expect(out[1].color).toBe(TRANSPARENT) // 保留底层色
|
||||
expect(out[0].char).toBe(' ')
|
||||
})
|
||||
|
||||
test('多个 overlay 不重叠时各自覆盖', () => {
|
||||
const ripple = '·········'
|
||||
const overlays: Overlay[] = [
|
||||
{ text: 'A', x: 0 },
|
||||
{ text: 'B', x: 4 },
|
||||
{ text: 'C', x: 8 },
|
||||
]
|
||||
// ripple 长 9:A 在 0;B 在 4;C 在 8;其余保留 '·'
|
||||
expect(mergeLayers(ripple, overlays)).toBe('A···B···C')
|
||||
test('overlay 指定 color 时同时覆盖 char + color', () => {
|
||||
const cells = makeCells([TRANSPARENT, TRANSPARENT, TRANSPARENT])
|
||||
const overlays: Overlay[] = [{ text: 'AB', x: 0, color: '#5769F7' }]
|
||||
const out = applyOverlaysToCells(cells, overlays)
|
||||
expect(out[0]).toEqual({ char: 'A', color: '#5769F7' })
|
||||
expect(out[1]).toEqual({ char: 'B', color: '#5769F7' })
|
||||
expect(out[2]).toEqual({ char: ' ', color: TRANSPARENT })
|
||||
})
|
||||
|
||||
test('overlay 超出右边界被截断', () => {
|
||||
const ripple = '·····'
|
||||
const overlays: Overlay[] = [{ text: 'abcdef', x: 3 }]
|
||||
// ripple 长 5,overlay 从 x=3 开始 → 'ab' 占 3,4,剩下 'cdef' 截断
|
||||
expect(mergeLayers(ripple, overlays)).toBe('···ab')
|
||||
const cells = makeCells([TRANSPARENT, TRANSPARENT, TRANSPARENT])
|
||||
const overlays: Overlay[] = [{ text: 'abcdef', x: 1 }]
|
||||
const out = applyOverlaysToCells(cells, overlays)
|
||||
expect(out[0].char).toBe(' ')
|
||||
expect(out[1].char).toBe('a')
|
||||
expect(out[2].char).toBe('b')
|
||||
// 'cdef' 被截断
|
||||
})
|
||||
|
||||
test('overlay x 为负数 → 从开头截断(不向左溢出)', () => {
|
||||
const ripple = '·····'
|
||||
const cells = makeCells([TRANSPARENT, TRANSPARENT, TRANSPARENT])
|
||||
const overlays: Overlay[] = [{ text: 'abc', x: -1 }]
|
||||
// x=-1 → 跳过 'a','bc' 占 0,1
|
||||
expect(mergeLayers(ripple, overlays)).toBe('bc···')
|
||||
})
|
||||
|
||||
test('overlay x=0 + 完整覆盖 → 全部覆盖', () => {
|
||||
const ripple = '·····'
|
||||
const overlays: Overlay[] = [{ text: 'HELLO', x: 0 }]
|
||||
expect(mergeLayers(ripple, overlays)).toBe('HELLO')
|
||||
})
|
||||
|
||||
test('文字字符永远胜出(即使 ripple 那里是高密度字符)', () => {
|
||||
const ripple = '▓▓▓▓▓'
|
||||
const overlays: Overlay[] = [{ text: 'X', x: 2 }]
|
||||
expect(mergeLayers(ripple, overlays)).toBe('▓▓X▓▓')
|
||||
const out = applyOverlaysToCells(cells, overlays)
|
||||
expect(out[0].char).toBe('b') // 跳过 'a','b' 占 0
|
||||
expect(out[1].char).toBe('c')
|
||||
expect(out[2].char).toBe(' ')
|
||||
})
|
||||
|
||||
test('多个 overlay 后者覆盖前者(同位置)', () => {
|
||||
const ripple = '·····'
|
||||
const cells = makeCells([TRANSPARENT, TRANSPARENT, TRANSPARENT])
|
||||
const overlays: Overlay[] = [
|
||||
{ text: 'AAA', x: 0 },
|
||||
{ text: 'B', x: 1 },
|
||||
{ text: 'AAA', x: 0, color: '#111' },
|
||||
{ text: 'B', x: 1, color: '#222' },
|
||||
]
|
||||
// ripple 长 5:A 在 0;B 在 1(覆盖第一个 A);2 为 A;3,4 为 '·'(未被 overlay 覆盖)
|
||||
expect(mergeLayers(ripple, overlays)).toBe('ABA··')
|
||||
const out = applyOverlaysToCells(cells, overlays)
|
||||
expect(out[0]).toEqual({ char: 'A', color: '#111' })
|
||||
expect(out[1]).toEqual({ char: 'B', color: '#222' }) // 第二个 overlay 覆盖
|
||||
expect(out[2]).toEqual({ char: 'A', color: '#111' })
|
||||
})
|
||||
|
||||
test('overlay 起始位置 >= 数组长度 → 完全跳过', () => {
|
||||
const cells = makeCells([TRANSPARENT, TRANSPARENT])
|
||||
const overlays: Overlay[] = [{ text: 'X', x: 5 }]
|
||||
const out = applyOverlaysToCells(cells, overlays)
|
||||
expect(out.every(c => c.char === ' ')).toBe(true)
|
||||
})
|
||||
|
||||
test('不修改原数组(防御式拷贝)', () => {
|
||||
const cells = makeCells([TRANSPARENT])
|
||||
const snapshot = cells.map(c => ({ ...c }))
|
||||
applyOverlaysToCells(cells, [{ text: 'X', x: 0 }])
|
||||
expect(cells).toEqual(snapshot)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cellsToSegments', () => {
|
||||
test('空数组 → 空数组', () => {
|
||||
expect(cellsToSegments([])).toEqual([])
|
||||
})
|
||||
|
||||
test('单 cell → 单段', () => {
|
||||
const cells: Cell[] = [{ char: 'a', color: '#111' }]
|
||||
expect(cellsToSegments(cells)).toEqual([{ text: 'a', color: '#111' }])
|
||||
})
|
||||
|
||||
test('全部同色 → 合并为一段', () => {
|
||||
const cells: Cell[] = [
|
||||
{ char: 'a', color: '#111' },
|
||||
{ char: 'b', color: '#111' },
|
||||
{ char: 'c', color: '#111' },
|
||||
]
|
||||
expect(cellsToSegments(cells)).toEqual([{ text: 'abc', color: '#111' }])
|
||||
})
|
||||
|
||||
test('颜色交替 → 每个独立段', () => {
|
||||
const cells: Cell[] = [
|
||||
{ char: 'a', color: '#111' },
|
||||
{ char: 'b', color: '#222' },
|
||||
{ char: 'c', color: '#111' },
|
||||
]
|
||||
expect(cellsToSegments(cells)).toEqual([
|
||||
{ text: 'a', color: '#111' },
|
||||
{ text: 'b', color: '#222' },
|
||||
{ text: 'c', color: '#111' },
|
||||
])
|
||||
})
|
||||
|
||||
test('相邻同色段合并,不同色段分开', () => {
|
||||
const cells: Cell[] = [
|
||||
{ char: 'a', color: TRANSPARENT },
|
||||
{ char: 'b', color: TRANSPARENT },
|
||||
{ char: 'X', color: '#5769F7' },
|
||||
{ char: 'Y', color: '#5769F7' },
|
||||
{ char: 'c', color: TRANSPARENT },
|
||||
]
|
||||
expect(cellsToSegments(cells)).toEqual([
|
||||
{ text: 'ab', color: TRANSPARENT },
|
||||
{ text: 'XY', color: '#5769F7' },
|
||||
{ text: 'c', color: TRANSPARENT },
|
||||
])
|
||||
})
|
||||
|
||||
test('段文本拼接顺序保持原顺序', () => {
|
||||
const cells: Cell[] = [
|
||||
{ char: '1', color: '#111' },
|
||||
{ char: '2', color: '#111' },
|
||||
{ char: '3', color: '#111' },
|
||||
]
|
||||
expect(cellsToSegments(cells)[0].text).toBe('123')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,62 +1,96 @@
|
||||
/**
|
||||
* EffortPanel ultracode 档位的背景波纹动画 —— 纯函数模块。
|
||||
* EffortPanel ultracode 档位的背景波纹动画 —— 纯函数模块(颜色驱动)。
|
||||
*
|
||||
* 设计目标:
|
||||
* 设计:
|
||||
* - 仅在 cursor 停在 ultracode 时启动(订阅时钟由 useRippleFrame 控制)
|
||||
* - 震源:面板右下(ultracode 字符位置),向左/上辐射同心圆波
|
||||
* - 字符强度由距离衰减 + 正弦相位决定
|
||||
* - 文字字符永远覆盖波纹背景(mergeLayers),保证可读性
|
||||
* - 每位置强度(0~1)→ 颜色(suggestion 系暗紫蓝渐变)
|
||||
* - 文字 overlay 在波纹之上(last-write-wins,颜色可单独指定)
|
||||
*
|
||||
* 渲染模型:每位置一个 cell(char + color),相邻同色合并为 segment。
|
||||
* 渲染层用 Box flexDirection="row" + 多个 Text 段输出(每段一个 color)。
|
||||
*
|
||||
* 所有函数纯:相同入参 → 相同出参,便于单测 + 帧快照。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 强度(0~1)→ 视觉密度递增的字符。
|
||||
* 0.0 空格(静默)→ 1.0 实心方块(最强)。
|
||||
* suggestion 系颜色梯度(暗 → suggestion 色 → 高光)。
|
||||
* intensity=0 → transparent(无波纹),波峰附近升到 suggestion,超高频涟漪可达高光。
|
||||
*/
|
||||
const RIPPLE_COLOR_STOPS = [
|
||||
'transparent', // 0.00 ~ 0.10
|
||||
'#15182b', // 0.10 ~ 0.25 — 暗深紫蓝
|
||||
'#1f2543', // 0.25 ~ 0.40
|
||||
'#2a3360', // 0.40 ~ 0.55
|
||||
'#3a4582', // 0.55 ~ 0.70
|
||||
'#5769F7', // 0.70 ~ 0.85 — suggestion (Medium blue)
|
||||
'#8aa0ff', // 0.85 ~ 1.00 — 高光
|
||||
] as const
|
||||
|
||||
/** 'transparent' 是合法 color 字面量(渲染层会跳过这种 cell 的样式)。 */
|
||||
export const TRANSPARENT = 'transparent'
|
||||
|
||||
/**
|
||||
* 强度(任意实数)→ 颜色字符串。
|
||||
*
|
||||
* 注:波峰附近的"涟漪"字符(~ ◌ ○ 等)通过 pickCharWavePeak 单独处理,
|
||||
* 让震源附近出现循环的高频涟漪字符(与单纯密度梯度区分)。
|
||||
* 钳到 [0, 1],按 RIPPLE_COLOR_STOPS 分级。
|
||||
* 极低强度(≤ 0.10)→ transparent(让波纹边缘自然消失)。
|
||||
*/
|
||||
const INTENSITY_CHARS = [' ', '·', '∙', '░', '▒', '▓'] as const
|
||||
|
||||
/**
|
||||
* 震源附近的波峰字符循环(dist < 6 时叠加)。
|
||||
* 让 ultracode 附近出现明显"水波"感,而非仅密度梯度。
|
||||
*/
|
||||
const WAVE_PEAK_CHARS = ['~', '◌', '○', '◑', '●'] as const
|
||||
|
||||
/**
|
||||
* 把强度(任意实数)钳到 [0, 1]。
|
||||
*/
|
||||
function clampIntensity(intensity: number): number {
|
||||
if (intensity < 0) return 0
|
||||
if (intensity > 1) return 1
|
||||
return intensity
|
||||
}
|
||||
|
||||
/**
|
||||
* 强度 → 字符。
|
||||
*
|
||||
* 强度 < 0.95 时按 INTENSITY_CHARS 分级(密度梯度);
|
||||
* 强度 >= 0.95 时按波峰字符循环(基于 time 偏移)。
|
||||
*/
|
||||
export function pickChar(intensity: number, time: number = 0): string {
|
||||
const v = clampIntensity(intensity)
|
||||
if (v >= 0.95) {
|
||||
// 波峰循环:以 time 为相位让字符循环流动
|
||||
const idx = Math.floor(time / 80) % WAVE_PEAK_CHARS.length
|
||||
return WAVE_PEAK_CHARS[idx]
|
||||
}
|
||||
// 密度梯度:v ∈ [0, 0.95) 映射到 INTENSITY_CHARS[0..5)
|
||||
export function intensityToColor(intensity: number): string {
|
||||
const v = intensity < 0 ? 0 : intensity > 1 ? 1 : intensity
|
||||
if (v <= 0.1) return TRANSPARENT
|
||||
// 把 (0.1, 1.0] 映射到 [1, stops.length-1]
|
||||
const scaled = ((v - 0.1) / 0.9) * (RIPPLE_COLOR_STOPS.length - 1)
|
||||
const idx = Math.min(
|
||||
INTENSITY_CHARS.length - 1,
|
||||
Math.floor(v * INTENSITY_CHARS.length),
|
||||
RIPPLE_COLOR_STOPS.length - 1,
|
||||
Math.max(1, Math.round(scaled)),
|
||||
)
|
||||
return INTENSITY_CHARS[idx]
|
||||
return RIPPLE_COLOR_STOPS[idx]
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算面板某一行 y 的完整波纹字符串。
|
||||
* 单位置 cell:char + color。
|
||||
* - color 为 'transparent' 时渲染层不染色(背景保持终端默认)。
|
||||
* - 文字 overlay cell 用具体颜色(suggestion / warning 等)。
|
||||
*/
|
||||
export type Cell = {
|
||||
char: string
|
||||
color: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染段:相邻同 color 的 cells 合并。
|
||||
* 减少 React Text 节点数量(一行从 72 个 Text 降到 ~5-10 个)。
|
||||
*/
|
||||
export type Segment = {
|
||||
text: string
|
||||
color: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 文字 overlay:在某行的 x 位置覆盖 text 字符串。
|
||||
* - color undefined 时保留底层波纹 cell 自身颜色(仅替换 char)
|
||||
* - color 指定时同时覆盖 char + color
|
||||
*
|
||||
* 后渲染的 overlay 在相同位置覆盖先渲染的(last-write-wins)。
|
||||
*/
|
||||
export type Overlay = {
|
||||
text: string
|
||||
/** 起始列;可为负(前缀被截断) */
|
||||
x: number
|
||||
/** overlay 字符颜色;undefined = 保留底层波纹颜色 */
|
||||
color?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 波纹背景字符。
|
||||
* 用空格让背景留空、只靠 color 染色(视觉上像"颜色斑点")。
|
||||
* 空格宽度稳定(永远 1 列),不像可变宽度 unicode 字符。
|
||||
*/
|
||||
const RIPPLE_BG_CHAR = ' '
|
||||
|
||||
/**
|
||||
* 计算面板某一行 y 的完整波纹 cell 列表。
|
||||
*
|
||||
* 波纹数学:
|
||||
* dx = x - sourceX
|
||||
@@ -68,19 +102,21 @@ export function pickChar(intensity: number, time: number = 0): string {
|
||||
* intensity = wave * falloff
|
||||
* 震源附近 (dist < 6):叠加高频涟漪 max(intensity, 0.5 + 0.5*sin(time*0.02 - dist*1.2))
|
||||
*
|
||||
* @returns 长度严格等于 width 的字符串
|
||||
* 每位置强度经 intensityToColor → 颜色字符串,写入 cell。
|
||||
*
|
||||
* @returns 长度严格等于 width 的 Cell 数组
|
||||
*/
|
||||
export function computeRippleLine(args: {
|
||||
export function computeRippleCells(args: {
|
||||
y: number
|
||||
width: number
|
||||
time: number
|
||||
sourceX: number
|
||||
sourceY: number
|
||||
}): string {
|
||||
}): Cell[] {
|
||||
const { y, width, time, sourceX, sourceY } = args
|
||||
if (width <= 0) return ''
|
||||
if (width <= 0) return []
|
||||
|
||||
let out = ''
|
||||
const cells: Cell[] = new Array(width)
|
||||
for (let x = 0; x < width; x++) {
|
||||
const dx = x - sourceX
|
||||
const dy = (y - sourceY) * 1.5
|
||||
@@ -100,42 +136,66 @@ export function computeRippleLine(args: {
|
||||
if (ripple > intensity) intensity = ripple
|
||||
}
|
||||
|
||||
out += pickChar(intensity, time)
|
||||
cells[x] = {
|
||||
char: RIPPLE_BG_CHAR,
|
||||
color: intensityToColor(intensity),
|
||||
}
|
||||
}
|
||||
return cells
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 overlays 文字覆盖到 cells。
|
||||
*
|
||||
* 行为:
|
||||
* - 文字字符永远胜出(替换底层 cell.char)
|
||||
* - overlay.color 为 undefined 时保留底层 cell.color(仅替换 char)
|
||||
* - overlay.color 指定时同时覆盖 char + color
|
||||
* - 超出右边界的文字被截断
|
||||
* - x 为负时跳过前 |x| 个字符
|
||||
*
|
||||
* 不修改原数组,返回新数组(防御式拷贝)。
|
||||
*/
|
||||
export function applyOverlaysToCells(
|
||||
cells: Cell[],
|
||||
overlays: Overlay[],
|
||||
): Cell[] {
|
||||
const out: Cell[] = cells.map(c => ({ ...c }))
|
||||
for (const overlay of overlays) {
|
||||
const start = overlay.x
|
||||
if (start >= out.length) continue
|
||||
for (let i = 0; i < overlay.text.length; i++) {
|
||||
const targetIdx = start + i
|
||||
if (targetIdx < 0) continue
|
||||
if (targetIdx >= out.length) break
|
||||
out[targetIdx] = {
|
||||
char: overlay.text[i],
|
||||
color: overlay.color ?? out[targetIdx].color,
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* 文字 overlay:在某行的 x 位置覆盖 text 字符串。
|
||||
* 后渲染的 overlay 在相同位置覆盖先渲染的(last-write-wins)。
|
||||
*/
|
||||
export type Overlay = {
|
||||
text: string
|
||||
/** 起始列;可为负(前缀被截断) */
|
||||
x: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 overlays 文字覆盖到 ripple 背景上。
|
||||
* - 文字字符永远胜出(即使背景是高密度字符)
|
||||
* - 超出右边界的文字被截断
|
||||
* - x 为负时跳过前 |x| 个字符
|
||||
* 合并相邻同色 cells 为 segments。
|
||||
*
|
||||
* @param ripple 原始波纹字符串(长度 = 行宽)
|
||||
* @param overlays 要覆盖的文字列表
|
||||
* @returns 合成后的字符串(长度严格等于 ripple.length)
|
||||
* 用于减少渲染节点:一行 72 cells 可能只有 5-10 个颜色变化点,
|
||||
* 合并后只需渲染 N 个 Text 段而非 N 个单字符 Text。
|
||||
*/
|
||||
export function mergeLayers(ripple: string, overlays: Overlay[]): string {
|
||||
const chars = ripple.split('')
|
||||
for (const overlay of overlays) {
|
||||
const start = overlay.x
|
||||
if (start >= chars.length) continue
|
||||
for (let i = 0; i < overlay.text.length; i++) {
|
||||
const targetIdx = start + i
|
||||
if (targetIdx < 0) continue
|
||||
if (targetIdx >= chars.length) break
|
||||
chars[targetIdx] = overlay.text[i]
|
||||
export function cellsToSegments(cells: Cell[]): Segment[] {
|
||||
if (cells.length === 0) return []
|
||||
const segments: Segment[] = []
|
||||
let current: Segment = { text: cells[0].char, color: cells[0].color }
|
||||
for (let i = 1; i < cells.length; i++) {
|
||||
const cell = cells[i]
|
||||
if (cell.color === current.color) {
|
||||
current.text += cell.char
|
||||
} else {
|
||||
segments.push(current)
|
||||
current = { text: cell.char, color: cell.color }
|
||||
}
|
||||
}
|
||||
return chars.join('')
|
||||
segments.push(current)
|
||||
return segments
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user