Created
September 21, 2025 11:25
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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