| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368 |
- 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<AdvancedGradientOptions, "direction" | "holdFrames">,
- ): 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<AdvancedGradientOptions, "direction" | "holdFrames" | "trailLength">,
- 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)
- }
|