summaryrefslogtreecommitdiff
path: root/examples/acmefs/life.py
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-06 18:11:36 -0300
committerGabriel Schneider <[email protected]>2026-09-07 13:59:12 -0300
commit60367d8fe23f6af98ec28e3cf6c2094dfe332df0 (patch)
tree310fc734173cf771881f4691c71909135fadde97 /examples/acmefs/life.py
parentfa82cac885cb4738fe36d1e49b4749b5a3e31a4a (diff)
downloadpardes-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-xexamples/acmefs/life.py350
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))