|
1 | 1 | /** |
2 | | - * [INPUT]: 依赖 react/ink 的渲染能力,依赖 figlet,依赖 assets/ansiShadowFont 的内嵌字体 |
| 2 | + * [INPUT]: 依赖 react 的 useEffect/useState,依赖 ink 的 Box/Text/useStdout,依赖 figlet,依赖 assets/ansiShadowFont 的内嵌字体 |
3 | 3 | * [OUTPUT]: 对外提供 Banner 组件 |
4 | | - * [POS]: 通用组件,被 ProviderSelect 顶部消费,品牌标识层 |
| 4 | + * [POS]: 通用组件,被 ProviderSelect 顶部消费,按终端宽度切换品牌字标 |
5 | 5 | * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md |
6 | 6 | */ |
7 | 7 |
|
8 | | -import React from 'react' |
9 | | -import { Box, Text } from 'ink' |
| 8 | +import React, { useEffect, useState } from 'react' |
| 9 | +import { Box, Text, useStdout } from 'ink' |
10 | 10 | import figlet from 'figlet' |
11 | 11 | import { ANSI_SHADOW_FONT } from '../assets/ansiShadowFont.js' |
12 | 12 |
|
| 13 | +type BannerVariant = { |
| 14 | + lines: string[] |
| 15 | + colors: string[] |
| 16 | + minWidth: number |
| 17 | + bold?: boolean |
| 18 | +} |
| 19 | + |
13 | 20 | // 内嵌字体注册,消除对文件系统的依赖(bun 独立二进制中无 node_modules) |
14 | 21 | figlet.parseFont('ANSI Shadow', ANSI_SHADOW_FONT as unknown as figlet.Fonts) |
15 | 22 |
|
16 | | -// 构建时同步生成 ASCII art,避免运行时延迟 |
17 | | -const ART = figlet.textSync('CC Switch', { |
| 23 | +// 预生成三档字标,避免运行时再去读字体文件或等待计算。 |
| 24 | +const LARGE_ART = figlet.textSync('CC Switch', { |
18 | 25 | font: 'ANSI Shadow', |
19 | 26 | horizontalLayout: 'default', |
20 | 27 | }) |
21 | 28 |
|
22 | | -// 将 art 按行分割,逐行渲染以支持渐变色效果 |
23 | | -const LINES = ART.split('\n') |
| 29 | +const MEDIUM_ART = [ |
| 30 | + ' ___ ___ ___ _ _ _', |
| 31 | + ' / __|/ __| / __|__ __ __(_)| |_ __ | |_', |
| 32 | + " | (__| (__ \\__ \\\\ V V /| || _|/ _|| ' \\", |
| 33 | + ' \\___|\\___| |___/ \\_/\\_/ |_| \\__|\\__||_||_|', |
| 34 | +].join('\n') |
| 35 | + |
| 36 | +const SMALL_ART = [ |
| 37 | + ' _ _ __ ', |
| 38 | + ' / / (_ o _|_ _ |_ ', |
| 39 | + ' \\_ \\_ __) \\/\\/ | |_ (_ | | ', |
| 40 | +].join('\n') |
| 41 | + |
| 42 | +const INLINE_ART = 'CC Switch' |
| 43 | +const HORIZONTAL_PADDING = 4 |
| 44 | + |
| 45 | +function normalizeLines(art: string): string[] { |
| 46 | + const lines = art.split('\n').map(line => line.replace(/\s+$/u, '')) |
| 47 | + |
| 48 | + while (lines.length > 0 && lines.at(-1) === '') { |
| 49 | + lines.pop() |
| 50 | + } |
| 51 | + |
| 52 | + return lines |
| 53 | +} |
| 54 | + |
| 55 | +function createVariant(art: string, colors: string[], bold = false): BannerVariant { |
| 56 | + const lines = normalizeLines(art) |
| 57 | + |
| 58 | + return { |
| 59 | + lines, |
| 60 | + colors, |
| 61 | + bold, |
| 62 | + minWidth: lines.reduce((max, line) => Math.max(max, line.length), 0), |
| 63 | + } |
| 64 | +} |
| 65 | + |
| 66 | +const VARIANTS: BannerVariant[] = [ |
| 67 | + createVariant(LARGE_ART, ['white', 'white', 'cyan', 'cyan', 'blueBright', 'blue']), |
| 68 | + createVariant(MEDIUM_ART, ['white', 'cyan', 'cyan', 'blue']), |
| 69 | + createVariant(SMALL_ART, ['white', 'cyan', 'blue']), |
| 70 | + createVariant(INLINE_ART, ['cyan'], true), |
| 71 | +] |
24 | 72 |
|
25 | | -// 每行对应的颜色,从亮到暗,模拟截图中的立体光影 |
26 | | -const LINE_COLORS = ['white', 'white', 'cyan', 'cyan', 'blueBright', 'blue'] |
| 73 | +function getAvailableWidth(stdout: NodeJS.WriteStream | undefined): number { |
| 74 | + const columns = stdout?.columns ?? 80 |
| 75 | + return Math.max(columns - HORIZONTAL_PADDING, 0) |
| 76 | +} |
| 77 | + |
| 78 | +function pickVariant(availableWidth: number): BannerVariant { |
| 79 | + return VARIANTS.find(variant => availableWidth >= variant.minWidth) ?? VARIANTS.at(-1)! |
| 80 | +} |
27 | 81 |
|
28 | 82 | export function Banner() { |
| 83 | + const { stdout } = useStdout() |
| 84 | + const [availableWidth, setAvailableWidth] = useState(() => getAvailableWidth(stdout)) |
| 85 | + |
| 86 | + useEffect(() => { |
| 87 | + const syncWidth = () => { |
| 88 | + const nextWidth = getAvailableWidth(stdout) |
| 89 | + setAvailableWidth(currentWidth => (currentWidth === nextWidth ? currentWidth : nextWidth)) |
| 90 | + } |
| 91 | + |
| 92 | + syncWidth() |
| 93 | + stdout?.on('resize', syncWidth) |
| 94 | + |
| 95 | + return () => { |
| 96 | + stdout?.removeListener('resize', syncWidth) |
| 97 | + } |
| 98 | + }, [stdout]) |
| 99 | + |
| 100 | + const variant = pickVariant(availableWidth) |
| 101 | + |
29 | 102 | return ( |
30 | 103 | <Box flexDirection="column" marginBottom={1}> |
31 | | - {LINES.map((line, i) => ( |
32 | | - <Text key={i} color={(LINE_COLORS[i] ?? 'gray') as any}> |
| 104 | + {variant.lines.map((line, i) => ( |
| 105 | + <Text key={i} color={(variant.colors[i] ?? 'gray') as any} bold={variant.bold}> |
33 | 106 | {line} |
34 | 107 | </Text> |
35 | 108 | ))} |
|
0 commit comments