spinner.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368
  1. import type { ColorInput } from "@opentui/core"
  2. import { RGBA } from "@opentui/core"
  3. import type { ColorGenerator } from "opentui-spinner"
  4. interface AdvancedGradientOptions {
  5. colors: ColorInput[]
  6. trailLength: number
  7. defaultColor?: ColorInput
  8. direction?: "forward" | "backward" | "bidirectional"
  9. holdFrames?: { start?: number; end?: number }
  10. enableFading?: boolean
  11. minAlpha?: number
  12. }
  13. interface ScannerState {
  14. activePosition: number
  15. isHolding: boolean
  16. holdProgress: number
  17. holdTotal: number
  18. movementProgress: number
  19. movementTotal: number
  20. isMovingForward: boolean
  21. }
  22. function getScannerState(
  23. frameIndex: number,
  24. totalChars: number,
  25. options: Pick<AdvancedGradientOptions, "direction" | "holdFrames">,
  26. ): ScannerState {
  27. const { direction = "forward", holdFrames = {} } = options
  28. if (direction === "bidirectional") {
  29. const forwardFrames = totalChars
  30. const holdEndFrames = holdFrames.end ?? 0
  31. const backwardFrames = totalChars - 1
  32. if (frameIndex < forwardFrames) {
  33. // Moving forward
  34. return {
  35. activePosition: frameIndex,
  36. isHolding: false,
  37. holdProgress: 0,
  38. holdTotal: 0,
  39. movementProgress: frameIndex,
  40. movementTotal: forwardFrames,
  41. isMovingForward: true,
  42. }
  43. } else if (frameIndex < forwardFrames + holdEndFrames) {
  44. // Holding at end
  45. return {
  46. activePosition: totalChars - 1,
  47. isHolding: true,
  48. holdProgress: frameIndex - forwardFrames,
  49. holdTotal: holdEndFrames,
  50. movementProgress: 0,
  51. movementTotal: 0,
  52. isMovingForward: true,
  53. }
  54. } else if (frameIndex < forwardFrames + holdEndFrames + backwardFrames) {
  55. // Moving backward
  56. const backwardIndex = frameIndex - forwardFrames - holdEndFrames
  57. return {
  58. activePosition: totalChars - 2 - backwardIndex,
  59. isHolding: false,
  60. holdProgress: 0,
  61. holdTotal: 0,
  62. movementProgress: backwardIndex,
  63. movementTotal: backwardFrames,
  64. isMovingForward: false,
  65. }
  66. } else {
  67. // Holding at start
  68. return {
  69. activePosition: 0,
  70. isHolding: true,
  71. holdProgress: frameIndex - forwardFrames - holdEndFrames - backwardFrames,
  72. holdTotal: holdFrames.start ?? 0,
  73. movementProgress: 0,
  74. movementTotal: 0,
  75. isMovingForward: false,
  76. }
  77. }
  78. } else if (direction === "backward") {
  79. return {
  80. activePosition: totalChars - 1 - (frameIndex % totalChars),
  81. isHolding: false,
  82. holdProgress: 0,
  83. holdTotal: 0,
  84. movementProgress: frameIndex % totalChars,
  85. movementTotal: totalChars,
  86. isMovingForward: false,
  87. }
  88. } else {
  89. return {
  90. activePosition: frameIndex % totalChars,
  91. isHolding: false,
  92. holdProgress: 0,
  93. holdTotal: 0,
  94. movementProgress: frameIndex % totalChars,
  95. movementTotal: totalChars,
  96. isMovingForward: true,
  97. }
  98. }
  99. }
  100. function calculateColorIndex(
  101. frameIndex: number,
  102. charIndex: number,
  103. totalChars: number,
  104. options: Pick<AdvancedGradientOptions, "direction" | "holdFrames" | "trailLength">,
  105. state?: ScannerState,
  106. ): number {
  107. const { trailLength } = options
  108. const { activePosition, isHolding, holdProgress, isMovingForward } =
  109. state ?? getScannerState(frameIndex, totalChars, options)
  110. // Calculate directional distance (positive means trailing behind)
  111. const directionalDistance = isMovingForward
  112. ? activePosition - charIndex // For forward: trail is to the left (lower indices)
  113. : charIndex - activePosition // For backward: trail is to the right (higher indices)
  114. // Handle hold frame fading: keep the lead bright, fade the trail
  115. if (isHolding) {
  116. // Shift the color index by how long we've been holding
  117. return directionalDistance + holdProgress
  118. }
  119. // Normal movement - show gradient trail only behind the movement direction
  120. if (directionalDistance > 0 && directionalDistance < trailLength) {
  121. return directionalDistance
  122. }
  123. // At the active position, show the brightest color
  124. if (directionalDistance === 0) {
  125. return 0
  126. }
  127. return -1
  128. }
  129. function createKnightRiderTrail(options: AdvancedGradientOptions): ColorGenerator {
  130. const { colors, defaultColor, enableFading = true, minAlpha = 0 } = options
  131. // Use the provided defaultColor if it's an RGBA instance, otherwise convert/default
  132. // We use RGBA.fromHex for the fallback to ensure we have an RGBA object.
  133. // Note: If defaultColor is a string, we convert it once here.
  134. const defaultRgba = defaultColor instanceof RGBA ? defaultColor : RGBA.fromHex((defaultColor as string) || "#000000")
  135. // Store the base alpha from the inactive factor
  136. const baseInactiveAlpha = defaultRgba.a
  137. let cachedFrameIndex = -1
  138. let cachedState: ScannerState | null = null
  139. return (frameIndex: number, charIndex: number, _totalFrames: number, totalChars: number) => {
  140. if (frameIndex !== cachedFrameIndex) {
  141. cachedFrameIndex = frameIndex
  142. cachedState = getScannerState(frameIndex, totalChars, options)
  143. }
  144. const state = cachedState!
  145. const index = calculateColorIndex(frameIndex, charIndex, totalChars, options, state)
  146. // Calculate global fade for inactive dots during hold or movement
  147. const { isHolding, holdProgress, holdTotal, movementProgress, movementTotal } = state
  148. let fadeFactor = 1.0
  149. if (enableFading) {
  150. if (isHolding && holdTotal > 0) {
  151. // Fade out linearly to minAlpha
  152. const progress = Math.min(holdProgress / holdTotal, 1)
  153. fadeFactor = Math.max(minAlpha, 1 - progress * (1 - minAlpha))
  154. } else if (!isHolding && movementTotal > 0) {
  155. // Fade in linearly from minAlpha during movement
  156. const progress = Math.min(movementProgress / Math.max(1, movementTotal - 1), 1)
  157. fadeFactor = minAlpha + progress * (1 - minAlpha)
  158. }
  159. }
  160. // Combine base inactive alpha with the fade factor
  161. // This ensures inactiveFactor is respected while still allowing fading animation
  162. defaultRgba.a = baseInactiveAlpha * fadeFactor
  163. if (index === -1) {
  164. return defaultRgba
  165. }
  166. return colors[index] ?? defaultRgba
  167. }
  168. }
  169. /**
  170. * Derives a gradient of tail colors from a single bright color using alpha falloff
  171. * @param brightColor The brightest color (center/head of the scanner)
  172. * @param steps Number of gradient steps (default: 6)
  173. * @returns Array of RGBA colors with alpha-based trail fade (background-independent)
  174. */
  175. export function deriveTrailColors(brightColor: ColorInput, steps: number = 6): RGBA[] {
  176. const baseRgba = brightColor instanceof RGBA ? brightColor : RGBA.fromHex(brightColor as string)
  177. const colors: RGBA[] = []
  178. for (let i = 0; i < steps; i++) {
  179. // Alpha-based falloff with optional bloom effect
  180. let alpha: number
  181. let brightnessFactor: number
  182. if (i === 0) {
  183. // Lead position: full brightness and opacity
  184. alpha = 1.0
  185. brightnessFactor = 1.0
  186. } else if (i === 1) {
  187. // Slight bloom/glare effect: brighten color but reduce opacity slightly
  188. alpha = 0.9
  189. brightnessFactor = 1.15
  190. } else {
  191. // Exponential alpha decay for natural-looking trail fade
  192. alpha = Math.pow(0.65, i - 1)
  193. brightnessFactor = 1.0
  194. }
  195. const r = Math.min(1.0, baseRgba.r * brightnessFactor)
  196. const g = Math.min(1.0, baseRgba.g * brightnessFactor)
  197. const b = Math.min(1.0, baseRgba.b * brightnessFactor)
  198. colors.push(RGBA.fromValues(r, g, b, alpha))
  199. }
  200. return colors
  201. }
  202. /**
  203. * Derives the inactive/default color from a bright color using alpha
  204. * @param brightColor The brightest color (center/head of the scanner)
  205. * @param factor Alpha factor for inactive color (default: 0.2, range: 0-1)
  206. * @returns The same color with reduced alpha for background-independent dimming
  207. */
  208. export function deriveInactiveColor(brightColor: ColorInput, factor: number = 0.2): RGBA {
  209. const baseRgba = brightColor instanceof RGBA ? brightColor : RGBA.fromHex(brightColor as string)
  210. // Use the full color brightness but adjust alpha for background-independent dimming
  211. return RGBA.fromValues(baseRgba.r, baseRgba.g, baseRgba.b, factor)
  212. }
  213. export type KnightRiderStyle = "blocks" | "diamonds"
  214. export interface KnightRiderOptions {
  215. width?: number
  216. style?: KnightRiderStyle
  217. holdStart?: number
  218. holdEnd?: number
  219. colors?: ColorInput[]
  220. /** Single color to derive trail from (alternative to providing colors array) */
  221. color?: ColorInput
  222. /** Number of trail steps when using single color (default: 6) */
  223. trailSteps?: number
  224. defaultColor?: ColorInput
  225. /** Alpha factor for inactive color when using single color (default: 0.2, range: 0-1) */
  226. inactiveFactor?: number
  227. /** Enable fading of inactive dots during hold and movement (default: true) */
  228. enableFading?: boolean
  229. /** Minimum alpha value when fading (default: 0, range: 0-1) */
  230. minAlpha?: number
  231. }
  232. /**
  233. * Creates frame strings for a Knight Rider style scanner animation
  234. * @param options Configuration options for the Knight Rider effect
  235. * @returns Array of frame strings
  236. */
  237. export function createFrames(options: KnightRiderOptions = {}): string[] {
  238. const width = options.width ?? 8
  239. const style = options.style ?? "diamonds"
  240. const holdStart = options.holdStart ?? 30
  241. const holdEnd = options.holdEnd ?? 9
  242. const colors =
  243. options.colors ??
  244. (options.color
  245. ? deriveTrailColors(options.color, options.trailSteps)
  246. : [
  247. RGBA.fromHex("#ff0000"), // Brightest Red (Center)
  248. RGBA.fromHex("#ff5555"), // Glare/Bloom
  249. RGBA.fromHex("#dd0000"), // Trail 1
  250. RGBA.fromHex("#aa0000"), // Trail 2
  251. RGBA.fromHex("#770000"), // Trail 3
  252. RGBA.fromHex("#440000"), // Trail 4
  253. ])
  254. const defaultColor =
  255. options.defaultColor ??
  256. (options.color ? deriveInactiveColor(options.color, options.inactiveFactor) : RGBA.fromHex("#330000"))
  257. const trailOptions = {
  258. colors,
  259. trailLength: colors.length,
  260. defaultColor,
  261. direction: "bidirectional" as const,
  262. holdFrames: { start: holdStart, end: holdEnd },
  263. enableFading: options.enableFading,
  264. minAlpha: options.minAlpha,
  265. }
  266. // Bidirectional cycle: Forward (width) + Hold End + Backward (width-1) + Hold Start
  267. const totalFrames = width + holdEnd + (width - 1) + holdStart
  268. // Generate dynamic frames where inactive pixels are dots and active ones are blocks
  269. const frames = Array.from({ length: totalFrames }, (_, frameIndex) => {
  270. return Array.from({ length: width }, (_, charIndex) => {
  271. const index = calculateColorIndex(frameIndex, charIndex, width, trailOptions)
  272. if (style === "diamonds") {
  273. const shapes = ["⬥", "◆", "⬩", "⬪"]
  274. if (index >= 0 && index < trailOptions.colors.length) {
  275. return shapes[Math.min(index, shapes.length - 1)]
  276. }
  277. return "·"
  278. }
  279. // Default to blocks
  280. // It's active if we have a valid color index that is within our colors array
  281. const isActive = index >= 0 && index < trailOptions.colors.length
  282. return isActive ? "■" : "⬝"
  283. }).join("")
  284. })
  285. return frames
  286. }
  287. /**
  288. * Creates a color generator function for Knight Rider style scanner animation
  289. * @param options Configuration options for the Knight Rider effect
  290. * @returns ColorGenerator function
  291. */
  292. export function createColors(options: KnightRiderOptions = {}): ColorGenerator {
  293. const holdStart = options.holdStart ?? 30
  294. const holdEnd = options.holdEnd ?? 9
  295. const colors =
  296. options.colors ??
  297. (options.color
  298. ? deriveTrailColors(options.color, options.trailSteps)
  299. : [
  300. RGBA.fromHex("#ff0000"), // Brightest Red (Center)
  301. RGBA.fromHex("#ff5555"), // Glare/Bloom
  302. RGBA.fromHex("#dd0000"), // Trail 1
  303. RGBA.fromHex("#aa0000"), // Trail 2
  304. RGBA.fromHex("#770000"), // Trail 3
  305. RGBA.fromHex("#440000"), // Trail 4
  306. ])
  307. const defaultColor =
  308. options.defaultColor ??
  309. (options.color ? deriveInactiveColor(options.color, options.inactiveFactor) : RGBA.fromHex("#330000"))
  310. const trailOptions = {
  311. colors,
  312. trailLength: colors.length,
  313. defaultColor,
  314. direction: "bidirectional" as const,
  315. holdFrames: { start: holdStart, end: holdEnd },
  316. enableFading: options.enableFading,
  317. minAlpha: options.minAlpha,
  318. }
  319. return createKnightRiderTrail(trailOptions)
  320. }