From 34e3efb8df836e287ec27d9549efb8a64a90deb0 Mon Sep 17 00:00:00 2001 From: Isaac Johnson Date: Mon, 1 Jun 2026 19:36:39 -0500 Subject: [PATCH] open and close animations --- src/tui.ts | 157 +++++++++++++++++++++++++++++++++++++++++++++---- src/types.ts | 2 +- test-frames.js | 89 ++++++++++++++++++++++++++++ 3 files changed, 235 insertions(+), 13 deletions(-) create mode 100644 test-frames.js diff --git a/src/tui.ts b/src/tui.ts index 1af8876..6fbb112 100644 --- a/src/tui.ts +++ b/src/tui.ts @@ -10,6 +10,90 @@ readline.emitKeypressEvents(process.stdin); // Spinner chars for loading animations const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; let spinnerIndex = 0; + +const GRAVESTONE_FRAMES = [ + [ + chalk.gray(' '), + chalk.gray(' '), + chalk.gray(' '), + chalk.gray(' '), + chalk.gray(' ___________ '), + ], + [ + chalk.gray(' '), + chalk.gray(' '), + chalk.gray(' '), + chalk.gray(' ___ '), + chalk.gray(' ___/___\\___ '), + ], + [ + chalk.gray(' '), + chalk.gray(' '), + chalk.gray(' ___ '), + chalk.gray(' / \\ '), + chalk.gray(' __|_____|__ '), + ], + [ + chalk.gray(' '), + chalk.gray(' ___ '), + chalk.gray(' / \\ '), + chalk.gray(' | RIP | '), + chalk.gray(' __|_____|__ '), + ], + [ + chalk.gray(' ___ '), + chalk.gray(' / \\ '), + chalk.gray(' | RIP | '), + chalk.gray(' | | '), + chalk.gray(' __|_____|__ '), + ] +]; + +const ZOMBIE_FRAMES = [ + [ + chalk.gray(' ___ '), + chalk.gray(' / \\ '), + chalk.gray(' | RIP | '), + chalk.gray(' | | '), + chalk.gray(' __|_____|__ '), + ], + [ + chalk.gray(' ___ '), + chalk.gray(' / \\ '), + chalk.gray(' | RIP | '), + chalk.gray(' | | '), + chalk.gray(' __|_ ') + chalk.green('.') + chalk.gray(' _|__ '), + ], + [ + chalk.gray(' ___ '), + chalk.gray(' / \\ '), + chalk.gray(' | RIP | '), + chalk.gray(' | ') + chalk.green('^') + chalk.gray(' | '), + chalk.gray(' __|_') + chalk.green('/ \\') + chalk.gray('_|__ '), + ], + [ + chalk.gray(' ___ '), + chalk.gray(' / \\ '), + chalk.gray(' | ') + chalk.green('o_o') + chalk.gray(' | '), + chalk.gray(' | ') + chalk.green('/|\\') + chalk.gray(' | '), + chalk.gray(' __|_ ') + chalk.green('|') + chalk.gray(' _|__ '), + ], + [ + chalk.gray(' ___ '), + chalk.gray(' /') + chalk.green('o_o') + chalk.gray('\\ '), + chalk.gray(' | ') + chalk.green('/|\\') + chalk.gray(' | '), + chalk.gray(' | ') + chalk.green('|') + chalk.gray(' | '), + chalk.gray(' __|_') + chalk.green('/ \\') + chalk.gray('_|__ '), + ], + [ + chalk.gray(' \\') + chalk.green('o_o') + chalk.gray('/ '), + chalk.gray(' / ') + chalk.green('|') + chalk.gray(' \\ '), + chalk.gray(' | ') + chalk.green('/ \\') + chalk.gray(' | '), + chalk.gray(' | | '), + chalk.gray(' __|_____|__ '), + ] +]; + let spinnerInterval: NodeJS.Timeout | null = null; /** @@ -105,6 +189,8 @@ export class TuiEngine { private onQuit: () => void = () => {}; private activeSearchInput: boolean = false; private searchInputBuffer: string = ''; + private animationFrame: number = 0; + private animationInterval: NodeJS.Timeout | null = null; constructor(initialState: AppState) { this.state = initialState; @@ -145,6 +231,10 @@ export class TuiEngine { */ public stop() { this.stopSpinner(); + if (this.animationInterval) { + clearInterval(this.animationInterval); + this.animationInterval = null; + } // Exit alternate screen buffer and show standard cursor process.stdout.write('\x1B[?1049l\x1B[?25h'); if (process.stdin.isTTY) { @@ -794,9 +884,29 @@ export class TuiEngine { this.renderAddTimeScreen(cols, rows); } else if (this.state.screen === 'confirm-state-change') { this.renderConfirmStateChangeScreen(cols, rows); + } else if (this.state.screen === 'animating-close') { + this.renderAnimationScreen(cols, rows, GRAVESTONE_FRAMES); + } else if (this.state.screen === 'animating-reopen') { + this.renderAnimationScreen(cols, rows, ZOMBIE_FRAMES); } } + private renderAnimationScreen(cols: number, rows: number, frames: string[][]) { + const frame = frames[Math.min(this.animationFrame, frames.length - 1)]; + const paddingRows = Math.max(0, Math.floor((rows - frame.length) / 2)); + + for (let i = 0; i < paddingRows; i++) console.log(' '.repeat(cols)); + + for (const line of frame) { + const plainLen = stripAnsi(line).length; + const padLeft = Math.max(0, Math.floor((cols - plainLen) / 2)); + console.log(' '.repeat(padLeft) + line); + } + + const remainingRows = Math.max(0, rows - paddingRows - frame.length - 1); + for (let i = 0; i < remainingRows; i++) console.log(' '.repeat(cols)); + } + /** * Draw Setup Form */ @@ -1944,21 +2054,44 @@ export class TuiEngine { if (!issue) return; this.state.error = null; - this.state.loading = true; + const newState = issue.state === 'open' ? 'closed' : 'open'; + this.state.screen = newState === 'closed' ? 'animating-close' : 'animating-reopen'; + this.animationFrame = 0; this.render(); - try { - const newState = issue.state === 'open' ? 'closed' : 'open'; - await changeIssueState(this.state.config, issue.number, newState); - this.state.screen = 'details'; - this.state.loading = false; - // Reload to show the updated state - this.reloadSingleIssue(); - } catch (err: any) { - this.state.error = err.message; - this.state.loading = false; - this.render(); + if (this.animationInterval) { + clearInterval(this.animationInterval); } + + this.animationInterval = setInterval(() => { + this.animationFrame++; + const frameCount = newState === 'closed' ? GRAVESTONE_FRAMES.length : ZOMBIE_FRAMES.length; + this.render(); + + if (this.animationFrame >= frameCount - 1) { + if (this.animationInterval) { + clearInterval(this.animationInterval); + this.animationInterval = null; + } + + setTimeout(async () => { + this.state.loading = true; + this.render(); + try { + await changeIssueState(this.state.config, issue.number, newState); + this.state.screen = 'details'; + this.state.loading = false; + this.reloadSingleIssue(); + } catch (err: any) { + this.state.error = err.message; + this.state.screen = 'details'; + this.state.loading = false; + this.render(); + } + }, 300); + } + }, 300); + return; } } diff --git a/src/types.ts b/src/types.ts index 77f8973..244ea73 100644 --- a/src/types.ts +++ b/src/types.ts @@ -69,7 +69,7 @@ export interface AddTimeForm { timeInput: string; } -export type ScreenType = 'setup' | 'repo-picker' | 'list' | 'details' | 'create-issue' | 'add-comment' | 'edit-issue' | 'add-time' | 'confirm-state-change'; +export type ScreenType = 'setup' | 'repo-picker' | 'list' | 'details' | 'create-issue' | 'add-comment' | 'edit-issue' | 'add-time' | 'confirm-state-change' | 'animating-close' | 'animating-reopen'; export interface RepoItem { diff --git a/test-frames.js b/test-frames.js new file mode 100644 index 0000000..ab88440 --- /dev/null +++ b/test-frames.js @@ -0,0 +1,89 @@ +const GRAVESTONE_FRAMES = [ + [ + ' ', + ' ', + ' ', + ' ', + ' ___________ ', + ], + [ + ' ', + ' ', + ' ', + ' ___ ', + ' ___/___\\___ ', + ], + [ + ' ', + ' ', + ' ___ ', + ' / \\ ', + ' __|_____|__ ', + ], + [ + ' ', + ' ___ ', + ' / \\ ', + ' | RIP | ', + ' __|_____|__ ', + ], + [ + ' ___ ', + ' / \\ ', + ' | RIP | ', + ' | | ', + ' __|_____|__ ', + ] +]; + +const ZOMBIE_FRAMES = [ + [ + ' ___ ', + ' / \\ ', + ' | RIP | ', + ' | | ', + ' __|_____|__ ', + ], + [ + ' ___ ', + ' / \\ ', + ' | RIP | ', + ' | | ', + ' __|_ . _|__ ', + ], + [ + ' ___ ', + ' / \\ ', + ' | RIP | ', + ' | ^ | ', + ' __|_/ \\_|__ ', + ], + [ + ' ___ ', + ' / \\ ', + ' | o_o | ', + ' | /|\\ | ', + ' __|_ | _|__ ', + ], + [ + ' ___ ', + ' /o_o\\ ', + ' | /|\\ | ', + ' | | | ', + ' __|_/ \\_|__ ', + ], + [ + ' \\o_o/ ', + ' / | \\ ', + ' | / \\ | ', + ' | | ', + ' __|_____|__ ', + ] +]; + +const checkLens = frames => frames.forEach((f, i) => f.forEach((l, j) => { + if (l.length !== 17) console.log(`Frame ${i} line ${j} len ${l.length}: ${l}`); +})); +checkLens(GRAVESTONE_FRAMES); +checkLens(ZOMBIE_FRAMES); +console.log("Checked lengths!");