diff options
Diffstat (limited to 'examples/acmefs/life.py')
| -rwxr-xr-x | examples/acmefs/life.py | 350 |
1 files changed, 350 insertions, 0 deletions
diff --git a/examples/acmefs/life.py b/examples/acmefs/life.py new file mode 100755 index 00000000..09318f6d --- /dev/null +++ b/examples/acmefs/life.py @@ -0,0 +1,350 @@ +#!/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 + <id>/ctl `name /+life`, `clean` + <id>/tag the command words -- a tag write appends to the editable tail + <id>/event O_RDWR: blocking reads for records, writes to pass records on + <id>/addr `,` (whole body) before each generation + <id>/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)) |
