cloudaxe-opencode/packages/opencode/src/cli/cmd/tui/ui/spinner.ts

356 lines
11 KiB
TypeScript
Raw Normal View History

2025-11-24 18:40:01 +00:00
import type { ColorInput } from "@opentui/core"
import { RGBA } from "@opentui/core"
import type { ColorGenerator } from "opentui-spinner"
2025-11-24 18:39:09 +00:00
interface AdvancedGradientOptions {
2025-11-24 18:40:01 +00:00
colors: ColorInput[]
trailLength: number
defaultColor?: ColorInput
direction?: "forward" | "backward" | "bidirectional"
holdFrames?: { start?: number; end?: number }
2025-11-24 18:39:09 +00:00
}
interface ScannerState {
2025-11-24 18:40:01 +00:00
activePosition: number
isHolding: boolean
holdProgress: number
holdTotal: number
movementProgress: number
movementTotal: number
isMovingForward: boolean
2025-11-24 18:39:09 +00:00
}
function getScannerState(
frameIndex: number,
totalChars: number,
options: Pick<AdvancedGradientOptions, "direction" | "holdFrames">,
): ScannerState {
2025-11-24 18:40:01 +00:00
const { direction = "forward", holdFrames = {} } = options
2025-11-24 18:39:09 +00:00
if (direction === "bidirectional") {
2025-11-24 18:40:01 +00:00
const forwardFrames = totalChars
const holdEndFrames = holdFrames.end ?? 0
const backwardFrames = totalChars - 1
2025-11-24 18:39:09 +00:00
if (frameIndex < forwardFrames) {
// Moving forward
return {
activePosition: frameIndex,
isHolding: false,
holdProgress: 0,
holdTotal: 0,
movementProgress: frameIndex,
movementTotal: forwardFrames,
isMovingForward: true,
2025-11-24 18:40:01 +00:00
}
2025-11-24 18:39:09 +00:00
} 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,
2025-11-24 18:40:01 +00:00
}
2025-11-24 18:39:09 +00:00
} else if (frameIndex < forwardFrames + holdEndFrames + backwardFrames) {
// Moving backward
2025-11-24 18:40:01 +00:00
const backwardIndex = frameIndex - forwardFrames - holdEndFrames
2025-11-24 18:39:09 +00:00
return {
activePosition: totalChars - 2 - backwardIndex,
isHolding: false,
holdProgress: 0,
holdTotal: 0,
movementProgress: backwardIndex,
movementTotal: backwardFrames,
isMovingForward: false,
2025-11-24 18:40:01 +00:00
}
2025-11-24 18:39:09 +00:00
} else {
// Holding at start
return {
activePosition: 0,
isHolding: true,
2025-11-24 18:40:01 +00:00
holdProgress: frameIndex - forwardFrames - holdEndFrames - backwardFrames,
2025-11-24 18:39:09 +00:00
holdTotal: holdFrames.start ?? 0,
movementProgress: 0,
movementTotal: 0,
isMovingForward: false,
2025-11-24 18:40:01 +00:00
}
2025-11-24 18:39:09 +00:00
}
} else if (direction === "backward") {
return {
activePosition: totalChars - 1 - (frameIndex % totalChars),
isHolding: false,
holdProgress: 0,
holdTotal: 0,
movementProgress: frameIndex % totalChars,
movementTotal: totalChars,
isMovingForward: false,
2025-11-24 18:40:01 +00:00
}
2025-11-24 18:39:09 +00:00
} else {
return {
activePosition: frameIndex % totalChars,
isHolding: false,
holdProgress: 0,
holdTotal: 0,
movementProgress: frameIndex % totalChars,
movementTotal: totalChars,
isMovingForward: true,
2025-11-24 18:40:01 +00:00
}
2025-11-24 18:39:09 +00:00
}
}
function calculateColorIndex(
frameIndex: number,
charIndex: number,
totalChars: number,
2025-11-24 18:40:01 +00:00
options: Pick<AdvancedGradientOptions, "direction" | "holdFrames" | "trailLength">,
2025-11-24 18:39:09 +00:00
state?: ScannerState,
): number {
2025-11-24 18:40:01 +00:00
const { trailLength } = options
2025-11-24 18:39:09 +00:00
const { activePosition, isHolding, holdProgress, isMovingForward } =
2025-11-24 18:40:01 +00:00
state ?? getScannerState(frameIndex, totalChars, options)
2025-11-24 18:39:09 +00:00
// Calculate directional distance (positive means trailing behind)
const directionalDistance = isMovingForward
? activePosition - charIndex // For forward: trail is to the left (lower indices)
2025-11-24 18:40:01 +00:00
: charIndex - activePosition // For backward: trail is to the right (higher indices)
2025-11-24 18:39:09 +00:00
// Handle hold frame fading: keep the lead bright, fade the trail
if (isHolding) {
// Shift the color index by how long we've been holding
2025-11-24 18:40:01 +00:00
return directionalDistance + holdProgress
2025-11-24 18:39:09 +00:00
}
// Normal movement - show gradient trail only behind the movement direction
if (directionalDistance > 0 && directionalDistance < trailLength) {
2025-11-24 18:40:01 +00:00
return directionalDistance
2025-11-24 18:39:09 +00:00
}
// At the active position, show the brightest color
if (directionalDistance === 0) {
2025-11-24 18:40:01 +00:00
return 0
2025-11-24 18:39:09 +00:00
}
2025-11-24 18:40:01 +00:00
return -1
2025-11-24 18:39:09 +00:00
}
2025-11-24 18:40:01 +00:00
function createKnightRiderTrail(options: AdvancedGradientOptions): ColorGenerator {
const { colors, defaultColor } = options
2025-11-24 18:39:09 +00:00
// 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.
2025-11-24 18:40:01 +00:00
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) => {
2025-11-24 18:39:09 +00:00
if (frameIndex !== cachedFrameIndex) {
2025-11-24 18:40:01 +00:00
cachedFrameIndex = frameIndex
cachedState = getScannerState(frameIndex, totalChars, options)
2025-11-24 18:39:09 +00:00
}
2025-11-24 18:40:01 +00:00
const state = cachedState!
2025-11-24 18:39:09 +00:00
2025-11-24 18:40:01 +00:00
const index = calculateColorIndex(frameIndex, charIndex, totalChars, options, state)
2025-11-24 18:39:09 +00:00
// Calculate global fade for inactive dots during hold or movement
2025-11-24 18:40:01 +00:00
const { isHolding, holdProgress, holdTotal, movementProgress, movementTotal } = state
let alpha = 1.0
2025-11-24 18:39:09 +00:00
if (isHolding && holdTotal > 0) {
// Fade out linearly
2025-11-24 18:40:01 +00:00
const progress = Math.min(holdProgress / holdTotal, 1)
alpha = Math.max(0, 1 - progress)
2025-11-24 18:39:09 +00:00
} else if (!isHolding && movementTotal > 0) {
// Fade in linearly during movement
2025-11-24 18:40:01 +00:00
const progress = Math.min(movementProgress / Math.max(1, movementTotal - 1), 1)
alpha = progress
2025-11-24 18:39:09 +00:00
}
// 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.
2025-11-24 18:40:01 +00:00
defaultRgba.a = alpha
2025-11-24 18:39:09 +00:00
if (index === -1) {
2025-11-24 18:40:01 +00:00
return defaultRgba
2025-11-24 18:39:09 +00:00
}
2025-11-24 18:40:01 +00:00
return colors[index] ?? defaultRgba
}
2025-11-24 18:39:09 +00:00
}
/**
* 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
*/
2025-11-24 18:40:01 +00:00
export function deriveTrailColors(brightColor: ColorInput, steps: number = 6): RGBA[] {
const baseRgba = brightColor instanceof RGBA ? brightColor : RGBA.fromHex(brightColor as string)
2025-11-24 18:39:09 +00:00
2025-11-24 18:40:01 +00:00
const colors: RGBA[] = []
2025-11-24 18:39:09 +00:00
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
2025-11-24 18:40:01 +00:00
let factor: number
2025-11-24 18:39:09 +00:00
if (i === 0) {
2025-11-24 18:40:01 +00:00
factor = 1.0 // Original brightness
2025-11-24 18:39:09 +00:00
} else if (i === 1) {
2025-11-24 18:40:01 +00:00
factor = 1.2 // Slight bloom/glare effect
2025-11-24 18:39:09 +00:00
} else {
// Exponential decay for natural-looking trail fade
2025-11-24 18:40:01 +00:00
factor = Math.pow(0.6, i - 1)
2025-11-24 18:39:09 +00:00
}
2025-11-24 18:40:01 +00:00
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)
2025-11-24 18:39:09 +00:00
2025-11-24 18:40:01 +00:00
colors.push(RGBA.fromValues(r, g, b, 1.0))
2025-11-24 18:39:09 +00:00
}
2025-11-24 18:40:01 +00:00
return colors
2025-11-24 18:39:09 +00:00
}
/**
* 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
*/
2025-11-24 18:40:01 +00:00
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)
2025-11-24 18:39:09 +00:00
}
2025-11-24 18:40:01 +00:00
export type KnightRiderStyle = "blocks" | "diamonds"
2025-11-24 18:39:09 +00:00
export interface KnightRiderOptions {
2025-11-24 18:40:01 +00:00
width?: number
style?: KnightRiderStyle
holdStart?: number
holdEnd?: number
colors?: ColorInput[]
2025-11-24 18:39:09 +00:00
/** Single color to derive trail from (alternative to providing colors array) */
2025-11-24 18:40:01 +00:00
color?: ColorInput
2025-11-24 18:39:09 +00:00
/** Number of trail steps when using single color (default: 6) */
2025-11-24 18:40:01 +00:00
trailSteps?: number
defaultColor?: ColorInput
2025-11-24 18:39:09 +00:00
/** Brightness factor for inactive color when using single color (default: 0.2) */
2025-11-24 18:40:01 +00:00
inactiveFactor?: number
2025-11-24 18:39:09 +00:00
}
/**
* 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[] {
2025-11-24 18:40:01 +00:00
const width = options.width ?? 8
const style = options.style ?? "diamonds"
const holdStart = options.holdStart ?? 30
const holdEnd = options.holdEnd ?? 9
2025-11-24 18:39:09 +00:00
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
2025-11-24 18:40:01 +00:00
])
2025-11-24 18:39:09 +00:00
const defaultColor =
options.defaultColor ??
2025-11-24 18:40:01 +00:00
(options.color ? deriveInactiveColor(options.color, options.inactiveFactor) : RGBA.fromHex("#330000"))
2025-11-24 18:39:09 +00:00
const trailOptions = {
colors,
trailLength: colors.length,
defaultColor,
direction: "bidirectional" as const,
holdFrames: { start: holdStart, end: holdEnd },
2025-11-24 18:40:01 +00:00
}
2025-11-24 18:39:09 +00:00
// Bidirectional cycle: Forward (width) + Hold End + Backward (width-1) + Hold Start
2025-11-24 18:40:01 +00:00
const totalFrames = width + holdEnd + (width - 1) + holdStart
2025-11-24 18:39:09 +00:00
// 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) => {
2025-11-24 18:40:01 +00:00
const index = calculateColorIndex(frameIndex, charIndex, width, trailOptions)
2025-11-24 18:39:09 +00:00
if (style === "diamonds") {
2025-11-24 18:40:01 +00:00
const shapes = ["⬥", "◆", "⬩", "⬪"]
2025-11-24 18:39:09 +00:00
if (index >= 0 && index < trailOptions.colors.length) {
2025-11-24 18:40:01 +00:00
return shapes[Math.min(index, shapes.length - 1)]
2025-11-24 18:39:09 +00:00
}
2025-11-24 18:40:01 +00:00
return "·"
2025-11-24 18:39:09 +00:00
}
// Default to blocks
// It's active if we have a valid color index that is within our colors array
2025-11-24 18:40:01 +00:00
const isActive = index >= 0 && index < trailOptions.colors.length
return isActive ? "■" : "⬝"
}).join("")
})
2025-11-24 18:39:09 +00:00
2025-11-24 18:40:01 +00:00
return frames
2025-11-24 18:39:09 +00:00
}
/**
* 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 {
2025-11-24 18:40:01 +00:00
const holdStart = options.holdStart ?? 30
const holdEnd = options.holdEnd ?? 9
2025-11-24 18:39:09 +00:00
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
2025-11-24 18:40:01 +00:00
])
2025-11-24 18:39:09 +00:00
const defaultColor =
options.defaultColor ??
2025-11-24 18:40:01 +00:00
(options.color ? deriveInactiveColor(options.color, options.inactiveFactor) : RGBA.fromHex("#330000"))
2025-11-24 18:39:09 +00:00
const trailOptions = {
colors,
trailLength: colors.length,
defaultColor,
direction: "bidirectional" as const,
holdFrames: { start: holdStart, end: holdEnd },
2025-11-24 18:40:01 +00:00
}
2025-11-24 18:39:09 +00:00
2025-11-24 18:40:01 +00:00
return createKnightRiderTrail(trailOptions)
2025-11-24 18:39:09 +00:00
}