You may want to inform yourself about human rights in China.

On Plan9's Acme: Events Management, Shortcuts

date: 2023-01-31
update: 2023-03-15

Introduction

One can poll various acme(1) events via its 9P file system, documented in acme(4). For some general ideas about acme(1), see this article; for some extra tools, see this article.

Regarding events, some aspects are unclear from the man pages, hence this little article.

There are two sources of events, that we’ll explore in the following two sections. I’ll conclude by a third and final section with an example, demonstrating how to use events to implement shortcuts.

Note: The implementation hasn’t been battle-tested yet; I saw acme(1) crash a few times too, so use with caution.

Day and night… (Marseille, c. 2021)

Day and night… (Marseille, c. 2021) by M. Bivert – CC-BY-SA-4.0

Log events

Those are the events one can access by reading acme/log; here’s a sample output:

% 9p read acme/log
64 focus /home/mb/acme-events.md
62 focus /home/mb/-earth
64 focus /home/mb/acme-events.md
65 focus /man/acme(4)
62 focus /home/mb/-earth
64 focus /home/mb/acme-events.md
65 focus /man/acme(4)
64 focus /home/mb/acme-events.md
62 focus /home/mb/-earth
66 new
66 focus
62 focus /home/mb/-earth
66 focus
66 del
62 focus /home/mb/-earth

Here’s the associated documentation from acme(4):

         log  reports a log of window operations since the opening of
               the log file.  Each line describes a single operation
               using three fields separated by single spaces: the dec-
               imal window ID, the operation, and the window name.
               Reading from log blocks until there is an operation to
               report, so reading the file can be used to monitor edi-
               tor activity and react to changes.  The reported opera-
               tions are `new' (window creation), `zerox' (window cre-
               ation via zerox), `get', `put', and `del' (window dele-
               tion).  The window name can be the empty string; in
               particular it is empty in `new' log entries correspond-
               ing to windows created by external programs.

Note that this is a read-only file, which means, by comparison with the other source of event we’ll talk about in the next section, that you can’t generate events by writing to this file:

% 9p ls -l acme/log
--r-------- M 0 mb mb 0 Jan 30 19:28 log

Note: You can still “generate” the corresponding events (e.g. opening/closing windows), by other means (e.g. echo -n '' | 9p write acme/new/ctl to create a new window as if clicking with button 2 on New).

This won’t be covered here (actually, I somehow assume that you’re already familiar with that kind of stuff). You may want to read acme(4), this article and the related code on GitHub for more on the topic.

Buffer/window events

Each window is identified by a unique (numeric) id; there are multiple ways to access them, at least:

To each window/buffer (I’ll use both terms interchangeably) is associated a directory, e.g. acme/64. In this directory, there’s, among others, a file event, that can both be read and written to:

% 9p ls -l acme/64/event
--rw------- M 0 mb mb 0 Jan 30 19:33 event

Now if you try to read this file, you will observe essentially (not always, there’s a special case, but I’ll ignore it) one line per-event:

% 9p read acme/64/event
...
KI2853 2854 0 1 ,
KI2854 2855 0 1
KI2855 2856 0 1 f
KI2856 2857 0 1 i
KI2857 2858 0 1 l
KI2858 2859 0 1 e
Mx43 43 2 0
Mx36 47 0 11 echo $winid
...

Once this file is opened, you should still be able to type in the corresponding window, cut/paste text with the mouse, which should all generate events printed to acme/<winid>/event. But you might observe that other actions are “broken”, in particular, executing commands from the tag line, such as B2 on Put.

The behavior is documented; let’s open acme(4) and carefully read it:

          event
               When a window's event file is open, changes to the win-
               dow occur as always but the actions are also reported
               as messages to the reader of the file.  Also, user
               actions with buttons 2 and 3 (other than chorded Cut
               and Paste, which behave normally) have no immediate
               effect on the window; it is expected that the program
               reading the event file will interpret them.  The mes-
               sages have a fixed format: a character indicating the
               origin or cause of the action, a character indicating
               the type of the action, four free-format blank-
               terminated decimal numbers, optional text, and a new-
               line.  The first and second numbers are the character
               addresses of the action, the third is a flag, and the
               final is a count of the characters in the optional
               text, which may itself contain newlines.  The origin
               characters are E for writes to the body or tag file, F
               for actions through the window's other files, K for the
               keyboard, and M for the mouse.  The type characters are
               D for text deleted from the body, d for text deleted
               from the tag, I for text inserted to the body, i for
               text inserted to the tag, L for a button 3 action in
               the body, l for a button 3 action in the tag, X for a
               button 2 action in the body, and x for a button 2
               action in the tag.

               If the relevant text has less than 256 characters, it
               is included in the message; otherwise it is elided, the
               fourth number is 0, and the program must read it from
               the data file if needed.  No text is sent on a D or d
               message.

               For D, d, I, and i the flag is always zero.  For X and
               x, the flag is a bitwise OR (reported decimally) of the
               following: 1 if the text indicated is recognized as an
               acme built-in command; 2 if the text indicated is a
               null string that has a non-null expansion; if so,
               another complete message will follow describing the
               expansion exactly as if it had been indicated
               explicitly (its flag will always be 0); 8 if the com-
               mand has an extra (chorded) argument; if so, two more
               complete messages will follow reporting the argument
               (with all numbers 0 except the character count) and
               where it originated, in the form of a fully-qualified
               button 3 style address.

               For L and l, the flag is the bitwise OR of the follow-
               ing: 1 if acme can interpret the action without loading
               a new file; 2 if a second (post-expansion) message fol-
               lows, analogous to that with X messages; 4 if the text
               is a file or window name (perhaps with address) rather
               than plain literal text.

               For messages with the 1 bit on in the flag, writing the
               message back to the event file, but with the flag,
               count, and text omitted, will cause the action to be
               applied to the file exactly as it would have been if
               the event file had not been open.

It’s a bit disconcerting that writing back messages “as-is” doesn’t work:

% echo Mx48 51 0 3 Put | 9p write acme/64/event
9p: write error: bad event syntax

Not only is this the format we read message from, but it clearly matches the man page’s description:

               The messages have a fixed format: a character indicating the
               origin or cause of the action, a character indicating
               the type of the action, four free-format blank-
               terminated decimal numbers, optional text, and a new-
               line.

But if you look closely:

               For messages with the 1 bit on in the flag, writing the
               message back to the event file, but with the flag,
               count, and text omitted, will cause the action to be
               applied to the file exactly as it would have been if
               the event file had not been open.

And indeed, if you skip the flag/count/extra text, you can generate some events:

% echo Mx38 38 | 9p write acme/64/event
%

You’d still have issues for others:

% echo KD10828 10829 | 9p write acme/64/event
9p: write error: bad event syntax

But alright, it’s expected from the man page, as the flag for such messages didn’t had its first bit set. Nevertheless, some events who still lack this characteristic can still be forwarded:

# This one was a right click (B3) on a man page string, acme(4);
# the event is forwarded, and ends up opening the corresponding
# man page, as expected.
#
# Here are the two events from which it's been drawn:
#	Ml83 83 2 0
#	Ml80 84 0 4 acme
% echo Ml83 83 | 9p write acme/57/event
%

Let’s head to the sources for confirmation.

% cd $PLAN9
% grep -Rn 'bad event syntax'
src/cmd/acme/xfid.c:25:char	Ebadevent[]	= "bad event syntax";
grep: bin/acme: binary file matches
% grep -n Ebadevent *.[ch]
xfid.c:25:char	Ebadevent[]	= "bad event syntax";
xfid.c:925:	err = Ebadevent;

Here’s the relevant code from xfid.c:

void
xfideventwrite(Xfid *x, Window *w)
{
	...

	for(n=0; n<x->fcall.count; n+=m){
		p = x->fcall.data+n;
		w->owner = *p++;	/* disgusting */
		c = *p++;
		while(*p == ' ')
			p++;
		q0 = strtoul(p, &q, 10);
		if(q == p)
			goto Rescue;
		p = q;
		while(*p == ' ')
			p++;
		q1 = strtoul(p, &q, 10);
		if(q == p)
			goto Rescue;
		p = q;
		while(*p == ' ')
			p++;
		if(*p++ != '\n')
			goto Rescue;
		m = p-(x->fcall.data+n);
		if('a'<=c && c<='z')
			t = &w->tag;
		else if('A'<=c && c<='Z')
			t = &w->body;
		else
			goto Rescue;
		if(q0>t->file->b.nc || q1>t->file->b.nc || q0>q1)
			goto Rescue;

		qlock(&row.lk);	/* just like mousethread */
		switch(c){
		case 'x':
		case 'X':
			execute(t, q0, q1, TRUE, nil);
			break;
		case 'l':
		case 'L':
			look3(t, q0, q1, TRUE);
			break;
		default:
			qunlock(&row.lk);
			goto Rescue;
		}
		qunlock(&row.lk);

	}

	Out:
	...
	return;

    Rescue:
	err = Ebadevent;
	goto Out;
}

Example: ^s to write the current buffer to disk

Let’s see how we can “implement” a basic shortcut to save (Put) a file on ^s/ctrl-s (note that there are other, perhaps better, ways to handle this, without relying on acme(1)’s events, e.g. via X11).

Pressing ^s in acme is interpreted as typing a “weird” character (, that is likely not to be displayed by your browser’s font); it’s actually the case for a bunch of ctrl- combinations, which means that you can implement other “shortcuts” using this technique.

If you try to snoop around the /event file at what happens when you press ^S you’ll see that, as expected, it generates a keyboard event, similar to when we type any other character:

% 9p read acme/64/event
...
KI10165 10166 0 1 
...

We’ll need to write two shell scripts for this to work:

Let’s look at them in more details, starting with the latest. A link to a more polished implementation of those scripts will be provided later.

hook-shortcut.sh

We need to proceed as such:

  1. Open the event file for a given buffer and poll it;
  2. Write back relevant events (e.g. Mx);
  3. Look for keyboard insertion events with a text of ;
  4. Remove the symbol  from the file;
  5. Store the file to disk.

We’ll do this in awk(1), wrapped in a sh(1) script. Event redirection can be performed as follow:

# Note that this will naturally die when the corresponding
# window is closed.
9p read acme/$id/event 2>/dev/null | awk '
{
	# debug
#	print $0
	# (flag & 1) || (flag & 2)
	if ($3 % 2 || ($3 / 10) % 2) {
		printf("%s %d\n", $1, $2) | "9p write acme/'$id'/event"
	}
}

Note: As already discussed, we need to redirect more events than just those with a flag with bit one set. Perhaps this will need to be adjusted even further later on.

If you try the previous code for a bit, you’ll discover that the automatic menu management gets thrown away once the event file is opened, which isn’t documented; see xfid.c:

void
xfidopen(Xfid *x)
{
	...

		case QWevent:
			if(w->nopen[q]++ == 0){
				if(!w->isdir && w->col!=nil){
					w->filemenu = FALSE;
					winsettag(w);
				}
			}
			break;

	...
}

And, well, the “automatic menu management” itself is yet another poorly documented feature too: writing menu/nomenu to <winid>/ctl files enables/disables it; it’s responsible e.g. to automatically show/hides Put in the tag when the files is modified/unmodified).

Note: For the record, I’m confident that most of the code related to external programs polling <winid>/event was written for win(1). There are almost no other (published) external programs.

Still in xfid.c:

void
xfidctlwrite(Xfid *x, Window *w)
{
	...

		if(strncmp(p, "nomenu", 6) == 0){	/* turn off automatic menu */
			w->filemenu = FALSE;
			settag = TRUE;
			m = 6;
		}else
		if(strncmp(p, "menu", 4) == 0){	/* enable automatic menu */
			w->filemenu = TRUE;
			settag = TRUE;
			m = 4;

	...
}

So we’ll want to adjust our previous piece of awk(1) to re-enable the automatic management once we’ve opened the event file, e.g.

	# For some reason, automatic menu management is thrown
	# away once /event is opened; do it here so as to avoid
	# any dead-lock.
	if (!menu) {
		printf("menu")  | "9p write acme/'$id'/ctl"
		menu = 1
	}

Regarding the  symbol removal: observe that we’ll be reading an event, and that this event will contain exactly the position of this character (the first two numbers). For instance, in the following it would be 10165 and 10166

% 9p read acme/64/event
...
KI10165 10166 0 1 
...

We can then use the following idiomatic pattern to programmatically alter a buffer:

  1. First, write the address to <winid>/addr in the format #m,#n where m and n are the position we get from the event (e.g. in our previous example, we’d need to write #10165,#10166 to “select” the character to remove).
  2. Then, write an empty string to <winid>/data to remove the portion of the text we’ve just selected.

We can then write put to <winid>/ctl to store the file. This gives the following final script:

#!/bin/sh

if [ -z "$1" ]; then
	echo `basename $0` '<winid>' 1>&2
	exit 1
fi

id=$1

# Note that this will naturally die when the corresponding
# window is closed.
9p read acme/$id/event 2>/dev/null | awk '
{
	# For some reason, automatic menu management is thrown
	# away once /event is opened; do it here so as to avoid
	# any dead-lock.
	if (!menu) {
		printf("menu")  | "9p write acme/'$id'/ctl"
		menu = 1
	}

	# Debug
#	print $0
	# (flag & 1) || (flag & 2)
	if ($3 % 2 || ($3 / 10) % 2) {
		printf("%s %d\n", $1, $2) | "9p write acme/'$id'/event"
	}
}

# $4 == 1 because otherwise this will be sometimes
# triggered e.g. on copy/pasting stuff containing a 
/K.*/ && $4 == 1 {
	q0 = substr($1, 3)
	q1 = $2
	# flush the previous message before starting to work with /data,
	# for otherwise, we may start writing stuff before
	# the address where we want to write has been registered.
	printf("#%d,#%d", q0, q1) | "9p write acme/'$id'/addr"
	close("9p write acme/'$id'/addr")
	printf("")     | "9p write acme/'$id'/data"
	close("9p write acme/'$id'/data")
	printf("put")  | "9p write acme/'$id'/ctl"
}'

Note: There’s still an inconvenient issue with this, again poorly documented: once there’s a reader for <winid>/event, an ad-hock dumping mechanism is required: it’s expected that the reader will provide a dumping command via <winid>/ctl. Otherwise, the windows simply won’t be dumped, see rows.c:

void
rowdump(Row *row, char *file)
{
	...
			/* windows owned by others get special treatment */
			if(w->nopen[QWevent] > 0)
				if(w->dumpstr == nil)
					continue;

	...
			}else if(w->dumpstr){
				dumped = FALSE;
				Bprint(b, "e%11d %11d %11d %11d %11.7f %s\n", i, t->file->dumpid,
					0, 0,
					100.0*(w->r.min.y-c->r.min.y)/Dy(c->r),
					fontname);

	...

			if(w->dumpstr){
				if(w->dumpdir)
					Bprint(b, "%s\n%s\n", w->dumpdir, w->dumpstr);
				else
					Bprint(b, "\n%s\n", w->dumpstr);
			}
	...
}
For now, the only workaround I have is to disconnect the hook before dumping, and reconnecting it afterward.

OK, we still have to register this hook systematically, for all buffers.

register-hook.sh

The main difficulty here is to provide a cleanup mechanism, so as to avoid having a potentially broken hooks hijacking events on all acme(1) windows.

This can be implemented with a basic trap(1) in sh(1).

Besides the fact that we cleverly avoids hooking win(1) buffers, the code is rather straightforward, so without further ado:

#!/bin/sh

if [ -z "$1" ]; then
	echo `basename $0` '<path/to/hook.sh>' 1>&2
	exit 1
fi

hook="$1"

pids=`mktemp`

cleanup() {
	while read pid; do
		pkill $pid
	done < $pids
	rm $pids
}

trap cleanup SIGINT

9p read acme/index | awk '$6 !~ /-[a-z]+$/ { print $1 }' | while read id; do
	echo hooking $id
	sh $hook $id &
	echo $! >> $pids
done

9p read acme/log | awk '/^[0-9]+ new / {
	system("sh '$hook' " $1 " & echo $! >> '$pids'")
}'

Note: A more polished implementation can be found on GitHub, alongside other acme(1)-related tools. The trap(1) based mechanism has been removed; termination is handled by a specific script which kills all processes in the group of the process who initiated the hook registration.

There’s also a workaround for the dumping issue.

Still, use with caution, unless you have time to fix some long-lasting acme(1) bugs.


Comments

By email, at mathieu.bivert chez:

email