mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-10 05:10:59 -07:00
feat: Show snowfall animation for holiday theme (#15494)
Co-authored-by: Jack Wotherspoon <jackwoth@google.com>
This commit is contained in:
162
packages/cli/src/ui/hooks/useSnowfall.ts
Normal file
162
packages/cli/src/ui/hooks/useSnowfall.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { getAsciiArtWidth } from '../utils/textUtils.js';
|
||||
import { debugState } from '../debug.js';
|
||||
import { themeManager } from '../themes/theme-manager.js';
|
||||
import { Holiday } from '../themes/holiday.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useTerminalSize } from './useTerminalSize.js';
|
||||
import { shortAsciiLogo } from '../components/AsciiArt.js';
|
||||
|
||||
interface Snowflake {
|
||||
x: number;
|
||||
y: number;
|
||||
char: string;
|
||||
}
|
||||
|
||||
const SNOW_CHARS = ['*', '.', '·', '+'];
|
||||
const FRAME_RATE = 150; // ms
|
||||
|
||||
const addHolidayTrees = (art: string): string => {
|
||||
const holidayTree = `
|
||||
*
|
||||
***
|
||||
*****
|
||||
*******
|
||||
*********
|
||||
|_|`;
|
||||
|
||||
const treeLines = holidayTree.split('\n').filter((l) => l.length > 0);
|
||||
const treeWidth = getAsciiArtWidth(holidayTree);
|
||||
const logoWidth = getAsciiArtWidth(art);
|
||||
|
||||
// Create three trees side by side
|
||||
const treeSpacing = ' ';
|
||||
const tripleTreeLines = treeLines.map((line) => {
|
||||
const paddedLine = line.padEnd(treeWidth, ' ');
|
||||
return `${paddedLine}${treeSpacing}${paddedLine}${treeSpacing}${paddedLine}`;
|
||||
});
|
||||
|
||||
const tripleTreeWidth = treeWidth * 3 + treeSpacing.length * 2;
|
||||
const paddingCount = Math.max(
|
||||
0,
|
||||
Math.floor((logoWidth - tripleTreeWidth) / 2),
|
||||
);
|
||||
const treePadding = ' '.repeat(paddingCount);
|
||||
|
||||
const centeredTripleTrees = tripleTreeLines
|
||||
.map((line) => treePadding + line)
|
||||
.join('\n');
|
||||
|
||||
// Add vertical padding and the trees below the logo
|
||||
return `\n\n${art}\n${centeredTripleTrees}\n\n`;
|
||||
};
|
||||
|
||||
export const useSnowfall = (displayTitle: string): string => {
|
||||
const isHolidaySeason =
|
||||
new Date().getMonth() === 11 || new Date().getMonth() === 0;
|
||||
|
||||
const currentTheme = themeManager.getActiveTheme();
|
||||
const { columns: terminalWidth } = useTerminalSize();
|
||||
const { history, historyRemountKey } = useUIState();
|
||||
|
||||
const hasStartedChat = history.some(
|
||||
(item) => item.type === 'user' && item.text !== '/theme',
|
||||
);
|
||||
const widthOfShortLogo = getAsciiArtWidth(shortAsciiLogo);
|
||||
|
||||
const [showSnow, setShowSnow] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setShowSnow(true);
|
||||
const timer = setTimeout(() => {
|
||||
setShowSnow(false);
|
||||
}, 15000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [historyRemountKey]);
|
||||
|
||||
const showAnimation =
|
||||
isHolidaySeason &&
|
||||
currentTheme.name === Holiday.name &&
|
||||
terminalWidth >= widthOfShortLogo &&
|
||||
!hasStartedChat &&
|
||||
showSnow;
|
||||
|
||||
const displayArt = useMemo(() => {
|
||||
if (showAnimation) {
|
||||
return addHolidayTrees(displayTitle);
|
||||
}
|
||||
return displayTitle;
|
||||
}, [displayTitle, showAnimation]);
|
||||
|
||||
const [snowflakes, setSnowflakes] = useState<Snowflake[]>([]);
|
||||
// We don't need 'frame' state if we just use functional updates for snowflakes,
|
||||
// but we need a trigger. A simple interval is fine.
|
||||
|
||||
const lines = displayArt.split('\n');
|
||||
const height = lines.length;
|
||||
const width = getAsciiArtWidth(displayArt);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showAnimation) {
|
||||
setSnowflakes([]);
|
||||
return;
|
||||
}
|
||||
debugState.debugNumAnimatedComponents++;
|
||||
|
||||
const timer = setInterval(() => {
|
||||
setSnowflakes((prev) => {
|
||||
// Move existing flakes
|
||||
const moved = prev
|
||||
.map((flake) => ({ ...flake, y: flake.y + 1 }))
|
||||
.filter((flake) => flake.y < height);
|
||||
|
||||
// Spawn new flakes
|
||||
// Adjust spawn rate based on width to keep density consistent
|
||||
const spawnChance = 0.3;
|
||||
const newFlakes: Snowflake[] = [];
|
||||
|
||||
if (Math.random() < spawnChance) {
|
||||
// Spawn 1 to 2 flakes
|
||||
const count = Math.floor(Math.random() * 2) + 1;
|
||||
for (let i = 0; i < count; i++) {
|
||||
newFlakes.push({
|
||||
x: Math.floor(Math.random() * width),
|
||||
y: 0,
|
||||
char: SNOW_CHARS[Math.floor(Math.random() * SNOW_CHARS.length)],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return [...moved, ...newFlakes];
|
||||
});
|
||||
}, FRAME_RATE);
|
||||
return () => {
|
||||
debugState.debugNumAnimatedComponents--;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [height, width, showAnimation]);
|
||||
|
||||
if (!showAnimation) return displayTitle;
|
||||
|
||||
// Render current frame
|
||||
if (snowflakes.length === 0) return displayArt;
|
||||
const grid = lines.map((line) => line.padEnd(width, ' ').split(''));
|
||||
|
||||
snowflakes.forEach((flake) => {
|
||||
if (flake.y >= 0 && flake.y < height && flake.x >= 0 && flake.x < width) {
|
||||
// Overwrite with snow character
|
||||
// We check if the row exists just in case
|
||||
if (grid[flake.y]) {
|
||||
grid[flake.y][flake.x] = flake.char;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return grid.map((row) => row.join('')).join('\n');
|
||||
};
|
||||
Reference in New Issue
Block a user