slTrain/sl
developtheweb@protonmail.com 53946c1897 Refactor sl from simple script to professional CLI tool
- Complete rewrite as standalone Python executable
- Add multiple train types (Classic, D51, C51)
- Implement flying mode, accident mode, and speed control
- Remove external dependencies (now pure Python with ANSI codes)
- Add professional documentation (README, CONTRIBUTING, CHANGELOG)
- Update author information to Reverend Steven Milanese
- Add installation via Makefile
- Clean up virtual environments and old files
- Enhance README with badges, platform instructions, and links

This transforms sl into a production-ready command-line tool following
software engineering best practices while maintaining its core purpose
as a fun terminal animation.
2025-06-05 21:42:48 -04:00

343 lines
No EOL
12 KiB
Python
Executable file

#!/usr/bin/env python3
"""
sl - Steam Locomotive
A joke command that displays an animated train when you type 'sl' instead of 'ls'
Author: Reverend Steven Milanese
License: MIT
"""
import os
import sys
import time
import random
import signal
import argparse
from typing import List, Tuple
# Version
__version__ = "2.0.0"
# ANSI escape codes for colors and cursor control
class ANSI:
# Colors
RED = '\033[91m'
GREEN = '\033[92m'
YELLOW = '\033[93m'
BLUE = '\033[94m'
MAGENTA = '\033[95m'
CYAN = '\033[96m'
WHITE = '\033[97m'
RESET = '\033[0m'
# Cursor control
HIDE_CURSOR = '\033[?25l'
SHOW_CURSOR = '\033[?25h'
CLEAR_SCREEN = '\033[2J\033[H'
CLEAR_LINE = '\033[2K'
@staticmethod
def move_cursor(x: int, y: int) -> str:
"""Move cursor to position."""
return f'\033[{y};{x}H'
class Train:
"""Represents the ASCII art train with different styles."""
# Classic steam locomotive
CLASSIC = [
" ==== ________ ___________",
" _D _| |_______/ \\__I_I_____===__|_________|",
" |(_)--- | H\\________/ | | =|___ ___| ",
" / | | H | | | | ||_| |_|| ",
" | | | H |__--------------------| [___] | ",
" | ________|___H__/__|_____/[][]~\\_______| | ",
" |/ | |-----------I_____I [][] [] D |=======|__",
"__/ =| o |=-~~\\ /~~\\ /~~\\ /~~\\ ____Y___________|__",
" |/-=|___|= || || || |_____/~\\___/ ",
" \\_/ \\O=====O=====O=====O_/ \\_/ "
]
# Small locomotive for faster animation
SMALL = [
" ++ +------ ",
" || |+-+ | ",
" /---------|| | | ",
" + ======== +-+ | ",
" _|--O========O~\\-+ ",
"//// \\_/ \\_/ "
]
# D51 locomotive (Japanese style)
D51 = [
" ==== ________ ___________ ",
" _D _| |_______/ \\__I_I_____===__|_________|",
" |(_)--- | H\\________/ | | =|___ ___| ",
" / | | H | | | | ||_| |_|| ",
" | | | H |__--------------------| [___] | ",
" | ________|___H__/__|_____/[][]~\\_______| | ",
" |/ | |-----------I_____I [][] [] D |=======|__",
"__/ =| o |=-O=====O=====O=====O \\ ____Y___________|__",
" |/-=|___|= || || || |_____/~\\___/ ",
" \\_/ \\__/ \\__/ \\__/ \\__/ \\_/ "
]
# C51 locomotive
C51 = [
" ___ ",
" _|_|_ _ __ __ ___________",
" D__/ \\_(_)___| |__H__| |_____I_Ii_()|_________|",
" | `---' |:: `--' H `--' | |___ ___| ",
" +|~~~~~~~~++::~~~~~~~H~~+=====+~~~~~~|~~||_| |_|| ",
" || | :: H +=====+ | |:: ...| ",
"| | _______|_::-----------------[][]-----| | ",
"| /~~ || |-----/~~~~\\ /[I_____I][][] --|||_______|__",
"------'|oOo|===[]- || || | ||=======_|__",
"/~\\____|___|/~\\_| O=======O=======O |__|\\ / ",
"\\_/ \\_/ \\____/ \\____/ \\____/ \\_____/ "
]
@staticmethod
def get_train(style: str = "classic") -> List[str]:
"""Get train ASCII art by style."""
trains = {
"classic": Train.CLASSIC,
"small": Train.SMALL,
"D51": Train.D51,
"C51": Train.C51
}
return trains.get(style, Train.CLASSIC)
class SmokeGenerator:
"""Generates dynamic smoke patterns."""
SMOKE_CHARS = ['@', '*', 'o', 'O', '.', '\'', '"']
@staticmethod
def generate(length: int, density: float = 0.4) -> str:
"""Generate a smoke pattern."""
smoke = []
for _ in range(length):
if random.random() < density:
smoke.append(random.choice(SmokeGenerator.SMOKE_CHARS))
else:
smoke.append(' ')
return ''.join(smoke)
@staticmethod
def get_smoke_lines(width: int, height: int = 4) -> List[str]:
"""Generate multiple lines of smoke."""
lines = []
for i in range(height):
density = 0.4 - (i * 0.1) # Decrease density as smoke rises
lines.append(SmokeGenerator.generate(width, density))
return lines
class SLAnimation:
"""Main animation controller."""
def __init__(self, train_type: str = "classic", speed: float = 1.0,
fly: bool = False, accident: bool = False):
self.train_type = train_type
self.speed = speed
self.fly = fly
self.accident = accident
self.running = True
# Get terminal size
self.update_terminal_size()
# Setup signal handler for window resize
signal.signal(signal.SIGWINCH, self._handle_resize)
signal.signal(signal.SIGINT, self._handle_interrupt)
def update_terminal_size(self):
"""Update terminal dimensions."""
try:
size = os.get_terminal_size()
self.width = size.columns
self.height = size.lines
except:
self.width = 80
self.height = 24
def _handle_resize(self, signum, frame):
"""Handle terminal resize."""
self.update_terminal_size()
def _handle_interrupt(self, signum, frame):
"""Handle Ctrl+C gracefully."""
self.running = False
def clear_screen(self):
"""Clear the terminal screen."""
sys.stdout.write(ANSI.CLEAR_SCREEN)
sys.stdout.flush()
def run(self):
"""Run the animation."""
# Hide cursor
sys.stdout.write(ANSI.HIDE_CURSOR)
sys.stdout.flush()
try:
train_lines = Train.get_train(self.train_type)
train_height = len(train_lines)
train_width = max(len(line) for line in train_lines)
# Calculate vertical position
if self.fly:
y_positions = self._calculate_fly_path(train_height)
else:
y_position = (self.height - train_height) // 2
y_positions = [y_position] * (self.width + train_width + 10)
# Animation loop
frame = 0
while self.running and frame < len(y_positions):
self.clear_screen()
# Calculate horizontal position (right to left)
x_position = self.width - frame
y_position = y_positions[frame]
# Add smoke above train
if not self.fly:
smoke_lines = SmokeGenerator.get_smoke_lines(train_width + 20, 4)
for i, smoke in enumerate(smoke_lines):
smoke_y = y_position - len(smoke_lines) + i
if smoke_y > 0:
sys.stdout.write(ANSI.move_cursor(max(1, x_position - 10), smoke_y))
sys.stdout.write(ANSI.WHITE + smoke + ANSI.RESET)
# Draw train
for i, line in enumerate(train_lines):
if 0 < y_position + i <= self.height:
# Calculate visible portion of line
if x_position < 1:
# Train is partially off-screen (left side)
start = abs(x_position) + 1
if start < len(line):
visible_line = line[start:]
sys.stdout.write(ANSI.move_cursor(1, y_position + i))
sys.stdout.write(self._colorize_line(visible_line))
elif x_position + len(line) > self.width:
# Train is partially off-screen (right side)
visible_line = line[:self.width - x_position]
sys.stdout.write(ANSI.move_cursor(x_position, y_position + i))
sys.stdout.write(self._colorize_line(visible_line))
else:
# Train is fully visible
sys.stdout.write(ANSI.move_cursor(x_position, y_position + i))
sys.stdout.write(self._colorize_line(line))
# Accident mode - show collision
if self.accident and x_position < self.width // 2:
self._show_accident(x_position, y_position, train_height)
break
sys.stdout.flush()
time.sleep(0.05 / self.speed)
frame += 1
# Stop when train is off screen
if x_position + train_width < 0:
break
finally:
# Clean up
self.clear_screen()
sys.stdout.write(ANSI.SHOW_CURSOR)
sys.stdout.flush()
def _colorize_line(self, line: str) -> str:
"""Add colors to train line."""
# Simple coloring based on characters
colored = line
colored = colored.replace('D', ANSI.RED + 'D' + ANSI.RESET)
colored = colored.replace('_', ANSI.YELLOW + '_' + ANSI.RESET)
colored = colored.replace('|', ANSI.BLUE + '|' + ANSI.RESET)
colored = colored.replace('=', ANSI.GREEN + '=' + ANSI.RESET)
colored = colored.replace('O', ANSI.WHITE + 'O' + ANSI.RESET)
colored = colored.replace('o', ANSI.WHITE + 'o' + ANSI.RESET)
colored = colored.replace('~', ANSI.CYAN + '~' + ANSI.RESET)
return colored
def _calculate_fly_path(self, train_height: int) -> List[int]:
"""Calculate flying path for train."""
path = []
total_frames = self.width + 60
for i in range(total_frames):
# Sinusoidal flight path
progress = i / total_frames
y = int((self.height - train_height) * (0.5 + 0.3 *
(progress * progress * progress)))
path.append(max(1, min(self.height - train_height, y)))
return path
def _show_accident(self, x_pos: int, y_pos: int, height: int):
"""Show accident/crash effect."""
crash_art = [
" CRASH! ",
" BOOM! ",
" * * * * * ",
" * BANG!! * ",
" * * * * * "
]
for i, line in enumerate(crash_art):
if 0 < y_pos + i <= self.height:
sys.stdout.write(ANSI.move_cursor(x_pos + 10, y_pos + i))
sys.stdout.write(ANSI.RED + line + ANSI.RESET)
sys.stdout.flush()
time.sleep(2)
def main():
"""Main entry point."""
parser = argparse.ArgumentParser(
description='sl - Display animated steam locomotive',
epilog='A joke command for when you type sl instead of ls'
)
parser.add_argument('-a', '--accident', action='store_true',
help='An accident occurs')
parser.add_argument('-F', '--fly', action='store_true',
help='Make the train fly')
parser.add_argument('-l', '--long', action='store_true',
help='Use a longer train')
parser.add_argument('-c', '--C51', action='store_true',
help='Use C51 train type')
parser.add_argument('-s', '--speed', type=float, default=1.0,
help='Animation speed multiplier (default: 1.0)')
parser.add_argument('-v', '--version', action='version',
version=f'sl version {__version__}')
args = parser.parse_args()
# Determine train type
if args.C51:
train_type = "C51"
elif args.long:
train_type = "D51"
else:
train_type = "classic"
# Run animation
animation = SLAnimation(
train_type=train_type,
speed=args.speed,
fly=args.fly,
accident=args.accident
)
animation.run()
if __name__ == "__main__":
main()