summaryrefslogtreecommitdiff
path: root/examples
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-25 02:07:23 -0300
committerGabriel Schneider <[email protected]>2026-08-25 09:42:07 -0300
commit6f48508aa08396bcf9dd4da2cab1d221bcc53f78 (patch)
tree83daec3db5ac27ea3172651df2b3e9cb62eddb4a /examples
parent28c70cabb6ceb7e5fecfd74f6984f5a995269f01 (diff)
downloadpardes-6f48508aa08396bcf9dd4da2cab1d221bcc53f78.tar.gz
pardes-6f48508aa08396bcf9dd4da2cab1d221bcc53f78.zip
acmefs: pardes --fs serves acme's control filesystem over raw Linux FUSE
Diffstat (limited to 'examples')
-rw-r--r--examples/README.md219
-rwxr-xr-xexamples/acmefs/clock.py163
-rwxr-xr-xexamples/acmefs/eventlog161
-rwxr-xr-xexamples/acmefs/life.py350
-rwxr-xr-xexamples/acmefs/pardesctl158
5 files changed, 1051 insertions, 0 deletions
diff --git a/examples/README.md b/examples/README.md
new file mode 100644
index 00000000..6112a696
--- /dev/null
+++ b/examples/README.md
@@ -0,0 +1,219 @@
+# examples
+
+Programs that drive pardes from the outside. Each subdirectory is one
+interface; `acmefs/` is the acme control filesystem.
+
+## The acme control filesystem
+
+Started with `--fs`, pardes serves a small filesystem describing itself: one
+directory per pane, holding the pane's text, its tag, its selection, a control
+file of verbs and an event stream. Reading a file asks the editor a question,
+writing one gives it an order, and a middle click can be delivered to a script
+instead of to the editor. That is the whole of plan9 `acme(4)`, which pardes
+follows closely enough that acme's own manual page is the reference; the
+divergences are listed at the end.
+
+The point is that a text editor becomes scriptable by anything that can open a
+file. The scripts here are python3 and bash with no dependencies at all, and
+none of them link, embed, or know anything about pardes.
+
+### Starting it
+
+```
+pardes --fs # mount under $XDG_RUNTIME_DIR/pardes/<pid>
+pardes --fs=/tmp/mypardes # or name the mount point yourself
+```
+
+The mount lives in `$XDG_RUNTIME_DIR/pardes/<pid>`, or
+`~/.local/state/pardes/<pid>` when there is no runtime directory. It is created
+at startup, mode 0700, and unmounted and removed on exit; a startup sweep
+removes directories left by a pardes that died without unmounting.
+
+Every shell pardes starts inside a pane inherits two variables:
+
+| variable | meaning |
+|---|---|
+| `PARDES_FS` | the mount directory |
+| `PARDES_PANE` | the id of the pane the shell is running in |
+
+So a script run from a pane already knows both where the editor is and which
+pane it is talking from, and every example below defaults to those.
+
+One rule the protocol inherits from acme: **a `ctl` verb, an `addr` expression
+and an `event` record must each arrive in ONE `write(2)`.** acme got that for
+free (a 9P message IS a write), and every ordinary client has it too — stdio
+buffers, `echo` and `dd` write whole strings, Python's `os.write` is one call.
+A client that writes a verb one byte at a time gets EINVAL per byte, because a
+control file cannot tell a half-finished verb from a wrong one. Write whole
+lines.
+
+### The tree
+
+```
+/
+ index r one line per pane
+ cons w appends to +Errors
+ new/ dir looking up ANY name here creates a pane
+ <id>/ dir one per pane; id is the pane serial, never reused
+ addr body ctl data errors event rdsel tag wrsel xdata
+```
+
+| file | mode | semantics |
+|---|---|---|
+| `index` | r | one line per pane: five `%11d` fields -- id, tag length, body length, isdir, dirty -- then the tag text. Seekable. |
+| `cons` | w | appended text goes to the `+Errors` pane for the writing pane's directory, created on first write. |
+| `new/<name>` | lookup | creates a pane and resolves to that pane's `<name>`, so `echo hi > $PARDES_FS/new/body` opens a pane containing `hi`. Listing `new/` enumerates nothing at all -- every name it could report is a name whose lookup would create a pane, and `ls -l` stats what a listing reports. |
+| `addr` | rw | read: the current address as two `%11d` byte offsets. Write: an address expression. Never disturbs the user's selection. |
+| `body` | rw | read at any offset; a write APPENDS, whatever the offset. Replacing text means `addr` + `data`. |
+| `tag` | rw | read the whole tag; a write appends to the editable tail. (A pardes tag carries a live read-only prefix, so appending to that would be meaningless.) |
+| `ctl` | rw | read: the five `index` numbers plus `%11d %q %11d` -- width in cells, font name, tab width in cells. Write: newline separated verbs, several per write, applied all or nothing. |
+| `data` | rw | read: whole graphemes from the start of `addr`, up to the read size, moving `addr` past them. Write: replaces the addressed text and leaves `addr` as the null string after the insertion. The file offset is ignored. |
+| `xdata` | rw | `data`, except that reads stop at the end of `addr`. |
+| `errors` | w | appends to `<dir>/+Errors` for this pane's directory. |
+| `rdsel` | r | the pane's current selection. |
+| `wrsel` | w | replaces the pane's current selection. |
+| `event` | rw | the pane's action stream, both directions. See below. |
+
+`ctl` verbs: `addr=dot`, `clean`, `dirty`, `cleartag`, `del`, `delete`,
+`dot=addr`, `get`, `limit=addr`, `mark`, `nomark`, `name <name>`, `noscroll`,
+`scroll`, `put`, `show`. An unknown verb fails the whole write with EINVAL and
+applies nothing, so a batch is safe to send blind.
+
+Addresses, all in bytes: `#n` an offset, `n` a line, `0` the start, `$` the
+end, `.` the selection, `a,b` a range (`,` alone is the whole body), `+` and
+`-` with a count or a regex, `/re/` forwards, `?re?` backwards. Anything else
+is EINVAL.
+
+### Events
+
+A record is two characters and four blank separated decimal numbers, then the
+text:
+
+```
+origin type q0 q1 flag length [text]
+```
+
+Origin is `E` for a write through the `body` or `tag` file, `F` for an action
+through one of the pane's other files, `K` for the keyboard and `M` for the
+mouse. Type is `D`/`d` for a delete, `I`/`i` for an insert, `L`/`l` for a
+button-3 Look and `X`/`x` for a button-2 Exec -- uppercase for the body,
+lowercase for the tag, which is the entire addressing convention. Text of 256
+bytes or more is elided with a length of 0 and can be fetched from `data`.
+Deletes carry no text.
+
+Two behaviours make this the interesting file:
+
+* **While a pane's event file is open, its Look and Exec are reported and not
+ performed.** Chorded Cut and Paste still work normally. That is what lets a
+ script put its own words in the tag and mean its own things by them --
+ `acmefs/life.py` is nothing but that trick.
+* **Writing a record back performs the action**, as though the event file had
+ never been open. The write is only `origin type q0 q1` and a newline: the
+ action is named by a range of the pane's own text. Passing on the records you
+ do not implement is how a script stays a good citizen of somebody else's
+ editor.
+
+### Deliberate divergences from acme
+
+acme counts runes; pardes counts bytes, clamped to grapheme boundaries.
+pardes is byte-addressed end to end -- selections, look spots, LSP offsets --
+and a second coordinate system would add an O(n) scan at every boundary and
+make `addr=dot` and `dot=addr` lossy. For ASCII the two are identical.
+
+`acme`, `draw`, `consctl`, `label` and `editout` are not served. The first four
+are rio and plan9 compatibility stubs with nothing behind them here, and
+`editout` is the output sink of acme's `Edit` language, which pardes does not
+have.
+
+## The examples
+
+All four take the mount from `$PARDES_FS` and accept an override, and all four
+exit quietly when the pane or the mount goes away, because "the editor exited"
+is a normal ending for a program living inside it.
+
+### `acmefs/clock.py` -- a pane that is a clock
+
+Creating a pane, naming it, and replacing its body in place once a second.
+
+```
+$ examples/acmefs/clock.py &
+```
+
+A pane named `/+clock` appears and fills with the time in doubled-width block
+digits. Ctrl-C removes it. Demonstrates: `new/ctl` as the creation handshake,
+batched `ctl` verbs, and `addr` + `data` as the only way to replace body text.
+
+### `acmefs/life.py` -- a game whose buttons are the tag
+
+```
+$ examples/acmefs/life.py &
+```
+
+A pane named `/+life` appears with `Step Run Stop Clear Random` in its tag and
+a random 40x20 board in its body. Middle-click the words: they are not pardes
+commands and pardes has never heard of them, but the script has the event file
+open, so the clicks arrive here as `x` records naming the text. Button 3 on a
+cell toggles it. Middle-clicking anything the script does not implement -- the
+pane's own `Del`, say -- is written back to the event file and performed by the
+editor as usual. Ctrl-C removes the pane.
+
+### `acmefs/pardesctl` -- the editor from the command line
+
+```
+$ examples/acmefs/pardesctl panes
+ ID DIRTY BYTES TAG
+ 1 - 4213 src/pardes.zig
+ 3 * 118 /+clock
+$ examples/acmefs/pardesctl send 3 'hello from the shell'
+$ examples/acmefs/pardesctl body 3 | wc -l
+$ examples/acmefs/pardesctl tag 1
+$ id=$(examples/acmefs/pardesctl new src/pardes.zig)
+$ examples/acmefs/pardesctl exec "$id" Help
+$ examples/acmefs/pardesctl exec "$id" 'date >/tmp/from-pardes'
+$ examples/acmefs/pardesctl del "$id"
+```
+
+`exec` is the remote control door: it appends the command to the pane's tag,
+works out the byte range it landed in, and writes an `x` record naming that
+range -- which is exactly what a middle click on the same text would have sent.
+The command stays in the tag afterwards, where acme leaves it too, so it can be
+clicked again.
+A pardes builtin (`Help`, `New`, `Changelog`, ...) runs as a builtin; anything
+else runs as a shell command with its output going to `+Errors`, the same as if
+you had typed it into a tag and clicked it.
+
+`-m <dir>` overrides `$PARDES_FS`. With no arguments it prints its usage.
+
+### `acmefs/eventlog` -- watch the protocol
+
+```
+$ examples/acmefs/eventlog 3
+ORIGIN ACTION WHERE Q0 Q1 FLAG TEXT
+mouse exec tag 41 45 builtin Help
+fs-write insert body 0 0 - (no text...)
+```
+
+Every record spelled out in words, flag bits included. Point it at a pane and
+type in it, click in it, write to it from `pardesctl`, and watch what the
+editor reports.
+
+## WARNING
+
+**Opening a pane's `event` file suppresses that pane's Look and Exec.** Button
+2 and button 3 in a watched pane are reported to the reader and are *not*
+performed by the editor, so a watched pane feels broken until the reader exits
+(chorded Cut and Paste are exempt). This is a feature -- it is what makes a
+script's own tag words possible -- but `eventlog` inherits it, so do not leave
+it attached to a pane you are trying to work in.
+
+**A record is consumed by whoever reads it first.** Two programs on one pane's
+event file split the stream between them and both misbehave. Do not point
+`eventlog` at the pane `life.py` is driving.
+
+**Writing an `X` or `x` record executes arbitrary commands, by design.** So
+does `Look` reaching an executable name. `pardesctl exec` is four lines of
+shell for a reason: the filesystem is a remote control, and anything that
+can write into the mount directory can run commands as you. The mount is mode
+0700 under your own runtime directory, and that is the only thing standing
+between the two facts. Do not put it on a shared filesystem, and do not serve
+it to anything you would not hand a shell to.
diff --git a/examples/acmefs/clock.py b/examples/acmefs/clock.py
new file mode 100755
index 00000000..d976544b
--- /dev/null
+++ b/examples/acmefs/clock.py
@@ -0,0 +1,163 @@
+#!/usr/bin/env python3
+"""A pardes pane that becomes a live clock, driven only through the acme
+control filesystem. python3 stdlib, nothing else.
+
+WHAT IT DEMONSTRATES
+
+ * Creating a pane is a LOOKUP, not a write: naming any file under `new/`
+ makes a pane and resolves to that pane's copy of the file. Opening
+ `new/ctl` is therefore the whole creation handshake, because the ctl read
+ hands back the new pane's id as its first field. Nothing else in the tree
+ can create a pane, and READDIR of `new/` creates nothing.
+
+ * The `ctl` verb stream: `name` and `clean` go out in ONE write, newline
+ separated. ctl writes are all-or-nothing, so a batch either applies whole
+ or leaves the pane untouched -- which is why sending the pair together is
+ safer than two writes that could half-fail.
+
+ * `addr` + `data` as a whole-body REPLACE. A `body` write always appends
+ (the offset is ignored), so redrawing a frame in place needs the address
+ machinery: write `,` to `addr` to select the entire body, then write the
+ frame to `data`, which substitutes the addressed text. After that write
+ `addr` is the null string just past the insertion, so if the kernel splits
+ a big frame across several write(2) calls the pieces still land in order:
+ the first replaces, the rest append at the growing end.
+
+FILES TOUCHED
+
+ new/ctl create the pane, read its id back
+ <id>/ctl `name /+clock`, `clean`
+ <id>/addr `,` (whole body) before each frame
+ <id>/data the frame itself
+ <id>/ctl `clean` again after each frame, see below
+
+Every frame ends with `clean` because a data write marks the pane dirty, and a
+generated clock face is not user data: a dirty pane refuses `del` and nags on
+exit. One extra ctl round trip per second is not a cost worth optimising.
+
+USAGE
+
+ clock.py [mountdir] default: $PARDES_FS (set in every pane shell)
+
+Ctrl-C removes the pane and exits. So does the pane being deleted from the
+editor: the next addr/data write fails with an OSError, which is the only
+"the other end is gone" signal the filesystem gives us, and it is enough.
+"""
+
+import os
+import sys
+import time
+
+# 3x5 cells per glyph, doubled horizontally below so the face is legible in a
+# character grid, where cells are about twice as tall as they are wide.
+FONT = {
+ "0": ("###", "# #", "# #", "# #", "###"),
+ "1": (" #", " #", " #", " #", " #"),
+ "2": ("###", " #", "###", "# ", "###"),
+ "3": ("###", " #", "###", " #", "###"),
+ "4": ("# #", "# #", "###", " #", " #"),
+ "5": ("###", "# ", "###", " #", "###"),
+ "6": ("###", "# ", "###", "# #", "###"),
+ "7": ("###", " #", " #", " #", " #"),
+ "8": ("###", "# #", "###", "# #", "###"),
+ "9": ("###", "# #", "###", " #", "###"),
+ ":": (" ", " # ", " ", " # ", " "),
+}
+BLANK = (" ",) * 5
+XSCALE = 2
+
+
+def art(text):
+ """Render `text` as five rows of doubled-width block characters."""
+ rows = []
+ for row in range(5):
+ line = " ".join(FONT.get(ch, BLANK)[row] for ch in text)
+ rows.append("".join(ch * XSCALE for ch in line).rstrip())
+ return rows
+
+
+def write_all(fd, data):
+ """One logical fs write. Short writes are looped over rather than trusted
+ away: see the addr/data note in the module comment for why the tail of a
+ split frame still lands in the right place."""
+ view = memoryview(data)
+ while view:
+ view = view[os.write(fd, view) :]
+
+
+def main(argv):
+ mount = argv[1] if len(argv) > 1 else os.environ.get("PARDES_FS", "")
+ if not mount:
+ sys.stderr.write(
+ "clock.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("clock.py: %s is not a directory\n" % mount)
+ return 1
+
+ # The lookup of `new/ctl` is the creation. Read it back for the id, which
+ # is the first of the five index numbers (id, tag len, body len, isdir,
+ # dirty) that a ctl read starts with. acme's ctl read has no trailing
+ # newline, so read the lot and split on whitespace rather than a line.
+ try:
+ with open(os.path.join(mount, "new", "ctl"), "rb") as f:
+ fields = f.read(256).split()
+ except OSError as e:
+ sys.stderr.write("clock.py: cannot create a pane: %s\n" % e)
+ return 1
+ if not fields or not fields[0].isdigit():
+ sys.stderr.write("clock.py: unexpected new/ctl contents: %r\n" % fields[:1])
+ return 1
+ pane = fields[0].decode()
+
+ d = os.path.join(mount, pane)
+ ctl = addr = data = None
+ try:
+ # O_WRONLY, never O_TRUNC: truncating a control file is a setattr the
+ # server has no reason to honour, and `open(..., "wb")` would send one.
+ ctl = os.open(os.path.join(d, "ctl"), os.O_WRONLY)
+ write_all(ctl, b"name /+clock\nclean\n")
+ addr = os.open(os.path.join(d, "addr"), os.O_WRONLY)
+ data = os.open(os.path.join(d, "data"), os.O_WRONLY)
+
+ while True:
+ now = time.localtime()
+ frame = art(time.strftime("%H:%M:%S", now))
+ frame.append("")
+ frame.append(time.strftime("%A %d %B %Y", now))
+ payload = ("\n".join(frame) + "\n").encode()
+ write_all(addr, b",")
+ write_all(data, payload)
+ write_all(ctl, b"clean\n")
+ # Sleep to the next second boundary so the face never skips or
+ # stutters, and so this loop is never a spin.
+ time.sleep(1.0 - (time.time() % 1.0))
+ except KeyboardInterrupt:
+ pass
+ except OSError:
+ # The pane (or the whole mount) went away. That is a normal ending for
+ # a script that lives inside someone else's editor, not a crash.
+ return 0
+ finally:
+ for fd in (addr, data):
+ if fd is not None:
+ try:
+ os.close(fd)
+ except OSError:
+ pass
+ if ctl is not None:
+ try:
+ write_all(ctl, b"clean\ndel\n")
+ except OSError:
+ pass
+ try:
+ os.close(ctl)
+ except OSError:
+ pass
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main(sys.argv))
diff --git a/examples/acmefs/eventlog b/examples/acmefs/eventlog
new file mode 100755
index 00000000..b7c7596a
--- /dev/null
+++ b/examples/acmefs/eventlog
@@ -0,0 +1,161 @@
+#!/usr/bin/env bash
+# Stream one pane's `event` file and print every record in words, so that the
+# protocol can be watched instead of guessed at. bash and coreutils only.
+#
+# WARNING -- THIS IS NOT A PASSIVE OBSERVER
+#
+# Two things change the moment this script starts.
+#
+# While a pane's event file is open, that pane's button-2 (Exec) and button-3
+# (Look) actions are REPORTED and NOT PERFORMED. Middle-clicking Del in the
+# tag of a watched pane will print a record here and do nothing to the pane.
+# (Chorded Cut and Paste are exempt and behave normally.) That suppression is
+# the feature -- it is what lets a script define its own tag commands -- but
+# while you are only watching, it makes the pane feel broken.
+#
+# And a record is consumed by whoever reads it first. If another program is
+# driving that pane through its event file, do not point this at the same pane:
+# the two readers will split the stream and both will misbehave. Watch a pane
+# nobody owns, or watch the script instead.
+#
+# WHAT IT DEMONSTRATES
+#
+# A record is two characters -- origin and type -- then four blank separated
+# decimal numbers (q0, q1, flag, text length) and the text. Uppercase types
+# refer to the body, lowercase to the tag; that single bit of case is the whole
+# addressing scheme. This script spells all of it out: `M X` prints as
+# "mouse exec body", and the flag bits print as the words they stand for.
+#
+# The event file is read through `cat` rather than opened by the shell. bash's
+# `read` buffers from a seekable fd and then seeks back to correct the file
+# position -- fine on a real file, silently lossy on a stream whose server
+# ignores offsets. `cat` reads strictly forward, and the pipe it writes into is
+# not seekable, so nothing can be skipped.
+#
+# FILES TOUCHED: <id>/event (read only, but see the warning).
+#
+# USAGE: eventlog [-m mountdir] [pane-id] default id: $PARDES_PANE
+set -u
+LC_ALL=C # so ${#text} counts BYTES: event offsets are byte offsets
+
+self=${0##*/}
+mount=${PARDES_FS:-}
+
+if [ "${1:-}" = "-m" ]; then
+ [ $# -ge 2 ] || { echo "$self: -m needs a directory" >&2; exit 2; }
+ mount=$2
+ shift 2
+fi
+pane=${1:-${PARDES_PANE:-}}
+
+if [ -z "$mount" ] || [ -z "$pane" ]; then
+ cat >&2 <<EOF
+usage: $self [-m mountdir] [pane-id]
+
+Prints one line per event record: origin, type, target, q0, q1, flag, text.
+The mount comes from \$PARDES_FS and the pane id from \$PARDES_PANE, both of
+which pardes sets in every pane shell when started with --fs.
+
+Opening a pane's event file suppresses that pane's Look and Exec while this
+runs, and consumes records any other client of the same pane needs.
+EOF
+ exit 2
+fi
+case $pane in
+*[!0-9]*) echo "$self: '$pane' is not a pane id" >&2; exit 2 ;;
+esac
+ev="$mount/$pane/event"
+[ -r "$ev" ] || { echo "$self: cannot read $ev (no such pane?)" >&2; exit 1; }
+
+origin_word() {
+ case $1 in
+ E) echo "fs-write" ;; # a write to this pane's body or tag
+ F) echo "fs-action" ;; # an action taken through another of its files
+ K) echo "keyboard" ;;
+ M) echo "mouse" ;;
+ *) echo "origin?$1" ;;
+ esac
+}
+
+# Uppercase = body, lowercase = tag. Nothing else distinguishes the two.
+type_word() {
+ case $1 in
+ D | I | L | X) echo "body" ;;
+ d | i | l | x) echo "tag" ;;
+ *) echo "?" ;;
+ esac
+}
+
+action_word() {
+ case $1 in
+ D | d) echo "delete" ;;
+ I | i) echo "insert" ;;
+ L | l) echo "look" ;; # button 3
+ X | x) echo "exec" ;; # button 2
+ *) echo "type?$1" ;;
+ esac
+}
+
+# The flag is a bitwise OR whose meaning depends on the type. Deletes and
+# inserts always carry 0, so only look and exec decode to anything.
+flag_words() {
+ local t=$1 f=$2 out=""
+ case $t in
+ X | x)
+ (((f & 1) != 0)) && out="$out,builtin"
+ (((f & 2) != 0)) && out="$out,expanded(record follows)"
+ (((f & 8) != 0)) && out="$out,chorded-arg(2 records follow)"
+ ;;
+ L | l)
+ (((f & 1) != 0)) && out="$out,no-load-needed"
+ (((f & 2) != 0)) && out="$out,expanded(record follows)"
+ (((f & 4) != 0)) && out="$out,file-or-pane-name"
+ ;;
+ esac
+ [ -n "$out" ] && printf '%s' "${out#,}" || printf -- '-'
+}
+
+printf '%-10s %-7s %-7s %8s %8s %-24s %s\n' ORIGIN ACTION WHERE Q0 Q1 FLAG TEXT
+cat -- "$ev" 2>/dev/null | while IFS= read -r line; do
+ # Blank lines are a record terminator, not a record: skip them. This is
+ # also what keeps the reader in step with either text layout below.
+ [ -n "$line" ] || continue
+ o=${line:0:1}
+ t=${line:1:1}
+ rest=${line:2}
+ # shellcheck disable=SC2034
+ read -r q0 q1 flag n text <<<"$rest" || :
+ q0=${q0:-0} q1=${q1:-0} flag=${flag:-0} n=${n:-0} text=${text:-}
+ case $n in *[!0-9]*) n=0 ;; esac
+ if [ "$n" -gt 0 ] && [ -z "$text" ]; then
+ # The counted bytes follow the newline.
+ IFS= read -r -N "$n" text || :
+ elif [ "$n" -gt 0 ] && [ "${#text}" -lt "$n" ]; then
+ # The text sat on the record line and contained a newline of its
+ # own, which the line read above swallowed. Take the remainder.
+ want=$((n - ${#text} - 1))
+ more=""
+ [ "$want" -gt 0 ] && { IFS= read -r -N "$want" more || :; }
+ text="$text
+$more"
+ fi
+ if [ -n "$text" ]; then
+ shown=$(printf '%q' "$text")
+ elif [ "$n" -gt 0 ]; then
+ shown="(short by $n bytes: the stream ended mid-record)"
+ else
+ # Count 0 means "no text was sent". For a delete that is the rule;
+ # for a look or an exec it means the text was 256 bytes or longer and
+ # was elided, or the selection was null and an expansion follows.
+ case $t in
+ X | x | L | l) shown="(no text: elided, or null -- read $pane/data)" ;;
+ *) shown="" ;;
+ esac
+ fi
+ printf '%-10s %-7s %-7s %8s %8s %-24s %s\n' \
+ "$(origin_word "$o")" "$(action_word "$t")" "$(type_word "$t")" \
+ "$q0" "$q1" "$(flag_words "$t" "$flag")" "$shown"
+done
+# cat ends when the pane or the whole mount goes away. That is the editor
+# exiting, not a failure, so say nothing and leave with 0.
+exit 0
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))
diff --git a/examples/acmefs/pardesctl b/examples/acmefs/pardesctl
new file mode 100755
index 00000000..0a7bc1a4
--- /dev/null
+++ b/examples/acmefs/pardesctl
@@ -0,0 +1,158 @@
+#!/usr/bin/env bash
+# A tiny command line over the pardes acme filesystem. bash and coreutils only:
+# every subcommand below is one or two ordinary file operations, which is the
+# point of serving the editor as a filesystem in the first place.
+#
+# WHAT IT DEMONSTRATES
+#
+# panes read `index` -- five %11d numbers (id, tag length, body length,
+# isdir, dirty) then the tag text, one line per pane
+# body read `<id>/body` tag read `<id>/tag`
+# send APPEND to `<id>/body` -- a body write ignores its offset, so there
+# is no such thing as a partial overwrite by accident
+# exec write an `X`/`x` event record, which makes the editor perform the
+# action as though nobody had been listening. This is the remote
+# control door: it runs pardes builtins and shell commands alike.
+# new LOOKUP under `new/`, which is what creates a pane
+# del ctl verb `del`
+#
+# TWO RULES THIS SCRIPT FOLLOWS, AND YOU SHOULD TOO
+#
+# Always `>>`, never `>`. A plain `>` opens O_TRUNC, which is a setattr with
+# size 0 -- a truncate request against a control file. Appending is what every
+# writable file here actually wants; the offset is ignored anyway.
+#
+# `ctl` and `new/ctl` reads carry no trailing newline (acme prints fields, not
+# lines), so `read` returns non-zero at end of file even though it has already
+# assigned the fields. Hence the `|| :` on those reads.
+#
+# HOW `exec` RUNS ARBITRARY TEXT
+#
+# An event write is only `origin type q0 q1` -- no text. The action is named by
+# a range of the pane's own text, lowercase type for the tag and uppercase for
+# the body. So to run a command that is not on screen yet, this script appends
+# it to the tag (a tag write appends to the editable tail), measures where it
+# landed, and executes exactly that range. The command text stays visible in
+# the tag afterwards, which is also how acme leaves it, and means the user can
+# click it again.
+#
+# USAGE: run with no arguments.
+set -u
+
+self=${0##*/}
+mount=${PARDES_FS:-}
+
+usage() {
+ cat >&2 <<EOF
+$self -- drive pardes through its acme filesystem
+
+usage: $self [-m mountdir] command [args]
+
+ panes one line per pane: id, dirty flag, body size, tag
+ body <id> print the pane's text
+ tag <id> print the pane's tag
+ send <id> <text...> append a line of text to the pane's body
+ exec <id> <cmd...> make the editor run <cmd> (builtin or shell command)
+ new [file] create a pane, optionally loading <file>; prints its id
+ del <id> delete the pane (refused if it has unsaved changes)
+
+The mount directory comes from \$PARDES_FS, which pardes sets in every pane
+shell when started with --fs, or from -m. \$PARDES_PANE is the id of the pane
+a shell is running in, so "$self send \$PARDES_PANE hello" talks to itself.
+EOF
+ exit 2
+}
+
+die() { printf '%s: %s\n' "$self" "$*" >&2; exit 1; }
+
+if [ "${1:-}" = "-m" ]; then
+ [ $# -ge 2 ] || usage
+ mount=$2
+ shift 2
+fi
+[ $# -ge 1 ] || usage
+[ -n "$mount" ] || die "no mount point: set \$PARDES_FS or pass -m <dir>"
+[ -d "$mount" ] || die "$mount is not a directory (has pardes exited?)"
+
+cmd=$1
+shift
+
+# Every pane file lives under <mount>/<id>/. Sets $d rather than printing it:
+# inside a command substitution `die` would exit only the subshell and the
+# caller would sail on with an empty path. Refuses anything that is not a plain
+# number, so a typo cannot wander out of the mount.
+pane_dir() {
+ case ${1:-} in
+ "" | *[!0-9]*) die "expected a pane id (see: $self panes)" ;;
+ esac
+ [ -d "$mount/$1" ] || die "no pane $1 (see: $self panes)"
+ d="$mount/$1"
+}
+
+case $cmd in
+panes)
+ printf '%6s %5s %8s %s\n' ID DIRTY BYTES TAG
+ while read -r id taglen bodylen isdir dirty tag; do
+ [ -n "${id:-}" ] || continue
+ printf '%6s %5s %8s %s\n' \
+ "$id" "$([ "${dirty:-0}" = 0 ] && echo - || echo '*')" \
+ "${bodylen:-0}" "${tag:-}"
+ done <"$mount/index"
+ ;;
+body)
+ pane_dir "${1:-}"
+ cat -- "$d/body"
+ ;;
+tag)
+ # A tag read carries no trailing newline, so supply one for the terminal.
+ pane_dir "${1:-}"
+ printf '%s\n' "$(cat -- "$d/tag")"
+ ;;
+send)
+ pane_dir "${1:-}"
+ shift
+ [ $# -ge 1 ] || die "send: nothing to send"
+ printf '%s\n' "$*" >>"$d/body" || die "send: pane went away"
+ ;;
+exec)
+ pane_dir "${1:-}"
+ shift
+ [ $# -ge 1 ] || die "exec: no command"
+ text=$*
+ # Where the command will land: byte length of the whole tag, plus the one
+ # space we prefix so it cannot merge with the word before it. Byte length,
+ # not character length, because addresses are byte offsets -- ${#text}
+ # would count characters and mis-address any non-ASCII command.
+ q0=$(($(wc -c <"$d/tag") + 1))
+ q1=$((q0 + $(printf '%s' "$text" | wc -c)))
+ printf ' %s' "$text" >>"$d/tag" || die "exec: pane went away"
+ # Lowercase 'x' is a tag exec; origin 'M' reports it as the mouse action
+ # this stands in for. The editor now runs it.
+ printf 'Mx%d %d\n' "$q0" "$q1" >>"$d/event" || die "exec: pane refused the event"
+ ;;
+new)
+ # The lookup itself creates the pane; the ctl read names it.
+ read -r id _ <"$mount/new/ctl" || :
+ case ${id:-} in
+ "" | *[!0-9]*) die "new: unexpected new/ctl contents" ;;
+ esac
+ if [ $# -ge 1 ] && [ -n "$1" ]; then
+ # `name` then `get`: one all-or-nothing ctl write, so the pane is
+ # never left named after a file it did not load.
+ printf 'name %s\nget\n' "$1" >>"$mount/$id/ctl" ||
+ die "new: cannot load $1 into pane $id"
+ fi
+ printf '%s\n' "$id"
+ ;;
+del)
+ pane_dir "${1:-}"
+ printf 'del\n' >>"$d/ctl" ||
+ die "del: pane $1 refused (unsaved changes; save it, or use: $self exec $1 Delete)"
+ ;;
+-h | --help | help)
+ usage
+ ;;
+*)
+ die "unknown command: $cmd (run with no arguments for usage)"
+ ;;
+esac