diff --git a/src/components/EffortPanel/EffortPanel.tsx b/src/components/EffortPanel/EffortPanel.tsx index eff563199..3006b8bcd 100644 --- a/src/components/EffortPanel/EffortPanel.tsx +++ b/src/components/EffortPanel/EffortPanel.tsx @@ -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 {envActive && {`⚠ CLAUDE_CODE_EFFORT_LEVEL=${envRaw} overrides this session`}} - {rippleActive ? ( - - ) : ( - - )} + {rippleActive ? : } ←/→ adjust · Enter confirm · Esc cancel @@ -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 ( <> - {fasterLine} - {separatorLine} - {cursorLine} - - {labelLine} - - {sublabelLine} + + + + + ); } + +/** + * 渲染一行波纹 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 ( + + {tokens.map((tok, i) => + tok.kind === 'space' ? ( + tok.color === TRANSPARENT ? ( + {tok.text} + ) : ( + + {tok.text} + + ) + ) : ( + + {tok.text} + + ), + )} + + ); +} diff --git a/src/components/EffortPanel/__tests__/rippleAnimation.test.ts b/src/components/EffortPanel/__tests__/rippleAnimation.test.ts index c550a6038..203a944b2 100644 --- a/src/components/EffortPanel/__tests__/rippleAnimation.test.ts +++ b/src/components/EffortPanel/__tests__/rippleAnimation.test.ts @@ -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() - 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') }) }) diff --git a/src/components/EffortPanel/rippleAnimation.ts b/src/components/EffortPanel/rippleAnimation.ts index 09f69d037..bdd5123b4 100644 --- a/src/components/EffortPanel/rippleAnimation.ts +++ b/src/components/EffortPanel/rippleAnimation.ts @@ -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 }