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 } enableFading?: boolean minAlpha?: 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, enableFading = true, minAlpha = 0 } = 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") // Store the base alpha from the inactive factor const baseInactiveAlpha = defaultRgba.a 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 fadeFactor = 1.0 if (enableFading) { if (isHolding && holdTotal > 0) { // Fade out linearly to minAlpha const progress = Math.min(holdProgress / holdTotal, 1) fadeFactor = Math.max(minAlpha, 1 - progress * (1 - minAlpha)) } else if (!isHolding && movementTotal > 0) { // Fade in linearly from minAlpha during movement const progress = Math.min(movementProgress / Math.max(1, movementTotal - 1), 1) fadeFactor = minAlpha + progress * (1 - minAlpha) } } // Combine base inactive alpha with the fade factor // This ensures inactiveFactor is respected while still allowing fading animation defaultRgba.a = baseInactiveAlpha * fadeFactor if (index === -1) { return defaultRgba } return colors[index] ?? defaultRgba } } /** * Derives a gradient of tail colors from a single bright color using alpha falloff * @param brightColor The brightest color (center/head of the scanner) * @param steps Number of gradient steps (default: 6) * @returns Array of RGBA colors with alpha-based trail fade (background-independent) */ 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++) { // Alpha-based falloff with optional bloom effect let alpha: number let brightnessFactor: number if (i === 0) { // Lead position: full brightness and opacity alpha = 1.0 brightnessFactor = 1.0 } else if (i === 1) { // Slight bloom/glare effect: brighten color but reduce opacity slightly alpha = 0.9 brightnessFactor = 1.15 } else { // Exponential alpha decay for natural-looking trail fade alpha = Math.pow(0.65, i - 1) brightnessFactor = 1.0 } const r = Math.min(1.0, baseRgba.r * brightnessFactor) const g = Math.min(1.0, baseRgba.g * brightnessFactor) const b = Math.min(1.0, baseRgba.b * brightnessFactor) colors.push(RGBA.fromValues(r, g, b, alpha)) } return colors } /** * Derives the inactive/default color from a bright color using alpha * @param brightColor The brightest color (center/head of the scanner) * @param factor Alpha factor for inactive color (default: 0.2, range: 0-1) * @returns The same color with reduced alpha for background-independent dimming */ export function deriveInactiveColor(brightColor: ColorInput, factor: number = 0.2): RGBA { const baseRgba = brightColor instanceof RGBA ? brightColor : RGBA.fromHex(brightColor as string) // Use the full color brightness but adjust alpha for background-independent dimming return RGBA.fromValues(baseRgba.r, baseRgba.g, baseRgba.b, factor) } 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 /** Alpha factor for inactive color when using single color (default: 0.2, range: 0-1) */ inactiveFactor?: number /** Enable fading of inactive dots during hold and movement (default: true) */ enableFading?: boolean /** Minimum alpha value when fading (default: 0, range: 0-1) */ minAlpha?: 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 }, enableFading: options.enableFading, minAlpha: options.minAlpha, } // 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 }, enableFading: options.enableFading, minAlpha: options.minAlpha, } return createKnightRiderTrail(trailOptions) }