#!/usr/bin/env python3 # # unimatrix.py # # # Python script to simulate the display from "The Matrix" in terminal. Uses # half-width katakana unicode characters by default, but can use custom # character sets. Accepts keyboard controls while running. # # Based on CMatrix by Chris Allegretta and Abishek V. Ashok. The following # option should produce virtually the same output as CMatrix: # $ unimatrix -n -s 96 -l o # # Unimatrix is free software: you can redistribute it and/or modify it under # the terms of the GNU General Public License as published by the Free Software # Foundation, either version 3 of the License, or (at your option) any later # version. # # Unimatrix is distributed in the hope that it will be useful, but WITHOUT ANY # WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR # A PARTICULAR PURPOSE. See the GNU General Public License at # for more details. # # Created by William Mannard # 2017/01/19 import curses import argparse from random import randint, choice help_msg = ''' USAGE unimatrix [-b] [-c COLOR] [-h] [-l CHARACTER_LIST] [-n] [-o] [-s SPEED] [-u CUSTOM_CHARACTERS] OPTIONAL ARGUMENTS -b Use only bold characters -c COLOR One of: green (default), red, blue, white, yellow, cyan, magenta, black -h Show this help message and exit. -l CHARACTER_LIST Select character set(s) using a string over letter codes (see CHARACTER SETS below.) -n Do not use bold characters (overrides -b) -o Disable on-screen status -s SPEED Speed, from 0 to 100. 100 uses a one-second delay before refreshing, 100 uses none. Use negative numbers for even lower speeds. Default=85 -u CUSTOM_CHARACTERS Your own string of characters to display. Enclose in single quotes ('') to escape special characters. For example: -u '#$('. LONG ARGUMENTS -b --all-bold -c --color=COLOR -h --help -l --character-list=CHARACTER_LIST -s --speed=SPEED -n --no-bold -o --status-off -u --custom_characters=CUSTOM_CHARACTERS CHARACTER SETS When using '-l' or '--character_list=' option, follow it with one or more of the following letters: a Lowercase alphabet A Uppercase alphabet c Lowercase Russian Cyrillic alphabet C Uppercase Russian Cyrillic alphabet e A few common emoji ( ☺☻✌♡♥❤⚘❀❃❁✼☀✌♫♪☃❄❅❆☕☂★ ) g Lowercase Greek alphabet G Uppercase Greek alphabet k Japanese katakana (half-width) m Default 'Matrix' set, equal to 'knnssss' n Numbers 0-9 o 'Old' style non-unicode set, like cmatrix. Equal to 'AaSn' r Lowercase Roman numerals ( mcclllxxxxvvvvviiiiii ) R Uppercase Roman numerals ( MCCLLLXXXXVVVVVIIIIII ) s A subset of symbols actually used in the Matrix films ( -=*_+|:<>" ) S All common keyboard symbols ( `-=~!z#$%^&*()_+[]{}|\;':",./<>?" ) u Custom characters selected using -u switch For exmaple: '-l naAS' or '--character_list=naAS' will give something similar to the output of the original cmatrix program in its default mode. '-l ACG' will use all the upper-case character sets. Use the same letter multiple times to increase the frequency of the character set. For example, the default setting is equal to '-l knnssss'. KEYBOARD CONTROL SPACE, CTRL-c or q exit - or LEFT decrease speed by 1 + or RIGHT increase speed by 1 [ or DOWN decrease speed by 10 ] or UP increase speed by 10 b cycle through bold character options (bold off-->bold on-->all bold) 1 to 8 set color: (1) Green (2) Red (3) Blue (4) White (5) Yellow (6) Cyan (7) Magenta (8) Black o toggle on-screen status EXAMPLES Mimic default output of cmatrix (no unicode characters, works in TTY): $ unimatrix -n -s 96 -l o Use the letters from the name of your favorite operating system in bold blue: $ unimatrix -B -u Linux -c blue Use default character set, plus dollar symbol (note single quotes around special character): $ unimatrix -l knnssssu -u '$' No bold characters, slowly, using emojis, numbers and a few symbols: $ unimatrix -n -l ens -s 50 ''' ### Set up parser and apply arguments settings parser = argparse.ArgumentParser(add_help=False) parser.add_argument('-b', '--all-bold', action='store_true', help='use all bold characters') parser.add_argument('-c', '--color', default='green', help='one of: green (default), red, blue, white, yellow, \ cyan, magenta, black', type=str) parser.add_argument('-h', '--help', help='display extented usage information and exit.', action='store_true') parser.add_argument('-l', '--character-list', help='character set. See details below', type=str) parser.add_argument('-n', '--no-bold', action='store_true', help='do not use bold characters') parser.add_argument('-o', '--status-off', action='store_true', help='Disable on-screen status') parser.add_argument('-s', '--speed', help='speed, from 1 to 100. Default=85', default=85, type=int) parser.add_argument('-u', '--custom-characters', help='your own string of characters to display', default='', type=str) args = parser.parse_args() if args.help: print(help_msg) exit() char_set = { 'a': 'qwertyuiopasdfghjklzxcvbnm', 'A': 'QWERTYUIOPASDFGHJKLZXCVBNM', 'c': 'абвгдежзиклмнопрстуфхцчшщъыьэюя', 'C': 'АБВГДЕЖЗИКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ', 'e': '☺☻✌♡♥❤⚘❀❃❁✼☀✌♫♪☃❄❅❆☕☂★', 'g': 'αβγδεζηθικλμνξοπρστυφχψως', 'G': 'ΑΒΓΔΕΖΗΘΙΚΛΜΝΞΟΠΡΣΤΥΦΧΨΩ', 'k': 'ヲァィゥェォャュョッーアイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン', 'm': 'ヲァィゥェォャュョッーアイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン1234567890' + '1234567890-=*_+|:<>"-=*_+|:<>"-=*_+|:<>"-=*_+|:<>"', 'n': '1234567890', 'o': 'qwertyuiopasdfghjklzxcvbnmQWERTYUIOPASDFGHJKLZXCVBNM1234567890' + '`-=~!@#$%^&*()_+[]{}|\;\':",./<>?"', 'r': 'mcclllxxxxvvvvviiiiii', 'R': 'MCCLLLXXXXVVVVVIIIIII', 's': '-=*_+|:<>"', 'S': '`-=~!@#$%^&*()_+[]{}|\;\':",./<>?"', 'u': args.custom_characters} colors_str = { 'green': curses.COLOR_GREEN, 'red': curses.COLOR_RED, 'blue': curses.COLOR_BLUE, 'white': curses.COLOR_WHITE, 'yellow': curses.COLOR_YELLOW, 'cyan': curses.COLOR_CYAN, 'magenta': curses.COLOR_MAGENTA, 'black': curses.COLOR_BLACK} start_color = colors_str[args.color] speed = args.speed start_delay = (100-speed)*10 # "-l" option has been used if args.character_list: chars = '' for letter in args.character_list: try: chars += char_set[letter] except KeyError: print("Letter '%s' does not represent a valid character list." % letter) exit() # "-l" not used, but "-u" is set elif args.custom_characters: chars = args.custom_characters # Neither "-l" nor "-u" has been set, use default characters else: chars = char_set['m'] if args.no_bold: args.all_bold = False chars_len = len(chars)-1 ### Classes class Canvas: """ Represents the whole screen and stores its height and width. Gets overwritten whenever the screen resizes. Serves as a container for columns. """ def __init__(self, screen): screen.clear() rows, cols = screen.getmaxyx() self.col_count = cols self.row_count = rows self.size_changed = False self.columns = [] for col in range(0, cols, 2): self.columns.append(Column(col, self.row_count)) class Status: """ Displays a status message at top left when a setting is changed. """ def __init__(self, screen): curses.init_pair(3, curses.COLOR_BLACK, curses.COLOR_WHITE) self.screen = screen self.countdown = 0 self.last_message = '' def update(self, message, delay): """ Writes new message to the status area """ if not args.status_off: message_str = message.ljust(11) self.screen.addstr(0, 0, message_str, curses.color_pair(3)) self.last_message = message_str # More frames for faster speeds: self.countdown = (100//(delay//10 + 1)) + 2 def refresh(self): """ Used to keep refreshing status message until countdown runs out """ message_str = self.last_message self.screen.addstr(0, 0, message_str, curses.color_pair(3)) def clear(self): """ Erases message with spaces when the countdown runs out """ self.screen.addstr(0, 0, ' '*11, curses.color_pair(1)) class Column: """ Creates and stores a list of nodes (points that move down the screen). Countdown timer determines time to spawn new node. """ def __init__(self, x_coord, row_count): self.drawing = False self.x_coord = x_coord self.timer = randint(1, row_count) self.nodes = [] def spawn_node(self, canvas): """ Creates nodes: points that move down the screen either writing or erasing characters as they go down """ self.drawing = not self.drawing if self.drawing: self.timer = randint(3, canvas.row_count-3) else: self.timer = randint(1, canvas.row_count) x = self.x_coord n_type = 'eraser' white = False if self.drawing: n_type = 'writer' if randint(0, 2) == 0: white = True self.nodes.append(Node(x, n_type, white)) class Node: """ A point that runs down the screen drawing or erasing characters. n_type -> 'writer' or 'eraser' white -> Bool. If True, a white char is written before the green one. last_char -> Stores last character, since white characters have to be overwritten with the same one in green one. expired -> Bool. If True, node is marked for deletion """ def __init__(self, x_coord, n_type, white=False): self.x_coord = x_coord self.y_coord = 0 self.n_type = n_type self.white = white self.last_char = None self.expired = False class Key_hander: """ Handles keyboard input. """ def __init__(self, screen, stat): self.screen = screen self.stat = stat self.screen.nodelay(True) self.delay = start_delay def cycle_bold(self): """ Called on 'b' press. Cycles though Bold options: off -> on -> all bold """ if args.all_bold: args.no_bold = True args.all_bold = False self.stat.update('Bold: off', self.delay) elif args.no_bold: args.no_bold = False args.all_bold = False self.stat.update('Bold: on', self.delay) else: args.no_bold = False args.all_bold = True self.stat.update('Bold: all', self.delay) def get(self): """ Handles key presses. Returns True if a key was found, False otherwise. """ key_pressed = True try: kp = self.screen.getch() except: kp = None return False if kp == ord(" ") or kp == ord("q") or kp == 27: #27 = ESC exit() elif kp == ord('-') or kp == ord('_') or kp == curses.KEY_LEFT: self.delay = min(self.delay+10, 1000) self.show_speed() elif kp == ord('=') or kp == ord('+') or kp == curses.KEY_RIGHT: self.delay = max(self.delay-10, 0) self.show_speed() elif kp == ord('[') or kp == curses.KEY_DOWN: self.delay = min(self.delay+100, 1000) self.show_speed() elif kp == ord(']') or kp == curses.KEY_UP: self.delay = max(self.delay-100, 0) self.show_speed() elif kp == ord('b'): self.cycle_bold() elif kp == ord('1'): curses.init_pair(1, curses.COLOR_GREEN, -1) self.stat.update('Green', self.delay) elif kp == ord('2'): curses.init_pair(1, curses.COLOR_RED, -1) self.stat.update('Red', self.delay) elif kp == ord('3'): curses.init_pair(1, curses.COLOR_BLUE, -1) self.stat.update('Blue', self.delay) elif kp == ord('4'): curses.init_pair(1, curses.COLOR_WHITE, -1) self.stat.update('White', self.delay) elif kp == ord('5'): curses.init_pair(1, curses.COLOR_YELLOW, -1) self.stat.update('Yellow', self.delay) elif kp == ord('6'): curses.init_pair(1, curses.COLOR_CYAN, -1) self.stat.update('Cyan', self.delay) elif kp == ord('7'): curses.init_pair(1, curses.COLOR_MAGENTA, -1) self.stat.update('Magenta', self.delay) elif kp == ord('8'): curses.init_pair(1, curses.COLOR_BLACK, -1) self.stat.update('Black', self.delay) elif kp == ord('o'): self.toggle_status() else: key_pressed = False return key_pressed def show_speed(self): """ Display current speed (0-100) when it is changed by keypress """ self.stat.update('Speed: %d' % (100 - self.delay//10), self.delay) def toggle_status(self): """ On 'o' keypress, turn status display on or off """ if args.status_off: args.status_off = False self.stat.update('Status: on', self.delay) else: self.stat.update('Status: off', self.delay) args.status_off = True class Writer: """ Initializes character writing options and contains methods for writing and erasing charcters from the screen. """ def __init__(self, screen): self.screen = screen self.screen.scrollok(0) curses.curs_set(0) curses.use_default_colors() curses.init_pair(1, start_color, -1) curses.init_pair(2, curses.COLOR_WHITE, -1) curses.init_pair(3, curses.COLOR_BLACK, curses.COLOR_WHITE) self.fg_color = curses.color_pair(1) self.white = curses.color_pair(2) def get_char(self): """ Returns a random character from the active character set """ return chars[randint(0, chars_len)] def get_attr(self, node, above=False): """ Returns either A_BOLD attribute or A_NORMAL based on Bold setting "above=True" means it an extra green character used to overwrite the while head character. """ if args.no_bold: return curses.A_NORMAL elif args.all_bold: return curses.A_BOLD else: if node.white and not above: return curses.A_BOLD else: return choice([curses.A_BOLD, curses.A_NORMAL]) def draw(self, node): """ Draws characters, included spaces to overwrite/erase characters. """ y = node.y_coord x = node.x_coord character = ' ' color = self.fg_color attr = self.get_attr(node) if node.n_type == 'writer': if not node.white and node.last_char: #Special green character for overwriting last white one #at bottom of column that was not being overwritten. character = node.last_char else: character = self.get_char() if node.white: color = self.white try: #Draw the character self.screen.addch(y, x, character, color|attr) if node.white: if node.last_char: #If it's a white node, also write a green character above #to overwrite last white character attr = self.get_attr(node, above=True) self.screen.addch(y-1, x, node.last_char, self.fg_color|attr) node.last_char = character except curses.error: # Override scrolling error character are pushed off the screen. pass ### Main loop def main(screen): writer = Writer(screen) stat = Status(screen) key = Key_hander(screen, stat) #Keep restarting however many times the screen resizes while True: canvas = Canvas(screen) #Loop to draw the green rain while canvas.size_changed == False: #Catch keypress if key.get(): continue #Spawn new nodes for col in canvas.columns: if col.timer == 0: col.spawn_node(canvas) col.timer -= 1 for node in col.nodes: writer.draw(node) #Move node down node.y_coord += 1 #Mark old nodes for deletion if node.y_coord >= canvas.row_count: if node.white: #Stop white nodes from staying 'stuck' on last row. #Creates a special green node with a last_char #attribute to overwrite last white node. node.white = False node.y_coord -= 1 else: node.expired = True #Rewrite nodes list without expired nodes col.nodes = [node for node in col.nodes if not node.expired] #End of loop, refresh screen if stat.countdown > 0: if stat.countdown == 1: stat.clear() else: stat.refresh() stat.countdown -= 1 screen.refresh() #Check for screen resize if screen.getmaxyx() != (canvas.row_count, canvas.col_count): canvas.size_changed = True #Add delay before next loop curses.napms(key.delay) ### Wrapper to allow CTRL-C to exit smoothly try: curses.wrapper(main) except KeyboardInterrupt: pass