#!/usr/bin/env python3 """Conway's Game of Life whose entire user interface is the pane's TAG. python3 stdlib, nothing else. WHAT IT DEMONSTRATES This is the acme trick that makes the filesystem worth having: a script can define its own commands without the editor knowing anything about them. 1. Write words into the pane's `tag`. They are now just text. 2. Open the pane's `event` file. While it is open, button-2 (Exec) and button-3 (Look) on that pane are REPORTED to us and NOT performed by the editor. (Chorded Cut/Paste keep working, so the tag stays editable.) 3. A middle click on `Run` therefore arrives here as an `x` record naming that text, and "Run" means whatever this script decides it means. Words we do not recognise are WRITTEN BACK to the event file unchanged, which makes the editor perform the action as though the event file had never been open. So the pane's own tag entries -- Del, Put, whatever the editor puts there -- still work while we are attached. A script that swallowed them would be a black hole; passing them through is the whole etiquette of the protocol. A button-3 click in the BODY (an `L` record) toggles the cell under the click. The body is rendered as exactly H lines of W cells plus a newline each, so the click offset q0 maps to a cell by plain division -- no coordinate lookup, no round trip. Anything decorative goes BELOW the grid, where it cannot disturb that arithmetic. FILES TOUCHED new/ctl create the pane, read its id back /ctl `name /+life`, `clean` /tag the command words -- a tag write appends to the editable tail /event O_RDWR: blocking reads for records, writes to pass records on /addr `,` (whole body) before each generation /data the generation itself USAGE life.py [mountdir] default: $PARDES_FS (set in every pane shell) Step one generation Clear empty the grid Run animate Random fill the grid at random Stop stop animating button 3 in the grid: toggle that cell Ctrl-C removes the pane and exits. So does the pane being deleted: the next write fails, or the event reader hits end of file, and either is a clean end. WHY A THREAD Event reads BLOCK -- the server holds the request until a record exists -- and a FUSE-backed regular file always polls readable, so select() cannot be used to wait on one. Life also has to advance on a timer. So one daemon thread does nothing but blocking reads and hands records to a Queue, and the main loop waits on the Queue with a deadline. That keeps the blocking read where it belongs and leaves the main loop free of spin. """ import os import queue import random import sys import threading import time W, H = 40, 20 TICK = 0.15 LIVE, DEAD = "#", "." COMMANDS = ("Step", "Run", "Stop", "Clear", "Random") class Records: """Counted event records off a blocking fd, kept in step byte-exactly. The record is two characters (origin, type), then four blank separated decimal numbers -- q0, q1, flag, text length -- then the text. Two layouts exist in the wild: plan9 acme puts the text before the record's terminating newline, while the pardes design note writes the newline after the four numbers and the counted bytes after it. Both are accepted here. Guessing wrong would not mangle one record, it would desynchronise the stream forever, so this reader takes whatever the line still holds as text and only goes back to the fd for bytes the count says are missing. Blank lines are skipped, which absorbs either layout's record terminator. """ def __init__(self, fd): self.fd = fd self.buf = b"" def _fill(self): chunk = os.read(self.fd, 4096) # blocks in the server until a record if not chunk: raise EOFError("event file closed") self.buf += chunk def _line(self): while True: nl = self.buf.find(b"\n") if nl >= 0: line, self.buf = self.buf[:nl], self.buf[nl + 1 :] if line: return line continue self._fill() def _take(self, n): while len(self.buf) < n: self._fill() out, self.buf = self.buf[:n], self.buf[n:] return out def next(self): line = self._line() while len(line) < 2: line = self._line() origin, typ = chr(line[0]), chr(line[1]) rest, nums, i = line[2:], [], 0 for _ in range(4): while i < len(rest) and rest[i : i + 1] == b" ": i += 1 j = i while j < len(rest) and rest[j : j + 1].isdigit(): j += 1 nums.append(int(rest[i:j]) if j > i else 0) i = j q0, q1, flag, count = nums tail = rest[i + 1 :] if rest[i : i + 1] == b" " else rest[i:] if count == 0: # Text of 256 bytes or more is elided: count 0 and no bytes. The # reader is meant to fetch it from `data` if it cares; we do not. text = tail elif tail: text = tail if len(text) < count: text += b"\n" # the newline we stopped on belongs to the text text += self._take(count - len(text)) else: text = self._take(count) return (origin, typ, q0, q1, flag, text.decode("utf-8", "replace")) def write_all(fd, data): view = memoryview(data) while view: view = view[os.write(fd, view) :] class Life: def __init__(self, mount): with open(os.path.join(mount, "new", "ctl"), "rb") as f: fields = f.read(256).split() if not fields or not fields[0].isdigit(): raise OSError("unexpected new/ctl contents: %r" % fields[:1]) self.pane = fields[0].decode() d = os.path.join(mount, self.pane) self.ctl = os.open(os.path.join(d, "ctl"), os.O_WRONLY) write_all(self.ctl, b"name /+life\nclean\n") self.tagfd = os.open(os.path.join(d, "tag"), os.O_RDWR) # O_RDWR on one fd: reading records and writing them back are the two # halves of one conversation, and the editor's "someone is listening" # state follows the open, so a second open would be a second listener. self.event = os.open(os.path.join(d, "event"), os.O_RDWR) self.addr = os.open(os.path.join(d, "addr"), os.O_WRONLY) self.data = os.open(os.path.join(d, "data"), os.O_WRONLY) write_all(self.tagfd, (" " + " ".join(COMMANDS)).encode()) self.tagtext = None self.passed = None self.cells = set() self.gen = 0 self.running = False def close(self): for fd in (self.tagfd, self.event, self.addr, self.data): try: os.close(fd) except OSError: pass try: write_all(self.ctl, b"clean\ndel\n") except OSError: pass try: os.close(self.ctl) except OSError: pass # --- the fs side ----------------------------------------------------- def render(self): rows = [] for y in range(H): rows.append("".join(LIVE if (x, y) in self.cells else DEAD for x in range(W))) rows.append("") rows.append( "generation %d %s %d alive button 3 in the grid toggles a cell" % (self.gen, "running" if self.running else "stopped", len(self.cells)) ) write_all(self.addr, b",") write_all(self.data, ("\n".join(rows) + "\n").encode()) write_all(self.ctl, b"clean\n") def tag_slice(self, q0, q1): """The text of a tag click, for the case where the record carried none. Read once and cached: the tag only changes when we or the user change it, and a wrong guess here costs an ignored click, not a corruption.""" if self.tagtext is None: self.tagtext = os.pread(self.tagfd, 8192, 0).decode("utf-8", "replace") return self.tagtext[q0:q1] def passthrough(self, origin, typ, q0, q1): """Hand a record we do not implement back to the editor, which then performs it exactly as if nobody had been listening. Flag, count and text are omitted: the two characters and two numbers are the whole identity of the action. The coordinates are remembered so that a record coming straight back at us can be recognised. A correct server does not re-report an action it was asked to perform -- acme marks the write-back path `external` precisely to skip its own reporting branch -- but if one ever did, a passthrough of a passthrough is an infinite loop, and this is a cheaper insurance policy than finding that out in someone's editor.""" self.passed = (typ, q0, q1) write_all(self.event, ("%c%c%d %d\n" % (origin, typ, q0, q1)).encode()) # --- the game side --------------------------------------------------- def step(self): counts = {} for (x, y) in self.cells: for dx in (-1, 0, 1): for dy in (-1, 0, 1): if dx or dy: n = ((x + dx) % W, (y + dy) % H) counts[n] = counts.get(n, 0) + 1 self.cells = {c for c, n in counts.items() if n == 3 or (n == 2 and c in self.cells)} self.gen += 1 def command(self, word): if word == "Step": self.step() elif word == "Run": self.running = True elif word == "Stop": self.running = False elif word == "Clear": self.cells.clear() self.gen = 0 elif word == "Random": self.cells = { (x, y) for x in range(W) for y in range(H) if random.random() < 0.28 } self.gen = 0 else: return False return True def toggle(self, q0): """Body offset -> cell. Rows are W cells plus a newline, so the row is the quotient and the column the remainder; a click on the newline, or anywhere in the status line below the grid, lands outside and is ignored.""" row, col = divmod(q0, W + 1) if row >= H or col >= W: return cell = (col, row) self.cells.symmetric_difference_update({cell}) def handle(self, rec): origin, typ, q0, q1, _flag, text = rec if not text and (typ, q0, q1) == self.passed: return False # a record we passed on, coming back: see passthrough if typ in "xX": word = (text or self.tag_slice(q0, q1)).strip() if not self.command(word): self.passthrough(origin, typ, q0, q1) return False elif typ == "L": self.toggle(q0) elif typ == "l": self.passthrough(origin, typ, q0, q1) return False else: return False # I/i/D/d: our own writes echoing back return True def reader(records, q): try: while True: q.put(records.next()) except (OSError, EOFError, ValueError): pass q.put(None) # the pane or the mount is gone def main(argv): mount = argv[1] if len(argv) > 1 else os.environ.get("PARDES_FS", "") if not mount: sys.stderr.write( "life.py: no mount point. Pass one, or run inside a pardes pane\n" " shell where $PARDES_FS is set (start pardes with --fs).\n" ) return 1 if not os.path.isdir(mount): sys.stderr.write("life.py: %s is not a directory\n" % mount) return 1 try: game = Life(mount) except OSError as e: sys.stderr.write("life.py: cannot set up a pane: %s\n" % e) return 1 q = queue.Queue() threading.Thread(target=reader, args=(Records(game.event), q), daemon=True).start() try: game.command("Random") game.render() deadline = time.monotonic() + TICK while True: if game.running: wait = deadline - time.monotonic() if wait <= 0: game.step() game.render() deadline = time.monotonic() + TICK wait = TICK else: wait = 0.25 # a bounded wait, not a spin: clicks stay prompt try: rec = q.get(timeout=wait) except queue.Empty: continue if rec is None: break if game.handle(rec): game.render() deadline = time.monotonic() + TICK except KeyboardInterrupt: pass except OSError: return 0 # the pane went away mid-write; nothing to report finally: game.close() return 0 if __name__ == "__main__": sys.exit(main(sys.argv))