diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-06 18:11:36 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-09-07 13:59:12 -0300 |
| commit | 60367d8fe23f6af98ec28e3cf6c2094dfe332df0 (patch) | |
| tree | 310fc734173cf771881f4691c71909135fadde97 /examples/acmefs/life.py | |
| parent | fa82cac885cb4738fe36d1e49b4749b5a3e31a4a (diff) | |
| download | pardes-60367d8fe23f6af98ec28e3cf6c2094dfe332df0.tar.gz pardes-60367d8fe23f6af98ec28e3cf6c2094dfe332df0.zip | |
Refactor panes and filesystem; replace FUSE with 9P
Consolidate pane, layout, memory and host code. Serve 9P by default over Unix sockets, with runtime mounts and optional TCP/QUIC transports. Remove FUSE and obsolete proof-of-concept examples.
Fix highlighting and terminal-history performance, expand differential and stress-test infrastructure, sort navigation results while preserving the next occurrence, add syntax-colored Braille minimaps, remove SPC-k, and document 9P interaction as a repository skill.
Diffstat (limited to 'examples/acmefs/life.py')
| -rwxr-xr-x | examples/acmefs/life.py | 350 |
1 files changed, 0 insertions, 350 deletions
diff --git a/examples/acmefs/life.py b/examples/acmefs/life.py deleted file mode 100755 index 09318f6d..00000000 --- a/examples/acmefs/life.py +++ /dev/null @@ -1,350 +0,0 @@ -#!/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)) |
