import { RGBA } from "@opentui/core"
import { resolveTheme, tint, type ThemeJson } from "@tui/context/theme"
import { detectCliTerminalLight } from "../util/cli-terminal-light"
import opencode from "../context/theme/opencode.json" with { type: "json" }
import { EOL } from "os"
const ansiC = [
` \u2583\u2583\u2583\u2583\u2583 `,
`\u2583\u2583\u2583\u2583\u2583\u2583\u2583\u2583\u2583`,
`\u2583\u2583\u2583 \u2583\u2583\u2583`,
`\u2584\u2584\u2584 `,
`\u2584\u2584\u2584 `,
`\u2585\u2585\u2585 \u2585\u2585\u2585`,
`\u2586\u2586\u2586\u2586\u2586\u2586\u2586\u2586\u2586`,
` \u2586\u2586\u2586\u2586\u2586 `,
]
const ansiO = [
` \u2583\u2583\u2583\u2583\u2583\u2583\u2583\u2583 `,
`\u2583\u2583\u2583\u2583\u2583\u2583\u2583\u2583\u2583\u2583`,
`\u2583\u2583\u2583 \u2583\u2583\u2583`,
`\u2584\u2584\u2584 \u2584\u2584\u2584`,
`\u2584\u2584\u2584 \u2584\u2584\u2584`,
`\u2585\u2585\u2585 \u2585\u2585\u2585`,
`\u2586\u2586\u2586\u2586\u2586\u2586\u2586\u2586\u2586\u2586`,
` \u2586\u2586\u2586\u2586\u2586\u2586\u2586\u2586 `,
]
const ansiD = [
`\u2583\u2583\u2583\u2583\u2583\u2583\u2583\u2583 `,
`\u2583\u2583\u2583\u2583\u2583\u2583\u2583\u2583\u2583 `,
`\u2583\u2583\u2583 \u2583\u2583\u2583`,
`\u2584\u2584\u2584 \u2584\u2584\u2584`,
`\u2584\u2584\u2584 \u2584\u2584\u2584`,
`\u2585\u2585\u2585 \u2585\u2585\u2585`,
`\u2586\u2586\u2586\u2586\u2586\u2586\u2586\u2586\u2586 `,
`\u2586\u2586\u2586\u2586\u2586\u2586\u2586\u2586 `,
]
const ansiE = [
`\u2583\u2583\u2583\u2583\u2583\u2583\u2583\u2583\u2583`,
`\u2583\u2583\u2583\u2583\u2583\u2583\u2583\u2583\u2583`,
`\u2583\u2583\u2583 `,
`\u2584\u2584\u2584\u2584\u2584\u2584\u2584 `,
`\u2584\u2584\u2584\u2584\u2584\u2584\u2584 `,
`\u2585\u2585\u2585 `,
`\u2586\u2586\u2586\u2586\u2586\u2586\u2586\u2586\u2586`,
`\u2586\u2586\u2586\u2586\u2586\u2586\u2586\u2586\u2586`,
]
const ansiV = [
`\u2583\u2583\u2583 \u2583\u2583\u2583`,
`\u2583\u2583\u2583 \u2583\u2583\u2583`,
`\u2583\u2583\u2583 \u2583\u2583\u2583`,
`\u2584\u2584\u2584 \u2584\u2584\u2584`,
`\u2584\u2584\u2584 \u2584\u2584\u2584`,
`\u2585\u2585\u2585 \u2585\u2585\u2585`,
` \u2586\u2586\u2586 \u2586\u2586\u2586 `,
` \u2586\u2586\u2586\u2586 `,
]
const ansiSmallC = [
` \u2583\u2583\u2583 `,
`\u2583 \u2583`,
`\u2584 `,
`\u2585 \u2585`,
` \u2586\u2586\u2586 `,
];
const ansiSmallO = [
` \u2583\u2583\u2583 `,
`\u2583 \u2583`,
`\u2584 \u2584`,
`\u2585 \u2585`,
` \u2586\u2586\u2586 `,
];
const ansiSmallD = [
`\u2583\u2583\u2583\u2583 `,
`\u2583 \u2583`,
`\u2584 \u2584`,
`\u2585 \u2585`,
`\u2586\u2586\u2586\u2586 `,
];
const ansiSmallE = [
`\u2583\u2583\u2583\u2583`,
`\u2583 `,
`\u2584\u2584\u2584\u2584`,
`\u2585 `,
`\u2586\u2586\u2586\u2586`,
];
const ansiSmallV = [
`\u2583 \u2583`,
`\u2583 \u2583`,
`\u2584 \u2584`,
`\u2585 \u2585`,
` \u2586\u2586\u2586 `,
];
export const wordFull = ansiD.map(
(d, i) =>
`${d} ${ansiE[i] ?? ""} ${ansiV[i] ?? ""} ${ansiE[i] ?? ""} ${ansiC[i] ?? ""} ${ansiO[i] ?? ""} ${ansiC[i] ?? ""} ${ansiO[i] ?? ""} ${ansiD[i] ?? ""} ${ansiE[i] ?? ""}`,
)
export const wordDeveco = ansiD.map(
(d, i) =>
`${d} ${ansiE[i] ?? ""} ${ansiV[i] ?? ""} ${ansiE[i] ?? ""} ${ansiC[i] ?? ""} ${ansiO[i] ?? ""}`,
)
export const wordDevecoSmall = ansiSmallD.map(
(d, i) =>
`${d} ${ansiSmallE[i] ?? ''} ${ansiSmallV[i] ?? ''} ${ansiSmallE[i] ?? ''} ${ansiSmallC[i] ?? ''} ${ansiSmallO[i] ?? ''}`,
);
export const wordFullSmall = ansiSmallD.map(
(d, i) =>
`${d} ${ansiSmallE[i] ?? ''} ${ansiSmallV[i] ?? ''} ${ansiSmallE[i] ?? ''} ${ansiSmallC[i] ?? ''} ${ansiSmallO[i] ?? ''} ${ansiSmallC[i] ?? ''} ${ansiSmallO[i] ?? ''} ${ansiSmallD[i] ?? ''} ${ansiSmallE[i] ?? ''}`,
);
function maxRowWidth(rows: readonly string[]): number {
let m = 0
for (const r of rows) if (r.length > m) m = r.length
return m
}
export const LOGO_ROW_CAP = 8
export const LOGO_WORD_FULL_MAX_COLS = maxRowWidth(wordFull)
export const LOGO_WORD_DEVECO_MAX_COLS = maxRowWidth(wordDeveco)
const LOGO_HEAD_CLIP_SCROLL_OFFSET = 0
export type Tone = { t: string; fg: RGBA }
export function useFullLettermark(viewportWidth: number): boolean {
return Math.max(0, Math.floor(viewportWidth)) >= LOGO_WORD_FULL_MAX_COLS
}
export function logoRowsForWidth(width: number): readonly string[] {
return useFullLettermark(width) ? wordFull : wordDeveco
}
export const LOGO_BLOCK_THICK = "\u2586"
export const LOGO_BLOCK_THIN = "\u2583"
const BLOCK_HEIGHT_INVERT: Record<string, string> = {
"\u2582": LOGO_BLOCK_THICK,
"\u2583": LOGO_BLOCK_THICK,
"\u2584": LOGO_BLOCK_THICK,
"\u2585": LOGO_BLOCK_THIN,
"\u2586": LOGO_BLOCK_THIN,
}
export function thickTopLogoLine(line: string): string {
let out = ""
for (const ch of line) out += BLOCK_HEIGHT_INVERT[ch] ?? ch
return out
}
export function thickTopLogoRows(rows: readonly string[]): readonly string[] {
return rows.map(thickTopLogoLine)
}
* scanCover white layer: per-row block height (上粗下细).
* Row 1–2 ▆, 3 ▅, 4–5 ▄, 6–8 ▃.
*/
export const SCAN_COVER_ROW_BLOCKS: readonly string[] = [
"\u2586",
"\u2586",
"\u2585",
"\u2584",
"\u2584",
"\u2583",
"\u2583",
"\u2583",
]
function isLogoBlockChar(ch: string): boolean {
return ch >= "\u2582" && ch <= "\u2586"
}
export function scanCoverLogoLine(line: string, rowIndex: number): string {
const block = SCAN_COVER_ROW_BLOCKS[Math.min(Math.max(0, rowIndex), SCAN_COVER_ROW_BLOCKS.length - 1)] ?? LOGO_BLOCK_THIN
let out = ""
for (const ch of line) out += isLogoBlockChar(ch) ? block : ch
return out
}
export function scanCoverLogoRows(rows: readonly string[]): readonly string[] {
return rows.map((line, i) => scanCoverLogoLine(line, i))
}
export function clipLogoRawLine(raw: string, viewportWidth: number, scrollOffset: number): string {
const vw = Math.max(0, Math.floor(viewportWidth))
if (vw === 0) return ""
if (raw.length <= vw) return raw
const smax = raw.length - vw
const off = Math.min(Math.max(0, Math.floor(scrollOffset)), smax)
return raw.slice(off, off + vw)
}
export function stripeLineForLight(background: RGBA): RGBA {
return tint(background, RGBA.fromInts(0, 0, 0), 0.1)
}
export function padTones(parts: Tone[], w: number, base: RGBA): Tone[] {
const n = parts.reduce((a, p) => a + p.t.length, 0)
if (w <= 0 || n >= w) return parts
const l = Math.floor((w - n) / 2)
const r = w - n - l
const head = l > 0 ? [{ t: " ".repeat(l), fg: base }] : []
const tail = r > 0 ? [{ t: " ".repeat(r), fg: base }] : []
return [...head, ...parts, ...tail]
}
export function padTonesStart(parts: Tone[], w: number, base: RGBA): Tone[] {
const n = parts.reduce((a, p) => a + p.t.length, 0)
if (w <= 0 || n >= w) return parts
const tail = w - n
return [...parts, { t: " ".repeat(tail), fg: base }]
}
export const LOGO_SCANLINE_CHAR = "─"
export function scanline(parts: Tone[], fg: RGBA): Tone[] {
return scanlineWithSpaceChar(parts, fg, LOGO_SCANLINE_CHAR)
}
* Scanlines with a custom space fill. {@link scanCoverSpaceBlock} uses ▆▅▄▃ per row (2–3s cover);
* {@link LOGO_SCANLINE_CHAR} is the thin variant everywhere else.
*/
export function scanlineWithSpaceChar(parts: Tone[], stripeLine: RGBA, spaceChar: string): Tone[] {
const out: Tone[] = []
let buf = ""
let cur = stripeLine
const flush = () => {
if (!buf) return
out.push({ t: buf, fg: cur })
buf = ""
}
for (const p of parts) {
for (const ch of p.t) {
const isSpace = ch === " "
const nextFg = isSpace ? stripeLine : p.fg
const nextCh = isSpace ? spaceChar : ch
if (nextFg !== cur) {
flush()
cur = nextFg
}
buf += nextCh
}
}
flush()
return out
}
export function scanCoverSpaceBlock(rowIndex: number): string {
return SCAN_COVER_ROW_BLOCKS[Math.min(Math.max(0, rowIndex), SCAN_COVER_ROW_BLOCKS.length - 1)] ?? LOGO_BLOCK_THIN
}
export type BannerLogoPalette = {
logoFg: RGBA
stripeLine: RGBA
base: RGBA
}
export function logoIntroStripeColor(palette: BannerLogoPalette, opacity: number): RGBA {
const o = Math.max(0, Math.min(1, opacity))
return RGBA.fromInts(
Math.round(palette.logoFg.r * 255),
Math.round(palette.logoFg.g * 255),
Math.round(palette.logoFg.b * 255),
Math.round(o * 255),
)
}
function bannerLogoCoverFadeRowTones(rowIndex: number, viewportWidth: number, stripe: RGBA): Tone[] {
const vw = Math.max(0, Math.floor(viewportWidth))
if (vw === 0) return []
const spaceBlock = scanCoverSpaceBlock(rowIndex)
return [{ t: spaceBlock.repeat(vw), fg: stripe }]
}
function bannerLogoBlankRowTones(viewportWidth: number, palette: BannerLogoPalette): Tone[] {
const vw = Math.max(0, Math.floor(viewportWidth))
if (vw === 0) return []
return [{ t: " ".repeat(vw), fg: palette.base }]
}
function logoLineMaskGlyphsToSpaces(line: string): string {
let out = ""
for (const ch of line) out += isLogoBlockChar(ch) ? " " : ch
return out
}
function bannerLogoScanlineGapRowTones(
normalLine: string,
viewportWidth: number,
palette: BannerLogoPalette,
stripe: RGBA,
): Tone[] {
const vw = Math.max(0, Math.floor(viewportWidth))
if (vw === 0) return []
const clipped = normalLine.length > vw
const slice = clipped ? clipLogoRawLine(normalLine, vw, LOGO_HEAD_CLIP_SCROLL_OFFSET) : normalLine
const gapSlice = logoLineMaskGlyphsToSpaces(slice)
const parts: Tone[] = [{ t: gapSlice, fg: palette.base }]
const padded = clipped ? padTonesStart(parts, vw, palette.base) : padTones(parts, vw, palette.base)
return scanline(padded, stripe)
}
function bannerLogoRowTonesNoScanlines(
rawLine: string,
viewportWidth: number,
palette: BannerLogoPalette,
logoBlend: number,
): Tone[] {
if (logoBlend <= 0) return bannerLogoBlankRowTones(viewportWidth, palette)
const vw = Math.max(0, Math.floor(viewportWidth))
if (vw === 0) return []
const clipped = rawLine.length > vw
const slice = clipped ? clipLogoRawLine(rawLine, vw, LOGO_HEAD_CLIP_SCROLL_OFFSET) : rawLine
const parts: Tone[] = [{ t: slice, fg: logoIntroLogoFg(palette, logoBlend) }]
return clipped ? padTonesStart(parts, vw, palette.base) : padTones(parts, vw, palette.base)
}
function bannerLogoThickTopWhiteCoverTones(
coverLine: string,
rowIndex: number,
viewportWidth: number,
palette: BannerLogoPalette,
stripe: RGBA,
glyphBlend: number,
): Tone[] {
const vw = Math.max(0, Math.floor(viewportWidth))
if (vw === 0) return []
const clipped = coverLine.length > vw
const slice = clipped ? clipLogoRawLine(coverLine, vw, LOGO_HEAD_CLIP_SCROLL_OFFSET) : coverLine
const g = Math.max(0, Math.min(1, glyphBlend))
const glyphFg = RGBA.fromInts(255, 255, 255, Math.round(stripe.a * 255 * g))
const parts: Tone[] = [{ t: slice, fg: glyphFg }]
const padded = clipped ? padTonesStart(parts, vw, palette.base) : padTones(parts, vw, palette.base)
return scanlineWithSpaceChar(padded, stripe, scanCoverSpaceBlock(rowIndex))
}
export const LOGO_PHASE_MS = 800
export const LOGO_PHASE_PAUSE_MS = 500
export const LOGO_SCAN_FADE_END_OPACITY = 0.1
export const LOGO_SHIFT_RIGHT_COLS = 2
export const LOGO_SHIFT_LEFT_COLS = -2
export const LOGO_SHIFT_VACATE_COLS = 2
export const LOGO_SHIFT_BEAT_MS = 500
export const LOGO_SHIFT_HOLD_MS = 100
export const LOGO_SHIFT_CENTER_MS = 300
export const LOGO_INTRO_DURATION_MS =
LOGO_PHASE_MS * 2 +
LOGO_PHASE_PAUSE_MS * 2 +
LOGO_SHIFT_BEAT_MS * 2 +
LOGO_SHIFT_HOLD_MS * 2 +
LOGO_SHIFT_CENTER_MS * 2
export type LogoIntroPhase =
| "scanCover"
| "logoRowReveal"
| "shiftRight"
| "shiftHold"
| "shiftCenter"
| "shiftLeft"
| "shiftLeftHold"
export type LogoIntroFrame =
| { kind: "done" }
| {
kind: "animating"
phase: LogoIntroPhase
revealedRow: number
stripeOpacity: number
logoBlend: number
shiftCols: number
thickTop: boolean
stripeThemeBlend: number
}
function phaseProgressInWindow(elapsedMs: number, startMs: number, durationMs: number): number {
return Math.max(0, Math.min(1, (elapsedMs - startMs) / durationMs))
}
function revealedRowForProgress(p: number, rowCount: number): number {
const rows = Math.max(1, Math.floor(rowCount))
if (p <= 0) return -1
return Math.min(rows - 1, Math.ceil(p * rows) - 1)
}
export function logoIntroFrameAt(elapsedMs: number, rowCount: number): LogoIntroFrame {
if (elapsedMs >= LOGO_INTRO_DURATION_MS) return { kind: "done" }
const rows = Math.max(1, Math.floor(rowCount))
const lastRow = rows - 1
const postReveal = {
revealedRow: lastRow,
stripeOpacity: LOGO_SCAN_FADE_END_OPACITY,
logoBlend: 1,
thickTop: false,
stripeThemeBlend: 1,
}
const tScanEnd = LOGO_PHASE_MS
const tScanPauseEnd = tScanEnd + LOGO_PHASE_PAUSE_MS
const tRevealEnd = tScanPauseEnd + LOGO_PHASE_MS
const tRevealPauseEnd = tRevealEnd + LOGO_PHASE_PAUSE_MS
if (elapsedMs < tScanEnd) {
const p = phaseProgressInWindow(elapsedMs, 0, LOGO_PHASE_MS)
return {
kind: "animating",
phase: "scanCover",
revealedRow: revealedRowForProgress(p, rows),
stripeOpacity: 1,
logoBlend: 0,
shiftCols: 0,
thickTop: true,
stripeThemeBlend: 0,
}
}
if (elapsedMs < tScanPauseEnd) {
return {
kind: "animating",
phase: "scanCover",
revealedRow: lastRow,
stripeOpacity: 1,
logoBlend: 0,
shiftCols: 0,
thickTop: true,
stripeThemeBlend: 0,
}
}
if (elapsedMs < tRevealEnd) {
const p = phaseProgressInWindow(elapsedMs, tScanPauseEnd, LOGO_PHASE_MS)
return {
kind: "animating",
phase: "logoRowReveal",
revealedRow: revealedRowForProgress(p, rows),
stripeOpacity: LOGO_SCAN_FADE_END_OPACITY,
logoBlend: 1,
shiftCols: 0,
thickTop: false,
stripeThemeBlend: 1,
}
}
if (elapsedMs < tRevealPauseEnd) {
return {
kind: "animating",
phase: "logoRowReveal",
...postReveal,
shiftCols: 0,
}
}
const tShiftREnd = tRevealPauseEnd + LOGO_SHIFT_BEAT_MS
if (elapsedMs < tShiftREnd) {
return {
kind: "animating",
phase: "shiftRight",
...postReveal,
shiftCols: LOGO_SHIFT_RIGHT_COLS,
}
}
const tShiftRHoldEnd = tShiftREnd + LOGO_SHIFT_HOLD_MS
if (elapsedMs < tShiftRHoldEnd) {
return {
kind: "animating",
phase: "shiftHold",
...postReveal,
shiftCols: LOGO_SHIFT_RIGHT_COLS,
}
}
const tCenter1End = tShiftRHoldEnd + LOGO_SHIFT_CENTER_MS
if (elapsedMs < tCenter1End) {
return { kind: "animating", phase: "shiftCenter", ...postReveal, shiftCols: 0 }
}
const tShiftLEnd = tCenter1End + LOGO_SHIFT_BEAT_MS
if (elapsedMs < tShiftLEnd) {
return {
kind: "animating",
phase: "shiftLeft",
...postReveal,
shiftCols: LOGO_SHIFT_LEFT_COLS,
}
}
const tShiftLHoldEnd = tShiftLEnd + LOGO_SHIFT_HOLD_MS
if (elapsedMs < tShiftLHoldEnd) {
return {
kind: "animating",
phase: "shiftLeftHold",
...postReveal,
shiftCols: LOGO_SHIFT_LEFT_COLS,
}
}
return { kind: "animating", phase: "shiftCenter", ...postReveal, shiftCols: 0 }
}
function rgbaKey(c: RGBA): string {
return `${Math.round(c.r * 255)},${Math.round(c.g * 255)},${Math.round(c.b * 255)}`
}
function contentLeftFromPadded(padded: Tone[], clipped: boolean, base: RGBA): number {
if (clipped) return 0
const first = padded[0]
if (!first || rgbaKey(first.fg) !== rgbaKey(base)) return 0
return first.t.length
}
function countLeadingSpaces(text: string): number {
let lead = 0
for (const ch of text) {
if (ch === " ") lead += 1
else break
}
return lead
}
function glyphLeftColumn(contentLeft: number, slice: string): number {
return contentLeft + countLeadingSpaces(slice)
}
const LETTERMARK_SEP = " "
const LETTERMARK_DEVECO_CODE_SEP = " "
function letterSegmentsForRow(rowIndex: number, fullMark: boolean): readonly string[] {
const i = rowIndex
if (fullMark) {
return [
ansiD[i] ?? "",
ansiE[i] ?? "",
ansiV[i] ?? "",
ansiE[i] ?? "",
ansiC[i] ?? "",
ansiO[i] ?? "",
ansiC[i] ?? "",
ansiO[i] ?? "",
ansiD[i] ?? "",
ansiE[i] ?? "",
]
}
return [ansiD[i] ?? "", ansiE[i] ?? "", ansiV[i] ?? "", ansiE[i] ?? "", ansiC[i] ?? "", ansiO[i] ?? ""]
}
function letterSeparatorsForRow(fullMark: boolean): readonly string[] {
if (fullMark) {
return [
LETTERMARK_SEP,
LETTERMARK_SEP,
LETTERMARK_SEP,
LETTERMARK_SEP,
LETTERMARK_SEP,
LETTERMARK_DEVECO_CODE_SEP,
LETTERMARK_SEP,
LETTERMARK_SEP,
LETTERMARK_SEP,
]
}
return [LETTERMARK_SEP, LETTERMARK_SEP, LETTERMARK_SEP, LETTERMARK_SEP, LETTERMARK_SEP]
}
function letterGlyphStartsInSlice(
segments: readonly string[],
separators: readonly string[],
slice: string,
clipOffset: number,
): number[] {
const starts: number[] = []
let off = 0
for (let s = 0; s < segments.length; s++) {
const absStart = off + countLeadingSpaces(segments[s] ?? "")
const inSlice = absStart - clipOffset
if (inSlice >= 0 && inSlice < slice.length && slice[inSlice] !== " ") starts.push(inSlice)
off += (segments[s] ?? "").length
if (s < segments.length - 1) off += (separators[s] ?? LETTERMARK_SEP).length
}
return starts
}
function letterVacateColumns(
rowIndex: number,
fullMark: boolean,
slice: string,
contentLeft: number,
clipOffset: number,
): ReadonlySet<number> {
const segments = letterSegmentsForRow(rowIndex, fullMark)
const separators = letterSeparatorsForRow(fullMark)
const cols = new Set<number>()
const w = Math.max(1, Math.floor(LOGO_SHIFT_VACATE_COLS))
for (const s of letterGlyphStartsInSlice(segments, separators, slice, clipOffset)) {
for (let d = 0; d < w; d++) cols.add(contentLeft + s + d)
}
return cols
}
function letterVacateColumnsRight(
rowIndex: number,
fullMark: boolean,
slice: string,
contentLeft: number,
clipOffset: number,
shiftLeftCols: number,
): ReadonlySet<number> {
const segments = letterSegmentsForRow(rowIndex, fullMark)
const separators = letterSeparatorsForRow(fullMark)
const cols = new Set<number>()
const w = Math.max(1, Math.floor(LOGO_SHIFT_VACATE_COLS))
const shift = Math.max(0, Math.round(Math.abs(shiftLeftCols)))
let off = 0
for (let si = 0; si < segments.length; si++) {
const seg = segments[si] ?? ""
let lastBlock = -1
for (let i = 0; i < seg.length; i++) {
if (isLogoBlockChar(seg[i]!)) lastBlock = off + i
}
const absStart = off + countLeadingSpaces(seg)
if (lastBlock >= absStart) {
const endInSlice = lastBlock - clipOffset
if (endInSlice >= 0 && endInSlice < slice.length) {
for (let d = 1; d <= w; d++) {
const col = contentLeft + endInSlice - shift + d
if (col >= 0) cols.add(col)
}
}
}
off += seg.length
if (si < segments.length - 1) off += (separators[si] ?? LETTERMARK_SEP).length
}
return cols
}
function shiftCharsVacateLeft(
chars: { ch: string; fg: RGBA }[],
shiftStart: number,
shiftCols: number,
base: RGBA,
): { ch: string; fg: RGBA }[] {
const shift = Math.max(0, Math.round(shiftCols))
if (shift === 0) return chars
const out: { ch: string; fg: RGBA }[] = []
for (let i = 0; i < chars.length; i++) {
if (i < shiftStart) {
out.push(chars[i] ?? { ch: " ", fg: base })
continue
}
if (i < shiftStart + shift) {
out.push({ ch: " ", fg: base })
continue
}
out.push(chars[i - shift] ?? { ch: " ", fg: base })
}
return out
}
function shiftCharsShiftLeft(
chars: { ch: string; fg: RGBA }[],
shiftStart: number,
shift: number,
base: RGBA,
): { ch: string; fg: RGBA }[] {
const left = Math.max(0, Math.round(shift))
if (left === 0) return chars
const out: { ch: string; fg: RGBA }[] = []
for (let i = 0; i < chars.length; i++) {
if (i < shiftStart - left) {
out.push(chars[i] ?? { ch: " ", fg: base })
continue
}
const src = i + left
if (src < chars.length) {
out.push(chars[src] ?? { ch: " ", fg: base })
} else {
out.push({ ch: " ", fg: base })
}
}
return out
}
function scanlineFromChars(
chars: { ch: string; fg: RGBA }[],
stripeLine: RGBA,
blankFg: RGBA,
vacateColumns: ReadonlySet<number>,
): Tone[] {
const out: Tone[] = []
let buf = ""
let cur = stripeLine
const flush = () => {
if (!buf) return
out.push({ t: buf, fg: cur })
buf = ""
}
for (let col = 0; col < chars.length; col++) {
const p = chars[col] ?? { ch: " ", fg: blankFg }
const vacated = vacateColumns.has(col)
const isSpace = p.ch === " "
const nextFg = vacated ? blankFg : isSpace ? stripeLine : p.fg
const nextCh = vacated ? " " : isSpace ? "─" : p.ch
if (nextFg !== cur) {
flush()
cur = nextFg
}
buf += nextCh
}
flush()
return out
}
export function logoIntroLogoFg(palette: BannerLogoPalette, logoBlend: number): RGBA {
const o = Math.max(0, Math.min(1, logoBlend))
return RGBA.fromInts(
Math.round(palette.logoFg.r * 255),
Math.round(palette.logoFg.g * 255),
Math.round(palette.logoFg.b * 255),
Math.round(o * 255),
)
}
function introStripeColor(palette: BannerLogoPalette, frame: Extract<LogoIntroFrame, { kind: "animating" }>): RGBA {
const intro = logoIntroStripeColor(palette, frame.stripeOpacity)
const t = Math.max(0, Math.min(1, frame.stripeThemeBlend))
if (t <= 0) return intro
if (t >= 1) return palette.stripeLine
return RGBA.fromInts(
Math.round(intro.r * 255 + (palette.stripeLine.r * 255 - intro.r * 255) * t),
Math.round(intro.g * 255 + (palette.stripeLine.g * 255 - intro.g * 255) * t),
Math.round(intro.b * 255 + (palette.stripeLine.b * 255 - intro.b * 255) * t),
Math.round(intro.a * 255 + (palette.stripeLine.a * 255 - intro.a * 255) * t),
)
}
* Home intro: block scan → pause → row reveal → pause → shift R/hold/center/L/hold/center → done.
*/
export function bannerLogoScannedLineTonesWithIntro(
normalLine: string,
_thickTopLine: string,
rowIndex: number,
frame: LogoIntroFrame,
viewportWidth: number,
palette: BannerLogoPalette,
): Tone[] {
if (frame.kind === "done") return bannerLogoScannedLineTones(normalLine, viewportWidth, palette)
const stripe = introStripeColor(palette, frame)
if (frame.phase === "scanCover") {
if (rowIndex > frame.revealedRow) return bannerLogoBlankRowTones(viewportWidth, palette)
return bannerLogoCoverFadeRowTones(rowIndex, viewportWidth, stripe)
}
if (frame.phase === "logoRowReveal") {
if (rowIndex > frame.revealedRow) {
return bannerLogoCoverFadeRowTones(rowIndex, viewportWidth, logoIntroStripeColor(palette, 1))
}
return bannerLogoScannedLineTones(normalLine, viewportWidth, palette, stripe, 0, frame.logoBlend, rowIndex)
}
return bannerLogoScannedLineTones(
normalLine,
viewportWidth,
palette,
stripe,
frame.shiftCols,
frame.logoBlend,
rowIndex,
)
}
export function bannerLogoScannedLineTones(
rawLine: string,
viewportWidth: number,
palette: BannerLogoPalette,
stripeLine: RGBA = palette.stripeLine,
shiftCols = 0,
logoBlend = 1,
rowIndex = 0,
): Tone[] {
const vw = Math.max(0, Math.floor(viewportWidth))
if (vw === 0) return []
const clipped = rawLine.length > vw
const clipOffset = clipped ? LOGO_HEAD_CLIP_SCROLL_OFFSET : 0
const slice = clipped ? clipLogoRawLine(rawLine, vw, clipOffset) : rawLine
const parts: Tone[] = [{ t: slice, fg: logoIntroLogoFg(palette, logoBlend) }]
const padded = clipped ? padTonesStart(parts, vw, palette.base) : padTones(parts, vw, palette.base)
const contentLeft = contentLeftFromPadded(padded, clipped, palette.base)
const shiftStart = glyphLeftColumn(contentLeft, slice)
const shift = Math.round(shiftCols)
let chars: { ch: string; fg: RGBA }[] = []
for (const p of padded) for (const ch of p.t) chars.push({ ch, fg: p.fg })
while (chars.length < vw) chars.push({ ch: " ", fg: palette.base })
chars = chars.slice(0, vw)
if (shift > 0) {
chars = shiftCharsVacateLeft(chars, shiftStart, shift, palette.base)
const vacateColumns = letterVacateColumns(rowIndex, useFullLettermark(vw), slice, contentLeft, clipOffset)
return scanlineFromChars(chars, stripeLine, palette.base, vacateColumns)
}
if (shift < 0) {
const left = Math.abs(shift)
chars = shiftCharsShiftLeft(chars, shiftStart, left, palette.base)
const vacateColumns = letterVacateColumnsRight(
rowIndex,
useFullLettermark(vw),
slice,
contentLeft,
clipOffset,
shiftCols,
)
return scanlineFromChars(chars, stripeLine, palette.base, vacateColumns)
}
return scanline(padded, stripeLine)
}
export function bannerLogoPalette(
isLight: boolean,
theme: { text: RGBA; textMuted: RGBA; border: RGBA; background: RGBA },
): BannerLogoPalette {
if (!isLight) {
return {
logoFg: RGBA.fromInts(255, 255, 255),
stripeLine: RGBA.fromInts(58, 58, 58),
base: theme.textMuted,
}
}
return {
logoFg: theme.text,
stripeLine: stripeLineForLight(theme.background),
base: theme.textMuted,
}
}
function ansiTruecolorFg(fg: RGBA): string {
const r = Math.round(Math.min(255, Math.max(0, fg.r * 255)))
const g = Math.round(Math.min(255, Math.max(0, fg.g * 255)))
const b = Math.round(Math.min(255, Math.max(0, fg.b * 255)))
return `\x1b[38;2;${r};${g};${b}m`
}
function appendAnsiFromParts(parts: Tone[], reset: string): string {
let s = ""
let prevKey = ""
for (const p of parts) {
const key = `${Math.round(p.fg.r * 255)},${Math.round(p.fg.g * 255)},${Math.round(p.fg.b * 255)}`
if (key !== prevKey) {
s += ansiTruecolorFg(p.fg)
prevKey = key
}
s += p.t
}
return s + reset
}
export function formatBannerLogoAnsiLines(
width: number,
palette: BannerLogoPalette,
options?: {
scanline?: boolean;
/** With {@link scanline}, stripe spaces inside the logo only (not width padding). */
scanlineWithinLogo?: boolean;
rows?: readonly string[];
align?: 'start' | 'center';
},
): string[] {
const w = Math.max(0, Math.floor(width));
const source = options?.rows ?? logoRowsForWidth(w);
const rows = source.slice(0, options?.rows ? source.length : LOGO_ROW_CAP);
const reset = '\x1b[0m';
const lines: string[] = [];
const withScanline = options?.scanline === true;
const scanLogoOnly = withScanline && options?.scanlineWithinLogo === true;
const left = options?.align === 'start';
for (const line of rows) {
const clipped = line.length > w;
const slice = clipped ? clipLogoRawLine(line, w, LOGO_HEAD_CLIP_SCROLL_OFFSET) : line;
const content = [{ t: slice, fg: palette.logoFg }];
let tones: Tone[];
if (scanLogoOnly) {
tones = scanline(content, palette.stripeLine);
} else if (left && !withScanline && !clipped) {
tones = content;
} else {
const parts =
clipped || left
? padTonesStart(content, w, palette.base)
: padTones(content, w, palette.base);
tones = withScanline ? scanline(parts, palette.stripeLine) : parts;
}
lines.push(appendAnsiFromParts(tones, reset));
}
return lines;
}
export function cliHelpBannerLogoPalette(): BannerLogoPalette {
const isLight = detectCliTerminalLight()
const theme = resolveTheme(opencode as ThemeJson, isLight ? "light" : "dark")
return bannerLogoPalette(isLight, theme)
}
export function formatCliHelpBannerLogoBlock(columns: number | undefined): string {
const w = typeof columns === 'number' && columns > 0 ? columns : 80;
return formatBannerLogoAnsiLines(w, cliHelpBannerLogoPalette(), {
rows: wordFullSmall,
align: 'start',
}).join(EOL);
}