diff options
Diffstat (limited to 'examples/acmefs/clock.py')
| -rwxr-xr-x | examples/acmefs/clock.py | 163 |
1 files changed, 163 insertions, 0 deletions
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)) |
