v ◢ ---------------------------------------------------------- Animating with ASCII (Or, An Analysis Of The Code Behind My PyCon 2017 Talk) Keynote North Bay Python 2017 @brandon_rhodes ---------------------------------------------------------- PyCon 2017 talk used ASCII animation ---------------------------------------------------------- PyCon 2017 talk used ASCII animation (demo) ---------------------------------------------------------- PyCon 2017 talk used ASCII animation “How did you do it?” ---------------------------------------------------------- PyCon 2017 talk used ASCII animation “How did you do it?” “Badly” ---------------------------------------------------------- Guardrails for My Talks ---------------------------------------------------------- 1. Per-slide timer number_of_seconds_left / number_of_slides_left ---------------------------------------------------------- 2. Constrain the design space ---------------------------------------------------------- 2. Constrain the design space Normal presentations: RestructuredText → HTML + Custom CSS ---------------------------------------------------------- 2. Constrain the design space Normal presentations: RestructuredText → HTML + Custom CSS ASCII presentations: • Python script • Terminal window • Ubuntu Mono ---------------------------------------------------------- 3. Brief slides Lessig ---------------------------------------------------------- Short slides are less distracting for audience ---------------------------------------------------------- Short slides keep you on track ---------------------------------------------------------- Short slides decrease the load on your memory ---------------------------------------------------------- Rule: if I discover during practice that a slide has two ideas inside, ---------------------------------------------------------- Rule: if I discover during practice that a slide has two ideas inside, I split it into two slides ---------------------------------------------------------- 4. Big font ---------------------------------------------------------- 4. Big font This is the biggest terminal font that still fits the code on slide 86 ---------------------------------------------------------- Guardrails for My Talks 1. Per-slide timer 2. Constrain the design space 3. Brief slides 4. Big font ---------------------------------------------------------- PyCon 2017 ---------------------------------------------------------- PyCon 2017 Wrote my own ASCII animation library because I couldn’t understand the others ---------------------------------------------------------- Challenges 1. Technical 2. Architectural ---------------------------------------------------------- The Technical Challenges ---------------------------------------------------------- f / space = Forward one slide b = Backward one slide r = Repeat slide ---------------------------------------------------------- keystroke = sys.stdin.read(1) ---------------------------------------------------------- keystroke = sys.stdin.read(1) — the animation froze ---------------------------------------------------------- if select([sys.stdin], (), (), 0)[0]: keystroke = sys.stdin.read(1) ---------------------------------------------------------- But my program still didn’t see my keystrokes until I pressed Enter ---------------------------------------------------------- You might think (your process) ↔ terminal ---------------------------------------------------------- You would be wrong (your process) ↔ termios ↔ terminal ---------------------------------------------------------- termios controlled not by write(), but an out-of-band system call tcsetattr() ---------------------------------------------------------- “man termios”, or — ---------------------------------------------------------- $ stty -a speed 38400 baud; rows 17; columns 56; line = 0; intr = ^C; quit = ^\; erase = ^?; kill = ^U; eof = ^D; eol =; eol2 = start = ^Q; stop = ^S; susp = ^Z; rprnt = ^R; werase = ^W; lnext = ^V; discard = ^O; min = 1; time = 0; -parenb -parodd -cmspar cs8 -hupcl -cstopb cread -clocal -crtscts -ignbrk -brkint -ignpar -parmrk -inpck -istrip -inlcr -igncr icrnl -ixon -ixoff -iuclc -ixany -imaxbel iutf8 opost -olcuc -ocrnl onlcr -onocr -onlret -ofill -ofdel nl0 cr0 tab0 bs0 vt0 ff0 isig icanon iexten echo echoe echok -echonl -noflsh -xcase -tostop -echoprt echoctl echoke -flusho -extproc ---------------------------------------------------------- Why have a driver between you and the terminal? ---------------------------------------------------------- $ man termios ⋮ ONLCR (XSI) Map NL to CR-NL on output. ⋮ ---------------------------------------------------------- CR: Moves cursor to left margin ---------------------------------------------------------- CR: Moves cursor to left margin def update_progress_bar(percent): out = sys.stdout out.write('\rFile {:.1f}% done'.format(percent)) out.flush() ---------------------------------------------------------- LF: Moves cursor straight down ---------------------------------------------------------- For simplicity, UNIX files put only a LF at the end of each line ---------------------------------------------------------- $ ps PID TTY TIME CMD 3712 pts/0 00:00:04 zsh 4412 pts/0 00:00:00 ps 8375 pts/0 00:23:22 emacs ---------------------------------------------------------- $ ps PID TTY TIME CMD 3712 pts/0 00:00:04 zsh 4412 pts/0 00:00:00 ps 8375 pts/0 00:23:22 emacs $ stty -onlcr ---------------------------------------------------------- $ ps PID TTY TIME CMD 3712 pts/0 00:00:04 zsh 4412 pts/0 00:00:00 ps 8375 pts/0 00:23:22 emacs $ stty -onlcr $ ps PID TTY TIME CMD 3712 pts/0 00:00:04 zsh 442 2 pts/0 00:00:00 ps 8375 pts/0 00:23:22 emacs $ ---------------------------------------------------------- $ man termios ⋮ ECHO Echo input characters. ⋮ ---------------------------------------------------------- termios also includes a built-in TEXT EDITOR — ---------------------------------------------------------- termios also includes a built-in TEXT EDITOR — that’s turned on by default! ---------------------------------------------------------- $ man termios ⋮ ICANON Enable canonical mode (described below). ⋮ ---------------------------------------------------------- $ man termios ⋮ ICANON Enable canonical mode (described below). ⋮ (demo) ---------------------------------------------------------- $ reset ---------------------------------------------------------- $ reset man page: ---------------------------------------------------------- $ reset man page: “This is useful after a program dies leaving a terminal in an abnormal state. Note, you may have to type; swtch = ; reset normally control-J) to get the terminal to work, as carriage-return may no longer work in the abnormal state.” ---------------------------------------------------------- >>> import termios >>> termios.ICANON 2 >>> termios.ECHO 8 ---------------------------------------------------------- >>> bin(termios.ICANON) '0b10' >>> bin(termios.ECHO) '0b1000' ---------------------------------------------------------- Turning flags on: >>> oflags = termios.tcgetattr(fd)[3] >>> bin(oflags) '0b1000101000110011 >>> oflags = oflags | termios.ECHO >>> bin(oflags) '0b1000101000111011' ---------------------------------------------------------- Turning flags off: >>> bin(~termios.ECHO + 2**32) '0b11111111111111111111111111110111' >>> oflags = oflags & ~termios.ECHO >>> bin(oflags) '0b1000101000110011' ---------------------------------------------------------- So, what about the terminal? (your process) ↔ termios ↔ terminal ---------------------------------------------------------- ANSI escape codes: “in-band” signal ---------------------------------------------------------- train.txt ---------------------------------------------------------- ESC = '\033' HIDE_CURSOR = ESC + '[?25l' SHOW_CURSOR = ESC + '[?12l' + ESC + '[?25h' GOTO_ORIGIN = ESC + '[H' FG = ESC + '[38;2;{0[0]};{0[1]};{0[2]}m' BG = ESC + '[48;2;{0[0]};{0[1]};{0[2]}m' ---------------------------------------------------------- try: set_echo_and_icanon(0) write(HIDE_CURSOR) ... finally: set_echo_and_icanon(1) write(SHOW_CURSOR) write(BG.format(rgb(black))) write(FG.format(rgb(white))) ---------------------------------------------------------- 2.7 3.5 3.6 ---------------------------------------------------------- exec() ---------------------------------------------------------- exec() Replaces a process with a new one without leaving a dangling parent process ---------------------------------------------------------- zsh$ ps PID TTY TIME CMD 7807 pts/16 00:00:00 zsh 7975 pts/16 00:00:00 ps ---------------------------------------------------------- zsh$ ps PID TTY TIME CMD 7807 pts/16 00:00:00 zsh 7975 pts/16 00:00:00 ps zsh$ exec /bin/bash ---------------------------------------------------------- zsh$ ps PID TTY TIME CMD 7807 pts/16 00:00:00 zsh 7975 pts/16 00:00:00 ps zsh$ exec /bin/bash bash$ ps PID TTY TIME CMD 7807 pts/16 00:00:00 bash 7993 pts/16 00:00:00 ps ---------------------------------------------------------- python = ... # path to correct binary os.execvp(python, [python], next_slide_number) ---------------------------------------------------------- python = ... # path to correct binary os.execvp(python, [python], next_slide_number) • Each slide can have a different Python version ---------------------------------------------------------- python = ... # path to correct binary os.execvp(python, [python], next_slide_number) • Each slide can have a different Python version • No need for auto-reload! ---------------------------------------------------------- Dictionary stability ---------------------------------------------------------- def main(): if os.environ['PYTHONHASHSEED'] != '0': print("You forgot to set PYTHONHASHSEED=0") sys.exit(2) ... ---------------------------------------------------------- def main(): if os.environ['PYTHONHASHSEED'] != '0': cmd = [sys.executable] + sys.argv env = dict(os.environ) env['PYTHONHASHSEED'] = '0' os.execvpe(sys.executable, cmd, env) ---------------------------------------------------------- Challenges 1. Technical 2. Architectural ---------------------------------------------------------- How can I store the screen to allow random-access modification? ---------------------------------------------------------- How can I store the screen to allow random-access modification? canvas = [ [' ', ' ', ' ', ...], # characters [(1,1,1), (1,1,1), ...], # foreground [(0,0,0), (0,0,0), ...], # background ] ---------------------------------------------------------- def scrawl(canvas, x, y, text, fg, bg): i = y * WIDTH + x j = i + len(string) chars, foreground, background = canvas chars[i:j] = text fgcolors[i:j] = [fg] * len(string) bgcolors[i:j] = [bg] * len(string) ---------------------------------------------------------- yield GOTO_ORIGIN old_fg, old_bg = None, None for character, fg, bg in zip(*canvas)[:-1]: if fg != ofg: yield FG.format(fg) ofg = fg if bg != obg: yield BG.format(bg) obg = bg yield character ---------------------------------------------------------- How did I write animations using scrawl()? ---------------------------------------------------------- Animations unfold through time ├─effect 1─┤ ├────effect 2────┤ ├─effect 3─┤ ├──────────┼──────────┼──────────┤ 0s 1s 2s 3s ---------------------------------------------------------- Animations unfold through time ├─effect 1─┤ ├────effect 2────┤ ├─effect 3─┤ ├┼┼┼┼┼┼┼┼┼┼┼┼┼┼┼┼┼┼┼┼┼┼┼┼┼┼┼┼┼┼┼┼┤ 0s 1s 2s 3s ---------------------------------------------------------- script = [ [function1], # frame 1 [function1], # frame 2 [function2], # frame 3 [function2, function3], # frame 4 [function2, function3], # frame 5 [function3], # frame 6 ... ] ---------------------------------------------------------- Sample animation: ---------------------------------------------------------- Beautiful is better than ugly ---------------------------------------------------------- def slide_5(script): fade_in(script, 0.0, 1.0, 5, 5, 'Beautiful') fade_in(script, 1.0, 1.0, 5, 6, 'is better') fade_in(script, 2.0, 1.0, 5, 7, 'than ugly') ---------------------------------------------------------- script = [ [function1], # frame 1 [function1], # frame 2 [function2], # frame 3 [function2, function3], # frame 4 ... fade_in() needs a callable to insert into the script ---------------------------------------------------------- Option 1: Bound method ---------------------------------------------------------- class FadeIn(object): def __init__(self, script, start, duration, x, y, s): self.duration = duration self.x = x self.y = y self.string = s end = start + duration for i in range(int(start * HZ), int(end * HZ)): script[i].append(self.draw) ---------------------------------------------------------- class FadeIn(object): def __init__(self, script, start, duration, x, y, s): self.duration = duration self.x = x self.y = y self.string = s end = start + duration for i in range(int(start * HZ), int(end * HZ)): script[i].append(self.draw) def draw(self, t, canvas): # `t`: time since `start` r = t / self.duration if t < self.duration else 1 fg = gray(r) scrawl(canvas, self.x, self.y, self.string, fg) ---------------------------------------------------------- Option 2: A closure ---------------------------------------------------------- def fade_in(script, start, duration, x, y, string): def draw(t, canvas): r = t / duration if t < duration else 1.0 scrawl(canvas, x, y, string, gray(r)) end = start + duration for i in range(int(start * HZ), int(end * HZ)): script[i].append(draw) ---------------------------------------------------------- Problem: what if the text I need to fade is itself produced by a function? def draw_dictionary(...): → ---------------------------------------------------------- def fade_in(script, start, duration, x, y, animation): def draw(t, canvas): r = t / duration if t < duration else 1.0 ? ... animation(t, canvas) ... ? end = start + duration for i in range(int(start * HZ), int(end * HZ)): script[i].append(draw) ---------------------------------------------------------- Monkey patch scrawl()? ---------------------------------------------------------- No ---------------------------------------------------------- No Monkey patching is software bankruptcy ---------------------------------------------------------- Problem: animations aren’t composable ---------------------------------------------------------- Crucial observation: `canvas` is never actually touched by an effect like fade_in()! → ---------------------------------------------------------- Solution: Noun → Verb ─────────────────────── def draw(t, canvas): scrawl(canvas, 4, 5, 'Hello', blue) ---------------------------------------------------------- Solution: Noun → Verb ─────────────────────── def draw(t, canvas): scrawl(canvas, 4, 5, 'Hello', blue) ↓ def draw(t, scrawl): scrawl(4, 5, 'Hello', blue) ---------------------------------------------------------- Win: Composability ---------------------------------------------------------- def fade_in(script, start, duration, animation): def draw(t, scrawl): def my_scrawl(x, y, text, fg, bg): faded_fg = blend(bg, fg, r) scrawl(x, y, text, faded_fg, bg) r = t / duration if t < duration else 1.0 animation(t, my_scrawl) for i in range(int(start * HZ), len(script)): script[i].append(self.draw) ---------------------------------------------------------- PyCon 2017 ---------------------------------------------------------- PyCon 2017 SO MANY WRAPPER FUNCTIONS ---------------------------------------------------------- Review fade_in(script,...) │ └draw(t,scrawl,…) d_dict(t,scrawl,…) d_item(t,scrawl,…) │ │ └my_scrawl(...) └my_scrawl(...) ---------------------------------------------------------- Let’s trace— fade_in(script,...) draw(t,scrawl,…) d_dict(t,scrawl,…) d_item(t,scrawl,…) my_scrawl(...) my_scrawl(...) ---------------------------------------------------------- 1. fade_in() call as slide is built ⬎ fade_in(script,...) ↵ draw(t,scrawl,…) d_dict(t,scrawl,…) d_item(t,scrawl,…) my_scrawl(...) my_scrawl(...) ---------------------------------------------------------- 2. draw() to paint a canvas fade_in(script,...) ⬎ draw(t,scrawl,…)→ d_dict(t,scrawl,…)→ d_item(t,scrawl,…) ↵ ← my_scrawl(...) ← my_scrawl(...) ↲ ---------------------------------------------------------- 1. It worked ---------------------------------------------------------- 1. It worked 2. I wasn’t happy ---------------------------------------------------------- Think about the data ---------------------------------------------------------- t=105 t=105 ⬎ draw(t,scrawl,…)→ d_dict(t,scrawl,…)→ d_item(t,scrawl,…) ↵ ← my_scrawl(...) ← my_scrawl(...) ↲ 6,7,'[item]',gray 5,6,'[item]',black 0,1,'item',black ---------------------------------------------------------- Can we create a “good parts” version? ---------------------------------------------------------- “No-Scrawl” Solution Restructure to explicitly defer drawing until later ---------------------------------------------------------- New design `frame`: list [(x, y, text, fg, bg), ...] ---------------------------------------------------------- New design `frame`: list [(x, y, text, fg, bg), ...] `animation`: generator that returns frames ---------------------------------------------------------- Beautiful is better than ugly ---------------------------------------------------------- def center_text(y, text, fg=black, bg=white): """Center `text` at height `y`.""" x = (WIDTH - len(text)) // 2 frame = [(x, y, text, fg, bg)] while True: yield frame ---------------------------------------------------------- def fade_in(duration, animation): """Fade another animation into visibility.""" levels = [i / duration for i in range(duration)] for level, frame in zip(levels, animation): yield [ (x, y, text, blend(bg, fg, level), bg) for (x, y, text, fg, bg) in frame ] ---------------------------------------------------------- def concat(*args): """Run several animations simultaneously.""" for frames in zip(*args): yield [tup for frame in frames for item in frame] ---------------------------------------------------------- def zen(): line1 = center_text(5, 'Beautiful') line2 = center_text(6, 'is better') line3 = center_text(7, 'than ugly') yield from fade_in(line1, HZ) yield from concat(line1, fade_in(line2, HZ)) yield from concat(line1, line2, fade_in(line3, HZ)) yield next(line1) + next(line2) + next(line3) ---------------------------------------------------------- Beautiful is better than ugly ---------------------------------------------------------- Challenges ---------------------------------------------------------- Challenges 1. Technical: A ---------------------------------------------------------- Challenges 1. Technical: A 2. Architectural: C- ---------------------------------------------------------- Challenges 1. Technical: A 2. Architectural: C- My code wound up more complicated than the problem it was trying to solve ---------------------------------------------------------- Conclusion ---------------------------------------------------------- Conclusion You can give the talk “The Clean Architecture” at PyCon Ireland in 2014 ---------------------------------------------------------- Conclusion You can give the talk “The Clean Architecture” at PyCon Ireland in 2014 — and still struggle to produce code that is shaped enough like its data(the line-feed character is