summaryrefslogtreecommitdiff
path: root/examples/acmefs/clock.py
blob: d976544b4e1b561d6541de7d8d725565afc04f90 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
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))