import type { ColorInput } from "@opentui/core" import { RGBA } from "@opentui/core" import type { ColorGenerator } from "opentui-spinner" interface AdvancedGradientOptions { colors: ColorInput[] trailLength: number defaultColor?: ColorInput direction?: "forward" | "backward" | "bidirectional" holdFrames?: { start?: number; end?: number } } interface ScannerState { activePosition: number isHolding: boolean holdProgress: number holdTotal: number movementProgress: number movementTotal: number isMovingForward: boolean } function getScannerState( frameIndex: number, totalChars: number, options: Pick, ): ScannerState { const { direction = "forward", holdFrames = {} } = options if (direction === "bidirectional") { const forwardFrames = totalChars const holdEndFrames = holdFrames.end ?? 0 const backwardFrames = totalChars - 1 if (frameIndex < forwardFrames) { // Moving forward return { activePosition: frameIndex, isHolding: false, holdProgress: 0, holdTotal: 0, movementProgress: frameIndex, movementTotal: forwardFrames, isMovingForward: true, } } else if (frameIndex < forwardFrames + holdEndFrames) { // Holding at end return { activePosition: totalChars - 1, isHolding: true, holdProgress: frameIndex - forwardFrames, holdTotal: holdEndFrames, movementProgress: 0, movementTotal: 0, isMovingForward: true, } } else if (frameIndex < forwardFrames + holdEndFrames + backwardFrames) { // Moving backward const backwardIndex = frameIndex - forwardFrames - holdEndFrames return { activePosition: totalChars - 2 - backwardIndex, isHolding: false, holdProgress: 0, holdTotal: 0, movementProgress: backwardIndex, movementTotal: backwardFrames, isMovingForward: false, } } else { // Holding at start return { activePosition: 0, isHolding: true, holdProgress: frameIndex - forwardFrames - holdEndFrames - backwardFrames, holdTotal: holdFrames.start ?? 0, movementProgress: 0, movementTotal: 0, isMovingForward: false, } } } else if (direction === "backward") { return { activePosition: totalChars - 1 - (frameIndex % totalChars), isHolding: false, holdProgress: 0, holdTotal: 0, movementProgress: frameIndex % totalChars, movementTotal: totalChars, isMovingForward: false, } } else { return { activePosition: frameIndex % totalChars, isHolding: false, holdProgress: 0, holdTotal: 0, movementProgress: frameIndex % totalChars, movementTotal: totalChars, isMovingForward: true, } } } function calculateColorIndex( frameIndex: number, charIndex: number, totalChars: number, options: Pick, state?: ScannerState, ): number { const { trailLength } = options const { activePosition, isHolding, holdProgress, isMovingForward } = state ?? getScannerState(frameIndex, totalChars, options) // Calculate directional distance (positive means trailing behind) const directionalDistance = isMovingForward ? activePosition - charIndex // For forward: trail is to the left (lower indices) : charIndex - activePosition // For backward: trail is to the right (higher indices) // Handle hold frame fading: keep the lead bright, fade the trail if (isHolding) { // Shift the color index by how long we've been holding return directionalDistance + holdProgress } // Normal movement - show gradient trail only behind the movement direction if (directionalDistance > 0 && directionalDistance < trailLength) { return directionalDistance } // At the active position, show the brightest color if (directionalDistance === 0) { return 0 } return -1 } function createKnightRiderTrail(options: AdvancedGradientOptions): ColorGenerator { const { colors, defaultColor } = options // Use the provided defaultColor if it's an RGBA instance, otherwise convert/default // We use RGBA.fromHex for the fallback to ensure we have an RGBA object. // Note: If defaultColor is a string, we convert it once here. const defaultRgba = defaultColor instanceof RGBA ? defaultColor : RGBA.fromHex((defaultColor as string) || "#000000") let cachedFrameIndex = -1 let cachedState: ScannerState | null = null return (frameIndex: number, charIndex: number, _totalFrames: number, totalChars: number) => { if (frameIndex !== cachedFrameIndex) { cachedFrameIndex = frameIndex cachedState = getScannerState(frameIndex, totalChars, options) } const state = cachedState! const index = calculateColorIndex(frameIndex, charIndex, totalChars, options, state) // Calculate global fade for inactive dots during hold or movement const { isHolding, holdProgress, holdTotal, movementProgress, movementTotal } = state let alpha = 1.0 if (isHolding && holdTotal > 0) { // Fade out linearly const progress = Math.min(holdProgress / holdTotal, 1) alpha = Math.max(0, 1 - progress) } else if (!isHolding && movementTotal > 0) { // Fade in linearly during movement const progress = Math.min(movementProgress / Math.max(1, movementTotal - 1), 1) alpha = progress } // Mutate the alpha of the default RGBA object // This assumes single-threaded, synchronous rendering per frame // where we can modify the state for the current frame. // Since this is run for every char in the frame, setting it repeatedly to the same value is fine. defaultRgba.a = alpha if (index === -1) { return defaultRgba } return colors[index] ?? defaultRgba } } /** * Derives a gradient of tail colors from a single bright color * @param brightColor The brightest color (center/head of the scanner) * @param steps Number of gradient steps (default: 6) * @returns Array of RGBA colors from brightest to darkest */ export function deriveTrailColors(brightColor: ColorInput, steps: number = 6): RGBA[] { const baseRgba = brightColor instanceof RGBA ? brightColor : RGBA.fromHex(brightColor as string) const colors: RGBA[] = [] for (let i = 0; i < steps; i++) { // Progressive darkening: // i=0: 100% brightness (original color) // i=1: add slight bloom/glare (lighten) // i=2+: progressively darken let factor: number if (i === 0) { factor = 1.0 // Original brightness } else if (i === 1) { factor = 1.2 // Slight bloom/glare effect } else { // Exponential decay for natural-looking trail fade factor = Math.pow(0.6, i - 1) } const r = Math.min(1.0, baseRgba.r * factor) const g = Math.min(1.0, baseRgba.g * factor) const b = Math.min(1.0, baseRgba.b * factor) colors.push(RGBA.fromValues(r, g, b, 1.0)) } return colors } /** * Derives the inactive/default color from a bright color * @param brightColor The brightest color (center/head of the scanner) * @param factor Brightness factor for inactive color (default: 0.2) * @returns A much darker version suitable for inactive dots */ export function deriveInactiveColor(brightColor: ColorInput, factor: number = 0.2): RGBA { const baseRgba = brightColor instanceof RGBA ? brightColor : RGBA.fromHex(brightColor as string) const r = baseRgba.r * factor const g = baseRgba.g * factor const b = baseRgba.b * factor return RGBA.fromValues(r, g, b, 1.0) } export type KnightRiderStyle = "blocks" | "diamonds" export interface KnightRiderOptions { width?: number style?: KnightRiderStyle holdStart?: number holdEnd?: number colors?: ColorInput[] /** Single color to derive trail from (alternative to providing colors array) */ color?: ColorInput /** Number of trail steps when using single color (default: 6) */ trailSteps?: number defaultColor?: ColorInput /** Brightness factor for inactive color when using single color (default: 0.2) */ inactiveFactor?: number } /** * Creates frame strings for a Knight Rider style scanner animation * @param options Configuration options for the Knight Rider effect * @returns Array of frame strings */ export function createFrames(options: KnightRiderOptions = {}): string[] { const width = options.width ?? 8 const style = options.style ?? "diamonds" const holdStart = options.holdStart ?? 30 const holdEnd = options.holdEnd ?? 9 const colors = options.colors ?? (options.color ? deriveTrailColors(options.color, options.trailSteps) : [ RGBA.fromHex("#ff0000"), // Brightest Red (Center) RGBA.fromHex("#ff5555"), // Glare/Bloom RGBA.fromHex("#dd0000"), // Trail 1 RGBA.fromHex("#aa0000"), // Trail 2 RGBA.fromHex("#770000"), // Trail 3 RGBA.fromHex("#440000"), // Trail 4 ]) const defaultColor = options.defaultColor ?? (options.color ? deriveInactiveColor(options.color, options.inactiveFactor) : RGBA.fromHex("#330000")) const trailOptions = { colors, trailLength: colors.length, defaultColor, direction: "bidirectional" as const, holdFrames: { start: holdStart, end: holdEnd }, } // Bidirectional cycle: Forward (width) + Hold End + Backward (width-1) + Hold Start const totalFrames = width + holdEnd + (width - 1) + holdStart // Generate dynamic frames where inactive pixels are dots and active ones are blocks const frames = Array.from({ length: totalFrames }, (_, frameIndex) => { return Array.from({ length: width }, (_, charIndex) => { const index = calculateColorIndex(frameIndex, charIndex, width, trailOptions) if (style === "diamonds") { const shapes = ["⬥", "◆", "⬩", "⬪"] if (index >= 0 && index < trailOptions.colors.length) { return shapes[Math.min(index, shapes.length - 1)] } return "·" } // Default to blocks // It's active if we have a valid color index that is within our colors array const isActive = index >= 0 && index < trailOptions.colors.length return isActive ? "■" : "⬝" }).join("") }) return frames } /** * Creates a color generator function for Knight Rider style scanner animation * @param options Configuration options for the Knight Rider effect * @returns ColorGenerator function */ export function createColors(options: KnightRiderOptions = {}): ColorGenerator { const holdStart = options.holdStart ?? 30 const holdEnd = options.holdEnd ?? 9 const colors = options.colors ?? (options.color ? deriveTrailColors(options.color, options.trailSteps) : [ RGBA.fromHex("#ff0000"), // Brightest Red (Center) RGBA.fromHex("#ff5555"), // Glare/Bloom RGBA.fromHex("#dd0000"), // Trail 1 RGBA.fromHex("#aa0000"), // Trail 2 RGBA.fromHex("#770000"), // Trail 3 RGBA.fromHex("#440000"), // Trail 4 ]) const defaultColor = options.defaultColor ?? (options.color ? deriveInactiveColor(options.color, options.inactiveFactor) : RGBA.fromHex("#330000")) const trailOptions = { colors, trailLength: colors.length, defaultColor, direction: "bidirectional" as const, holdFrames: { start: holdStart, end: holdEnd }, } return createKnightRiderTrail(trailOptions) }