From 6f48508aa08396bcf9dd4da2cab1d221bcc53f78 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 25 Aug 2026 02:07:23 -0300 Subject: acmefs: pardes --fs serves acme's control filesystem over raw Linux FUSE --- examples/acmefs/clock.py | 163 +++++++++++++++++++++ examples/acmefs/eventlog | 161 +++++++++++++++++++++ examples/acmefs/life.py | 350 ++++++++++++++++++++++++++++++++++++++++++++++ examples/acmefs/pardesctl | 158 +++++++++++++++++++++ 4 files changed, 832 insertions(+) create mode 100755 examples/acmefs/clock.py create mode 100755 examples/acmefs/eventlog create mode 100755 examples/acmefs/life.py create mode 100755 examples/acmefs/pardesctl (limited to 'examples/acmefs') 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 + /ctl `name /+clock`, `clean` + /addr `,` (whole body) before each frame + /data the frame itself + /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: /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 <&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 + /ctl `name /+life`, `clean` + /tag the command words -- a tag write appends to the editable tail + /event O_RDWR: blocking reads for records, writes to pass records on + /addr `,` (whole body) before each generation + /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 `/body` tag read `/tag` +# send APPEND to `/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 < print the pane's text + tag print the pane's tag + send append a line of text to the pane's body + exec make the editor run (builtin or shell command) + new [file] create a pane, optionally loading ; prints its id + del 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 " +[ -d "$mount" ] || die "$mount is not a directory (has pardes exited?)" + +cmd=$1 +shift + +# Every pane file lives under //. 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 -- cgit v1.3