Skip to content

Instantly share code, notes, and snippets.

@jwa91
Created March 10, 2026 23:01
Show Gist options
  • Select an option

  • Save jwa91/1b55714f4b3f6ac157810bb39fe1ddc4 to your computer and use it in GitHub Desktop.

Select an option

Save jwa91/1b55714f4b3f6ac157810bb39fe1ddc4 to your computer and use it in GitHub Desktop.
Ghostty style animation with "Twentse Ros"
#!/usr/bin/env python3
"""
Twentse Ros - Pre-rendered ASCII animation for the terminal.
Ghostty-style smooth animation: pre-rendered frames, two colors,
character density for depth, subtle breathing wave.
Usage:
python3 twentse_ros_anim.py [ros_ascii.txt]
Ctrl+C to quit
"""
import math
import os
import signal
import sys
import time
from collections import deque
# --- Config ---
NUM_FRAMES = 180 # 6-second loop
FPS = 30
# --- ANSI ---
ESC = "\033"
HIDE_CURSOR = f"{ESC}[?25l"
SHOW_CURSOR = f"{ESC}[?25h"
CLEAR = f"{ESC}[2J"
HOME = f"{ESC}[H"
RESET = f"{ESC}[0m"
# Two colors: default terminal fg + Twente red accent
ACCENT = f"{ESC}[38;2;180;25;25m"
ACCENT_DIM = f"{ESC}[38;2;100;14;14m"
DEFAULT_FG = f"{ESC}[39m"
# --- Characters ---
# Body: ordered light → heavy (18 chars)
BODY_CHARS = "·.,;:-~=+*xo%S#@$W"
# Border: lighter decorative set
BORDER_CHARS = "·.+~*=-:"
# --- Density mapping from original ASCII ---
CHAR_DENSITY = {}
for c in "W": CHAR_DENSITY[c] = 1.0
for c in "#@": CHAR_DENSITY[c] = 0.9
for c in "%&$": CHAR_DENSITY[c] = 0.85
for c in "S": CHAR_DENSITY[c] = 0.75
for c in "E": CHAR_DENSITY[c] = 0.7
for c in "N": CHAR_DENSITY[c] = 0.6
for c in "AHMX": CHAR_DENSITY[c] = 0.55
for c in "0123456789": CHAR_DENSITY[c] = 0.5
for c in "abcdefghijklmnopqrstuvwxyz": CHAR_DENSITY[c] = 0.4
for c in "+=*?": CHAR_DENSITY[c] = 0.35
for c in "~-:;": CHAR_DENSITY[c] = 0.25
for c in ".,": CHAR_DENSITY[c] = 0.15
def density_to_char(d):
d = max(0.0, min(1.0, d))
return BODY_CHARS[int(d * (len(BODY_CHARS) - 1))]
def load_ascii_art(path):
with open(path) as f:
lines = [l.rstrip('\n') for l in f]
# Find bounding box of content
min_x = min_y = float('inf')
max_x = max_y = 0
for y, line in enumerate(lines):
for x, ch in enumerate(line):
if ch != ' ':
min_x = min(min_x, x)
max_x = max(max_x, x)
min_y = min(min_y, y)
max_y = max(max_y, y)
if min_x == float('inf'):
print("No content found!")
sys.exit(1)
# Add margin for the border aura
margin = 3
grid_x0 = max(0, min_x - margin)
grid_y0 = max(0, min_y - margin)
grid_x1 = max_x + margin
grid_y1 = max_y + margin
h = grid_y1 - grid_y0 + 1
w = grid_x1 - grid_x0 + 1
grid = []
for y in range(grid_y0, grid_y1 + 1):
row = []
for x in range(grid_x0, grid_x1 + 1):
ch = ' '
if 0 <= y < len(lines) and 0 <= x < len(lines[y]):
ch = lines[y][x]
row.append(ch)
grid.append(row)
return grid, w, h
def compute_outer_aura(grid, h, w, max_dist=2):
"""Find empty pixels near the OUTER edge of the horse only.
Flood-fills from the grid border to find all 'outside' empty pixels,
then computes distance from filled pixels for only those outside pixels.
Interior holes (like the eye) are excluded.
"""
# Step 1: flood-fill from edges to mark all "outside" empty pixels
outside = [[False] * w for _ in range(h)]
q = deque()
# Seed from all border cells that are empty
for y in range(h):
for x in range(w):
if (y == 0 or y == h - 1 or x == 0 or x == w - 1) and grid[y][x] == ' ':
outside[y][x] = True
q.append((x, y))
while q:
px, py = q.popleft()
for dx in (-1, 0, 1):
for dy in (-1, 0, 1):
if dx == 0 and dy == 0:
continue
nx, ny = px + dx, py + dy
if 0 <= nx < w and 0 <= ny < h and not outside[ny][nx] and grid[ny][nx] == ' ':
outside[ny][nx] = True
q.append((nx, ny))
# Step 2: BFS distance from filled pixels, but only for outside empty pixels
dist = [[999] * w for _ in range(h)]
q = deque()
for y in range(h):
for x in range(w):
if grid[y][x] != ' ':
dist[y][x] = 0
q.append((x, y))
while q:
px, py = q.popleft()
for dx in (-1, 0, 1):
for dy in (-1, 0, 1):
if dx == 0 and dy == 0:
continue
nx, ny = px + dx, py + dy
if 0 <= nx < w and 0 <= ny < h:
nd = dist[py][px] + 1
if nd < dist[ny][nx] and nd <= max_dist and outside[ny][nx]:
dist[ny][nx] = nd
q.append((nx, ny))
return dist
def generate_frames(grid, dist, h, w):
"""Pre-render all animation frames."""
frames = []
for f in range(NUM_FRAMES):
phase = f / NUM_FRAMES * 2.0 * math.pi
lines = []
for y in range(h):
parts = []
color_state = 'default'
ny = y / h
for x in range(w):
ch = grid[y][x]
pd = dist[y][x]
if ch != ' ':
# --- Body pixel: default terminal color, original char ---
if color_state != 'default':
parts.append(DEFAULT_FG)
color_state = 'default'
parts.append(ch)
elif 1 <= pd <= 2:
# --- Border aura: Twente red accent ---
# Slow traveling shimmer (whole-number multipliers = seamless loop)
s = math.sin(y * 0.15 + x * 0.1 + phase)
s += math.sin(y * 0.08 - x * 0.06 + phase * 2.0) * 0.4
# Show character when wave crest passes
thresh = 0.1 if pd == 1 else 0.5
if s > thresh:
want_color = ACCENT if pd == 1 else ACCENT_DIM
if color_state != want_color:
parts.append(want_color)
color_state = want_color
bci = int(abs(s * 5.0 + ny * 3.0)) % len(BORDER_CHARS)
parts.append(BORDER_CHARS[bci])
else:
if color_state != 'default':
parts.append(DEFAULT_FG)
color_state = 'default'
parts.append(' ')
else:
# --- Empty pixel ---
if color_state != 'default':
parts.append(DEFAULT_FG)
color_state = 'default'
parts.append(' ')
if color_state != 'default':
parts.append(DEFAULT_FG)
lines.append(''.join(parts))
frames.append(lines)
return frames
def main():
path = sys.argv[1] if len(sys.argv) > 1 else "ros_ascii.txt"
grid, w, h = load_ascii_art(path)
try:
term_w, term_h = os.get_terminal_size()
except OSError:
term_w, term_h = 120, 50
offset_x = max(0, (term_w - w) // 2)
offset_y = max(0, (term_h - h) // 2)
dist = compute_outer_aura(grid, h, w, max_dist=2)
sys.stderr.write("Generating frames...")
sys.stderr.flush()
frames = generate_frames(grid, dist, h, w)
sys.stderr.write(f" {NUM_FRAMES} frames ready.\n")
sys.stderr.flush()
# Setup terminal
sys.stdout.write(HIDE_CURSOR + CLEAR)
sys.stdout.flush()
def cleanup(sig=None, _frame=None):
sys.stdout.write(SHOW_CURSOR + RESET + CLEAR + HOME)
sys.stdout.flush()
sys.exit(0)
signal.signal(signal.SIGINT, cleanup)
signal.signal(signal.SIGTERM, cleanup)
frame_idx = 0
try:
while True:
frame_lines = frames[frame_idx % NUM_FRAMES]
buf = []
for y, line in enumerate(frame_lines):
row = offset_y + y + 1
if 1 <= row <= term_h:
buf.append(f"{ESC}[{row};{offset_x + 1}H{line}")
sys.stdout.write(''.join(buf))
sys.stdout.flush()
frame_idx += 1
time.sleep(1.0 / FPS)
except KeyboardInterrupt:
cleanup()
if __name__ == "__main__":
main()
@jwa91
Copy link
Author

jwa91 commented Mar 10, 2026

ros_ascii.txt



                                                    SSN
                                                NS NSSSSSNS                  SJS
                                                SSSSWWWWWWSSSSSSSNNNS         SN
                                             SSSSWWWWWWWWWWWWWWS    NN     2  SN
                                            SSSSSSSSWWWWWWWWWWWSS          SNSN
                                           SSWSo   SWWWWWWWWWWWWSSS          NS
                                          SSSWSSSNSSWWWWWWWWWWWWWSN           SSj
                                        NSSWWWWWWWW2WWWWWWWWWWWSSSSSN         SSSS   NS
                      NNSNS           NSSWWWWWWWWWWSSSWWWWWWWWWS   S       Nf aSSSN  SSS
                    SSSSWSSJ        ESSWWWWWW2WWSSSW SWWWWWWWWWSSJ         SS SSWSS  SSJ
                  SSNSSSSSSS        NSSSSSSSSSSSW  SSSWWWWWWWWWSSSSSJ      SN SSSSN  SSS
               NSSSS     SSSSW       SSS        SSSSWWWWWWWWWWWN  2SSf    SSSSSSSa  SSSN
             SSSSS        SSSSS        fSSSSSSSSSW2WWWWWWWWWWWWN   NSW   SSWWWSS  SSSSS
           NSSWS          sSSWSSN   SSSSSWWWWWWWWWWWWWWWWWWWWSSSSN  a   SSWWWWWsfSSSSN
           SSWWSS          NSSSS  NSSSWWWWWWWWWWWWWWWWWWWWWWSSSSSSS     NSWWWWSSSS2SS    xNS
            NSWSS           SS  SSSWWWWWWWWWWWWWWWWWWWWWWSSSS   NSN      SSWWWWWWWSS      SS
             SSSS              NSSWWWWWWWWWWWWWWWWWWWWWWWS SNNJ  SSS      SSSWWWWWSS     NSS
            aNN               oSWWWWWWWWWWWWWWWWWWWWWWWWWS                 SSSWWWWSS  oNSSSS
                              SSWWWWWWWWWWWWWWWWWWWWWWSSSSNSJNNS        SNS  SSWWWSS SSSSSS
                 SSNSSSSSSSSSSSSWWWWWWWWWWWWWWW2WWWWW2S   x   7J         aSS  SSWWWSSSWWS
                 NSSSSNSSSSSSSSSSWWW2WWWWWSSSSSWWWWWWWSN                  SS   NSWWWWWWSS
                 SSSW          xSNSSSSSSSSSS  SWWWWWWWWSSj               SSS   xSWWWWWWSN
                 SSS                       2SSSWWWWWWWWWSSN              SSS    SWWWWWWSS
                 SS            SNSSSSSSSSSSSSWWWWWWWWWWWWSSS             SSSSS  S2W2WSSSW
                oSN              NSWWW2WWWWWWWWWWWWWWWWWWWWSSS            NSSSSSSWWSSS2
                SSS  7            NSSWWWWWWWWWWWWWWWWWWWWWWWSSSS           NSSW2WSSS  SSNS
                SSSSS2             2SSWWWWWWWWWWWWWWWWWWWWWWWWSSSSSSs        SSWWSS  SSN
                SS2SS                SSSWWWWWWWWWWWWWSSSSWWWWWWWWWSSSSSS      SWWS   SS
                SSSSS                  SSSWWWWWWWWWSSS  SWWWWWWWWWWWWWSSSS    SWWSS  SSo
                  SSSSSJ                 SSSSWWWWWSS SSSSWWWWWWWWWWWWWWWSSS   SWSSN  SSS
                    SSSS                   SSSSSSSSW SWWWWWWWWWWWWWWWWWWWSSSSSSWS2  sSSS
                      S                        fSSW 7SWWWWWWWWWWWWWWWWWWW2SSSNSSSSSSSSNJ
                                             SSSWSS SSWWWWWWWWWWWWWWWWWWWSS     SNSSS
                                             fSSWWS  SWWWWWWWWWWWWWWWWWWSSS
                                              NSSWSN SSWWWWWWWWWWWWWWSSSS
                                               SSWWS  SSWWWWWWWWWSSSNSN
                                sox            jS2WSS  SSWWWWWWSSNs
                              SSSSSSSSSSSSo  x SSWWWSS  SSWWWSSS
                              SSWWSSSSSSSSSSSSSSSWWWSS  NSSWWSS
                             SSWSSSS        fSSSSSSSSS   SSWWSs
                            SSW2SSS                      sSWWS
                            SSSSSj                      NSSSSS
                           oNSN                       SSSSNSS
                                                    SSSSS
                                                  SNSS
                                                SSSS
                                               SSWWSSS
                                               SSWSSW
                                               WSSS
                                                 SSSSS
                                                 SSSS
                                                  SS


Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment