Skip to content

Instantly share code, notes, and snippets.

@aaronedev
Created September 21, 2025 11:25
Show Gist options
  • Select an option

  • Save aaronedev/436ac07ad03c38a3164e3a494788a695 to your computer and use it in GitHub Desktop.

Select an option

Save aaronedev/436ac07ad03c38a3164e3a494788a695 to your computer and use it in GitHub Desktop.
TerminalTextEffects (TTE) previewer script with adjustable options to cycle through all available options
#!/usr/bin/env python3
"""
Author: Aaron-Samuel Hauck
GitHub: https://github.com/aaronedev
Email: [email protected]
License: MIT License
description:
tte cycle controller with reliable stdin and top-right overlay.
Converted from Bash. Adds interactive live controls, helpers, and options.
Usage examples:
./tte.py # uses stdin or default text
./tte.py file.txt # read from file
./tte.py --frame-rate 60 --cols-overlay --tte-arg "--xterm-colors"
Interactive keys (when /dev/tty is focused):
← / → : previous / next effect
↑ / ↓ : increase / decrease fps by step
space/n : next effect
q : quit
+ / - : increase / decrease fps by 1
c : toggle --xterm-colors argument
w : toggle --wrap-text argument
r : restart current effect
Notes:
- Requires external `tte` binary in PATH.
- The script spawns `tte` in a new process group so it can be killed reliably.
"""
from __future__ import annotations
import argparse
import os
import signal
import shutil
import subprocess
import sys
import tempfile
import threading
import time
from contextlib import contextmanager
from dataclasses import dataclass, field
from typing import List, Optional
# ------------------ Configuration ------------------
DEFAULT_EFFECTS = [
"beams",
"binarypath",
"blackhole",
"bouncyballs",
"bubbles",
"burn",
"colorshift",
"crumble",
"decrypt",
"errorcorrect",
"expand",
"fireworks",
"highlight",
"laseretch",
"matrix",
"middleout",
"orbittingvolley",
"overflow",
"pour",
"print",
"rain",
"randomsequence",
"rings",
"scattered",
"slice",
"slide",
"spotlights",
"spray",
"swarm",
"sweep",
"synthgrid",
"unstable",
"vhstape",
"waves",
"wipe",
]
FPS_STEP = 10
OVERLAY_INTERVAL = 0.2
# ------------------ Helpers / Utilities ------------------
@dataclass
class TTEController:
effects: List[str]
input_file: str
framerate: int = 100
extra_args: List[str] = field(default_factory=list)
tte_proc: Optional[subprocess.Popen] = None
running: bool = False
overlay_thread: Optional[threading.Thread] = None
overlay_stop: threading.Event = field(default_factory=threading.Event)
lock: threading.Lock = field(default_factory=threading.Lock)
def start_effect(self, idx: int) -> None:
"""Start tte for self.effects[idx]. Kill previous one first."""
self.kill_tte()
effect = self.effects[idx]
print("\033[H\033[2J", end="") # clear screen
print(f"Effect: {effect}\n")
cmd = ["tte", "--frame-rate", str(self.framerate)] + self.extra_args + [effect]
# use setsid to start new process group so we can kill group later
try:
proc = subprocess.Popen(
cmd,
stdin=open(self.input_file, "rb"),
stdout=sys.stdout,
stderr=sys.stderr,
preexec_fn=os.setsid,
)
except FileNotFoundError:
print("Error: `tte` binary not found in PATH.")
self.tte_proc = None
return
except Exception as e:
print("Failed to start tte:", e)
self.tte_proc = None
return
self.tte_proc = proc
def kill_tte(self) -> None:
if not self.tte_proc:
return
try:
pgid = os.getpgid(self.tte_proc.pid)
os.killpg(pgid, signal.SIGTERM)
# give it a moment then SIGKILL if still alive
for _ in range(10):
if self.tte_proc.poll() is not None:
break
time.sleep(0.05)
if self.tte_proc.poll() is None:
os.killpg(pgid, signal.SIGKILL)
except Exception:
pass
try:
self.tte_proc.wait(timeout=0.1)
except Exception:
pass
self.tte_proc = None
def start_overlay(self, idx: int) -> None:
self.stop_overlay()
self.overlay_stop.clear()
t = threading.Thread(target=self._overlay_loop, args=(idx,), daemon=True)
self.overlay_thread = t
t.start()
def _overlay_loop(self, idx_at_start: int) -> None:
# overlay writes to stdout to display effect name and fps in top-right
try:
while not self.overlay_stop.is_set():
cols = shutil.get_terminal_size((80, 24)).columns
with self.lock:
overlay_txt = (
f" {self.effects[idx_at_start]} | fps:{self.framerate} "
)
length = len(overlay_txt)
col = cols - length
if col < 0:
col = 0
# save cursor, move to top-right, print overlay, clear to EOL, restore cursor
sys.stdout.write("\x1b7")
# move to row 1, col (1-indexed): ESC[{row};{col}H
sys.stdout.write(f"\x1b[1;{col+1}H")
sys.stdout.write(overlay_txt)
sys.stdout.write("\x1b[K")
sys.stdout.write("\x1b8")
sys.stdout.flush()
time.sleep(OVERLAY_INTERVAL)
except Exception:
# overlay should never crash whole program
return
def stop_overlay(self) -> None:
self.overlay_stop.set()
if self.overlay_thread:
self.overlay_thread.join(timeout=0.5)
self.overlay_thread = None
def cleanup(self, created_tmp: bool, tmp_path: Optional[str]) -> None:
self.kill_tte()
self.stop_overlay()
if created_tmp and tmp_path:
try:
os.remove(tmp_path)
except Exception:
pass
# ------------------ Terminal raw mode helper ------------------
@contextmanager
def raw_tty(fd):
import termios
old = termios.tcgetattr(fd)
new = termios.tcgetattr(fd)
# lflag: turn off canonical mode and echo
new[3] = new[3] & ~(termios.ECHO | termios.ICANON)
# cc VMIN VTIME
new[6][termios.VMIN] = 0
new[6][termios.VTIME] = 1
termios.tcsetattr(fd, termios.TCSADRAIN, new)
try:
yield
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old)
# ------------------ Main interactive loop ------------------
def create_input_file(path_arg: Optional[str]) -> tuple[str, bool, Optional[str]]:
created_tmp = False
tmp_path = None
if not sys.stdin.isatty():
# read from stdin into temp file
tf = tempfile.NamedTemporaryFile(delete=False)
tf.write(sys.stdin.buffer.read())
tf.close()
created_tmp = True
tmp_path = tf.name
return tf.name, created_tmp, tmp_path
if path_arg:
return path_arg, False, None
tf = tempfile.NamedTemporaryFile(delete=False)
tf.write(b"Terminal effects demo\n")
tf.close()
created_tmp = True
tmp_path = tf.name
return tf.name, created_tmp, tmp_path
def main(argv=None):
p = argparse.ArgumentParser(description="TTE controller in Python")
p.add_argument("input", nargs="?", help="Input file to feed to tte")
p.add_argument("--frame-rate", type=int, default=100)
p.add_argument(
"--effects-file", help="Path to file with effects list, one per line"
)
p.add_argument("--fps-step", type=int, default=FPS_STEP)
p.add_argument(
"--tte-arg",
action="append",
default=[],
help="Additional argument to pass to tte (repeatable)",
)
p.add_argument("--overlay-interval", type=float, default=OVERLAY_INTERVAL)
args = p.parse_args(argv)
# set module-level overlay interval without a `global` statement to avoid
# "used prior to global declaration" SyntaxError when the name is referenced
# earlier in the function (for example in default values).
input_file, created_tmp, tmp_path = create_input_file(args.input)
if args.effects_file:
try:
with open(args.effects_file, "r") as fh:
effects = [line.strip() for line in fh if line.strip()]
except Exception:
effects = DEFAULT_EFFECTS
else:
effects = DEFAULT_EFFECTS
ctrl = TTEController(
effects=effects,
input_file=input_file,
framerate=args.frame_rate,
extra_args=args.tte_arg,
)
idx = 0
n = len(effects)
def sig_handler(_signum, _frame):
ctrl.cleanup(created_tmp, tmp_path)
sys.exit(0)
signal.signal(signal.SIGINT, sig_handler)
signal.signal(signal.SIGTERM, sig_handler)
ctrl.start_effect(idx)
ctrl.start_overlay(idx)
# interactive key loop reading from /dev/tty
try:
with open("/dev/tty", "rb") as tty:
fd = tty.fileno()
with raw_tty(fd):
ctrl.running = True
while ctrl.running:
b = tty.read(3)
if not b:
time.sleep(0.05)
continue
k = b.decode("utf-8", errors="ignore")
if k == "\x1b[C": # right
idx = (idx + 1) % n
ctrl.start_effect(idx)
elif k == "\x1b[D": # left
idx = (idx - 1 + n) % n
ctrl.start_effect(idx)
elif k == "\x1b[A": # up
ctrl.framerate += args.fps_step
ctrl.start_effect(idx)
elif k == "\x1b[B": # down
ctrl.framerate = max(1, ctrl.framerate - args.fps_step)
ctrl.start_effect(idx)
elif k in (" ", "n", "N"):
idx = (idx + 1) % n
ctrl.start_effect(idx)
elif k == "q":
break
elif k == "+":
ctrl.framerate += 1
ctrl.start_effect(idx)
elif k == "-":
ctrl.framerate = max(1, ctrl.framerate - 1)
ctrl.start_effect(idx)
elif k == "c":
# toggle --xterm-colors
if "--xterm-colors" in ctrl.extra_args:
ctrl.extra_args.remove("--xterm-colors")
else:
ctrl.extra_args.append("--xterm-colors")
ctrl.start_effect(idx)
elif k == "w":
# toggle --wrap-text
if "--wrap-text" in ctrl.extra_args:
ctrl.extra_args.remove("--wrap-text")
else:
ctrl.extra_args.append("--wrap-text")
ctrl.start_effect(idx)
elif k == "r":
ctrl.start_effect(idx)
# refresh overlay by restarting it so it captures newest fps/effects
ctrl.stop_overlay()
ctrl.start_overlay(idx)
finally:
ctrl.cleanup(created_tmp, tmp_path)
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment