tilde-train/tilde-train.py
Noelle Leigh 6063c8b3b3
Escape HTML characters when printing the train
Since it's wrapped in a `<pre>`, I assume it's meant to be inserted into
HTML?
2025-06-02 17:24:04 -04:00

362 lines
11 KiB
Python
Executable File

#!/usr/bin/env python3
# _ _ _ _ _ _
# | |_(_) |__| |___ | |_ _ _ __ _(_)_ _
# | _| | / _` / -_)| _| '_/ _` | | ' \
# \__|_|_\__,_\___(_)__|_| \__,_|_|_||_|
#
# tilde.train is an instance of TerminalTrain. It was originally developed
# by cmccabe on tilde.town (https://tildegit.org/cmccabe/TerminalTrain) but is now
# maintained by vilmibm.
#
# If you want to contribute code improvements, create a pull request here:
# https://git.tilde.town/vilmibm/tilde-train
#
# -----------------
#
# WHAT IS IT?
# like sl (the ls typo prank), but each car on the train is a x*y character ascii art
# produced by tilde.town.
#
# ( original sl source code of sl? https://github.com/mtoyoda/sl )
#
# TODO:
# * loosen the restriction on allowable characters in train cars. right now, limited
# to characters in python's string.printable list.
# * turn main loop into a function, so cmd line arg reader can call it (with -p) and quit.
# * figure out why tilde.train doesn't work in some terminals (sthg sthg unicode...)
# * BUGFIX-1 - something about inclusion default cars adding extra "links" to the train.
# * the -p (print train) option should print all cars, not limited to the max_cars value.
# * related to BUGFIX-1, that seems to impact spacers (links) between cars.
# * allow users to create multiple frames so their cars can be animated (difficulty=med+)
# * allow user configurable speed and number of train cars
# * allow users to move the train up or down with arrow keys
# -- worked with asciimatics, but python curses blocks on getch()
#
from random import shuffle # allowing us to randomize selection of cars.
import glob # allowing us to search the file system for .choochoo files.
import sys # so we can read command line arguments.
import curses
from signal import signal, SIGINT
import time # allowing the loop steps of train animation to be slowed
import string # for input validation
from html import escape
from inspect import cleandoc
from pathlib import Path
traincarFN = ".choochoo"
max_x = 35 # max length of train car.
max_y = 10 # max height of train car.
max_cars = 10 # most cars to include in one train.
print_train = False # print train to file (instead of the screen scroll)
train = [""]*max_y # empty train of correct height.
cars: list[list[str]] = []
engine = [
r" ____ ",
r" |____| ------------",
r" | | === | ------ |",
r" ___| |__| |_____| | O | |",
r" | | | |__/V\_| |",
r" [[ | |",
r" | | ------------ | ~town |",
r" |__|______________|__________|",
r" //// / _\__/__\__/__\ / \ ",
r"//// \__/ \__/ \__/ \__/ ",
]
caboose = [
r" || ",
r" ============= || ",
r"=========| |========== ",
r" | ---- ---- | ",
r" | | | | | | ",
r" | ---- ---- | ",
r" | tilde.town railways | ",
r"==| |== ",
r"== - / \-/ \-----/ \-/ \ - == ",
r" \__/ \__/ \__/ \__/ ",
]
default_car = [
r" ---------------------------- ",
r"| |",
r"| YOUR TRAIN CAR HERE! |",
r"| Just create a |",
r"| ~/.choochoo file! |",
r"| __ __ __ __ |",
r" - / \-/ \------/ \-/ \ - ",
r" \__/ \__/ \__/ \__/ ",
]
class CarError(Exception):
"""Error related validating a car."""
def print_help():
print(
cleandoc(
f"""
~ ~ Hooray! You've found the tilde.train! ~ ~
To add your own car to a future train, create
a .choochoo file in your home directory and
make sure it is 'other' readable, for example:
chmod 644 ~/.choochoo
The file should contain an ascii drawing of a
train car no more than {max_x} characters wide
and {max_y} characters tall.
Only printable ascii characters are accepted for now.
Run the command again followed by a -t switch to test
your .choochoo file and report any non accepted chars.
Each train contains a random selection of cars
from across tilde.town user home directories.
Don't worry, yours will be coming around the
bend soon!
~ ~ ~ ~ ~ ~
"""
)
)
def test_user_car():
fname = Path.home() / traincarFN
try:
choochoo_string = fname.read_text("utf-8")
except OSError as err:
raise OSError(
f"Couldn't open {fname}\n"
"Either it doesn't exist, or is not readble by the tilde.train script."
) from err
choochoo_list = choochoo_string.split("\n")
car = "\n".join(choochoo_list)
car2 = car.replace("\t", "") # do not allow tabs
car2 = car2.replace("\v", "") # do not allow vertical tabs
car2 = car2.replace("\f", "") # do not allow line feeds
car2 = car2.replace("\r", "") # do not allow carriage returns
car2 = ''.join([i if string.printable.find(i) >= 0 else ' ' for i in car2])
print("")
print("Test results:")
if car != car2:
print("")
print("Your train car contains an invalid character. Sorry, ")
print("for now only standard ascii characters are allowed.")
print("You can still use most other characters (except tabs),")
print("but they will be replaced by a standard ascii char when")
print("the train is built.")
bad_chars = []
for i in enumerate(car):
if string.printable.find(i[1]) < 0:
bad_chars.append(i[1])
if i[1] in ("\t", "\v", "\f", "\r"):
bad_chars.append("other whitespace")
bad_chars = set(bad_chars)
bad_chars = ", ".join(bad_chars)
print("")
print("The following currently the only accepted characters: ")
print(string.printable.strip())
print("")
print("Yours contained " + bad_chars)
sys.exit(1)
train_height = len(choochoo_list)
train_length = len(max(choochoo_list, key=len))
if train_height > max_y+1:
print("FAIL. Your train car is too tall.")
print("It should be no taller than " + str(max_y) + " lines in height.")
sys.exit(1)
if train_length > max_x:
print("FAIL. Your train car is too long.")
print("It should be no longer than " + str(max_x) + " characters in length.")
sys.exit(1)
print("PASS. Your train car will work on the tilde.town tracks! :)")
sys.exit()
def link_car(car: list[str]):
for idx,row in enumerate(car):
car[idx] = " " + row
car[len(car)-3] = "+" + car[len(car)-3][1:]
car[len(car)-2] = "+" + car[len(car)-2][1:]
return car
def validate_car(car: list[str]):
# this function (1) checks that a train car isn't too tall or too long
# (2) pads it vertically or on the right side if it is too short or if
# not all lines are the same length and (3) removes bad characters.
car_str = "\n".join(car)
car_str = ''.join([i if ord(i) < 128 else ' ' for i in car_str])
car_list = car_str.split("\n")
# remove blank lines from top and bottom of car,
# so we can estimate its true size.
while car_list[0].strip() == "":
car_list.pop(0) # clear top
while car_list[(len(car_list)-1)].strip() == "":
car_list.pop() # clear bottom
# len(car_list) is the height of the train car, in number of rows.
if len(car_list) > max_y+1:
raise CarError(f"Car is too tall ({len(car_list)} > {max_y + 1}).")
if len(car_list) == 0:
raise CarError(f"Car is empty.")
# vertically pad short cars with 1 space (lines will be lengthened later).
while len(car_list) < max_y:
car_list = [" "] + car_list
for idx,row in enumerate(car_list):
car_list[idx] = row.rstrip()
longest_line = len(max(car_list, key=len)) # longest line in .choochoo file.
for idx,row in enumerate(car_list):
if len(row) > max_x+1: # check length of each row in .choochoo file.
raise CarError(f"Car is too wide ({len(row)} > {max_x + 1}).")
elif "\t" in row or "\v" in row or "\f" in row or "\r" in row:
raise CarError(f"Car contains illegal control characters.")
elif len(row) < longest_line:
padding = " "*(longest_line - len(row))
car_list[idx] += padding # add padding spaces.
return car_list
def print_all_cars():
for fname in glob.glob('/home/*/' + traincarFN):
try:
with open(fname, 'r') as myfile:
choochoo_string = myfile.read()
choochoo_list = choochoo_string.split("\n")
if len(choochoo_list) > max_y+1:
continue # the train car was too tall; skip it.
car = validate_car(choochoo_list) # printing is only a DEBUG feature.
print("")
print(fname + ":")
print("\n".join(car)) # print the car to stdout
except:
pass
def chuggachugga(stdscr: curses.window):
curses.curs_set(0)
h, w = stdscr.getmaxyx()
x_pos = w-1
y_pos = int(round(h/2))
while True:
for idx,train_layer in enumerate(reversed(train)):
train_snip_start = 0 if (x_pos >= 0) else min(abs(x_pos), len(train_layer))
train_snip_end = w-x_pos if (w-x_pos <= len(train_layer)) else len(train_layer)
x = max(0, x_pos)
stdscr.addstr((y_pos-idx),x,train_layer[train_snip_start:train_snip_end])
x_pos -= 1
if x_pos == -train_len:
return
stdscr.refresh()
time.sleep(.03)
def handler(signal_received, frame):
print("Oops. The train broke. The engineer is looking into it!")
print("(Note: the train does not work in all terminals yet.)")
sys.exit(1)
default_car = validate_car(default_car)
if len(sys.argv) == 2 and ("-h" in sys.argv[1] or "help" in sys.argv[1]):
print_help()
sys.exit()
if len(sys.argv) == 2 and ("-t" in sys.argv[1] or "test" in sys.argv[1]):
test_user_car()
sys.exit()
if len(sys.argv) == 2 and ("-p" in sys.argv[1] or "print" in sys.argv[1]):
print_train = True
if len(sys.argv) == 2 and ("-a" in sys.argv[1] or "all" in sys.argv[1]):
print_all_cars()
sys.exit()
# start a loop that collects all .choochoo files and processes on in each loop
for fname in glob.glob('/home/*/' + traincarFN):
car_len = 1
try:
with open(fname, 'r') as myfile:
# print fname # debug, print file path and name
choochoo_string = myfile.read()
choochoo_list = choochoo_string.split("\n")
if len(choochoo_list) > max_y+1:
continue # the train car was too tall; skip it.
car = validate_car(choochoo_list) # printing is only a DEBUG feature.
cars.append(car) # start a list of lists (list of cars) here.
except:
pass
while len(cars) < max_cars:
cars.append([*default_car]) # add copies of default cars if train too short
shuffle(cars)
cars = cars[0:max_cars]
for idx,car in enumerate(cars):
cars[idx] = link_car(cars[idx])
cars.insert(0, engine)
caboose = link_car(caboose)
cars.append(caboose)
for i in cars:
n = 0
for j in i:
train[n] += j
n+=1
train_len = len(str(train[0]))
train_str = "\n".join(train)
if print_train:
print("<pre>")
print(escape(train_str))
print("</pre>")
sys.exit()
pad_str = " "*train_len
train.insert(0,pad_str)
train.append(pad_str)
if __name__ == "__main__":
signal(SIGINT, handler)
try:
curses.wrapper(chuggachugga)
except curses.error as err:
print(
f"{err}\n"
"Couldn't print the train for some reason. "
"Maybe your terminal window is too short?"
)
sys.exit(1)