mirror of
https://github.com/sst/opencode.git
synced 2025-12-23 10:11:41 +00:00
non-corpo loading spinner
This commit is contained in:
parent
883ed4d424
commit
82ebf66cba
5 changed files with 434 additions and 92 deletions
5
bun.lock
5
bun.lock
|
|
@ -252,6 +252,7 @@
|
|||
"jsonc-parser": "3.3.1",
|
||||
"minimatch": "10.0.3",
|
||||
"open": "10.1.2",
|
||||
"opentui-spinner": "0.0.5",
|
||||
"partial-json": "0.1.7",
|
||||
"remeda": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
|
|
@ -1948,6 +1949,8 @@
|
|||
|
||||
"cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="],
|
||||
|
||||
"cli-spinners": ["cli-spinners@3.3.0", "", {}, "sha512-/+40ljC3ONVnYIttjMWrlL51nItDAbBrq2upN8BPyvGU/2n5Oxw3tbNwORCaNuNqLJnxGqOfjUuhsv7l5Q4IsQ=="],
|
||||
|
||||
"clipboardy": ["clipboardy@4.0.0", "", { "dependencies": { "execa": "^8.0.1", "is-wsl": "^3.1.0", "is64bit": "^2.0.0" } }, "sha512-5mOlNS0mhX0707P2I0aZ2V/cmHUEO/fL7VFLqszkhUsxt7RwnmrInf/eEQKlf5GzvYeHIjT+Ov1HRfNmymlG0w=="],
|
||||
|
||||
"cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="],
|
||||
|
|
@ -2962,6 +2965,8 @@
|
|||
|
||||
"openid-client": ["openid-client@5.6.4", "", { "dependencies": { "jose": "^4.15.4", "lru-cache": "^6.0.0", "object-hash": "^2.2.0", "oidc-token-hash": "^5.0.3" } }, "sha512-T1h3B10BRPKfcObdBklX639tVz+xh34O7GjofqrqiAQdm7eHsQ00ih18x6wuJ/E6FxdtS2u3FmUGPDeEcMwzNA=="],
|
||||
|
||||
"opentui-spinner": ["opentui-spinner@0.0.5", "", { "dependencies": { "cli-spinners": "^3.3.0" }, "peerDependencies": { "@opentui/core": "^0.1.49", "@opentui/react": "^0.1.49", "@opentui/solid": "^0.1.49", "typescript": "^5" }, "optionalPeers": ["@opentui/react", "@opentui/solid"] }, "sha512-abSWzWA7eyuD0PjerAWbBznLmOQn+8xRDaLGCVIs4ctETi2laNFr5KwicYnPXsHZpPc2neV7WtQm+diCEfOhLA=="],
|
||||
|
||||
"own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="],
|
||||
|
||||
"oxc-minify": ["oxc-minify@0.96.0", "", { "optionalDependencies": { "@oxc-minify/binding-android-arm64": "0.96.0", "@oxc-minify/binding-darwin-arm64": "0.96.0", "@oxc-minify/binding-darwin-x64": "0.96.0", "@oxc-minify/binding-freebsd-x64": "0.96.0", "@oxc-minify/binding-linux-arm-gnueabihf": "0.96.0", "@oxc-minify/binding-linux-arm-musleabihf": "0.96.0", "@oxc-minify/binding-linux-arm64-gnu": "0.96.0", "@oxc-minify/binding-linux-arm64-musl": "0.96.0", "@oxc-minify/binding-linux-riscv64-gnu": "0.96.0", "@oxc-minify/binding-linux-s390x-gnu": "0.96.0", "@oxc-minify/binding-linux-x64-gnu": "0.96.0", "@oxc-minify/binding-linux-x64-musl": "0.96.0", "@oxc-minify/binding-wasm32-wasi": "0.96.0", "@oxc-minify/binding-win32-arm64-msvc": "0.96.0", "@oxc-minify/binding-win32-x64-msvc": "0.96.0" } }, "sha512-dXeeGrfPJJ4rMdw+NrqiCRtbzVX2ogq//R0Xns08zql2HjV3Zi2SBJ65saqfDaJzd2bcHqvGWH+M44EQCHPAcA=="],
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@
|
|||
"jsonc-parser": "3.3.1",
|
||||
"minimatch": "10.0.3",
|
||||
"open": "10.1.2",
|
||||
"opentui-spinner": "0.0.5",
|
||||
"partial-json": "0.1.7",
|
||||
"remeda": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent, t, dim, fg, type KeyBinding } from "@opentui/core"
|
||||
import { createEffect, createMemo, type JSX, onMount, createSignal, onCleanup, Show, Switch, Match } from "solid-js"
|
||||
import "opentui-spinner/solid";
|
||||
import { useLocal } from "@tui/context/local"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
import { EmptyBorder } from "@tui/component/border"
|
||||
|
|
@ -20,7 +21,7 @@ import type { FilePart } from "@opencode-ai/sdk"
|
|||
import { TuiEvent } from "../../event"
|
||||
import { iife } from "@/util/iife"
|
||||
import { Locale } from "@/util/locale"
|
||||
import { Shimmer } from "../../ui/shimmer"
|
||||
import { createColors, createFrames } from "../../ui/spinner.ts"
|
||||
|
||||
export type PromptProps = {
|
||||
sessionID?: string
|
||||
|
|
@ -545,6 +546,22 @@ export function Prompt(props: PromptProps) {
|
|||
return local.agent.color(local.agent.current().name)
|
||||
})
|
||||
|
||||
const spinnerDef = createMemo(() => {
|
||||
const color = local.agent.color(local.agent.current().name)
|
||||
return {
|
||||
frames: createFrames({
|
||||
color,
|
||||
style: "blocks",
|
||||
inactiveFactor: 0.25,
|
||||
}),
|
||||
color: createColors({
|
||||
color,
|
||||
style: "blocks",
|
||||
inactiveFactor: 0.25,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
renderer.setCursorColor(highlight())
|
||||
})
|
||||
|
|
@ -813,7 +830,11 @@ export function Prompt(props: PromptProps) {
|
|||
justifyContent={status().type === "retry" ? "space-between" : "flex-start"}
|
||||
>
|
||||
<box flexShrink={0} flexDirection="row" gap={1}>
|
||||
<Loader />
|
||||
<spinner
|
||||
color={spinnerDef().color}
|
||||
frames={spinnerDef().frames}
|
||||
interval={40}
|
||||
/>
|
||||
<box flexDirection="row" gap={1} flexShrink={0}>
|
||||
{(() => {
|
||||
const retry = createMemo(() => {
|
||||
|
|
@ -884,37 +905,3 @@ export function Prompt(props: PromptProps) {
|
|||
)
|
||||
}
|
||||
|
||||
function Loader() {
|
||||
const FRAMES = [
|
||||
"▱▱▱▱▱▱▱",
|
||||
"▱▱▱▱▱▱▱",
|
||||
"▱▱▱▱▱▱▱",
|
||||
"▱▱▱▱▱▱▱",
|
||||
"▰▱▱▱▱▱▱",
|
||||
"▰▰▱▱▱▱▱",
|
||||
"▰▰▰▱▱▱▱",
|
||||
"▱▰▰▰▱▱▱",
|
||||
"▱▱▰▰▰▱▱",
|
||||
"▱▱▱▰▰▰▱",
|
||||
"▱▱▱▱▰▰▰",
|
||||
"▱▱▱▱▱▰▰",
|
||||
"▱▱▱▱▱▱▰",
|
||||
"▱▱▱▱▱▱▱",
|
||||
"▱▱▱▱▱▱▱",
|
||||
"▱▱▱▱▱▱▱",
|
||||
"▱▱▱▱▱▱▱",
|
||||
]
|
||||
const [frame, setFrame] = createSignal(0)
|
||||
|
||||
onMount(() => {
|
||||
const timer = setInterval(() => {
|
||||
setFrame((frame() + 1) % FRAMES.length)
|
||||
}, 100)
|
||||
onCleanup(() => {
|
||||
clearInterval(timer)
|
||||
})
|
||||
})
|
||||
|
||||
const { theme } = useTheme()
|
||||
return <text fg={theme.diffAdded}>{FRAMES[frame()]}</text>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,56 +0,0 @@
|
|||
import { RGBA } from "@opentui/core"
|
||||
import { useTimeline } from "@opentui/solid"
|
||||
import { createMemo, createSignal } from "solid-js"
|
||||
|
||||
export type ShimmerProps = {
|
||||
text: string
|
||||
color: RGBA
|
||||
}
|
||||
|
||||
const DURATION = 2_500
|
||||
|
||||
export function Shimmer(props: ShimmerProps) {
|
||||
const timeline = useTimeline({
|
||||
duration: DURATION,
|
||||
loop: true,
|
||||
})
|
||||
const characters = props.text.split("")
|
||||
const color = props.color
|
||||
|
||||
const shimmerSignals = characters.map((_, i) => {
|
||||
const [shimmer, setShimmer] = createSignal(0.4)
|
||||
const target = {
|
||||
shimmer: shimmer(),
|
||||
setShimmer,
|
||||
}
|
||||
|
||||
timeline!.add(
|
||||
target,
|
||||
{
|
||||
shimmer: 1,
|
||||
duration: DURATION / (props.text.length + 1),
|
||||
ease: "linear",
|
||||
alternate: true,
|
||||
loop: 2,
|
||||
onUpdate: () => {
|
||||
target.setShimmer(target.shimmer)
|
||||
},
|
||||
},
|
||||
(i * (DURATION / (props.text.length + 1))) / 2,
|
||||
)
|
||||
|
||||
return shimmer
|
||||
})
|
||||
|
||||
return (
|
||||
<text>
|
||||
{(() => {
|
||||
return characters.map((ch, i) => {
|
||||
const shimmer = shimmerSignals[i]
|
||||
const fg = RGBA.fromInts(color.r * 255, color.g * 255, color.b * 255, shimmer() * 255)
|
||||
return <span style={{ fg }}>{ch}</span>
|
||||
})
|
||||
})()}
|
||||
</text>
|
||||
)
|
||||
}
|
||||
405
packages/opencode/src/cli/cmd/tui/ui/spinner.ts
Normal file
405
packages/opencode/src/cli/cmd/tui/ui/spinner.ts
Normal file
|
|
@ -0,0 +1,405 @@
|
|||
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<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 } = 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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue