summaryrefslogtreecommitdiff
path: root/examples/acmefs/life.py
diff options
context:
space:
mode:
Diffstat (limited to 'examples/acmefs/life.py')
-rwxr-xr-xexamples/acmefs/life.py350
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))