diff --git a/README.md b/README.md index 42f0a96..8797081 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,12 @@ grain whoami grain logout ``` +Small interactive animations are enabled in TTY by default. Disable them with: + +```bash +GRAIN_NO_ANIM=1 grain +``` + Run `grain` with no subcommand to open the interactive upload wizard. ## Upload basics diff --git a/src/cli.ts b/src/cli.ts index 0c0813c..a03bbeb 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -8,6 +8,24 @@ import { getAuthorizedAgent, loginWithOAuth, logoutOAuth } from "./oauth"; import { promptForAltTextFallback, runUploadWizard } from "./tui"; import type { AltAiConfig, MediaInput } from "./types"; +const LENS_FRAMES = ["[lens: . ]", "[lens: .. ]", "[lens: ...]", "[lens: clear]"]; + +function terminalAnimationEnabled(): boolean { + return Boolean(process.stdout.isTTY && process.env.CI !== "true" && process.env.TERM !== "dumb" && process.env.GRAIN_NO_ANIM !== "1"); +} + +async function animateStatus(label: string, frames: string[], delayMs = 70): Promise { + if (!terminalAnimationEnabled()) { + return; + } + + for (const frame of frames) { + process.stdout.write(`\r${label} ${frame}`); + await Bun.sleep(delayMs); + } + process.stdout.write(`\r${label} [ready] \n`); +} + function printHelp(): void { console.log(`grain @@ -96,7 +114,7 @@ async function cmdLogin(argv: string[]): Promise { throw new GrainError("missing_handle", "Handle is required for login."); } - console.log("Starting secure browser login..."); + await animateStatus("Starting secure browser login", LENS_FRAMES, 65); const result = await loginWithOAuth(handle); console.log(`Logged in as ${result.handle}`); diff --git a/src/tui.ts b/src/tui.ts index 62d2f00..ad59125 100644 --- a/src/tui.ts +++ b/src/tui.ts @@ -10,6 +10,25 @@ type Prompt = { askYesNo(message: string, defaultYes?: boolean): Promise; }; +const FOCUS_FRAMES = ["[focus: . ]", "[focus: .. ]", "[focus: ...]", "[focus: ok ]"]; +const SHUTTER_FRAMES = ["[shutter: open ]", "[shutter: half ]", "[shutter: click]", "[shutter: open ]"]; + +function animationsEnabled(): boolean { + return Boolean(process.stdout.isTTY && process.env.CI !== "true" && process.env.TERM !== "dumb" && process.env.GRAIN_NO_ANIM !== "1"); +} + +async function animateLine(label: string, frames: string[], delayMs = 70): Promise { + if (!animationsEnabled()) { + return; + } + + for (const frame of frames) { + process.stdout.write(`\r${label} ${frame}`); + await Bun.sleep(delayMs); + } + process.stdout.write(`\r${label} [done] \n`); +} + function normalizeAscii(value: string): string { return value .normalize("NFKD") @@ -120,6 +139,7 @@ export async function runUploadWizard(): Promise { const prompt = buildPrompt(); console.log("grain - interactive gallery upload"); + await animateLine("Setting up your gallery", FOCUS_FRAMES); console.log("Tip: local paths must start with @, for example @photo.jpg or @./images/a.png"); const title = await prompt.askText("Gallery title"); @@ -172,6 +192,7 @@ export async function runUploadWizard(): Promise { } try { mediaInputs.push(parseWizardMediaEntry(value)); + await animateLine(`Framed image ${mediaInputs.length}`, SHUTTER_FRAMES, 60); } catch (error) { console.log(error instanceof Error ? error.message : String(error)); } @@ -215,6 +236,7 @@ export async function promptForAltTextFallback(context: { console.log(`AI alt text failed for ${context.sourceLabel}: ${context.errorMessage ?? "unknown error"}`); } + await animateLine("Opening preview", FOCUS_FRAMES, 60); await openInNativeViewer(context.bytes, context.mimeType); const value = await prompt.askText(`Alt text for image ${context.index + 1}/${context.total} (${context.sourceLabel})`, { allowEmpty: true,