#!/usr/bin/env python3 """Looping bathtub-and-duck terminal animation for Downterm demos.""" from __future__ import annotations import argparse import atexit import math import shutil import signal import sys import time from typing import Sequence CSI = "\x1b[" RESET = f"{CSI}0m" HIDE_CURSOR = f"{CSI}?25l" SHOW_CURSOR = f"{CSI}?25h" ALT_SCREEN_ON = f"{CSI}?1049h" ALT_SCREEN_OFF = f"{CSI}?1049l" CLEAR = f"{CSI}2J" HOME = f"{CSI}H" TUB = "tub" TUB_DARK = "tub_dark" WATER = "water" WATER_DEEP = "water_deep" FOAM = "foam" BUBBLE = "bubble" DUCK = "duck" DUCK_LIGHT = "duck_light" BEAK = "beak" EYE = "eye" LABEL = "label" LABEL_SHADOW = "label_shadow" RGB = { TUB: (171, 177, 191), TUB_DARK: (112, 118, 133), WATER: (71, 185, 214), WATER_DEEP: (36, 121, 161), FOAM: (196, 242, 250), BUBBLE: (184, 233, 245), DUCK: (255, 208, 64), DUCK_LIGHT: (255, 232, 132), BEAK: (255, 154, 58), EYE: (251, 251, 253), LABEL: (207, 212, 222), LABEL_SHADOW: (74, 79, 92), } WATER_SHADE = " .:-=+*#" def parse_args(argv: Sequence[str]) -> argparse.Namespace: parser = argparse.ArgumentParser( description="Render a looping bathtub-and-duck animation." ) parser.add_argument( "--fps", type=float, default=18.0, help="Target frames per second. Default: 18", ) parser.add_argument( "--speed", type=float, default=1.0, help="Animation speed multiplier. Default: 1.0", ) parser.add_argument( "--duration", type=float, default=0.0, help="Optional run duration in seconds. Default: infinite", ) parser.add_argument( "--label", default="", help="Optional centered label, e.g. --label Downterm", ) parser.add_argument( "--mono", action="store_true", help="Disable color and render as monochrome ASCII.", ) return parser.parse_args(argv) def terminal_size() -> tuple[int, int]: size = shutil.get_terminal_size((90, 28)) return max(40, size.columns), max(12, size.lines) def clamp(value: float, low: float, high: float) -> float: return max(low, min(high, value)) def scene_size(width: int, height: int) -> tuple[int, int]: visible_height = max(8, height - 1) scene_width = min(max(48, width - 2), width, 110) scene_height = min(max(16, visible_height - 1), visible_height, 24) return scene_width, scene_height def make_canvas(width: int, height: int) -> list[list[tuple[str, str | None]]]: return [[(" ", None) for _ in range(width)] for _ in range(height)] def put(canvas: list[list[tuple[str, str | None]]], x: int, y: int, char: str, color: str | None) -> None: if 0 <= y < len(canvas) and 0 <= x < len(canvas[y]) and char: canvas[y][x] = (char[0], color) def draw_text( canvas: list[list[tuple[str, str | None]]], x: int, y: int, text: str, color: str | None, ) -> None: for index, char in enumerate(text): if char != " ": put(canvas, x + index, y, char, color) def draw_sprite_text( canvas: list[list[tuple[str, str | None]]], x: int, y: int, text: str, color: str | None, ) -> None: for index, char in enumerate(text): if char == " ": continue if char == "~": put(canvas, x + index, y, " ", None) continue put(canvas, x + index, y, char, color) def water_char(intensity: float) -> str: index = int(clamp(intensity, 0.0, 0.9999) * len(WATER_SHADE)) return WATER_SHADE[index] def tub_geometry(scene_width: int, scene_height: int) -> dict[str, int]: top_y = 1 notch_width = max(28, min(scene_width - 18, int(scene_width * 0.44))) rim_left = max(8, (scene_width - notch_width) // 2) rim_right = min(scene_width - 9, rim_left + notch_width) wall_left = rim_left + 1 wall_right = rim_right - 1 body_bottom = min(scene_height - 10, top_y + max(6, scene_height // 3)) water_top = top_y + 2 return { "rim_left": rim_left, "rim_right": rim_right, "top_y": top_y, "wall_left": wall_left, "wall_right": wall_right, "body_bottom": body_bottom, "water_top": water_top, } def draw_tub(canvas: list[list[tuple[str, str | None]]], geometry: dict[str, int]) -> None: rim_left = geometry["rim_left"] rim_right = geometry["rim_right"] top_y = geometry["top_y"] wall_left = geometry["wall_left"] wall_right = geometry["wall_right"] body_bottom = geometry["body_bottom"] draw_text(canvas, 2, top_y, "_" * max(0, rim_left - 2), TUB) put(canvas, rim_left, top_y, "_", TUB) draw_text(canvas, rim_right + 1, top_y, "_" * max(0, len(canvas[0]) - rim_right - 3), TUB) put(canvas, rim_right, top_y, "_", TUB) for y in range(top_y + 1, body_bottom): put(canvas, wall_left, y, "|", TUB) put(canvas, wall_right, y, "|", TUB) put(canvas, wall_left, body_bottom, "\\", TUB_DARK) draw_text(canvas, wall_left + 1, body_bottom, "_" * max(0, wall_right - wall_left - 1), TUB_DARK) put(canvas, wall_right, body_bottom, "/", TUB_DARK) def draw_water_fill(canvas: list[list[tuple[str, str | None]]], geometry: dict[str, int], t: float) -> None: rim_left = geometry["wall_left"] + 1 rim_right = geometry["wall_right"] - 1 water_top = geometry["water_top"] body_bottom = geometry["body_bottom"] - 1 depth = max(1, body_bottom - water_top) for row in range(depth + 1): y = water_top + row for x in range(rim_left, rim_right + 1): wave = math.sin((x * 0.23) + (t * 2.1) + row * 0.55) shimmer = math.sin((x * 0.07) - (t * 1.35)) if row == 0: char = "~" if wave > -0.22 else "-" color = FOAM if shimmer > 0.25 else WATER else: density = 0.40 + row / max(1, depth) value = 0.45 + (wave * 0.25) + (shimmer * 0.10) + density * 0.30 char = water_char(value) color = WATER if row <= 2 else WATER_DEEP put(canvas, x, y, char, color) def draw_water_surface(canvas: list[list[tuple[str, str | None]]], geometry: dict[str, int], t: float) -> None: left = geometry["wall_left"] + 1 right = geometry["wall_right"] - 1 y = geometry["water_top"] for x in range(left, right + 1): wave = math.sin((x * 0.23) + (t * 2.1)) shimmer = math.sin((x * 0.11) - (t * 1.15)) char = "~" if wave > -0.18 else "-" color = FOAM if shimmer > 0.35 else WATER put(canvas, x, y, char, color) def draw_bubbles(canvas: list[list[tuple[str, str | None]]], geometry: dict[str, int], t: float) -> None: rim_left = geometry["wall_left"] water_top = geometry["water_top"] seeds = ( (0.00, rim_left + 11, 0.9), (0.29, rim_left + 19, 1.1), (0.57, rim_left + 27, 0.8), (0.82, rim_left + 35, 1.0), ) for offset, base_x, speed in seeds: progress = (t * 0.11 * speed + offset) % 1.0 y = water_top + 4 - int(progress * 9) x = base_x + int(round(math.sin((t * 0.8 * speed) + offset * 8.0) * 2)) put(canvas, x, y, "o" if progress < 0.5 else ".", BUBBLE) def draw_duck(canvas: list[list[tuple[str, str | None]]], geometry: dict[str, int], t: float) -> None: rim_left = geometry["wall_left"] rim_right = geometry["wall_right"] water_top = geometry["water_top"] span = rim_right - rim_left bob = math.sin(t * 1.35) * 0.55 drift = math.sin(t * 0.55) * 2.0 blink_phase = int(t * 2.1) % 12 eye_char = "-" if blink_phase == 7 else "." x = int(rim_left + span * 0.34 + drift) y = int(water_top - 1 + bob) rows = [ " __", f" ___(~{eye_char})>", " \\~<_.~) ", " `---' ", ] colors = [DUCK_LIGHT, DUCK, DUCK, DUCK] for index, row in enumerate(rows): draw_sprite_text(canvas, x, y + index, row, colors[index]) put(canvas, x + 8, y + 1, ">", BEAK) put(canvas, x + 6, y + 1, eye_char, EYE) if 0.74 < (t * 0.18) % 1.0 < 0.90: splash_x = x + 9 splash_y = geometry["water_top"] - 1 for dx, dy, char in ((0, 0, "."), (2, -1, "o"), (4, 0, "."), (5, -2, "o")): put(canvas, splash_x + dx, splash_y + dy, char, BUBBLE) def logo_rows(text: str) -> list[str]: if text == "commandNotch": return [ " ██████╗ ██████╗ ███╗ ███╗███╗ ███╗ █████╗ ███╗ ██╗██████╗ ", " ██╔════╝██╔═══██╗████╗ ████║████╗ ████║██╔══██╗████╗ ██║██╔══██╗", " ██║ ██║ ██║██╔████╔██║██╔████╔██║███████║██╔██╗ ██║██║ ██║", " ██║ ██║ ██║██║╚██╔╝██║██║╚██╔╝██║██╔══██║██║╚██╗██║██║ ██║", " ╚██████╗╚██████╔╝██║ ╚═╝ ██║██║ ╚═╝ ██║██║ ██║██║ ╚████║██████╔╝", " ╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═══╝╚═════╝ ", "", " ███╗ ██╗ ██████╗ ████████╗ ██████╗██╗ ██╗", " ████╗ ██║██╔═══██╗╚══██╔══╝██╔════╝██║ ██║", " ██╔██╗ ██║██║ ██║ ██║ ██║ ███████║", " ██║╚██╗██║██║ ██║ ██║ ██║ ██╔══██║", " ██║ ╚████║╚██████╔╝ ██║ ╚██████╗██║ ██║", " ╚═╝ ╚═══╝ ╚═════╝ ╚═╝ ╚═════╝╚═╝ ╚═╝", ] return [text] def draw_label(canvas: list[list[tuple[str, str | None]]], label: str) -> None: logo = logo_rows(label or "commandNotch") start_y = len(canvas) - len(logo) - 1 for index, row in enumerate(logo): start_x = max(0, (len(canvas[0]) - len(row)) // 2) draw_text(canvas, start_x, start_y + index, row, LABEL) def scene_canvas(scene_width: int, scene_height: int, t: float, label: str) -> list[list[tuple[str, str | None]]]: canvas = make_canvas(scene_width, scene_height) geometry = tub_geometry(scene_width, scene_height) draw_tub(canvas, geometry) draw_water_fill(canvas, geometry, t) draw_water_surface(canvas, geometry, t) draw_duck(canvas, geometry, t) draw_bubbles(canvas, geometry, t) draw_label(canvas, label) return canvas def ansi_rgb(color_name: str | None) -> str: if color_name is None: return RESET red, green, blue = RGB[color_name] return f"{CSI}38;2;{red};{green};{blue}m" def render_scene_line(cells: list[tuple[str, str | None]], mono: bool) -> str: if mono: return "".join(char for char, _ in cells) chunks: list[str] = [] current_color: str | None = None for char, color in cells: if color != current_color: chunks.append(ansi_rgb(color)) current_color = color chunks.append(char) chunks.append(RESET) return "".join(chunks) def render_frame(width: int, height: int, t: float, label: str, mono: bool) -> str: visible_height = max(8, height - 1) scene_width, scene_height = scene_size(width, height) scene = scene_canvas(scene_width, scene_height, t, label) top_padding = max(0, (visible_height - scene_height) // 2) left_padding = max(0, (width - scene_width) // 2) pieces: list[str] = [HOME] blank_line = " " * width for row in range(visible_height): if top_padding <= row < top_padding + scene_height: scene_row = scene[row - top_padding] rendered = render_scene_line(scene_row, mono) right_padding = max(0, width - left_padding - scene_width) line = (" " * left_padding) + rendered + (" " * right_padding) else: line = blank_line pieces.append(line) if row != visible_height - 1: pieces.append("\n") return "".join(pieces) def restore_terminal() -> None: sys.stdout.write(f"{RESET}{SHOW_CURSOR}{ALT_SCREEN_OFF}") sys.stdout.flush() def install_cleanup() -> None: def handle_signal(signum, _frame) -> None: restore_terminal() raise SystemExit(128 + signum) atexit.register(restore_terminal) signal.signal(signal.SIGINT, handle_signal) signal.signal(signal.SIGTERM, handle_signal) def main(argv: Sequence[str]) -> int: args = parse_args(argv) if args.fps <= 0: raise SystemExit("--fps must be greater than 0") if args.speed <= 0: raise SystemExit("--speed must be greater than 0") install_cleanup() sys.stdout.write(f"{ALT_SCREEN_ON}{HIDE_CURSOR}{CLEAR}") sys.stdout.flush() frame_interval = 1.0 / args.fps start = time.perf_counter() frame_index = 0 while True: now = time.perf_counter() elapsed = now - start if args.duration > 0 and elapsed >= args.duration: break width, height = terminal_size() t = elapsed * args.speed sys.stdout.write(render_frame(width, height, t, args.label, args.mono)) sys.stdout.flush() frame_index += 1 target = start + (frame_index * frame_interval) sleep_for = target - time.perf_counter() if sleep_for > 0: time.sleep(sleep_for) return 0 if __name__ == "__main__": raise SystemExit(main(sys.argv[1:]))