shithub: purgatorio

Download patch

ref: 71292834bbafcf6bc409cfd7691f9e6650df025b
parent: 948870cffe2572efb68ef0b3873b36238fb53263
author: henesy <devnull@localhost>
date: Wed Oct 9 18:41:23 EDT 2019

add wm/vixen(1) and vixenplumb(1) from mjl as per https://github.com/mjl-/vixen

--- a/appl/cmd/mkfile
+++ b/appl/cmd/mkfile
@@ -171,6 +171,7 @@
 	vacfs.dis\
 	vacget.dis\
 	vacput.dis\
+	vixenplumb.dis\
 	wav2iaf.dis\
 	wc.dis\
 	webgrab.dis\
--- /dev/null
+++ b/appl/cmd/vixenplumb.b
@@ -1,0 +1,337 @@
+# keep track of vixen instances, register plumb dst "edit" and forward plumb messages
+
+implement Vixenplumb;
+
+include "sys.m";
+	sys: Sys;
+	sprint: import sys;
+include "draw.m";
+	draw: Draw;
+include "arg.m";
+include "string.m";
+	str: String;
+include "plumbmsg.m";
+	plumbmsg: Plumbmsg;
+	Msg: import plumbmsg;
+include "sh.m";
+	sh: Sh;
+include "names.m";
+	names: Names;
+
+Vixenplumb: module {
+	init:	fn(ctxt: ref Draw->Context, argv: list of string);
+};
+
+dflag := 0;
+
+Client: adt {
+	fid:	int;
+	path:	string;  # path being edited
+	rc:	Sys->Rread;  # for read
+	pending:	string;  # pending string for next rc
+
+	text:	fn(c: self ref Client): string;
+};
+
+Client.text(c: self ref Client): string
+{
+	return sprint("Client(fid %d, path %q, rc nil %d, pending %q)", c.fid, c.path, c.rc == nil, c.pending);
+}
+
+
+clients: list of ref Client;
+drawcontext: ref Draw->Context;
+
+Iomax: con 8*1024;  # max file2chan message size, also in vixenplumbreader() in vixen
+
+init(ctxt: ref Draw->Context, args: list of string)
+{
+	sys = load Sys Sys->PATH;
+	if(ctxt == nil)
+		fail("no window context");
+	drawcontext = ctxt;
+	draw = load Draw Draw->PATH;
+	arg := load Arg Arg->PATH;
+	str = load String String->PATH;
+	plumbmsg = load Plumbmsg Plumbmsg->PATH;
+	sh = load Sh Sh->PATH;
+	sh->initialise();
+	names = load Names Names->PATH;
+
+	sys->pctl(Sys->NEWPGRP, nil);
+
+	arg->init(args);
+	arg->setusage(arg->progname()+" [-d] [path]");
+	while((c := arg->opt()) != 0)
+		case c {
+		'd' =>	dflag++;
+		* =>	arg->usage();
+		}
+	args = arg->argv();
+	pathpat: string;
+	case len args {
+	0 =>	{}
+	1 =>	pathpat = hd args;
+	* =>	arg->usage();
+	}
+
+	if(plumbmsg->init(0, "edit", 0) < 0)
+		fail(sprint("plumb init: %r"));
+
+	fio := sys->file2chan("/chan", "vixenplumb");
+	if(fio == nil)
+		fail(sprint("file2chan: %r"));
+
+	msgc := chan of ref Msg;
+	spawn plumbreceiver(msgc);
+
+	if(pathpat != nil) {
+		wd := workdir();
+		spawn edit(wd, pathget(wd, pathpat));
+	}
+
+	spawn serve(fio, msgc);
+}
+
+serve(fio: ref Sys->FileIO, msgc: chan of ref Msg)
+{
+	for(;;) alt {
+	m := <-msgc =>
+		if(m == nil)
+			fail("nil plumbmsg received");
+		handle(m);
+		
+	(nil, count, fid, rc) := <-fio.read =>
+		if(rc == nil) {
+			del(fid);
+		} else if(count < Iomax) {
+			rc <-= (nil, sprint("read > Iomax (%d)", Iomax));
+		} else {
+			putrc(fid, rc);
+		}
+		
+	(nil, data, fid, rc) := <-fio.write =>
+		if(rc == nil)
+			del(fid);
+		else {
+			err := set(fid, string data);
+			rc <-= (len data, err);
+		}
+	}
+}
+
+# given a working dir and a pathpat consisting of either absolute or relative path
+# and an optional colon and address, return the final path and the pattern
+pathget(wd, pathpat: string): (string, string)
+{
+	(path, pat) := str->splitstrr(pathpat, ":");
+	if(path == nil) {
+		path = pat;
+		pat = nil;
+	} else
+		path = path[:len path-1];
+	if(!str->prefix("/", path) && !str->prefix("#", path))
+		path = wd+"/"+path;
+	if(pat == nil)
+		pat = ".";
+	path = names->cleanname(path);
+	return (path, pat);
+}
+
+edit(wd: string, pathpat: (string, string))
+{
+	sys->pctl(Sys->FORKNS, nil);  # separate working directory
+	(path, pat) := pathpat;
+	sys->chdir(wd);
+	say(sprint("edit, wd %q, path %q, pat %q", wd, path, pat));
+	if(pat == nil)
+		args := list of {"wm/vixen", path};
+	else
+		args = list of {"wm/vixen", "-c", ":"+pat, path};
+	sh->run(drawcontext, args);
+}
+
+write(fd: ref Sys->FD, s: string, pidc: chan of int, rc: chan of (int, string))
+{
+	pidc <-= pid();
+	if(sys->write(fd, buf := array of byte s, len buf) != len buf)
+		rc <-= (0, sprint("write: %r"));
+}
+
+runeditnew(fd0: ref Sys->FD, dir: string, pidc: chan of int, rc: chan of (int, string))
+{
+	pidc <-= pid();
+	sys->pctl(Sys->NEWFD|Sys->FORKNS|Sys->FORKENV, list of {fd0.fd, 1, 2});
+	sys->dup(fd0.fd, 0);
+	err := sh->run(drawcontext, list of {"wm/vixen", "-i", dir});
+	rc <-= (1, err);
+}
+
+editnew(dir, data: string)
+{
+	sys->pctl(Sys->FORKNS, nil);  # separate working directory
+	if(sys->chdir(dir) < 0)
+		return warn(sprint("chdir %q: %r", dir));
+	
+	sys->pctl(Sys->NEWFD, list of {1, 2});
+	if(sys->pipe(fd0 := array[2] of ref Sys->FD) < 0)
+		return warn(sprint("pipe: %r"));
+
+	pidc := chan of int;
+	rc := chan of (int, string); # done, err
+	spawn write(fd0[0], data, pidc, rc);
+	writepid := <-pidc;
+	spawn runeditnew(fd0[1], dir, pidc, rc);
+	runpid := <-pidc;
+	fd0 = nil;
+
+	(done, err) := <-rc;
+	if(err != nil)
+		warn(err);
+	kill(writepid);
+	if(!done)
+		killgrp(runpid);
+}
+
+handle(m: ref Msg)
+{
+	# see if it exists, if so, send it the pattern
+	# otherwise, start a new wm/vixen
+	say(sprint("msg: %s", string m.pack()));
+	case m.kind {
+	"text" =>
+		(path, pat) := pathget(m.dir, string m.data);
+		c := findpath(path);
+		if(c == nil) {
+			spawn edit(m.dir, (path, pat));
+		} else {
+			c.pending = pat;
+			respond(c);
+		}
+	"newtext" =>
+		c := findpath(m.dir);
+		if(c == nil) {
+			spawn editnew(names->cleanname(m.dir), string m.data);
+		} else {
+			c.pending = string m.data;
+			respond(c);
+		}
+	* =>
+		return warn(sprint("kind %q ignored", m.kind));
+	}
+}
+
+respond(c: ref Client)
+{
+	say(sprint("respond, c %s", c.text()));
+	if(c.rc != nil && c.pending != nil) {
+		c.rc <-= (array of byte c.pending, nil);
+		c.rc = nil;
+		c.pending = nil;
+	}
+}
+
+putrc(fid: int, rc: Sys->Rread)
+{
+	say(sprint("putrc, fid %d", fid));
+	for(l := clients; l != nil; l = tl l) {
+		c := hd l;
+		if(c.fid == fid) {
+			if(c.rc != nil)
+				rc <-= (nil, "one read at a time please");
+			else {
+				c.rc = rc;
+				respond(c);
+			}
+			return;
+		}
+	}
+	c := ref Client (fid, nil, rc, "");
+	clients = c::clients;
+	say(sprint("putrc, new client %s", c.text()));
+}
+
+del(fid: int)
+{
+	nc: list of ref Client;
+	for(l := clients; l != nil; l = tl l)
+		if((hd l).fid != fid)
+			nc = hd l::nc;
+	clients = nc;
+}
+
+findpath(path: string): ref Client
+{
+	for(l := clients; l != nil; l = tl l)
+		if((hd l).path == path)
+			return hd l;
+	return nil;
+}
+
+set(fid: int, path: string): string
+{
+	o := findpath(path);
+	for(l := clients; l != nil; l = tl l) {
+		c := hd l;
+		if(c.fid == fid) {
+			if(o != nil && o != c)
+				return "file already open";
+			say(sprint("new path for client, fid %d, old path %q, new path %q", fid, c.path, path));
+			c.path = path;
+			return nil;
+		}
+	}
+	c := ref Client (fid, path, nil, "");
+	clients = c::clients;
+	say(sprint("set, new client %s", c.text()));
+	return nil;
+}
+
+plumbreceiver(msgc: chan of ref Msg)
+{
+	for(;;)
+		msgc <-= Msg.recv();
+}
+
+workdir(): string
+{
+	return sys->fd2path(sys->open(".", Sys->OREAD));
+}
+
+pid(): int
+{
+	return sys->pctl(0, nil);
+}
+
+progctl(pid: int, s: string)
+{
+	sys->fprint(sys->open(sprint("/prog/%d/ctl", pid), sys->OWRITE), "%s", s);
+}
+
+kill(pid: int)
+{
+	progctl(pid, "kill");
+}
+
+killgrp(pid: int)
+{
+	progctl(pid, "killgrp");
+}
+
+say(s: string)
+{
+	if(dflag)
+		warn(s);
+}
+
+warn(s: string)
+{
+	sys->fprint(sys->fildes(2), "%s\n", s);
+}
+
+fail(s: string)
+{
+	warn(s);
+	killgrp(pid());
+	raise "fail:"+s;
+}
--- /dev/null
+++ b/appl/wm/vixen/mkfile
@@ -1,0 +1,32 @@
+<../../../mkconfig
+
+TARG=\
+	vixen.dis\
+
+MODULES=\
+	vixen/buffers.b\
+	vixen/change.b\
+	vixen/cmd.b\
+	vixen/ex.b\
+	vixen/filter.b\
+	vixen/interp.b\
+	vixen/misc.b\
+	vixen/subs.b\
+
+SYSMODULES=\
+	arg.m\
+	bufio.m\
+	draw.m\
+	keyboard.m\
+	names.m\
+	plumbmsg.m\
+	regex.m\
+	sh.m\
+	string.m\
+	sys.m\
+	tk.m\
+	tkclient.m\
+
+DISBIN=$ROOT/dis/wm
+
+<$ROOT/mkfiles/mkdis
--- /dev/null
+++ b/appl/wm/vixen/vixen.b
@@ -1,0 +1,1620 @@
+implement Vixen;
+
+include "sys.m";
+	sys: Sys;
+	sprint: import sys;
+include "draw.m";
+	draw: Draw;
+include "arg.m";
+include "bufio.m";
+	bufio: Bufio;
+	Iobuf: import bufio;
+include "string.m";
+	str: String;
+include "tk.m";
+	tk: Tk;
+include "tkclient.m";
+	tkclient: Tkclient;
+include "keyboard.m";
+	kb: Keyboard;
+include "regex.m";
+	regex: Regex;
+include "plumbmsg.m";
+	plumbmsg: Plumbmsg;
+	Msg: import plumbmsg;
+include "names.m";
+	names: Names;
+include "sh.m";
+	sh: Sh;
+
+include "vixen/buffers.b";
+include "vixen/change.b";
+include "vixen/cmd.b";
+include "vixen/ex.b";
+include "vixen/filter.b";
+include "vixen/interp.b";
+include "vixen/misc.b";
+include "vixen/subs.b";
+
+Vixen: module {
+	init:	fn(ctxt: ref Draw->Context, argv: list of string);
+};
+
+
+iflag: int;
+
+# 't' for tk events
+# 'k' for tk commands
+# 'e' for edit
+# 'x' for ex
+# 'i' for interp (insert/replace, command, visual, move)
+# 'd' for misc
+# 'c' for cursor
+# 'u' for change (undo)
+# 'm' for modifications (textdel, textinsert)
+debug := array[128] of {* => int 0};
+startupmacro: string;  # macro to interpret at startup after opening the file
+
+Insert, Replace, Command0, Visual, Visualline: con iota;  # modes
+modes := array[] of {"insert", "replace", "command", "visual", "visual line"};
+
+
+# parameter "rec" to textdel & textins.
+Cnone, Cmod, Cmodrepl, Cchange, Cchangerepl,	# how to record as change, for undo
+Csetcursorlo, Csetcursorhi,			# where (if) to set cursor
+Csetreg: con 1<<iota;				# whether to set "last change" register
+Cchangemask: con Csetcursorlo-1;
+Csetcursormask: con Csetcursorlo|Csetcursorhi;
+
+mode: int;
+visualstart: ref Cursor;  # start of visual select, non-nil when mode == Visual or Visualline
+visualend: ref Cursor;  # end of visual select
+cmdcur: ref Cmd;  # current command
+cmdprev: ref Cmd;  # previous (completed) command, for '.'
+recordreg := -1;  # register currently recording to, < 0 when not recording
+record: string;  # chars typed while recording, set to register when done
+colsnap := -1;  # column to snap vertical movements to.  only valid >= 0
+
+filename: string;  # may be nil initially
+filefd: ref Sys->FD;  # may be nil initially
+filestat: Sys->Dir;  # stat after previous read or write, to check before writing.  only valid if filefd not nil.
+
+modified: int;  # whether text has unsaved changes
+text: ref Buf;  # contents
+textgen: big;   # generation of text, increased on each changed, restored on undo/redo.
+textgenlast: big;  # last used gen
+cursor: ref Cursor;  # current position in text
+
+statustext: string;  # info/warning/error text to display in status bar
+
+searchregex: Regex->Re;  # current search, set by [/?*#]
+searchreverse: int;  # whether search is in reverse, for [nN]
+searchcache: array of (int, int);  # cache of search results, only valid if textgen is searchcachegen.
+searchcachegen := big -1;
+
+lastfind: int;  # last find command, one of tTfF, for ';' and ','
+lastfindchar: int;  # parameter to lastfind
+
+lastmacro: int; # last macro execution, for '@@'
+
+edithist: list of string;  # history of edit commands, hd is last typed
+edithistcur := -1;  # currently selected history item, -1 when none
+edithisttext: string;  # text currently prefix-searching for (nil at start, after edit field changed, and esc)
+
+completecache: array of string;  # matches with completetext.  invalid when nil
+completetext: string;  # text for which completecache is the completion
+completeindex: int;  # current index in completecache results
+
+change: ref Change;  # current change (with 1 modification) that is being created (while in insert mode)
+changes: array of ref Change;  # change history, for undo.  first elem is oldest change.
+changeindex: int;  # points to next new/last undone change.  may be one past end of 'changes'.
+
+# marks & registers are index by ascii char, not all are valid though
+marks := array[128] of ref Cursor;
+registers := array[128] of string;
+register := '"';  # register to write next text deletion to
+
+b3start: ref Pos; # start of button3 press
+b3prev: ref Pos;  # previous position while button3 down
+
+statusvisible := 1;  # whether tk frame with status label is visible (and edit entry is not)
+
+highlightstart, highlightend: ref Cursor;  # range to highlight for search match, can be nil
+plumbvisible: int;  # whether address or last inserted text is visible (cleared on interp)
+
+vpfd: ref Sys->FD;  # fd to /chan/vixenplumb, for handling plumbing
+
+plumbed: int;
+top: ref Tk->Toplevel;
+wmctl: chan of string;
+drawcontext: ref Draw->Context;
+
+# text selection color scheme.  Green for plumbing.
+Normal, Green: con iota;
+
+tkcmds0 := array[] of {
+"frame .t",
+"text .t.text -background black -foreground white -yscrollcommand {.t.vscroll set}",
+"scrollbar .t.vscroll -command {.t.text yview}",
+"frame .s",
+"label .s.status -text status",
+"frame .e",
+"entry .e.edit",
+
+"bind .e.edit <Key-\n> {send edit return}",
+"bind .e.edit {<Key-\t>} {send edit tab}",
+"bind .e.edit <KeyPress> +{send edit press %K}",
+"bind .t.text <KeyPress> {send key %K}",
+"bind .t.text <ButtonPress-1> {send text b1down @%x,%y}",
+"bind .t.text <ButtonRelease-1> {send text b1up @%x,%y}",
+"bind .t.text <ButtonPress-3> {send text b3down @%x,%y}",
+"bind .t.text <ButtonRelease-3> {send text b3up @%x,%y}",
+"bind .t.text <Configure> {send text resized}",
+
+".t.text tag configure eof -foreground blue -background white",
+".t.text tag configure search -background yellow -foreground black",
+".t.text tag configure plumb -background blue -foreground white",
+".t.text tag raise sel",
+
+"pack .t.vscroll -fill y -side left",
+"pack .t.text -fill both -expand 1 -side right",
+"pack .t -fill both -expand 1",
+
+"pack .s.status -fill x -side left",
+"pack .s -fill x -side bottom -after .t",
+
+"pack .e.edit -fill x -expand 1 -side left",
+#"pack .e -fill x -side bottom -after .t",
+
+"pack propagate . 0",
+". configure -width 700 -height 500",
+"focus .t.text",
+};
+
+tkaddeof()
+{
+	tkcmd(".t.text insert end \u0003");
+	tkcmd(".t.text tag add eof {end -1c} end");
+}
+
+tkbinds()
+{
+	tkcmd(sprint("bind .e.edit <Key-%c> {send edit esc}", kb->Esc));
+
+	tkcmd(sprint("bind .e.edit <Key-%c> {send edit up}", kb->Up));
+	tkcmd(sprint("bind .e.edit <Key-%c> {send edit down}", kb->Down));
+
+	binds := array[] of {'a', '<', 'b', 'd', 'e','>', 'f', 'h', 'k', 'n', 'o', 'p', 'u', 'v', 'w'};
+	for(i := 0; i < len binds; i++)
+		tkcmd(sprint("bind .t.text <Control-\\%c> {}", binds[i]));
+	binds = array[] of {
+		kb->Home, kb->Left, kb->End, kb->Right,
+		kb->Del, kb->Down, kb->Up, kb->Pgdown, kb->Pgup
+	};
+	for(i = 0; i < len binds; i++)
+		tkcmd(sprint("bind .t.text <Key-\\%c> {send key %%K}", binds[i]));
+
+	binds = array[] of {'h', 'w', 'u', 'f', 'b', 'd', 'y', 'e', 'l', 'g', 'r', 'n', 'p'};
+	for(i = 0; i < len binds; i++)
+		tkcmd(sprint("bind .t.text <Control-\\%c> {send key %x}", binds[i], kb->APP|binds[i]));
+}
+
+
+init(ctxt: ref Draw->Context, args: list of string)
+{
+	sys = load Sys Sys->PATH;
+	if(ctxt == nil)
+		fail("no window context");
+	drawcontext = ctxt;
+	draw = load Draw Draw->PATH;
+	arg := load Arg Arg->PATH;
+	bufio = load Bufio Bufio->PATH;
+	str = load String String->PATH;
+	tk = load Tk Tk->PATH;
+	tkclient = load Tkclient Tkclient->PATH;
+	regex = load Regex Regex->PATH;
+	plumbmsg = load Plumbmsg Plumbmsg->PATH;
+	names = load Names Names->PATH;
+	sh = load Sh Sh->PATH;
+	sh->initialise();
+
+	sys->pctl(Sys->NEWPGRP|Sys->FORKNS, nil);
+
+	arg->init(args);
+	arg->setusage(arg->progname()+" [-d debug] [-c macro] [-i] path");
+	while((c := arg->opt()) != 0)
+		case c {
+		'c' =>	startupmacro = arg->arg();
+		'd' =>
+			s := arg->arg();
+			for(i := 0; i < len s; i++)
+				case x := s[i] {
+				'+' =>		debug = array[128] of {* => 1};
+				'a' to 'z' =>	debug[x]++;
+				* =>		fail(sprint("debug char %c a-z or +", x));
+				}
+		'i' =>	iflag++;
+		* =>	arg->usage();
+		}
+	args = arg->argv();
+	case len args {
+	0 =>	{}
+	1 =>	filename = hd args;
+	* =>	arg->usage();
+	}
+
+	plumbed = plumbmsg->init(1, nil, 0) >= 0;
+
+	vpc := chan of (string, string);
+	vpfd = sys->open("/chan/vixenplumb", Sys->ORDWR);
+	if(vpfd != nil)
+		spawn vixenplumbreader(vpfd, vpc);
+	else
+		warn(sprint("no plumbing, open /chan/vixenplumb: %r"));
+
+	text = text.new();
+	cursor = text.pos(Pos(1, 0));
+	xmarkput('`', cursor);
+	cmdcur = Cmd.new();
+	xregput('!', "mk");
+
+	openerr: string;
+	if(filename != nil) {
+		filefd = sys->open(filename, Sys->ORDWR);
+		if(filefd == nil)
+			openerr = sprint("%r");
+		else {
+			ok: int;
+			(ok, filestat) = sys->fstat(filefd);
+			if(ok < 0) {
+				openerr = sprint("stat: %r");
+				filefd = nil;
+			}
+		}
+		# if filefd is nil, we warn that this is a new file when tk is initialized
+	}
+
+	tkclient->init();
+	(top, wmctl) = tkclient->toplevel(ctxt, "", "vixen", Tkclient->Appl);
+
+	textc := chan of string;
+	keyc := chan of string;
+	editc := chan of string;
+	tk->namechan(top, textc, "text");
+	tk->namechan(top, keyc, "key");
+	tk->namechan(top, editc, "edit");
+	tkcmds(tkcmds0);
+	tkselcolor(Normal);
+	tkbinds();
+
+	if(filename != nil)
+		filenameset(filename);
+
+	if(filename != nil && filefd == nil) {
+		(ok, dir) := sys->stat(filename);
+		if(ok < 0)
+			statuswarn(sprint("new file %q", filename));
+		else if(dir.mode & Sys->DMDIR)
+			statuswarn(sprint("%q is directory", filename));
+		else
+			statuswarn(sprint("open: %s", openerr));
+	}
+	if(iflag)
+		oerr := textfill(sys->fildes(0));
+	else if(filefd != nil)
+		oerr = textfill(filefd);
+	if(oerr != nil)
+		statuswarn("reading: "+oerr);
+	tkaddeof();
+	up();
+
+	modeset(Command0);
+
+	if(startupmacro != nil) {
+		cmdcur = Cmd.mk(startupmacro);
+		interpx();
+	}
+
+	tkclient->onscreen(top, nil);
+	tkclient->startinput(top, "kbd"::"ptr"::nil);
+
+	for(;;) alt {
+	s := <-top.ctxt.kbd =>
+		tk->keyboard(top, s);
+
+	s := <-top.ctxt.ptr =>
+		tk->pointer(top, *s);
+
+	s := <-top.ctxt.ctl or
+	s = <-top.wreq =>
+		tkclient->wmctl(top, s);
+
+	menu := <-wmctl =>
+		case menu {
+		"exit" =>	quit();
+		* =>		tkclient->wmctl(top, menu);
+		}
+
+	txt := <-textc =>
+		# special keys/mouse from text widget
+		say('t', sprint("text: %q", txt));
+		(nil, t) := sys->tokenize(txt, " ");
+		case hd t {
+		"b1down" =>
+			v := tkcmd(".t.text index "+hd tl t);
+			if(str->prefix("!", v))
+				break;
+			pos := Pos.parse(v);
+			modeset(Command0);
+			cursorset(text.pos(pos));
+			tkselectionset(cursor.pos, cursor.pos);
+			tkselcolor(Normal);
+		"b1up" =>
+			v := tkcmd(".t.text index "+hd tl t);
+			if(str->prefix("!", v))
+				break;
+			nc := text.pos(Pos.parse(v));
+			ranges := tkcmd(".t.text tag ranges sel");
+			if(ranges != nil) {
+				(nil, l) := sys->tokenize(ranges, " ");
+				if(len l != 2) {
+					tkcmd(".t.text tag remove sel "+ranges);
+					warn(sprint("bad selection range %q?", ranges));
+					continue;
+				}
+				modeset(Visual);
+				visualstart = text.pos(Pos.parse(hd l));
+				cursor = text.pos(Pos.parse(hd tl l));
+				if(Cursor.cmp(nc, cursor) < 0)
+					(cursor, visualstart) = (visualstart, cursor);
+				visualend = cursor.clone();
+				cursorset(cursor);
+			}
+		"b3down" =>
+			v := tkcmd(".t.text index "+hd tl t);
+			if(str->prefix("!", v))
+				break;
+			pos := ref Pos.parse(v);
+			if(b3start == nil) {
+				tkselectionset(cursor.pos, cursor.pos);
+				tkselcolor(Green);
+				b3start = b3prev = pos;
+			} else if(!Pos.eq(*pos, *b3prev)) {
+				(a, b) := Pos.order(*pos, *b3start);
+				tkselectionset(a, b);
+				b3prev = pos;
+			}
+			say('t', sprint("b3down at char %s", (*pos).text()));
+		"b3up" =>
+			v := tkcmd(".t.text index "+hd tl t);
+			if(str->prefix("!", v))
+				break;
+			pos := Pos.parse(v);
+			say('t', sprint("b3up at char %s", pos.text()));
+			if(Pos.eq(*b3start, pos)) {
+				cx := text.pos(pos);
+				(cs, ce) := cx.pathpattern(0);
+				if(cs == nil)
+					statuswarn("not a path");
+				else
+					plumb(text.get(cs, ce), nil, plumbdir());
+			} else {
+				cs := text.pos(*b3start);
+				ce := text.pos(pos);
+				(cs, ce) = Cursor.order(cs, ce);
+				plumb(text.get(cs, ce), nil, plumbdir());
+			}
+			b3start = b3prev = nil;
+			case mode {
+			Visual or
+			Visualline =>
+				cursorset(cursor);
+				visualset();
+			* =>
+				tkcmd(sprint(".t.text tag remove sel 1.0 end"));
+			}
+			tkselcolor(Normal);
+		"resized" =>
+			tkcmd(".t.text see insert");
+		* =>
+			warn(sprint("text unhandled, %q", txt));
+		}
+		up();
+
+	s := <-keyc =>
+		# keys from text widget
+		say('t', sprint("cmd: %q", s));
+		(x, rem) := str->toint(s, 16);
+		if(rem != nil) {
+			warn(sprint("bogus char code %q, ignoring", s));
+			continue;
+		}
+		key(x);
+		interpx();
+
+	e := <-editc =>
+		# special keys from edit widget
+		say('t', sprint("edit: %q", e));
+		editinput(e);
+		up();
+
+	(s, err) := <-vpc =>
+		say('d', sprint("vpc, s %q, err %q", s, err));
+		if(err != nil) {
+			statuswarn("vixenplumb failed: "+err);
+			continue;
+		}
+		if(iflag) {
+			ps := text.end();
+			textins(Cchange, ps, s);
+			tkplumbshow(ps.pos, text.end().pos);
+			tkcmd(sprint(".t.text see %s", ps.pos.text()));
+		} else {
+			nc: ref Cursor;
+			(nc, err) = address(Cmd.mk(s), cursor);
+			if(err != nil) {
+				statuswarn(sprint("bad address from vixenplumb: %q: %s", s, err));
+			} else {
+				cursorset(nc);
+				tkplumbshow(nc.mvcol(0).pos, nc.mvlineend(1).pos);
+				statuswarn(sprint("new address from vixenplumb: %s", s));
+			}
+		}
+		tkclient->wmctl(top, "raise");
+		tkclient->wmctl(top, "kbdfocus 1");
+		tkclient->onscreen(top, "onscreen");
+		up();
+	}
+}
+
+filenameset(s: string)
+{
+	filename = names->cleanname(names->rooted(workdir(), s));
+	if(isdir(filename) && (filename != nil && filename[len filename-1] != '/'))
+		filename[len filename] = '/';
+	tkclient->settitle(top, "vixen "+filename);
+	if(vpfd != nil) {
+		f := filename;
+		if(f[len f-1] == '/')
+			f = f[:len f-1];
+		if(sys->fprint(vpfd, "%s", f) < 0)
+			statuswarn(sprint("telling vixenplumb about filename: %r"));
+	}
+}
+
+vixenplumbreader(fd: ref Sys->FD, vpc: chan of (string, string))
+{
+	buf := array[8*1024] of byte;  # Iomax in vixenplumb
+	for(;;) {
+		n := sys->read(fd, buf, len buf);
+		if(n <= 0) {
+			err := "eof";
+			if(n < 0)
+				err = sprint("%r");
+			vpc <-= (nil, err);
+			break;
+		}
+		s := string buf[:n];
+		vpc <-= (s, nil);
+	}
+}
+
+editinput(e: string)
+{
+	case e {
+	"return" =>
+		s := tkcmd(".e.edit get");
+		if(s == nil)
+			raise "empty string from entry";
+		say('e', sprint("edit command: %q", s));
+		s = s[1:];  # first char has already been read
+		tkcmd(".e.edit delete 0 end");
+		edithistput(s);
+		for(i := 0; i < len s; i++)
+			key(s[i]);
+		key('\n');
+		interpx();
+		tkcmd("focus .t.text");
+	"tab" =>
+		Completebreak: con " \t!\"\'#$%&'()*+,:;<=>?@\\]^_`{|}~";
+		s := tkcmd(".e.edit get");
+		i := int tkcmd(".e.edit index insert");
+		while(i-1 >= 0 && !str->in(s[i-1], Completebreak))
+			--i;
+		s = s[i:];
+		r: string;
+		++completeindex;
+		if(completecache != nil && completeindex >= len completecache) {
+			r = completetext;
+			completecache = nil;
+		} else {
+			if(completecache == nil) {
+				err: string;
+				(completecache, err) = complete(s);
+				if(err != nil)
+					return statuswarn("complete: "+err);
+				if(len completecache == 0)
+					return statuswarn("no match");
+				completeindex = 0;
+				completetext = s;
+			}
+			r = completecache[completeindex];
+			if(len completecache == 1)
+				completecache = nil;
+		}
+		tkcmd(sprint(".e.edit delete %d end", i));
+		tkcmd(".e.edit insert end '"+r);
+	"up" or
+	"down" =>
+		# if up/down was down without esc or text editing afterwards,
+		# we use the originally typed text to search, not what's currently in the edit field.
+		a := l2a(rev(edithist));
+		say('e', sprint("edithist, edithistcur=%d:", edithistcur));
+		for(i := 0; i < len a; i++)
+			say('e', sprint("%3d %s", i, a[i]));
+		editnavigate(e == "up");
+	"esc" =>
+		editesc();
+	* =>
+		if(str->prefix("press ", e)) {
+			(x, rem) := str->toint(e[len "press ":], 16);
+			if(rem != nil)
+				return warn(sprint("bad edit press %q", e));
+
+			# key presses are interpreted by tk widget first, then sent here.
+			# on e.g. ^h of last char, we see an empty string in the entry, so we abort.
+			if(x != '\n' && tkcmd(".e.edit get") == nil)
+				editesc();
+
+			# we get up/down and other specials too, they don't change the text
+			if((x & kb->Spec) != kb->Spec && x != '\t') {
+				edithistcur = -1;
+				edithisttext = nil;
+				completecache = nil;
+			}
+		} else
+			warn(sprint("unhandled edit command %q", e));
+	}
+}
+
+# key from text widget or from macro execute
+key(x: int)
+{
+	if(recordreg >= 0)
+		record[len record] = x;
+	cmdcur.put(x);
+}
+
+
+editesc()
+{
+	tkcmd(".e.edit delete 0 end");
+	edithistcur = -1;
+	edithisttext = nil;
+	tkcmd("focus .t.text");
+	key(kb->Esc);
+	interpx();
+}
+
+editset0(index: int, s: string)
+{
+	edithistcur = index;
+	tkcmd(sprint("focus .e.edit; .e.edit delete 0 end; .e.edit insert 0 '%s", s));
+}
+
+editset(s: string)
+{
+	editset0(-1, s);
+}
+
+xeditget(c: ref Cmd, pre: string): string
+{
+	if(statusvisible) {
+		tkcmd("pack forget .s; pack .e -fill x -side bottom -after .t");
+		statusvisible = 0;
+	}
+
+	if(!c.more())
+		raise "edit:"+pre;
+
+	if(c.char() == kb->Esc)
+		xabort(nil);
+	s: string;
+Read:
+	for(;;)
+		case x := c.get() {
+		-1 =>
+			# text from .e.entry has a newline, but don't require one from -c or '@'
+			break Read;
+		'\n' =>
+			say('e', sprint("xeditget, returning %q", s));
+			break Read;
+		* =>
+			s[len s] = x;
+		}
+	r := pre[0];
+	if(r == '?')
+		r = '/';
+	xregput(r, s);
+	return s; 
+}
+
+editnavigate(up: int)
+{
+	if(edithisttext == nil)
+		edithisttext = tkcmd(".e.edit get");
+	a := l2a(rev(edithist));
+	if(up) {
+		for(i := edithistcur+1; i < len a; ++i)
+			if(str->prefix(edithisttext, a[i]))
+				return editset0(i, a[i]);
+	} else {
+		for(i := edithistcur-1; i >= 0; --i)
+			if(str->prefix(edithisttext, a[i]))
+				return editset0(i, a[i]);
+	}
+	statuswarn("no match");
+}
+
+edithistput(s: string)
+{
+	if(s != nil) {
+		edithist = s::edithist;
+		edithistcur = -1;
+	}
+}
+
+complete(pre: string): (array of string, string)
+{
+	(path, f) := str->splitstrr(pre, "/");
+say('e', sprint("complete, pre %q, path %q, f %q", pre, path, f));
+	dir := path;
+	if(path == nil)
+		dir = ".";
+	fd := sys->open(dir, Sys->OREAD);
+	if(fd == nil)
+		return (nil, sprint("open: %r"));
+	l: list of string;
+	for(;;) {
+		(n, a) := sys->dirread(fd);
+		if(n == 0)
+			break;
+		if(n < 0)
+			return (nil, sprint("dirread: %r"));
+		for(i := 0; i < n; i++)
+			if(str->prefix(f, a[i].name)) {
+				s := path+a[i].name;
+				if(a[i].mode & Sys->DMDIR)
+					s += "/";
+				l = s::l;
+			}
+	}
+	return (l2a(rev(l)), nil);
+}
+
+# return directory to plumb from:  dir where filename is in, or workdir if no filename is set
+plumbdir(): string
+{
+	if(filename == nil)
+		return workdir();
+	return names->dirname(filename);
+}
+
+plumb(s, kind, dir: string)
+{
+	if(!plumbed)
+		return statuswarn("cannot plumb");
+	if(kind == nil)
+		kind = "text";
+	msg := ref Msg("vixen", "", dir, kind, "", array of byte s);
+	say('d', sprint("plumbing %s", string msg.pack()));
+	msg.send();
+}
+
+changesave()
+{
+	if(change == nil)
+		return;
+	changeadd(change);
+	change = nil;
+}
+
+changeadd(c: ref Change)
+{
+	if(changeindex < len changes) {
+		changes = changes[:changeindex+1];
+	} else {
+		n := array[len changes+1] of ref Change;
+		n[:] = changes;
+		changes = n;
+	}
+	if(c.ogen == c.ngen)
+		raise "storing a change with same orig as new gen?";
+	c.ngen = textgen;
+	say('u', "changeadd, storing:");
+	say('u', c.text());
+	changes[changeindex++] = c;
+}
+
+apply(c: ref Change): int
+{
+	say('u', "apply:");
+	say('u', c.text());
+	for(l := c.l; l != nil; l = tl l)
+		pick m := hd l {
+		Ins =>	textins(Cnone, text.pos(m.p), m.s);
+		Del =>	textdel(Cnone, text.pos(m.p), text.cursor(m.o+len m.s));
+		}
+	textgen = c.ngen;
+	cursorset(text.pos(c.beginpos()));
+	return 1;
+}
+
+undo()
+{
+	say('u', sprint("undo, changeindex=%d, len changes=%d", changeindex, len changes));
+	if(changeindex == 0)
+		return statuswarn("already at oldest change");
+	if(apply(changes[changeindex-1].invert()))
+		--changeindex;
+}
+
+redo()
+{
+	say('u', "redo");
+	if(changeindex >= len changes)
+		return statuswarn("already at newest change");;
+	c := ref *changes[changeindex];
+	c.l = rev(c.l);
+	if(apply(c))
+		++changeindex;
+}
+
+
+searchset(s: string): int
+{
+	searchcachegen = big -1;
+	searchcache = nil;
+	err: string;
+	(searchregex, err) = regex->compile(s, 0);
+	if(err != nil) {
+		searchregex = nil;
+		statuswarn("bad pattern");
+		return 0;
+	}
+	return 1;
+}
+
+searchall(re: Regex->Re): array of (int, int)
+{
+	if(textgen == searchcachegen)
+		return searchcache;
+
+	l: list of (int, int);
+	o := 0;
+	s := text.str();
+	sol := 1;
+	while(o < len s) {
+		for(e := o; e < len s && s[e] != '\n'; ++e)
+			{}
+		r := regex->executese(re, s, (o, e), sol, 1);
+		if(len r >= 1 && r[0].t0 >= 0) {
+			l = r[0]::l;
+			o = r[0].t1;
+			sol = 0;
+		} else {
+			sol = 1;
+			o = e+1;
+		}
+	}
+	r := array[len l] of (int, int);
+	for(i := len r-1; i >= 0; --i) {
+		r[i] = hd l;
+		l = tl l;
+	}
+	searchcache = r;
+	searchcachegen = textgen;
+	return r;
+}
+
+search(rev, srev: int, re: Regex->Re, cr: ref Cursor): (ref Cursor, ref Cursor)
+{
+	if(re == nil) {
+		statuswarn("no search pattern set");
+		return (nil, nil);
+	}
+	if(srev)
+		rev = !rev;
+	
+	r := searchall(re);
+	if(len r == 0 || r[0].t0 < 0) {
+		statuswarn("pattern not found");
+		return (nil, nil);
+	}
+	i: int;
+	if(rev) {
+		for(i = len r-1; i >= 0; i--)
+			if(r[i].t0 < cr.o)
+				break;
+		if(i < 0) {
+			i = len r-1;
+			statuswarn("search wrapped");
+		}
+	} else {
+		for(i = 0; i < len r; i++)
+			if(r[i].t0 > cr.o)
+				break;
+		if(i >= len r) {
+			i = 0;
+			statuswarn("search wrapped");
+		}
+	}
+	return (text.cursor(r[i].t0), text.cursor(r[i].t1));
+}
+
+
+xregset(c: int)
+{
+	# we don't know if it will be for get or set yet, so % is valid
+	if(c != '%')
+		xregcanput(c);
+	register = c;
+}
+
+xregget(c: int): string
+{
+	(s, err) := regget(c);
+	if(err == nil && s == nil)
+		err = sprint("register %c empty", c);
+	if(err != nil)
+		xabort(err);
+	return s;
+}
+
+xregcanput(c: int)
+{
+	case c {
+	'a' to 'z' or
+	'/' or
+	':' or
+	'.' or
+	'"' or
+	'A' to 'Z' or
+	'*' or
+	'!' =>	return;
+	'%' =>	xabort("register % is read-only");
+	* =>	xabort(sprint("bad register %c", c));
+	}
+}
+
+xregput(x: int, s: string)
+{
+	err := regput(x, s);
+	if(err != nil)
+		xabort(err);
+}
+
+regget(c: int): (string, string)
+{
+	r: string;
+	case c {
+	'a' to 'z' or
+	'/' or
+	':' or
+	'.' or
+	'"' or
+	'!' =>		r = registers[c];
+	'A' to 'Z' =>	r = registers[c-'A'+'a'];
+	'%' =>		r = filename;
+	'*' =>		r = tkclient->snarfget();
+	* =>		return (nil, sprint("bad register %c", c));
+	}
+	return (r, nil);
+}
+
+regput(c: int, s: string): string
+{
+	case c {
+	'a' to 'z' or
+	'/' or
+	':' or
+	'.' or
+	'"' or
+	'!' =>
+		registers[c] = s;
+	'A' to 'Z' =>
+		registers[c-'A'+'a'] += s;
+	'%' =>
+		return "register % is read-only";
+	'*' =>
+		tkclient->snarfput(s);
+		return nil;
+	* =>	
+		return sprint("bad register %c", c);
+	}
+	return nil;
+}
+
+
+markget(c: int): (ref Cursor, string)
+{
+	m: ref Cursor;
+	case c {
+	'a' to 'z' or
+	'`' or
+	'\'' or
+	'.' or
+	'^' =>	m = marks[c];
+	'<' or
+	'>' =>
+		if(mode != Visual && mode != Visualline)
+			return (nil, "selection not set");
+		(vs, ve) := visualrange();
+		case c {
+		'<' =>	m = vs;
+		'>' =>	m = ve;
+		}
+	* =>
+		return (nil, sprint("bad mark %c", c));
+	}
+	if(m == nil)
+		return (nil, sprint("mark %c not set", c));
+	return (m, nil);
+}
+
+xmarkget(c: int): ref Cursor
+{
+	(m, err) := markget(c);
+	if(err != nil)
+		xabort(err);
+	return m;
+}
+
+xmarkput(c: int, m: ref Cursor)
+{
+	m = m.clone();
+	case c {
+	'a' to 'z' or
+	'.' or
+	'^' =>	marks[c] = m;
+	'`' or
+	'\'' =>	marks['`'] = marks['\''] = m;
+	# < and > cannot be set explicitly
+	* =>	xabort(sprint("bad mark %c", c));
+	}
+}
+
+# fix marks, cs-ce have just been deleted (and their positions are no longer valid!)
+markfixdel(cs, ce: ref Cursor)
+{
+	for(i := 0; i < len marks; i++) {
+		m := marks[i];
+		if(m == nil || m.o < cs.o)
+			continue;
+		if(m.o < ce.o)
+			marks[i] = nil;
+		else
+			marks[i] = text.cursor(m.o-Cursor.diff(cs, ce));
+	}
+}
+
+# fix marks, n bytes have just been inserted at cs
+markfixins(cs: ref Cursor, n: int)
+{
+	for(i := 0; i < len marks; i++) {
+		m := marks[i];
+		if(m == nil || m.o < cs.o)
+			continue;
+		marks[i] = text.cursor(m.o+n);
+	}
+}
+
+
+# 'q' was received while in command or visual mode.
+recordq(c: ref Cmd)
+{
+	say('d', sprint("recordq, recordreg %c, record %q, c %s", recordreg, record, c.text()));
+	if(recordreg >= 0) {
+		xregput(recordreg, record[:len record-1]); # strip last 'q' at end
+		say('d', sprint("register %c now %q", recordreg, registers[recordreg]));
+		record = nil;
+		recordreg = -1;
+	} else {
+		y := c.xget();
+		xregcanput(y);
+		recordreg = y;
+	}
+}
+
+# whether text was inserted/replaced
+inserted(): int
+{
+	if(change != nil)
+		pick m := hd change.l {
+		Ins =>
+			return m.o+len m.s == cursor.o;
+		}
+	return 0;
+}
+
+textrepl(rec: int, a, b: ref Cursor, s: string)
+{
+	if(a == nil)
+		a = cursor;
+	if(b == nil)
+		b = cursor;
+	textdel(rec, a, b);
+	textins(rec, a, s);
+}
+
+# delete from a to b.
+# rec indicates whether a Change must be recorded,
+# where the cursor should be,
+# and whether the last change register should be set.
+textdel(rec: int, a, b: ref Cursor)
+{
+	tkhighlightclear();
+
+	if(a == nil)
+		a = cursor;
+	if(b == nil)
+		b = cursor;
+
+	setreg := rec & Csetreg;
+	setcursor := rec & Csetcursormask;
+
+	swap := Cursor.cmp(a, b) > 0;
+	if(swap)
+		(a, b) = (b, a);
+	s := text.get(a, b);
+
+	rec &= Cchangemask;
+Change:
+	case rec {
+	Cnone =>
+		{}
+	Cmodrepl =>
+		say('m', sprint("textdel, Cmodrepl, s %q, a %s, b %s", s, a.text(), b.text()));
+		if(change == nil)
+			return statuswarn("beep!");
+		pick m := hd change.l {
+		Ins =>
+			say('m', "textdel, last was insert");
+			if(m.o+len m.s != b.o)
+				raise "delete during replace should be at end of previous insert";
+			if(len s > len m.s) {
+				a = text.cursor(b.o-len m.s);
+				s = text.get(a, b);
+			}
+			m.s = m.s[:len m.s-len s];
+			# we check below whether we have to remove this Mod.Ins
+		Del =>
+			say('m', "textdel, last was del");
+			return statuswarn("beep!");
+		}
+	Cmod or
+	Cchange =>
+		if(change != nil)
+			pick m := hd change.l {
+			Ins =>
+				if(m.o+len m.s == b.o) {
+					if(len s > len m.s) {
+						a = text.cursor(b.o-len m.s);
+						s = text.get(a, b);
+					}
+					m.s = m.s[:len m.s-len s];
+					if(m.s == nil) {
+						change.l = tl change.l;
+						if(change.l == nil)
+							change = nil;
+					}
+					break Change;
+				}
+			Del =>
+				if(rec != Cmod && rec != Cmodrepl && m.o == a.o) {
+					m.s += s;
+					break Change;
+				}
+			}
+		if(rec == Cmod)
+			return statuswarn("beep!");
+		if(change == nil)
+			change = ref Change (0, nil, textgen, ~big 0);
+		change.l = ref Mod.Del (a.o, a.pos, s)::change.l;
+	Cchangerepl =>
+		raise "should not happen";
+	* =>
+		raise "bad rec";
+	}
+	if(setreg)
+		xregput(register, s);
+	tkcmd(sprint(".t.text delete %s %s", a.pos.text(), b.pos.text()));
+	text.del(a, b);
+	textgen = textgenlast++;;
+	markfixdel(a, b);
+	if(rec != Cnone)
+		xmarkput('.', a);
+
+	if(rec == Cmodrepl) {
+		# Mod.Del may be absent, eg when replace was started at end of file
+		if(tl change.l != nil) {
+			pick m := hd tl change.l {
+			Del =>
+				# if a is in this del, remove till end of it, and insert at the cursor
+				if(a.o >= m.o && a.o < m.o+len m.s) {
+					nn := a.o-m.o;
+					os := m.s[nn:];
+					m.s = m.s[:nn];
+					text.ins(a, os);
+					textgen = textgenlast++;
+					markfixins(a, len os);
+					tkcmd(sprint(".t.text insert %s '%s", a.pos.text(), os));
+					if(a.o+len os >= text.chars())
+						tkcmd(sprint(".t.text tag remove eof %s {%s +%dc}", a.pos.text(), a.pos.text(), len os));
+				}
+			}
+		}
+		pick m := hd change.l {
+		Ins =>
+			if(m.s == nil)
+				change.l = tl change.l;
+		}
+		pick m := hd change.l {
+		Del =>
+			if(m.s == nil)
+				change.l = tl change.l;
+		}
+		if(change.l == nil)
+			change = nil;
+	}
+	if(setcursor) {
+		n: ref Cursor;
+		case setcursor {
+		0 =>		{}
+		Csetcursorlo =>	n = a;
+		Csetcursorhi =>	n = b;
+		* =>		raise "bad rec";
+		}
+		cursorset(n);
+	}
+}
+
+textins(rec: int, c: ref Cursor, s: string)
+{
+	tkhighlightclear();
+
+	if(c == nil)
+		c = cursor;
+
+	setcursor := rec&Csetcursormask;
+	rec &= Cchangemask;
+
+Change:
+	case rec {
+	Cnone =>
+		{}
+	Cmod or 
+	Cmodrepl =>
+		raise "should not happen";
+	Cchange or
+	Cchangerepl =>
+		ins := 0;
+		if(change != nil) {
+			pick m := hd change.l {
+			Ins =>
+				if(m.o+len m.s == c.o) {
+					m.s += s;
+					ins = 1;
+				}
+			}
+		}
+		if(!ins) {
+			if(change == nil)
+				change = ref Change (0, nil, textgen, ~big 0);
+			change.l = ref Mod.Ins (c.o, c.pos, s)::change.l;
+		}
+		if(rec == Cchangerepl) {
+			n := min(len s, len text.str()-c.o);
+			if(n > 0) {
+				(a, b) := (text.cursor(c.o), text.cursor(c.o+n));
+				tkcmd(sprint(".t.text delete %s %s", a.pos.text(), b.pos.text()));
+				os := text.del(a, b);
+				textgen = textgenlast++;
+				markfixdel(a, b);
+				if(tl change.l != nil) {
+					pick m := hd tl change.l {
+					Del =>
+						if(c.o == m.o+len m.s) {
+							m.s += os;
+							break Change;
+						}
+					}
+				}
+				m := ref Mod.Del (c.o, c.pos, os);
+				change.l = hd change.l::m::tl change.l;
+			}
+		}
+	* =>
+		raise "bad rec";
+	}
+
+	tkcmd(sprint(".t.text insert %s '%s", c.pos.text(), s));
+	if(c.o+len s >= text.chars())
+		tkcmd(sprint(".t.text tag remove eof %s {%s +%dc}", c.pos.text(), c.pos.text(), len s));
+	nc := text.ins(c, s);
+	textgen = textgenlast++;
+	markfixins(c, len s);
+	case setcursor {
+	0 =>	{}
+	Csetcursorlo =>	cursorset(c);
+	Csetcursorhi =>	cursorset(nc);
+	* =>	raise "bad rec";
+	}
+
+	modified = 1;
+	say('m', sprint("textins, inserted %q, cursor now %s", s, cursor.text()));
+}
+
+
+textfill(fd: ref Sys->FD): string
+{
+	b := bufio->fopen(fd, Sys->OREAD);
+	if(b == nil)
+		return sprint("fopen: %r");
+	s: string;
+	n := 0;
+	for(;;) {
+		case x := b.getc() {
+		Bufio->EOF =>
+			tkcmd(".t.text insert end '"+s);
+			text.set(s);
+			return nil;
+		bufio->ERROR =>
+			return sprint("read: %r");
+		* =>
+			s[n++] = x;
+		}
+	}
+}
+
+writemodifiedquit(force: int)
+{
+	if(modified) {
+		if(filename == nil)
+			return statuswarn("no filename set");
+		err := textwrite(force, filename, nil, nil);
+		if(err != nil)
+			return statuswarn(err);
+		modified = 0;
+	}
+	if(modified && !force)
+		return statuswarn("unsaved changes");
+	quit();
+}
+
+# write cs-ce to f (force makes it overwrite f when it exists or when cs/ce is not nil).
+textwrite(force: int, f: string, cs, ce: ref Cursor): string
+{
+	fd: ref Sys->FD;
+	if(f == nil)
+		return "no filename set";
+	if(filefd == nil || f != filename) {
+		fd = sys->open(f, Sys->ORDWR);
+		if(fd != nil && !force)
+			return "file already exists";
+		if(fd == nil)
+			fd = sys->create(f, Sys->ORDWR, 8r666);
+		if(fd == nil)
+			return sprint("create: %r");
+		if(f == filename)
+			filefd = fd;
+	} else {
+		(ok, st) := sys->fstat(filefd);
+		if(ok < 0)
+			return sprint("stat: %r");
+		if(!force) {
+			if(st.qid.vers != filestat.qid.vers)
+				return sprint("file's qid version has changed, not writing");
+			if(st.mtime != filestat.mtime || st.length != filestat.length)
+				return sprint("file's length or mtime has changed, not writing");
+		}
+		sys->seek(filefd, big 0, Sys->SEEKSTART);
+		d := sys->nulldir;
+		d.length = big 0;
+		if(sys->fwstat(filefd, d) < 0)
+			return sprint("truncate %q: %r", f);
+		fd = filefd;
+	}
+	err := bufwritefd(text, cs, ce, fd);
+	if(filefd != nil) {
+		ok: int;
+		(ok, filestat) = sys->fstat(filefd);
+		if(ok < 0)
+			return sprint("stat after write: %r");
+	}
+	return err;
+}
+
+textappend(f: string, cs, ce: ref Cursor): string
+{
+	if(cs == nil)
+		s := text.str();
+	else
+		s = text.get(cs, ce);
+	b := bufio->open(f, Sys->OWRITE);
+	if(b == nil)
+		return sprint("open: %r");
+	b.seek(big 0, bufio->SEEKEND);
+	if(b.puts(s) == Bufio->ERROR || b.flush() == Bufio->ERROR)
+		return sprint("write: %r");
+	return nil;
+}
+
+readfile(f: string): (string, string)
+{
+	b := bufio->open(f, Bufio->OREAD);
+	if(b == nil)
+		return (nil, sprint("open: %r"));
+	s := "";
+	for(;;)
+	case c := b.getc() {
+	Bufio->EOF =>	return (s, nil);
+	Bufio->ERROR =>	return (nil, sprint("read: %r"));
+	* =>		s[len s] = c;
+	}
+}
+
+
+statuswarn(s: string)
+{
+	say('d', "statuswarn: "+s);
+	statustext = s;
+	statusset();
+}
+
+statusset()
+{
+	s := sprint("%9s ", "("+modes[mode]+")");
+	if(recordreg >= 0)
+		s += "recording ";
+	if(filename == nil)
+		s += "(no filename)";
+	else
+		s += sprint("%q", names->basename(filename, nil));
+	s += sprint(", %4d lines, %5d chars, pos %s", text.lines(), text.chars(), cursor.pos.text());
+	if(cmdcur.rem() != nil)
+		s += ", "+cmdcur.rem();
+	if(statustext != nil)
+		s += ", "+statustext;
+	tkcmd(sprint(".s.status configure -text '%s", s));
+	if(!statusvisible) {
+		tkcmd("pack forget .e; pack .s -fill x -side bottom -after .t");
+		statusvisible = 1;
+	}
+}
+
+visualrange(): (ref Cursor, ref Cursor)
+{
+	(a, b) := Cursor.order(visualstart.clone(), visualend.clone());
+	if(mode == Visualline) {
+		a = a.mvcol(0);
+		b = b.mvlineend(1);
+	}
+	return (a, b);
+}
+
+visualset()
+{
+	(a, b) := visualrange();
+	tkselectionset(a.pos, b.pos);
+}
+
+tkselectionset(a, b: Pos)
+{
+	say('t', sprint("selectionset, from %s to %s", a.text(), b.text()));
+	tkcmd(".t.text tag remove sel 1.0 end");
+	tkcmd(sprint(".t.text tag add sel %s %s", a.text(), b.text()));
+}
+
+
+redraw()
+{
+	(spos, nil) := tkvisible();
+	tkcmd(".t.text delete 1.0 end");
+	tkcmd(".t.text insert 1.0 '"+text.str());
+	tkaddeof();
+	case mode {
+	Visual or
+	Visualline =>
+		visualset();
+	}
+	cursorset(cursor);
+	if(highlightstart != nil)
+		tkhighlight(highlightstart, highlightend);
+	plumbvisible = 0;
+	tkcmd(sprint(".t.text see %s", spos.text()));
+}
+
+tkhighlightclear()
+{
+	if(highlightstart != nil) {
+		tkcmd(".t.text tag remove search 1.0 end");
+		highlightstart = highlightend = nil;
+	}
+}
+
+tkhighlight(s, e: ref Cursor)
+{
+	tkhighlightclear();
+	tkcmd(sprint(".t.text tag add search %s %s", s.pos.text(), e.pos.text()));
+	(highlightstart, highlightend) = (s, e);
+}
+
+tkplumbclear()
+{
+	if(plumbvisible) {
+		tkcmd(".t.text tag remove plumb 1.0 end");
+		plumbvisible = 0;
+	}
+}
+
+tkplumbshow(s, e: Pos)
+{
+	tkplumbclear();
+	tkcmd(sprint(".t.text tag add plumb %s %s", s.text(), e.text()));
+	plumbvisible = 1;
+}
+
+tkinsertset(p: Pos)
+{
+	tkcmd(sprint(".t.text mark set insert %s", p.text()));
+}
+
+cursorset0(c: ref Cursor, see: int)
+{
+	say('c', sprint("new cursor: %s", c.text()));
+	cursor = c;
+	tkinsertset(c.pos);
+	if(see)
+		tkcmd(sprint(".t.text see %s", c.pos.text()));
+}
+
+cursorset(c: ref Cursor)
+{
+	cursorset0(c, 1);
+}
+
+up()
+{
+	tkcmd("update");
+}
+
+tkvisibletop(): Pos
+{
+	return Pos.parse(tkcmd(".t.text index @0,0"));
+}
+
+tkvisiblebottom(): Pos
+{
+	height := tkcmd(".t.text cget -actheight");
+	s := tkcmd(sprint(".t.text index @0,%d", int height-1));
+	return Pos.parse(s);
+}
+
+tkvisible(): (Pos, Pos)
+{
+	return (tkvisibletop(), tkvisiblebottom());
+}
+
+tklinesvisible(): int
+{
+	(a, b) := tkvisible();
+	return b.l+1-a.l;
+}
+
+tkselcolor(w: int)
+{
+	case w {
+	Normal =>	tkcmd(".t.text tag configure sel -background white -foreground black");
+	Green =>	tkcmd(".t.text tag configure sel -background green -foreground white");
+	}
+}
+
+tkcmd(s: string): string
+{
+	say('k', s);
+	r := tk->cmd(top, s);
+	if(r != nil && r[0] == '!')
+		warn(sprint("tkcmd: %q: %s", s, r));
+	if(r != nil)
+		say('k', " -> "+r);
+	return r;
+}
+
+tkcmds(a: array of string)
+{
+	for(i := 0; i < len a; i++)
+		tkcmd(a[i]);
+}
+
+quit()
+{
+	killgrp(pid());
+	exit;
+}
+
+pid(): int
+{
+	return sys->pctl(0, nil);
+}
+
+progctl(pid: int, s: string)
+{
+	sys->fprint(sys->open(sprint("/prog/%d/ctl", pid), sys->OWRITE), "%s", s);
+}
+
+kill(pid: int)
+{
+	progctl(pid, "kill");
+}
+
+killgrp(pid: int)
+{
+	progctl(pid, "killgrp");
+}
+
+min(a, b: int): int
+{
+	if(a < b)
+		return a;
+	return b;
+}
+
+max(a, b: int): int
+{
+	if(a > b)
+		return a;
+	return b;
+}
+
+abs(a: int): int
+{
+	if(a < 0)
+		a = -a;
+	return a;
+}
+
+l2a[T](l: list of T): array of T
+{
+	a := array[len l] of T;
+	i := 0;
+	for(; l != nil; l = tl l)
+		a[i++] = hd l;
+	return a;
+}
+
+rev[T](l: list of T): list of T
+{
+	r: list of T;
+	for(; l != nil; l = tl l)
+		r = hd l::r;
+	return r;
+}
+
+warn(s: string)
+{
+	sys->fprint(sys->fildes(2), "%s\n", s);
+}
+
+say(c: int, s: string)
+{
+	if(debug[c])
+		warn(s);
+}
+
+fail(s: string)
+{
+	warn(s);
+	killgrp(pid());
+	raise "fail:"+s;
+}
--- /dev/null
+++ b/appl/wm/vixen/vixen/buffers.b
@@ -1,0 +1,666 @@
+Pos: adt {
+	l, c:	int;  # line, column
+
+	cmp:		fn(a, b: Pos): int;
+	order:		fn(a, b: Pos): (Pos, Pos);
+	eq:		fn(a, b: Pos): int;
+	parse:		fn(s: string): Pos;
+	text:		fn(p: self Pos): string;
+};
+nullpos: Pos;
+
+Colkeep, Colstart, Colfirstnonblank, Colend, Colpastnewline: con -iota-1;  # mvline's colmv
+Cursor: adt {
+	b:	ref Buf;
+	o:	int;	# offset into buf.  o can be set to length of buffer
+	pos:	Pos;
+
+	clone:		fn(c: self ref Cursor): ref Cursor;
+	char:		fn(c: self ref Cursor): int;
+	charprev:	fn(c: self ref Cursor): int;
+	prev,
+	next:		fn(c: self ref Cursor): int;
+	walk:		fn(c: self ref Cursor, rev: int): int;
+
+	mvchar,
+	mvcol:		fn(c: self ref Cursor, col: int): ref Cursor;
+	mvline:		fn(c: self ref Cursor, rel: int, colmv: int): ref Cursor;
+	mvpos:		fn(c: self ref Cursor, p: Pos): ref Cursor;
+	mvlineend:	fn(c: self ref Cursor, nl: int): ref Cursor;
+	mvword,
+	mvwordend:	fn(c: self ref Cursor, capital: int, n: int): ref Cursor;
+	mvfirst:	fn(c: self ref Cursor): ref Cursor;
+	mvsentence:	fn(c: self ref Cursor, prev: int): ref Cursor;
+	mvparagraph:	fn(c: self ref Cursor, prev: int): ref Cursor;
+	mvskip:		fn(c: self ref Cursor, cl: string): ref Cursor;
+
+	word:		fn(c: self ref Cursor): (ref Cursor, ref Cursor);
+	pathpattern:	fn(c: self ref Cursor, search: int): (ref Cursor, ref Cursor);
+	findchar:	fn(c: self ref Cursor, cl: string, rev: int): ref Cursor;
+	findstr:	fn(c: self ref Cursor, s: string, rev: int): ref Cursor;
+	findlinechar:	fn(c: self ref Cursor, x: int, rev: int): ref Cursor;
+
+	cmp:		fn(a, b: ref Cursor): int;
+	order:		fn(a, b: ref Cursor): (ref Cursor, ref Cursor);
+	diff:		fn(a, b: ref Cursor): int;
+	linelength:	fn(c: self ref Cursor, nl: int): int;
+	text:		fn(c: self ref Cursor): string;
+};
+
+Buf: adt {
+	s:	string;
+	nlines:	int;
+
+	new:		fn(): ref Buf;
+	get:		fn(b: self ref Buf, f, t: ref Cursor): string;
+	del:		fn(b: self ref Buf, f, t: ref Cursor): string;
+	ins:		fn(b: self ref Buf, w: ref Cursor, s: string): ref Cursor;
+	set:		fn(b: self ref Buf, s: string);
+	cursor:		fn(b: self ref Buf, o: int): ref Cursor;
+	pos:		fn(b: self ref Buf, pos: Pos): ref Cursor;
+	lines:		fn(b: self ref Buf): int;
+	chars:		fn(b: self ref Buf): int;
+	end:		fn(b: self ref Buf): ref Cursor;
+	str:		fn(b: self ref Buf): string;
+};
+
+
+checkcursor(c: ref Cursor)
+{
+	if(c.o < 0 || c.o > len c.b.s)
+		raise "cursor.o invalid";
+	cc := ref *c;
+	cc.o = 0;
+	cc.pos = Pos (1, 0);
+	while(c.o != cc.o)
+		cc.next();
+	if(!Pos.eq(c.pos, cc.pos))
+		raise sprint("cursor has wrong pos, got %s, pos should be %s", c.text(), cc.pos.text());
+}
+
+Pos.cmp(a, b: Pos): int
+{
+	if(a.l < b.l || a.l == b.l && a.c < b.c) return -1;
+	if(a.l > b.l || a.l == b.l && a.c > b.c) return 1;
+	return 0;
+}
+
+Pos.order(a, b: Pos): (Pos, Pos)
+{
+	if(a.l > b.l || (a.l == b.l && a.c > b.c))
+		return (b, a);
+	return (a, b);
+}
+
+Pos.eq(a, b: Pos): int
+{
+	return a.l == b.l && a.c == b.c;
+}
+
+Pos.parse(s: string): Pos
+{
+	(a, b) := str->splitstrl(s, ".");
+	if(b != nil)
+		b = b[1:];
+	return Pos (int a, int b);
+}
+
+Pos.text(p: self Pos): string
+{
+	return sprint("%d.%d", p.l, p.c);
+}
+
+
+Cursor.clone(c: self ref Cursor): ref Cursor
+{
+	return ref *c;
+}
+
+Cursor.char(c: self ref Cursor): int
+{
+	if(c.o < 0 || c.o >= len c.b.s)
+		return -1;
+	return c.b.s[c.o];
+}
+
+Cursor.charprev(c: self ref Cursor): int
+{
+	o := c.o-1;
+	if(o < 0 || o >= len c.b.s)
+		return -1;
+	return c.b.s[o];
+}
+
+# move back one char in the buffer and return it.
+# if already at start, return -1.
+Cursor.prev(c: self ref Cursor): int
+{
+	if(c.o <= 0)
+		return -1;
+	r := c.b.s[--c.o];
+	--c.pos.c;
+	if(r == '\n') {
+		--c.pos.l;
+		c.pos.c = linelength(c, c.o);
+	} else if(c.pos.c < 0)
+		raise "bogus col";
+	return r;
+}
+
+# return length of line without newline, don't use c.pos.c, it may be wrong
+linelength(c: ref Cursor, o: int): int
+{
+	for(so := o; so > 0 && c.b.s[so-1] != '\n'; --so)
+		{}
+	for(eo := o; eo < len c.b.s && c.b.s[eo] != '\n'; ++eo)
+		{}
+	return eo-so;
+}
+
+
+# move cursor forward, return the character under it.
+# if already under last character, we move forward, but return -1.
+Cursor.next(c: self ref Cursor): int
+{
+	if(c.o >= len c.b.s)
+		return -1;
+	x := c.b.s[c.o];
+	++c.o;
+	++c.pos.c;
+	if(x == '\n') {
+		++c.pos.l;
+		c.pos.c = 0;
+	}
+	if(c.o >= len c.b.s)
+		return -1;
+	return c.b.s[c.o];
+}
+
+Cursor.walk(c: self ref Cursor, rev: int): int
+{
+	if(rev)
+		return c.prev();
+	return c.next();
+}
+
+# move cursor by 'rel' characters, staying on the same line.
+Cursor.mvchar(cc: self ref Cursor, rel: int): ref Cursor
+{
+	c := cc.clone();
+	while(rel > 0) {
+		x := c.char();
+		if(x < 0 || x == '\n')
+			break;
+		c.next();
+		--rel;
+	}
+	while(rel < 0) {
+		x := c.charprev();
+		if(x < 0 || x == '\n')
+			break;
+		c.prev();
+		++rel;
+	}
+	checkcursor(c);
+	return c;
+}
+
+# move cursor by 'rel' lines, try to keep cursor on same col.
+Cursor.mvline(c: self ref Cursor, rel: int, colmv: int): ref Cursor
+{
+	c = c.clone();
+	col := 0;
+	if(colmv == Colkeep)
+		col = c.pos.c;
+	else if(colmv >= 0)
+		col = colmv;
+	c = c.mvpos(Pos (max(1, c.pos.l+rel), col));
+	case colmv {
+	Colkeep =>	{}
+	Colstart =>	{}
+	Colfirstnonblank =>	c = c.mvfirst();
+	Colend =>		c = c.mvlineend(0);
+	Colpastnewline =>	c = c.mvlineend(1);
+	* =>
+		if(colmv < 0)
+			raise "bad colmv";
+	}
+	return c;
+}
+
+# move to column on current line, moving to end if col is past end of line
+Cursor.mvcol(c: self ref Cursor, col: int): ref Cursor
+{
+	if(col < 0)
+		col = 0;
+	if(col > c.pos.c)
+		col = min(c.linelength(0), col);
+	cc := ref Cursor (c.b, c.o+col-c.pos.c, Pos (c.pos.l, col));
+	checkcursor(cc);
+	return cc;
+}
+
+Cursor.mvpos(cc: self ref Cursor, p: Pos): ref Cursor
+{
+	return cc.b.pos(p);
+}
+
+Cursor.mvlineend(c: self ref Cursor, nl: int): ref Cursor
+{
+	c = c.clone();
+	x := c.char();
+	while(x >= 0 && x != '\n')
+		x = c.next();
+	if(nl && x == '\n')
+		c.next();
+	return c;
+}
+
+Cursor.cmp(a, b: ref Cursor): int
+{
+	if(a.o == b.o) return 0;
+	if(a.o < b.o) return -1;
+	return 1;
+}
+
+Cursor.order(a, b: ref Cursor): (ref Cursor, ref Cursor)
+{
+	if(a.o > b.o)
+		return (b, a);
+	return (a, b);
+}
+
+Cursor.diff(a, b: ref Cursor): int
+{
+	return b.o-a.o;
+}
+
+# return length of line, including newline if present and nl is set
+Cursor.linelength(cc: self ref Cursor, nl: int): int
+{
+	if(nl)
+		nl = 1;
+	# find newline or end of file
+	c := cc.clone();
+	x := c.char();
+	for(;;) {
+		if(x < 0)
+			return c.pos.c;
+		if(x == '\n')
+			return c.pos.c+nl;
+		x = c.next();
+	}
+}
+
+whitespace: con " \t\n";
+interpunction: con "!\"#$%&'()*+,./:;<=>?@\\]^_`{|}~-[";
+whitespaceinterpunction: con " \t\n!\"#$%&'()*+,./:;<=>?@\\]^_`{|}~-[";
+Cursor.mvword(cc: self ref Cursor, capital: int, n: int): ref Cursor
+{
+	c := cc.clone();
+	while(n > 0) {
+		mvwordforward(c, capital);
+		n--;
+	}
+	while(n < 0) {
+		mvwordbackward(c, capital);
+		n++;
+	}
+	checkcursor(c);
+	return c;
+}
+
+mvwordforward(c: ref Cursor, cap: int)
+{
+	x := c.char();
+	if(cap)
+		stop := whitespace;
+	else
+		stop = whitespaceinterpunction;
+
+	if(!cap && str->in(x, interpunction))
+		while(x >= 0 && str->in(x, interpunction))
+			x = c.next();
+	while(x >= 0 && !str->in(x, stop))
+		x = c.next();
+	while(x >= 0 && str->in(x, whitespace))
+		x = c.next();
+}
+
+mvwordbackward(c: ref Cursor, cap: int)
+{
+	c.prev();
+	x: int;
+	x = c.char();
+	while(x >= 0 && str->in(x, whitespace))
+		x = c.prev();
+	if(cap) {
+		# read back until whitespace
+		c.prev();
+		while((x = c.charprev()) >= 0 && !str->in(x, whitespace))
+			c.prev();
+	} else {
+		# if interpunction, read to start of it.
+		# otherwise read to end of last whitespace/interpunction
+		x = c.char();
+		if(x < 0) {
+			# nothing
+		} else if(str->in(x, interpunction)) {
+			while((x = c.charprev()) >= 0 && str->in(x, interpunction))
+				c.prev();
+		} else {
+			while((x = c.charprev()) >= 0 && !str->in(x, whitespaceinterpunction))
+				c.prev();
+		}
+	}
+}
+
+Cursor.mvwordend(cc: self ref Cursor, cap: int, n: int): ref Cursor
+{
+	if(n < 0)
+		raise "bad mvwordend";
+	c := cc.clone();
+	while(n-- > 0) {
+		c.next();
+		x := c.char();
+		while(x >= 0 && str->in(x, whitespace))
+			x = c.next();
+		if(cap || str->in(x, interpunction))
+			stop := whitespace;
+		else
+			stop = whitespaceinterpunction;
+		while(x >= 0 && !str->in(x, stop))
+			x = c.next();
+	}
+	checkcursor(c);
+	return c;
+}
+
+Cursor.mvfirst(c: self ref Cursor): ref Cursor
+{
+	return c.clone().mvcol(0).mvskip(" \t");
+}
+
+Cursor.mvsentence(c: self ref Cursor, prev: int): ref Cursor
+{
+	c = c.clone();
+	Lineend: con ".!?";
+	if(prev) {
+		# beginning of previous sentence
+		y := c.prev();
+		while(y >= 0 && (str->in(y, Lineend) || str->in(y, whitespace)))
+			y = c.prev();
+		c = c.findchar(Lineend, 1);
+		if(c != nil)
+			c = c.mvskip(Lineend+whitespace);
+		else
+			c = text.cursor(0);
+	} else {
+		c = c.mvskip("^"+Lineend);
+		c = c.mvskip(Lineend+whitespace);
+	}
+	return c;
+}
+
+Cursor.mvparagraph(c: self ref Cursor, prev: int): ref Cursor
+{
+	c = c.clone();
+	if(prev) {
+		x := c.prev();
+		while(x == '\n')
+			x = c.prev();
+		c = c.findstr("\n\n", 1);
+		if(c == nil)
+			c = text.cursor(0);
+		else
+			c.next();
+	} else {
+		x := c.char();
+		while(x == '\n')
+			x = c.next();
+		c = c.findstr("\n\n", 0);
+		if(c == nil)
+			c = text.end();
+		else
+			c.next();
+	}
+	return c;
+}
+
+Cursor.mvskip(cc: self ref Cursor, cl: string): ref Cursor
+{
+	c := cc.clone();
+	x := c.char();
+	while(x >= 0 && str->in(x, cl))
+		x = c.next();
+	return c;
+}
+
+# return start & end of word under cursor.  (nil, nil) if cursor not under a word.
+Cursor.word(c: self ref Cursor): (ref Cursor, ref Cursor)
+{
+	x := c.char();
+	if(x < 0 || str->in(x, whitespaceinterpunction))
+		return (nil, nil);
+	a := c.clone();
+	b := c.clone();
+	while((x = a.charprev()) > 0 && !str->in(x, whitespaceinterpunction))
+		a.prev();
+	x = b.char();
+	while(x > 0 && !str->in(x, whitespaceinterpunction))
+		x = b.next();
+	return (a, b);
+}
+
+# return start & end of path and optionally pattern under cursor.
+# (nil, nil) if no path is under the cursor, or no path could be found with `search' set.
+Cursor.pathpattern(c: self ref Cursor, search: int): (ref Cursor, ref Cursor)
+{
+	# read a file:address pattern (not whole line!), also look backwards.
+	# this could be done with a vim-like text object "motion" some day.
+	Break: con " \t\n!\"'(),;<>?[]{}";
+	c = c.clone();
+	if(search)
+		c = c.mvskip(" \t\n");
+	else if(str->in(c.char(), whitespace))
+		return (nil, nil);
+	ce := c.clone();
+	for(;;) {
+		x := c.charprev();
+		if(x < 0 || has(x, Break))
+			break;
+		c.prev();
+	}
+	ncolon := 0;
+	x := ce.char();
+	for(;;) {
+		if(x < 0 || has(x, Break))
+			break;
+		if(x == ':' && ++ncolon >= 2)
+			break;
+		x = ce.next();
+	}
+	return (c, ce);
+}
+
+# move cursor forward (or backward if rev!=0) until cursor is at char from cl
+# return nil if no such cursor exists.
+Cursor.findchar(c: self ref Cursor, cl: string, rev: int): ref Cursor
+{
+	c = c.clone();
+	x := c.char();
+	for(;;) {
+		if(x < 0)
+			return nil;
+		if(str->in(x, cl))
+			return c;
+		if(rev)
+			x = c.prev();
+		else
+			x = c.next();
+	}
+}
+
+Cursor.findstr(c: self ref Cursor, s: string, rev: int): ref Cursor
+{
+	if(s == nil)
+		return c;
+	c = c.clone();
+	x := c.char();
+	while(x > 0) {
+		if(s[0] == x && str->prefix(s, c.b.s[c.o:])) {
+			say('c', sprint("findstr, have match, c %s", c.text()));
+			return c;
+		}
+		x = c.walk(rev);
+	}
+	return nil;
+}
+
+Cursor.findlinechar(c: self ref Cursor, x: int, rev: int): ref Cursor
+{
+	c = c.clone();
+	y: int;
+	if(rev) {
+		if(c.pos.c == 0)
+			return nil;
+		y = c.prev();
+		do {
+			if(y == x)
+				return c;
+			y = c.prev();
+		} while(c.pos.c >= 0);
+	} else {
+		if(c.char() == '\n')
+			return nil;
+		do {
+			y = c.next();
+			if(y == x)
+				return c;
+		} while(y >= 0 && y != '\n');
+	}
+	return nil;
+}
+
+Cursor.text(c: self ref Cursor): string
+{
+	return sprint("Cursor(len b.s=%d, o=%d, pos=%s)", len c.b.s, c.o, c.pos.text());
+}
+
+
+Buf.new(): ref Buf
+{
+	return ref Buf ("", 1);
+}
+
+Buf.get(b: self ref Buf, f, t: ref Cursor): string
+{
+	return b.s[f.o:t.o];
+}
+
+Buf.del(b: self ref Buf, f, t: ref Cursor): string
+{
+	r := b.s[f.o:t.o];
+	b.s = b.s[:f.o]+b.s[t.o:];
+	for(i := 0; i < len r; i++)
+		if(r[i] == '\n')
+			--b.nlines;
+	return r;
+}
+
+Buf.ins(b: self ref Buf, cc: ref Cursor, s: string): ref Cursor
+{
+	c := cc.clone();
+	for(i := 0; i < len s; i++) {
+		x := s[i];
+		if(x == '\n')
+			++b.nlines;
+		if(len b.s == c.o) {
+			b.s[c.o] = x;
+		} else {
+			ns := c.b.s[:c.o];
+			ns[len ns] = x;
+			ns += c.b.s[c.o:];
+			b.s = ns;
+		}
+		c.next();
+	}
+	checkcursor(c);
+	return c;
+}
+
+Buf.set(b: self ref Buf, s: string)
+{
+	text.s = s;
+	b.nlines = 1;
+	for(i := 0; i < len s; i++)
+		if(s[i] == '\n')
+			++b.nlines;
+}
+
+Buf.cursor(b: self ref Buf, o: int): ref Cursor
+{
+	if(o < 0)
+		o = 0;
+	if(o > len b.s)
+		o = len b.s;
+	c := ref Cursor (b, 0, Pos (1, 0));
+	for(i := 0; i < o; i++) {
+		++c.pos.c;
+		if(b.s[i] == '\n') {
+			c.pos.c = 0;
+			++c.pos.l;
+		}
+	}
+	c.o = i;
+	return c;
+}
+
+Buf.pos(b: self ref Buf, pos: Pos): ref Cursor
+{
+	if(pos.l < 1 || pos.c < 0)
+		raise "bad pos";
+	c := ref Cursor (b, 0, Pos (1, 0));
+
+	x := c.char();
+	for(;;) {
+		if(x < 0 || c.pos.l == pos.l && (x == '\n' || c.pos.c == pos.c))
+			break;
+		x = c.next();
+	}
+
+	say('c', sprint("Buf.pos %s -> %s", pos.text(), c.pos.text()));
+	return c;
+}
+
+Buf.lines(b: self ref Buf): int
+{
+	return b.nlines;
+}
+
+Buf.chars(b: self ref Buf): int
+{
+	return len b.s;
+}
+
+Buf.end(b: self ref Buf): ref Cursor
+{
+	return b.cursor(max(0, b.chars()));
+}
+
+Buf.str(b: self ref Buf): string
+{
+	return b.s;
+}
+
+# if cs/ce is nil, write whole buf
+bufwritefd(b: ref Buf, cs, ce: ref Cursor, fd: ref Sys->FD): string
+{
+	if(cs == nil)
+		buf := array of byte b.s;
+	else
+		buf = array of byte text.get(cs, ce);
+	if(sys->write(fd, buf, len buf) != len buf)
+		return "write failed: %r";
+	return nil;
+}
--- /dev/null
+++ b/appl/wm/vixen/vixen/change.b
@@ -1,0 +1,70 @@
+Mod: adt {
+	o: int;
+	p: Pos;
+	s: string;
+	pick {
+	Ins or
+	Del =>
+		# o & p are for start of change
+	}
+
+	invert:	fn(m: self ref Mod): ref Mod;
+	text:	fn(m: self ref Mod): string;
+};
+
+Change: adt {
+	inverted:	int;  # whether this is inverted.  significant for cursor position after applying
+	l:	list of ref Mod;  # hd of list is last modification (also at later positions in file)
+	ogen, ngen:	big;  # gen before and after this change
+
+	beginpos:	fn(c: self ref Change): Pos;
+	invert:		fn(c: self ref Change): ref Change;
+	text:		fn(c: self ref Change): string;
+};
+
+
+Mod.invert(mm: self ref Mod): ref Mod
+{
+	pick m := mm {
+	Ins =>	return ref Mod.Del (m.o, m.p, m.s);
+	Del =>	return ref Mod.Ins (m.o, m.p, m.s);
+	}
+}
+
+modtags := array[] of {"Ins", "Del"};
+Mod.text(mm: self ref Mod): string
+{
+	s := sprint("Mod.%s(", modtags[tagof mm]);
+	pick m := mm {
+	Ins =>	s += sprint("o=%d, p=%s, s=%q", m.o, m.p.text(), m.s);
+	Del =>	s += sprint("o=%d, p=%s, s=%q", m.o, m.p.text(), m.s);
+	}
+	s += ")";
+	return s;
+}
+
+Change.beginpos(c: self ref Change): Pos
+{
+	if(c.inverted)
+		m := hd c.l;
+	else
+		m = hd rev(c.l);
+	return m.p;
+}
+
+Change.invert(cc: self ref Change): ref Change
+{
+	c := ref Change (!cc.inverted, nil, cc.ngen, cc.ogen);
+	for(l := rev(cc.l); l != nil; l = tl l)
+		c.l = (hd l).invert()::c.l;
+	return c;
+}
+
+Change.text(c: self ref Change): string
+{
+	s := sprint("Change(inverted=%d, ogen=%bd, ngen=%bd\n", c.inverted, c.ogen, c.ngen);
+	for(l := c.l; l != nil; l = tl l)
+		s += "\t"+(hd l).text()+"\n";
+	s += ")";
+	return s;
+}
--- /dev/null
+++ b/appl/wm/vixen/vixen/cmd.b
@@ -1,0 +1,182 @@
+Cmd: adt {
+	i:	int; # index of next char to return
+	s:	string;
+	n1,
+	n2:	string;
+
+	new:		fn(): ref Cmd;
+	mk:		fn(s: string): ref Cmd;
+	clone:		fn(c: self ref Cmd): ref Cmd;
+	char:		fn(c: self ref Cmd): int;
+	get:		fn(c: self ref Cmd): int;
+	xget:		fn(c: self ref Cmd): int;
+	put:		fn(c: self ref Cmd, c: int);
+	unget:		fn(c: self ref Cmd);
+	havenum:	fn(c: self ref Cmd): int;
+	more:		fn(c: self ref Cmd): int;
+	rem:		fn(c: self ref Cmd): string;
+	num1:		fn(c: self ref Cmd, def: int): int;
+	num2:		fn(c: self ref Cmd, def: int): int;
+	getint:		fn(c: self ref Cmd, def: int): int;
+	getnum:		fn(c: self ref Cmd): string;
+	xgetnum:	fn(c: self ref Cmd): string;
+	getnum1:	fn(c: self ref Cmd);
+	getnum2:	fn(c: self ref Cmd);
+	xgetnum1:	fn(c: self ref Cmd);
+	xgetnum2:	fn(c: self ref Cmd);
+	str:		fn(c: self ref Cmd): string;
+	text:		fn(c: self ref Cmd): string;
+};
+
+Cmd.new(): ref Cmd
+{
+	return Cmd.mk(nil);
+}
+
+Cmd.mk(s: string): ref Cmd
+{
+	return ref Cmd (0, s, "", "");
+}
+
+Cmd.clone(c: self ref Cmd): ref Cmd
+{
+	return ref *c;
+}
+
+Cmd.char(c: self ref Cmd): int
+{
+	if(c.i >= len c.s)
+		return -1;
+	return c.s[c.i];
+}
+
+Cmd.get(c: self ref Cmd): int
+{
+	if(c.i >= len c.s)
+		return -1;
+	return c.s[c.i++];
+}
+
+Cmd.xget(c: self ref Cmd): int
+{
+	if(!c.more())
+		raise "more:";
+	x := c.get();
+	if(x == kb->Esc)
+		raise "abort:";
+	return x;
+}
+
+Cmd.put(c: self ref Cmd, x: int)
+{
+	c.s[len c.s] = x;
+}
+
+Cmd.unget(c: self ref Cmd)
+{
+	if(c.i <= 0)
+		raise "unget at index <= 0";
+	--c.i;
+}
+
+# whether we have a whole num in the buffer
+Cmd.havenum(c: self ref Cmd): int
+{
+	x := c.char();
+	if(x < 0 || !str->in(x, "1-9"))
+		return 0;
+	# look for a non-num to finish the num
+	for(i := c.i+1; i < len c.s; i++) {
+		x = c.s[i];
+		if(x < '0' || x > '9')
+			return 1;
+	}
+	return 0;
+}
+
+Cmd.more(c: self ref Cmd): int
+{
+	return c.i < len c.s;
+}
+
+Cmd.rem(c: self ref Cmd): string
+{
+	return c.s[c.i:];
+}
+
+Cmd.num1(c: self ref Cmd, def: int): int
+{
+	if(c.n1 == nil)
+		return def;
+	return int c.n1;
+}
+
+Cmd.num2(c: self ref Cmd, def: int): int
+{
+	if(c.n2 == nil)
+		return def;
+	return int c.n2;
+}
+
+# getint reads as many digits as possible.
+# getnum below is for movements, where a num is only complete if it ends with a non-digit.
+Cmd.getint(c: self ref Cmd, def: int): int
+{
+	s: string;
+	while(c.more() && str->in(c.char(), "0-9"))
+		s[len s] = c.get();
+	if(s != nil)
+		def = int s;
+	return def;
+}
+
+Cmd.getnum(c: self ref Cmd): string
+{
+	s := "";
+	if(c.havenum()) {
+		s[len s] = c.get();
+		while(c.more() && str->in(c.char(), "0-9"))
+			s[len s] = c.get();
+	}
+	return s;
+}
+
+Cmd.xgetnum(c: self ref Cmd): string
+{
+	x := c.char();
+	if(x >= 0 && str->in(x, "1-9") && !c.havenum())
+		raise "more:";
+	return c.getnum();
+}
+
+Cmd.getnum1(c: self ref Cmd)
+{
+	c.n1 = c.getnum();
+}
+
+Cmd.getnum2(c: self ref Cmd)
+{
+
+	c.n2 = c.getnum();
+}
+
+Cmd.xgetnum1(c: self ref Cmd)
+{
+	c.n1 = c.xgetnum();
+}
+
+Cmd.xgetnum2(c: self ref Cmd)
+{
+	c.n2 = c.xgetnum();
+}
+
+Cmd.str(c: self ref Cmd): string
+{
+	return c.s;
+}
+
+Cmd.text(c: self ref Cmd): string
+{
+	return sprint("Cmd(i=%d, s=%q, n1=%q, n2=%q)", c.i, c.s, c.n1, c.n2);
+}
+
--- /dev/null
+++ b/appl/wm/vixen/vixen/ex.b
@@ -1,0 +1,411 @@
+ex(s: string)
+{
+	{
+		err := ex0(s, cursor);
+		if(err != nil)
+			statuswarn(err);
+	} exception x {
+	"ex:*" =>
+		statuswarn(x[len "ex:":]);
+	}
+}
+
+exerror(s: string)
+{
+	raise "ex:"+s;
+}
+
+exgetc(c: ref Cmd): int
+{
+	if(!c.more())
+		exerror(sprint("command not complete: %s", c.str()));
+	return c.get();
+}
+
+SepRequired, SepOptional: con iota;
+exreadsep(c: ref Cmd, sep: int)
+{
+	if(!c.more() || !str->in(c.char(), " \t")) {
+		if(sep == SepRequired)
+			exerror("missing whitespace separator");
+	} else {
+		while(str->in(c.char(), " \t"))
+			c.get();
+	}
+}
+
+RemNonempty, RemEmpty: con iota;
+exrem(c: ref Cmd, rem: int): string
+{
+	s := c.rem();
+	if(s == nil && rem == RemNonempty)
+		exerror("empty parameter");
+	return s;
+}
+
+exempty(c: ref Cmd)
+{
+	if(c.more())
+		exerror("trailing characters");
+}
+
+exnoaddr(c: ref Cursor)
+{
+	if(c != nil)
+		exerror("illegal address");
+}
+
+# pass in end of range, must be nil
+exnorange(c: ref Cursor)
+{
+	if(c != nil)
+		exerror("illegal range");
+}
+
+optget(c: ref Cmd, x: int): int
+{
+	if(c.char() != x)
+		return 0;
+	c.get();
+	return 1;
+}
+
+# interpret remainder of Cmd as filename or filter, read/execute it and return result
+exreadarg(c: ref Cmd): string
+{
+	exreadsep(c, SepOptional);
+	filt := optget(c, '!');
+	exreadsep(c, SepOptional);
+
+	cmd := exrem(c, RemNonempty);
+	if(filt)
+		(res, err) := filter(cmd, "", 0);
+	else
+		(res, err) = readfile(cmd);
+	if(err != nil)
+		exerror(err);
+	return res;
+}
+
+ex0(excmd: string, dot: ref Cursor): string
+{
+	if(excmd == nil)
+		return nil;
+
+	c := Cmd.mk(excmd);
+	(cs, ce, err) := range(c, dot);
+	if(err != nil)
+		return "bad range: "+err;
+	(csdef, cedef) := (cs, ce);
+	if(csdef == nil)
+		csdef = dot.mvcol(0);
+	if(cedef == nil)
+		cedef = csdef.mvlineend(1);
+
+	case x := c.get() {
+	'!' =>
+		# :!command		run command
+		# :addr!command		replace line with output from command (that gets the original line as input)
+		# :addr,addr!command	replace lines ...
+		cmd := c.rem();
+		say('x', sprint("! on %q", cmd));
+		if(cs == nil) {
+			err = sh->system(drawcontext, cmd);
+			if(err != nil)
+				return "error: "+err;
+		} else {
+			if(ce == nil) {
+				cs = cs.mvcol(0);
+				ce = cs.mvline(1, Colstart);
+			}
+			txt := text.get(cs, ce);
+			say('x', sprint("input is: %q", txt));
+			res: string;
+			(res, err) = filter(cmd, txt, 1);
+			if(err != nil) {
+				statuswarn("error: "+err);
+				res = err;
+			}
+			say('x', "result is: "+res);
+			textdel(Cchange|Csetcursorlo, cs, ce);
+			textins(Cchange, nil, res);
+		}
+	's' =>
+		# if no range, then current line (cursor) only.
+		# if 1 address, only that line.
+		# otherwise, substitute in that range
+		if(cs == nil)
+			cs = dot;
+		if(ce == nil) {
+			cs = cs.mvcol(0);
+			ce = cs.mvlineend(1);
+		}
+		sep := exgetc(c);
+		src, dst: string;
+		(src, err) = patternget(c, sep);
+		if(err == nil)
+			(dst, err) = patternget(c, sep);
+		if(err != nil)
+			return "missing parameters";
+		g := optget(c, 'g');
+		exempty(c);
+		err = substitute(cs, ce, src, dst, g);
+		if(err != nil)
+			return err;
+	'r' =>
+		if(cs == nil)
+			cs = dot.mvlineend(1);
+		if(ce != nil)
+			cs = ce.mvlineend(1);
+
+		res := exreadarg(c);
+		textins(Cchange|Csetcursorhi, cs, res);
+	'w' or
+	'q' =>
+		if(cs != nil && ce == nil)
+			ce = cs.mvlineend(1);
+		c.unget();
+		w := optget(c, 'w');
+		q := optget(c, 'q');
+		force := optget(c, '!');
+
+		if(q && cs != nil)
+			return "range not allowed";
+
+		ofilename: string;
+		exreadsep(c, SepOptional);
+		append := 0;
+		if(c.more()) {
+			ofilename = c.rem();
+			if(str->prefix(">>", ofilename)) {
+				append = 1;
+				ofilename = ofilename[len ">>":];
+			} else if(filename == nil)
+				filenameset(ofilename);
+		} else
+			ofilename = filename;
+
+		err: string;
+		if(w) {
+			if(append)
+				err = textappend(ofilename, cs, ce);
+			else
+				err = textwrite(force, ofilename, cs, ce);
+			if(err != nil)
+				statuswarn(err);
+			else {
+				statuswarn("written");
+				if(!append && cs == nil)
+					modified = 0;
+			}
+		}
+		if(q) {
+			if(err == nil && (!modified || force))
+				quit();
+			if(err == nil)
+				statuswarn("unsaved changes, use :q!");
+		}
+	'x' =>
+		force := optget(c, '!');
+		exempty(c);
+		writemodifiedquit(force);
+	'c' =>
+		case exgetc(c) {
+		'd' =>
+			# change dir.  ignore range/address.
+			exreadsep(c, SepRequired);
+			if(sys->chdir(exrem(c, RemNonempty)) < 0)
+				exerror(sprint("cd: %r"));
+		* =>
+			exerror(sprint("bad ex command: %q", excmd));
+		}
+	'f' =>
+		if(!c.more()) {
+			if(filename == nil)
+				statuswarn("(no filename)");
+			else
+				statuswarn(filename);
+		} else {
+			exreadsep(c, SepRequired);
+			filenameset(exrem(c, RemNonempty));
+			filefd = nil;
+			statusset();
+		}
+	'g' =>
+		# [range]g[!]/pattern/command
+		not := optget(c, '!');
+
+		pat: string;
+		(pat, err) = patternget(c, exgetc(c));
+		if(err != nil)
+			return err;
+		# xxx on empty pat, use last?
+		
+		re: Regex->Re;
+		(re, err) = regex->compile(pat, 0);
+		if(err != nil)
+			return "bad regex: "+err;
+		exreadsep(c, SepOptional);
+		arg := exrem(c, RemNonempty);
+
+		# below needs more care: 
+		# first all changes must be collected (one for each matching line)
+		# then they all must be executed.
+		return "'g' not yet supported";
+
+		while(Cursor.cmp(csdef, cedef) < 0) {
+			s := text.get(csdef, csdef.mvlineend(0));
+			a := regex->executese(re, s, (0, len s), 1, 1);
+			match := len a >= 1 && a[0].t0 >= 0;
+			if(match && !not || !match && not) {
+				{
+					err = ex0(arg, cs);
+					if(err != nil)
+						return err;
+				} exception ex {
+				"ex:*" =>
+					return ex[len "ex:":];
+				}
+			}
+			csdef = csdef.mvlineend(1);
+		}
+	'D' =>
+		# toggle debug string
+		exreadsep(c, SepOptional);
+		s := exrem(c, RemNonempty);
+		for(i := 0; i < len s; i++)
+			case y := s[i] {
+			'-' =>		debug = array[128] of {* => 0};
+			'+' =>		debug = array[128] of {* => 1};
+			'a' to 'z' =>	debug[y] = !debug[y];
+			* =>	exerror(sprint("bad debug char %c", y));
+			}
+	'b' =>
+		s: string;
+		if(!c.more()) {
+			(s, err) = regget('!');
+			if(err == nil && s == nil)
+				err = "no previous command";
+		} else {
+			exreadsep(c, SepRequired);
+			s = exrem(c, RemNonempty);
+		}
+		r: string;
+		if(err == nil)
+			(r, err) = filter(s, "", 1);
+		say('x', sprint(":b on %q, err %q, r %q", s, err, r));
+		if(err != nil)
+			exerror(err);
+		plumb(r, "newtext", workdir());
+		err = regput('!', s);
+		if(err != nil)
+			exerror(err);
+		
+	* =>
+		if(x >= 0)
+			c.unget();
+		exempty(c);
+		if(ce != nil)
+			cs = nil;
+		if(cs == nil)
+			break;
+		cursorset(cs.mvfirst());
+	}
+	return nil;
+}
+
+# read & evaluate an address.
+address(c: ref Cmd, dot: ref Cursor): (ref Cursor, string)
+{
+	if(dot == nil)
+		raise "missing dot";
+	n := 0;
+	exreadsep(c, SepOptional);
+Address:
+	while(c.more()) {
+		case x := c.get() {
+		'.' =>	{}
+		'$' =>	dot = text.pos(Pos (text.lines(), 0));
+		'0' to '9' =>
+			c.unget();
+			line := int c.getint(0);
+			dot = text.pos(Pos (max(1, line), 0));
+		'+' or
+		'-' =>
+			mult := 1;
+			if(x == '-')
+				mult = -1;
+			dot = dot.mvline(mult*c.getint(1), Colkeep);
+		'/' or
+		'?' =>
+			(pat, err) := patternget(c, x);
+			if(err != nil)
+				return (nil, "bad pattern: "+err);
+			re: Regex->Re;
+			(re, err) = regex->compile(pat, 0);
+			if(err != nil)
+				return (nil, "bad regex: "+err);
+			(cs, nil) := search(x == '?', 0, re, dot);
+			if(cs == nil)
+				return (nil, sprint("pattern %q not found", pat));
+			dot = cs;
+		'\'' =>
+			if(!c.more())
+				return (nil, "incomplete mark address");
+			x = c.get();
+			err: string;
+			(dot, err) = markget(x);
+			if(err != nil)
+				return (nil, err);
+		* =>
+			c.unget();
+			break Address;
+		}
+		n++;
+		exreadsep(c, SepOptional);
+	}
+	if(n == 0)
+		dot = nil;
+	return (dot, nil);
+}
+
+range(c: ref Cmd, dot: ref Cursor): (ref Cursor, ref Cursor, string)
+{
+	if(!c.more())
+		return (nil, nil, nil);
+	case c.get() {
+	'%' =>	return (text.cursor(0), text.end(), nil);
+	'*' =>	return (nil, nil, "range '*' not implemented");
+	}
+	c.unget();
+	(cs, err) := address(c, dot);
+	ce: ref Cursor;
+	if(err == nil && cs != nil && (c.char() == ',' || c.char() == ';')) {
+		if(c.get() == ';')
+			dot = cs;
+		(ce, err) = address(c, dot);
+	}
+	return (cs, ce, err);
+}
+
+# read until sep or until end of string
+patternget(c: ref Cmd, sep: int): (string, string)
+{
+	if(str->in(sep, "a-zA-Z0-9 \t\n\"|"))
+		return (nil, sprint("bad separator %c", sep));
+	s: string;
+	while(c.more()) {
+		x := c.get();
+		if(x == sep)
+			break;
+		case x {
+		'\\' =>
+			if(!c.more())
+				return (nil, "pattern unfinished");
+			if(c.char() == sep)
+				x = c.get();
+		}
+		s[len s] = x;
+	}
+	return (s, nil);
+}
--- /dev/null
+++ b/appl/wm/vixen/vixen/filter.b
@@ -1,0 +1,74 @@
+filter1(cmd: string, fd0, fd1: ref Sys->FD, stderr: int, resc: chan of (string, string), pidc: chan of int)
+{
+	pidc <-= pid();
+	sys->pctl(Sys->NEWFD|Sys->FORKNS|Sys->FORKENV, list of {fd0.fd, fd1.fd, 2});
+	sys->dup(fd0.fd, 0);
+	sys->dup(fd1.fd, 1);
+	if(stderr)
+		sys->dup(fd1.fd, 2);
+	fd0 = fd1 = nil;
+	err := sh->system(drawcontext, cmd);
+	if(err != nil)
+		resc <-= (nil, err);
+}
+
+writer(fd: ref Sys->FD, s: string, resc: chan of (string, string), pidc: chan of int)
+{
+	pidc <-= pid();
+	if(sys->write(fd, buf := array of byte s, len buf) != len buf)
+		resc <-= (nil, sprint("writing to command: %r"));
+}
+
+reader(fd: ref Sys->FD, resc: chan of (string, string), pidc: chan of int)
+{
+	pidc <-= pid();
+	s := "";
+	b := bufio->fopen(fd, Bufio->OREAD);
+	if(b == nil) {
+		resc <-= (nil, sprint("fopen: %r"));
+		return;
+	}
+	for(;;)
+	case c := b.getc() {
+	Bufio->EOF =>
+		resc <-= (s, nil);
+		return;
+	Bufio->ERROR =>
+		resc <-= (nil, sprint("reading from command: %r"));
+		return;
+	* =>
+		s[len s] = c;
+	}
+}
+
+filter0(cmd, input: string, stderr: int, outc: chan of (string, string))
+{
+	sys->pctl(Sys->NEWFD, list of {2});
+	fd0 := array[2] of ref Sys->FD;
+	fd1 := array[2] of ref Sys->FD;
+	if(sys->pipe(fd0) < 0 || sys->pipe(fd1) < 0) {
+		outc <-= (nil, sprint("pipe: %r"));
+		return;
+	}
+	pidc := chan of int;
+	resc := chan of (string, string);
+	spawn writer(fd0[1], input, resc, pidc);
+	wpid := <-pidc;
+	spawn reader(fd1[1], resc, pidc);
+	rpid := <-pidc;
+	spawn filter1(cmd, fd0[0], fd1[0], stderr, resc, pidc);
+	fpid := <-pidc;
+	fd0 = fd1 = nil;
+
+	(res, err) := <-resc;
+	kill(wpid);
+	kill(rpid);
+	kill(fpid);
+	outc <-= (res, err);
+}
+
+filter(cmd, input: string, stderr: int): (string, string)
+{
+	spawn filter0(cmd, input, stderr, outc := chan of (string, string));
+	return <-outc;
+}
--- /dev/null
+++ b/appl/wm/vixen/vixen/interp.b
@@ -1,0 +1,832 @@
+xabort(s: string)	{ raise "abort:"+s; }
+xmore()			{ raise "more:"; }
+xdone()			{ raise "done:"; }
+xconsumed()		{ raise "consumed:"; }
+xchange()		{ raise "change:"; }
+xmoveonly()		{ raise "moveonly:"; }
+
+interp(cc: ref Cmd)
+{
+	say('i', sprint("interp: mode %s, %s", modes[mode], cc.text()));
+	case mode {
+	Insert =>	insert(cc, 0);
+	Replace =>	insert(cc, 1);
+	Command0 =>	command(cc);
+	Visual or
+	Visualline =>	visual(cc);
+	}
+}
+
+interpx()
+{
+	say('i', sprint("interpx: mode %s, %s", modes[mode], cmdcur.text()));
+	cc := cmdcur.clone();
+	statustext = nil;
+	tkplumbclear();
+Interp:
+	while(cc.more()) {
+		{
+			interp(cc);
+			raise "interp returned";
+		} exception ex {
+		"abort:*" =>
+			# error while executing, discard command
+			ex = ex[len "abort:":];
+			if(ex != nil)
+				statuswarn(ex);
+			cmdcur = Cmd.new();
+			modeset(Command0);
+			register = '"';
+			break Interp;
+		"consumed:*" =>
+			# characters consumed, nothing special to do
+			cmdcur = cc;
+			statusset();
+		"change:*" =>
+			# a changing command finished.  store to cmdprev for repeat.
+			cmdprev = Cmd.mk(cmdcur.str());
+			cmdcur = Cmd.new();
+			modeset(Command0);  # calls statusset
+			register = '"';
+			changesave();
+		"done:*" =>
+			cmdcur = Cmd.new();
+			statusset();
+			register = '"';
+			changesave();
+		"moveonly:*" =>
+			# command that was move-only (don't store as cmdprev)
+			cmdcur = Cmd.new();
+			statusset();
+		"more:*" =>
+			# more input needed
+			statusset();
+			break Interp;
+		"edit:*" =>
+			# input needed from edit entry
+			editset(ex[len "edit:":]);
+			break Interp;
+		}
+	}
+	up();
+}
+
+
+modeset(m: int)
+{
+	tkcmd(".t.text tag remove sel 1.0 end");
+	case mode {
+	Insert or
+	Replace =>
+		xmarkput('^', cursor);
+		if(change != nil)
+			pick mm := hd change.l {
+			Ins =>
+				xregput('.', mm.s);
+			}
+	}
+	mode = m;
+	statusset();
+}
+
+
+macro(n: int, s: string)
+{
+	ocmd := cmdcur;
+	while(n-- > 0) {
+		cmdcur = Cmd.mk(s);
+		interpx();
+	}
+	cmdcur = ocmd;
+}
+
+
+Beep: adt {
+	beeped:	int;
+	base:	ref Cursor;
+	dst:	ref Cursor;
+
+	mk:	fn(base: ref Cursor): ref Beep;
+	set:	fn(b: self ref Beep, c: ref Cursor);
+	beepset:	fn(b: self ref Beep, c: ref Cursor);
+};
+
+Beep.mk(base: ref Cursor): ref Beep
+{
+	return ref Beep (0, base.clone(), base);
+}
+
+Beep.set(b: self ref Beep, c: ref Cursor)
+{
+	*b.dst = *c;
+}
+
+Beep.beepset(b: self ref Beep, c: ref Cursor)
+{
+	if(c == nil || Cursor.cmp(b.base, c) == 0)
+		b.beeped++;
+	else
+		b.set(c);
+}
+
+Nosetjump, Setjump: con iota;
+move(cc: ref Cmd, mult, setjump: int, cr: ref Cursor)
+{
+	say('i', "move: "+cc.text());
+	c := cc.clone();
+	numstr := c.xgetnum();
+	num := 1;
+	if(numstr != nil)
+		num = int numstr;
+	num *= mult;
+
+	b := Beep.mk(cr);
+
+	jump := 0;
+	newcolsnap := 1;
+
+	case x := c.xget() {
+	kb->Home or
+	'0' =>	b.set(cr.mvcol(0));
+	kb->Left or
+	'h' =>	b.beepset(cr.mvchar(-num));
+	kb->Right or
+	'l' =>	b.beepset(cr.mvchar(+num));
+	' ' =>
+		nc := cr.clone();
+		nc.next();
+		b.beepset(nc);
+	'w' =>	b.beepset(cr.mvword(0, +num));
+	'W' =>	b.beepset(cr.mvword(1, +num));
+	'b' =>	b.beepset(cr.mvword(0, -num));
+	'B' =>	b.beepset(cr.mvword(1, -num));
+	'e' =>	b.beepset(cr.mvwordend(0, +num));
+	'E' =>	b.beepset(cr.mvwordend(1, +num));
+	'G' =>	
+		if(numstr == nil)
+			b.set(text.end());
+		else
+			b.set(text.pos(Pos (num, 0)).mvfirst());
+		jump = 1;
+	kb->End or
+	'$' =>	b.set(cr.mvline(+num-1, Colend));
+	'^' =>	b.set(cr.mvfirst());
+	'-' =>	b.set(cr.mvline(-num, Colfirstnonblank));
+	'+' or
+	'\n' =>	b.beepset(cr.mvline(+num, Colfirstnonblank));
+	'_' =>	b.beepset(cr.mvline(num-1, Colfirstnonblank));
+	'f' or
+	'F' or
+	't' or
+	'T' or
+	';' or
+	',' =>
+		if((x == ';' || x == ',') && lastfind == 0)
+			xabort("no previous find");
+		y: int;
+		case x {
+		';' =>
+			x = lastfind;
+			y = lastfindchar;
+		',' =>
+			x = swapcasex(lastfind);
+			y = lastfindchar;
+		* =>
+			y = c.xget();
+			lastfind = x;
+			lastfindchar = y;
+		}
+		rev := x == 'F' || x == 'T';
+		nc := cr;
+		while(num--) {
+			nc = nc.findlinechar(y, rev);
+			if(nc == nil)
+				xabort("not found");
+		}
+		if(x == 't' && nc.pos.c > 0)
+			nc.prev();
+		if(x == 'T' && nc.char() != '\n')
+			nc.next();
+		b.beepset(nc);
+	'/' or
+	'?' =>
+		s := xeditget(c, sprint("%c", x));
+		searchreverse = (x == '?');
+		ms, me: ref Cursor;
+		if(s == nil || searchset(s)) {
+			(ms, me) = search(0, searchreverse, searchregex, cr);
+			b.beepset(ms);
+			if(ms == nil)
+				break;
+		}
+		if(ms != nil)
+			tkhighlight(ms, me);
+		jump = 1;
+	'n' or
+	'N' =>
+		rev := x == 'N';
+		ms, me: ref Cursor;
+		while(num--) {
+			(ms, me) = search(rev, searchreverse, searchregex, b.dst);
+			b.beepset(ms);
+			if(ms == nil)
+				break;
+		}
+		if(ms != nil)
+			tkhighlight(ms, me);
+		jump = 1;
+	'*' or
+	'#' =>	
+		(ws, we) := cursor.word();
+		if(ws == nil)
+			xabort("no word under cursor");
+		rev := x == '#';
+		ss := text.get(ws, we);
+		#Wordbreak: con "\\;\\]\\;\\-\\.\\^\\$\\(\\)\\*\\+\\?\\|\\[\\\\ \t!\"#%&',/:;<=>@\\^_`{|}~\\[";
+		#Wordbreak: con " \t";
+		#restr := sprint("(^|[%s])%s($|[%s])", Wordbreak, ss, Wordbreak);
+		restr := ss;
+		(re, err) := regex->compile(restr, 0);
+		if(err != nil)
+			xabort("bad pattern (internal error)");
+		if(rev)
+			b.beepset(ws);
+		ms, me: ref Cursor;
+		while(num--) {
+			(ms, me) = search(rev, 0, re, b.dst);
+			b.beepset(ms);
+			if(ms == nil)
+				break;
+		}
+		if(ms != nil)
+			tkhighlight(ms, me);
+		jump = 1;
+	'%' =>
+		if(numstr != nil) {
+			# move to percentage of file, in lines
+			perc := int numstr;
+			if(perc > 0 && perc <= 100)
+				b.beepset(text.pos(Pos (perc*text.lines()/100, Colfirstnonblank)));
+			else
+				b.beepset(nil);
+		} else {
+			# move to matching (){}[].  if other char under cursor, search forward for one.
+			if(cr.char() < 0)
+				break;
+			nc := b.dst.clone();
+			if(!str->in(nc.char(), "(){}[]"))
+				nc = nc.findchar("(){}[]", 0);
+			if(nc != nil) {
+				sep: string;
+				case look := nc.char() {
+				'(' or ')' =>	sep = "()";
+				'{' or '}' =>	sep = "{}";
+				'[' or ']' =>	sep = "[]";
+				}
+				rev := nc.char() == sep[1];
+				level := 1;
+				nc.walk(rev);
+				for(;;) {
+					nc = nc.findchar(sep, rev);
+					if(nc == nil)
+						break;
+					if(nc.char() != look)
+						--level;
+					else
+						++level;
+					if(level <= 0)
+						break;
+					nc.walk(rev);
+				}
+			}
+			b.beepset(nc);
+		}
+		jump = 1;
+	'|' =>
+		n := 0;
+		if(numstr != nil)
+			n = int numstr;
+		b.set(cr.mvcol(n*mult));
+	'(' or
+	')' =>
+		prev := x == '(';
+		while(num--) {
+			nc := b.dst.mvsentence(prev);
+			b.beepset(nc);
+			if(nc == nil)
+				break;
+		}
+		jump = 1;
+	'{' or
+	'}' =>
+		prev := x == '{';
+		while(num--) {
+			nc := b.dst.mvparagraph(prev);
+			b.beepset(nc);
+			if(nc == nil)
+				break;
+		}
+		jump = 1;
+	'`' =>
+		b.set(xmarkget(c.xget()));
+		jump = 1;
+	'\'' =>
+		b.set(xmarkget(c.xget()).mvfirst());
+		jump = 1;
+	'H' or
+	'M' or
+	'L' =>
+		(ps, pe) := tkvisible();
+		l: int;
+		case x {
+		'H' =>	l = min(ps.l+num-1, pe.l);
+		'M' =>	l = (ps.l+pe.l)/2;
+		'L' =>	l = max(ps.l, pe.l-num+1);
+		}
+		b.set(cr.mvpos(Pos (l, 0)).mvfirst());
+	'g' =>
+		case c.xget() {
+		'g' =>
+			if(numstr == nil)
+				b.set(text.pos(Pos(1, 0)));
+			else
+				b.set(text.pos(Pos (num, 0)).mvfirst());
+			jump = 1;
+		'o' =>
+			b.set(text.cursor(num-1));
+			jump = 1;
+		}
+	* =>
+		colkeep := Colkeep;
+		if(colsnap >= 0)
+			colkeep = colsnap;
+		case x {
+		kb->APP|'n' or
+		kb->Down or
+		'j' =>		b.beepset(cr.mvline(+num, colkeep));
+		kb->APP|'p' or
+		kb->Up or
+		'k' =>		b.beepset(cr.mvline(-num, colkeep));
+		kb->APP|'b' or
+		kb->Pgup =>	b.beepset(cr.mvline(-num*max(1, tklinesvisible()), colkeep));
+		kb->APP|'f' or
+		kb->Pgdown =>	b.beepset(cr.mvline(+num*max(1, tklinesvisible()), colkeep));
+		kb->APP|'u' =>	b.beepset(cr.mvline(-num*max(1, tklinesvisible()/2), colkeep));
+		kb->APP|'d' =>	b.beepset(cr.mvline(+num*max(1, tklinesvisible()/2), colkeep));
+		kb->APP|'y' =>
+			ps := tkvisibletop();
+			if(ps.l <= 1 && ps.c == 0)
+				break;
+			l := ps.l-1;
+			# change one line at a time so tk doesn't center location
+			while(num-- && l >= 1)
+				tkcmd(sprint(".t.text see %d.0; update", l--));
+			pe := tkvisiblebottom();
+			nc := cr;
+			if(Pos.cmp(nc.pos, pe) > 0)
+				nc = text.pos(Pos (pe.l, 0));
+			b.set(nc);
+		kb->APP|'e' =>
+			pe := tkvisiblebottom();
+			nl := text.lines();
+			if(pe.l >= nl)
+				break;
+			l := pe.l+1;
+			# change one line at a time so tk doesn't center location
+			while(num-- && l <= nl)
+				tkcmd(sprint(".t.text see %d.0; update", l++));
+			ps := tkvisibletop();
+			nc := cr;
+			if(Pos.cmp(nc.pos, ps) < 0)
+				nc = text.pos(ps);
+			b.set(nc);
+		* =>
+			xabort(sprint("bad command %c", x));
+		}
+		newcolsnap = 0;
+	}
+	if(newcolsnap)
+		colsnap = b.dst.pos.c;
+	*cc = *c;
+	if(b.beeped)
+		statuswarn("beep!");
+	if(jump && setjump && !b.beeped)
+		xmarkput('`', b.base);
+}
+
+
+insert(c: ref Cmd, repl: int)
+{
+	say('i', sprint("insert/replace, c %s", c.text()));
+	(cmod, cchange) := (Cmod, Cchange);
+	if(repl)
+		(cmod, cchange) = (Cmodrepl, Cchangerepl);
+	while(c.more())
+		case x := c.get() {
+		kb->Esc =>	
+				if(inserted())
+					cursorset(cursor.mvchar(-1));
+				xchange();
+		kb->APP|'h' or
+		kb->Del =>	textdel(cmod|Csetcursorlo, cursor.mvchar(-1), nil);
+		kb->APP|'w' =>	textdel(cmod|Csetcursorlo, cursor.mvword(0, -1), nil);
+		kb->APP|'u' =>	textdel(cmod|Csetcursorlo, cursor.mvcol(0), nil);
+		* =>
+			if(repl && x == '\n' || cursor.char() == '\n')
+				(cmod, cchange) = (Cmod, Cchange);
+			textins(cchange|Csetcursorhi, nil, sprint("%c", x));
+		}
+	xconsumed();
+}
+
+visual(cc: ref Cmd)
+{
+	c := cc.clone();
+	c.xgetnum1();
+
+	(vs, ve) := visualrange();
+	say('i', sprint("visual, vs %s, ve %s", vs.text(), ve.text()));
+
+	case x := c.xget() {
+	kb->Esc =>
+		xabort(nil);
+	'd' or
+	'D' =>
+		textdel(Cchange|Csetcursorlo|Csetreg, vs, ve);
+	'p' =>
+		textrepl(Cchange|Csetcursorlo, vs, ve, xregget(register));
+	'y' =>
+		xregput(register, text.get(vs, ve));
+	'J' =>
+		join(vs, ve, 1);
+	'<' or
+	'>' =>
+		indent(vs.mvcol(0), ve, x == '<');
+	'r' =>
+		y := c.xget();
+		s: string;
+		n := abs(Cursor.diff(vs, ve));
+		while(n--)
+			s[len s] = y;
+		mv := Csetcursorlo;
+		if(y == '\n')
+			mv = Csetcursorhi;
+		textrepl(Cchange|mv, vs, ve, s);
+	'!' =>
+		ex(xeditget(c, ":'<,'>!"));
+	':' =>
+		ex(xeditget(c, ":'<,'>"));
+	'~' =>
+		textrepl(Cchange|Csetcursorlo, vs, ve, swapcase(text.get(vs, ve)));
+	'g' =>
+		case c.xget() {
+		'J' =>
+			join(vs, ve, 0);
+		'~' =>
+			textrepl(Cchange|Csetcursorlo, vs, ve, swapcase(text.get(vs, ve)));
+		'u' =>
+			textrepl(Cchange|Csetcursorlo, vs, ve, str->tolower(text.get(vs, ve)));
+		'U' =>
+			textrepl(Cchange|Csetcursorlo, vs, ve, str->toupper(text.get(vs, ve)));
+		'p' or
+		'P' =>
+			plumb(text.get(vs, ve), nil, plumbdir());
+		* =>
+			c = cc.clone();
+			move(c, 1, Setjump, ce := cursor.clone());
+			visualend = ce.clone();
+			visualset();
+			cursorset(ce);
+			*cc = *c;
+			xconsumed();
+		}
+	* =>
+		case x {
+		'q' =>
+			recordq(c);
+		* =>
+			case x {
+			kb->APP|'l' =>
+				redraw();
+			'o' =>
+				(cursor, visualstart) = (visualstart, cursor);
+				visualset();
+				cursorset(cursor);
+			'"' =>
+				xregset(c.xget());
+			'c' or
+			's' =>
+				textdel(Cchange|Csetcursorlo, vs, ve);
+				modeset(Insert);
+			'C' or
+			'S' or
+			'R' =>
+				if(mode != Visualline)
+					ve = ve.mvlineend(1);
+				textdel(Cchange|Csetcursorlo, vs.mvcol(0), ve);
+				modeset(Insert);
+			* =>
+				c = cc.clone();
+				move(c, 1, Setjump, ce := cursor.clone());
+				visualend = ce.clone();
+				visualset();
+				cursorset(ce);
+			}
+			*cc = *c;
+			xconsumed();
+		}
+		*cc = *c;
+		xdone();
+	}
+	*cc = *c;
+	xchange();
+}
+
+commandmove(c: ref Cmd, num1, end: int): (int, ref Cursor)
+{
+	cc := c.clone();
+	cc.xgetnum2();
+	num2 := cc.num2(1);
+	x := cc.xget();
+
+	if(x == end) {
+		*c = *cc;
+		return (num2, nil);
+	}
+	move(c, num1, Nosetjump, ce := cursor.clone());
+	return (num2, ce);
+}
+
+command(cc: ref Cmd)
+{
+	c := cc.clone();
+	c.xgetnum1();
+	num1 := c.num1(1);
+
+	cs := cursor;
+	case x := c.xget() {
+	kb->Esc =>
+		xabort(nil);
+	'x' =>
+		textdel(Cchange|Csetcursorlo|Csetreg, nil, cursor.mvchar(num1));
+	'X' =>
+		textdel(Cchange|Csetcursorlo|Csetreg, cursor.mvchar(-num1), nil);
+	'd' =>
+		(num2, ce) := commandmove(c, num1, 'd');
+		if(ce == nil) {
+			(cs, ce) = (cursor.mvcol(0), cursor.mvline(num1*num2-1, Colpastnewline));
+			textdel(Cchange|Csetcursorlo|Csetreg, cs, ce);
+			cursorset(cursor.mvfirst());
+		} else
+			textdel(Cchange|Csetcursorlo|Csetreg, cs, ce);
+	'D' =>
+		textdel(Cchange|Csetcursorlo|Csetreg, nil, cursor.mvline(num1-1, Colend));
+	'y' =>
+		(num2, ce) := commandmove(c, num1, 'y');
+		if(ce == nil)
+			(cs, ce) = (cs.mvcol(0), cs.mvline(num1*num2-1, Colpastnewline));
+		(cs, ce) = Cursor.order(cs, ce);
+		xregput(register, text.get(cs, ce));
+	'Y' =>
+		s := text.get(cursor.mvcol(0), cursor.mvline(num1, Colstart));
+		xregput(register, s);
+	'p' =>
+		s := xregget(register);
+		if(s[len s-1] == '\n')
+			cs = cs.mvlineend(1);
+		else
+			cs = cs.mvchar(+1);
+		r: string;
+		while(num1--)
+			r += s;
+		textins(Cchange|Csetcursorlo, cs, r);
+	'P' =>
+		s := xregget(register);
+		if(s[len s-1] == '\n')
+			cursorset(cursor.mvcol(0));
+		r: string;
+		while(num1--)
+			r += s;
+		textins(Cchange, nil, r);
+	'<' or
+	'>' =>
+		(num2, ce) := commandmove(c, num1, x);
+		if(ce == nil)
+			ce = cursor.mvline(max(0, num1*num2-1), Colend);
+		(cs, ce) = Cursor.order(cs, ce);
+		indent(cs.mvcol(0), ce.mvlineend(0), x == '<');
+	'J' =>
+		cs = cursor.mvlineend(0);
+		ce := cursor.mvline(max(0, num1), Colpastnewline);
+		join(cs, ce, 1);
+	'm' =>
+		y := c.xget();
+		xmarkput(y, cursor);
+	'r' =>
+		ce := cursor.mvchar(+num1);
+		if(Cursor.diff(cursor, ce) < num1)
+			xabort(nil);
+		y := c.xget();
+		textdel(Cchange|Csetcursorlo, nil, ce);
+		s: string;
+		while(num1--)
+			s[len s] = y;
+		mv := 0;
+		if(y == '\n')
+			mv = Csetcursorhi;
+		textins(Cchange|mv, nil, s);
+	'!' =>
+		(num2, ce) := commandmove(c, num1, '!');
+		if(ce == nil)
+			ce = cs.mvline(num1*num2-1, 0);
+		if(cursor.pos.l == ce.pos.l)
+			pre := ":.!";
+		else
+			pre = sprint(":.,%+d!", ce.pos.l-cursor.pos.l);
+		s := xeditget(c, pre);
+		ex(s);
+	'Z' =>
+		case c.xget() {
+		'Z' =>	writemodifiedquit(0);
+		* =>	xabort(nil);
+		}
+	'~' =>
+		ce := cs.mvchar(+num1);
+		r := swapcase(text.get(cs, ce));
+		textrepl(Cchange|Csetcursorhi, cs, ce, r);
+	'g' =>
+		case y := c.xget() {
+		'J' =>
+			cs = cursor.mvlineend(0);
+			ce := cursor.mvline(max(1, num1-1), Colstart);
+			join(cs, ce, 0);
+		'~' =>
+			(num2, ce) := commandmove(c, num1, '~');
+			if(ce == nil) {
+				ce = cursor.mvline(num1*num2-1, Colpastnewline).mvfirst();
+				cs = cs.mvfirst();
+			}
+			(cs, ce) = Cursor.order(cs, ce);
+			s := swapcase(text.get(cs, ce));
+			textrepl(Cchange|Csetcursorlo, cs, ce, s);
+		'u' or
+		'U' =>
+			(num2, ce) := commandmove(c, num1, y);
+			if(ce == nil) {
+				ce = cs.mvline(num1*num2-1, Colpastnewline).mvfirst();
+				cs = cs.mvfirst();
+			}
+			(cs, ce) = Cursor.order(cs, ce);
+			s := text.get(cs, ce);
+			if(y == 'u')
+				s = str->tolower(s);
+			else
+				s = str->toupper(s);
+			textrepl(Cchange|Csetcursorlo, cs, ce, s);
+		'i' =>
+			cursorset(xmarkget('^'));
+			modeset(Insert);
+			*cc = *c;
+			xconsumed();
+		'I' =>
+			cursorset(cursor.mvcol(0));
+			modeset(Insert);
+			*cc = *c;
+			xconsumed();
+		'b' =>
+			s := xregget('!');
+			(r, err) := filter(s, "", 1);
+			say('i', sprint("gb, s %q, err %q, r %q", s, err, r));
+			if(err != nil)
+				xabort(err);
+			plumb(r, "newtext", workdir());
+		'p' =>
+			(num2, ce) := commandmove(c, num1, 'p');
+			if(ce == nil)
+				ce = cursor.mvline(num1*num2-1, Colend);
+			s := text.get(cs, ce);
+			say('i', sprint("plumbing %q", s));
+			plumb(s, nil, plumbdir());
+		'P' =>
+			ce: ref Cursor;
+			(cs, ce) = cs.pathpattern(1);
+			if(cs == nil)
+				xabort("no path under cursor");
+			s := text.get(cs, ce);
+			say('i', sprint("plumbing %q", s));
+			plumb(s, nil, plumbdir());
+		* =>
+			c = cc.clone();
+			move(c, 1, Setjump, ce := cursor.clone());
+			cursorset(ce);
+			*cc = *c;
+			xmoveonly();
+		}
+	* =>
+		case x {
+		kb->APP|'g' =>
+			statusset();
+		kb->APP|'r' =>
+			redo();
+		kb->APP|'l' =>
+			redraw();
+		'@' =>
+			y := c.xget();
+			if(y == '@') {
+				if(lastmacro == 0)
+					xabort("no previous macro");
+				y = lastmacro;
+			} else
+				lastmacro = y;
+			ss := xregget(y);
+			macro(num1, ss);
+		'Q' or
+		':' =>
+			ex(xeditget(c, ":"));
+		'u' =>
+			undo();
+		'.' =>
+			if(cmdprev == nil)
+				xabort("no previous command");
+			say('i', sprint("cmdprev: %s", cmdprev.text()));
+			cmd := cmdprev.clone();
+			while(cmd.more())
+				{
+					interp(cmd);
+					raise "interp returned";
+				} exception ex {
+				"abort:*" =>	*cc = *c; raise ex;
+				"more:*" =>	raise "internal error, repeat needs more chars";
+				"consumed:*" =>	{}
+				"change:*" =>	*cc = *c; modeset(Command0); xdone();
+				"done:*" =>	*cc = *c; raise ex;
+				"moveonly:*" =>	raise "internal error, repeat was just movement";  # inspect cmdprev
+				"edit:*" =>	raise "internal error, repeat required edit input";
+				}
+		'q' =>
+			recordq(c);
+		* =>
+			case x {
+			'v' or
+			'V' =>
+				if(x == 'v')
+					modeset(Visual);
+				else
+					modeset(Visualline);
+				visualstart = cursor.clone();
+				visualend = visualstart.clone();
+				visualset();
+			'c' =>
+				(num2, ce) := commandmove(c, num1, 'c');
+				if(ce == nil)
+					ce = cursor.mvline(num1*num2, Colpastnewline);
+				textdel(Cchange|Csetcursorlo|Csetreg, nil, ce);
+				modeset(Insert);
+			'C' =>
+				textdel(Cchange|Csetcursorlo|Csetreg, nil, cursor.mvlineend(0));
+				modeset(Insert);
+			's' =>
+				cs = cursor.clone();
+				ce := cs.mvchar(+num1);
+				textdel(Cchange|Csetcursorlo|Csetreg, cs, ce);
+				modeset(Insert);
+			'S' =>
+				textdel(Cchange|Csetcursorlo|Csetreg, cursor.mvcol(0), cursor.mvline(num1-1, Colpastnewline));
+				modeset(Insert);
+			'i' =>
+				modeset(Insert);
+			'I' =>
+				cursorset(cursor.mvfirst());
+				modeset(Insert);
+			'a' =>
+				cursorset(cursor.mvchar(+1));
+				modeset(Insert);
+			'A' =>
+				cursorset(cursor.mvlineend(0));
+				modeset(Insert);
+			'o' =>
+				modeset(Insert);
+				textins(Cchange|Csetcursorhi, cursor.mvlineend(0), "\n");
+			'O' =>
+				modeset(Insert);
+				textins(Cchange|Csetcursorlo, cursor.mvcol(0), "\n");
+			'R' =>
+				modeset(Replace);
+			'"' =>
+				xregset(c.xget());
+			* =>
+				c = cc.clone();
+				move(c, 1, Setjump, ce := cursor.clone());
+				cursorset(ce);
+				*cc = *c;
+				xmoveonly();
+			}
+			*cc = *c;
+			xconsumed();
+		}
+		*cc = *c;
+		xdone();
+	}
+	*cc = *c;
+	xchange();
+}
--- /dev/null
+++ b/appl/wm/vixen/vixen/misc.b
@@ -1,0 +1,113 @@
+dropindent(c: ref Cmd)
+{
+	for(i := 8; i > 0; i--)
+		case c.get() {
+		' ' =>	;
+		'\t' =>	return;
+		* =>	c.unget(); return;
+		}
+}
+
+# (un)indent text
+indent(cs, ce: ref Cursor, rev: int)
+{
+	s := text.get(cs, ce);
+	r: string;
+	if(rev) {
+		c := Cmd.mk(s);
+		while(c.more()) {
+			dropindent(c);
+			while((x := c.get()) >= 0) {
+				r[len r] = x;
+				if(x == '\n')
+					break;
+			}
+		}
+	} else {
+		if(len s > 0 && s[0] != '\n')
+			r[len r] = '\t';
+		for(i := 0; i < len s; i++) {
+			r[len r] = s[i];
+			if(s[i] == '\n' && i+1 < len s && s[i+1] != '\n')
+				r[len r] = '\t';
+		}
+	}
+	
+	textdel(Cchange|Csetcursorlo, cs, ce);
+	textins(Cchange, nil, r);
+	cursorset(cursor.mvfirst());
+}
+
+# could try harder with more broader unicode support
+swapcasex(c: int): int
+{
+	case c {
+	'a' to 'z' =>	return c-'a'+'A';
+	'A' to 'Z' =>	return c-'A'+'a';
+	* =>		return c;
+	}
+}
+
+swapcase(s: string): string
+{
+	r: string;
+	for(i := 0; i < len s; i++)
+		r[len r] = swapcasex(s[i]);
+	return r;
+}
+
+# remove empty lines, replace newline by a nothing or a space
+join(cs, ce: ref Cursor, space: int)
+{
+	s := text.get(cs, ce);
+
+	r := "";
+	n := len s;
+	if(n >= 0 && s[n-1] == '\n')
+		--n;
+	lastnl := 0;  # offset in r at which last newline from s was collapsed
+	for(i := 0; i < n; i++)
+		case s[i] {
+		'\n' =>
+			lastnl = len r;
+			if(space)
+				r[len r] = ' ';
+			while(i+1 < len s && s[i+1] == '\n')
+				++i;
+		* =>
+			r[len r] = s[i];
+		}
+	if(n == len s-1)
+		r[len r] = '\n';
+
+	textrepl(Cchange, cs, ce, r);
+	cursorset(text.cursor(cs.o+lastnl));
+}
+
+hasnewline(s: string): int
+{
+	for(i := 0; i < len s; i++)
+		if(s[i] == '\n')
+			return 1;
+	return 0;
+}
+
+has(c: int, s: string): int
+{
+	ns := len s;
+	for(i := 0; i < ns; i++)
+		if(c == s[i])
+			return 1;
+	return 0;
+}
+
+workdir(): string
+{
+	return sys->fd2path(sys->open(".", Sys->OREAD));
+}
+
+isdir(f: string): int
+{
+	(ok, d) := sys->stat(f);
+	return ok >= 0 && (d.mode & Sys->DMDIR);
+}
--- /dev/null
+++ b/appl/wm/vixen/vixen/subs.b
@@ -1,0 +1,92 @@
+Repl: adt {
+	l: list of (string, int);  # string, regex match index (only valid >= 0)
+};
+
+# parse replacement string
+parserepl(s: string): (ref Repl, string)
+{
+	r := ref Repl;
+	d: string;
+	for(i := 0; i < len s; i++) {
+		case c := s[i] {
+		'\\' =>
+			if(i+1 >= len s)
+				return (nil, "\\ at end of dst pattner");
+			++i;
+			case c = s[i] {
+			'\\' =>
+				d[len d] = c;
+			'0' to '9' =>
+				r.l = (d, c-'0')::r.l;
+				d = nil;
+			't' =>	d[len d] = '\t';
+			'n' =>	d[len d] = '\n';
+			'r' =>	d[len d] = '\r';
+			'f' =>	d[len d] = '\f';
+			* =>
+				return (nil, sprint("bad escape \\%c", c));
+			}
+		* =>
+			d[len d] = c;
+		}
+	}
+	if(d != nil)
+		r.l = (d, -1)::r.l;
+	return (r, nil);
+}
+
+# returns list of matches, hd is last in file
+refind(re: Regex->Re, s: string, ss, se: int, g: int): list of array of (int, int)
+{
+	l: list of array of (int, int);
+	for(;;) {
+		bol := ss == 0 || s[ss-1] == '\n';
+		eol := se == len s || s[se] == '\n';
+		r := regex->executese(re, s, (ss, se), bol, eol);
+		if(len r == 0 || r[0].t0 < 0)
+			break;
+		l = r::l;
+		ss = r[0].t1;
+		if(!g)
+			break;
+	}
+	return l;
+}
+
+reapply(repl: ref Repl, r: array of (int, int)): string
+{
+	# build up new string first
+	s: string;
+	for(l := repl.l; l != nil; l = tl l) {
+		(t, i) := hd l;
+		s += t;
+		if(i >= len r)
+			return sprint("bad backreference %d", i);
+		if(i < 0 || r[i].t0 < 0)
+			continue;
+		s += text.str()[r[i].t0:r[i].t1];
+	}
+
+	cs := text.cursor(r[0].t0);
+	ce := text.cursor(r[0].t1);
+	textdel(Cchange|Csetcursorlo, cs, ce);
+	textins(Cchange, cs, s);
+	return nil;
+}
+
+substitute(cs, ce: ref Cursor, src, dst: string, g: int): string
+{
+	(repl, err) := parserepl(dst);
+	re: Regex->Re;
+	if(err == nil)
+		(re, err) = regex->compile(src, 1);
+	if(err != nil)
+		return err;
+
+	r := refind(re, text.str(), cs.o, ce.o, g);
+	if(r == nil)
+		return "no match";
+	for(; err == nil && r != nil; r = tl r)
+		err = reapply(repl, hd r);
+	return err;
+}
--- a/lib/emptydirs
+++ b/lib/emptydirs
@@ -186,4 +186,5 @@
 dis/usb
 dis/wm
 dis/wm/brutus
+dis/wm/vixen
 dis/zip
--- /dev/null
+++ b/man/1/vixen
@@ -1,0 +1,1011 @@
+.TH VIXEN 1
+.SH NAME
+vixen \- vi-like text editor
+.SH SYNOPSIS
+.B wm/vixen
+[
+.B -d
+.I debug
+] [
+.B -c
+.I macro
+] [
+.B -i
+]
+.I path
+.SH DESCRIPTION
+.I Vixen
+is a vi-like text editor for Inferno.
+
+.SS Options
+.I Vixen
+edits
+.IR path ,
+or a new buffer if no path is given.
+If
+.I path
+is a directory, nothing is read.
+.TP
+.BI -d " debug"
+Print debug messages to standard error.
+Each character in
+.I debug
+enables debug printing for functionality associated with that character (
+.I `a-z'
+only).  See the source code for the mapping of character to functionality.  As a special case,
+.I `+'
+enables all debug printing.
+.TP
+.BI -c " macro"
+After startup, before giving control to the user,
+.I macro
+is executed.
+.TP
+.B -i
+Reads initial buffer from standard input instead of from file.  This can be used to start
+.I vixen
+with a directory as
+.I filename
+and pipe the output of a command to it.
+
+
+.SS Modes
+.I Vixen
+is a modeful text editor.  It starts in
+.I command
+mode, interpreting key strokes as commands.  Commands can move the insertion cursor, modify the buffer, change the mode, and more.  Many commands that modify the buffer expect to be followed by a motion commands, causing the command to operate on text from the insertion cursor to the position after the motion.
+.I Insert
+mode, entered by
+.B i
+(among others),
+inserts further keys at the insertion cursor and advances it.
+.I Replace
+mode, started by
+.BR r ,
+is similar but overwrites text instead of inserting it.
+.I Visual
+mode, started by
+.BR v ,
+accepts commands just as
+.I command
+mode, but commands operate on the selection (indicated by reverse colors), and motion commands change the selection.
+.I "Visual line"
+mode only allows whole lines to be selected.
+.I Ex
+commands can be entered by typing a colon, though this is not considered a separate mode.
+Pressing escape in any mode switches back to
+.I command
+mode.
+
+.SS User interface
+The main text
+.I window
+is a view of the current buffer.  The title contains the name ``vixen'' and the absolute path of the file being edited.
+.I Vixen
+makes sure the insertion cursor is always visible, unless the view is scrolled by the scrollbar.
+Under the window is the status bar, it contains the current mode, the buffer's file name (or that none has been set yet), the number of lines and characters in the file, the current insertion position, the characters that have been collected as a command, and optionally an error message.  When entering line-based commands, such as
+.I ex
+commands and searches, the status bar is temporarily replaced by a text entry widget.
+The end of the buffer is indicated by an EOT character in blue color on white background color.
+The insertion cursor is shown before the character it is positioned at.  The visual selection is indicated in reverse colors.
+A text search match is indicated with black text on a yellow background, after a change of the text the colors are removed.
+After an incoming plumb message, the added text or addressed line is indicated by white text on a blue background.  The colors are removed on the first key stroke or command.
+The edit entry, for ex commands and searches, has file name completion and a history.  Pressing
+.I tab
+interprets the word before the cursor as a filename completes it to the largest common prefix of the matches.  If the word is already the largest common prefix, each
+.I tab
+fills in the next match.
+History is accessed with the up and down arrow keys.  Up selects the previous line that prefix-matches the text typed so far, down the next.
+
+.SS Buffer
+The buffer is the data that commands operate on.  All data is utf-8, arbitrary binary files cannot be edited.
+.I Vixen
+always has exactly one buffer
+The buffer can be in `modified' state, i.e. not yet flushed to disk.  A file name may be associated with a buffer, either because the buffer was read from that file or because the buffer has previously been written to it.
+
+.SS Insertion cursor and positions
+The buffer always has a insertion cursor (or just `cursor'), where text in
+.I insert
+or
+.I replace
+mode will be inserted, and commands will operate from.  The
+.I position
+of the insertion cursor is represented as a line (starting at 1) and column (starting at 0) within that line.  Positions always represent a character with one exception:  it can be positioned after the last character in the buffer, at the end of file.  Different from most vi clones, the insertion cursor can be positioned at a newline.  In an empty buffer, e.g. when starting
+.I vixen
+without parameters, the only valid position is
+.IR ``1.0'' .
+
+.SS Selection
+The selection is indicated by reverse colors, and only present while in one of the visual modes.  On entering the
+.I visual
+mode, the start and end of the selection is set to the cursor position.
+On entering the
+.I "visual line"
+mode, the selection is set from start of the cursor's line to the end of the cursor's line (including newline).
+Motion commands are interpreted from the end of the selection, even if its position is before the start of the selection (possible due to backwards motions).
+
+.SS Registers and macro's
+Registers can hold textual data.  They are useful to temporarily store text, for later use.  Special registers exist too, automatically updated.  Some commands, especially those that delete or insert text, modify a register.  This register can be changed by prefixing the command with the
+.B "<reg>"
+command.  The same applies to commands that use a register, e.g. yank.
+The contents of a register can also be executed with the
+.B @<reg>
+command, as a
+.IR macro .
+Macro's can also be recorded by the
+.B q<reg>
+command, and ended by a subsequent
+.B q
+command.
+.BR "<reg>" .
+
+.PP
+Registers:
+.TP
+.B "
+Holds text removed during last deletion.
+.TP
+.B \.
+Holds last insertion with
+.I insert
+or
+.I replace
+mode.
+.TP
+.B %
+Current filename, may be empty.  Read-only.
+.TP
+.B /
+Pattern of last text search.
+.TP
+.B *
+Contents of snarf buffer.
+.TP
+.B !
+Command last executed by
+.I ex
+command
+.B :b
+.IR command .
+.TP
+.B a-z
+Registers available to user.
+.TP
+.B A-Z
+Aliases for registers
+.IR a-z .
+Writes to these registers append to the lower cased registers, instead of writing them.
+
+.SS Marks
+Marks are
+.I positions
+in the file.  They can be set by the
+.B m<mark>
+command and jumped to by
+.B `<mark>
+and
+.B '<mark>
+commands (the first jumps to the exact position, the second to the first non-blank character of the position's line).  Some marks are automatically set when navigating the buffer, some cannot be modified by the user, see the table below.  A mark is automatically deleted when the text at its position is deleted.  Marks always point to the same character, and are automatically adjusted on insertions and deletions before them.
+The following marks are available:
+.TP
+.B <
+First character of visual selection (regardless of direction of selection, so not necessarily the start of the selection).  Set automatically when changing the visual selection, read-only.
+.BR m -command.
+.TP
+.B >
+Last character of visual selection.
+.TP
+.B "` and '"
+Many non-trivial motion commands are considered jumps.  These identical marks contain the position of the insertion cursor before the last jump.
+.TP
+.B .
+Location of last change, insertion or deletion.  Read-only.
+.TP
+.B ^
+Location of last insertion of text.  Read-only.
+.TP
+.B a-z
+No special meaning.  Note that marks A-Z and 0-9 are not available.
+
+.SS Changes and undo/redo
+Each modification of the buffer, e.g. by a command or an insertion through
+.I insert
+mode, is recorded as a change.  It can be undone by the
+.B u
+command.  Undone commands can be redone by the
+.B ^r
+command.
+
+.SS Inserting text
+Text can be inserted in the buffer while in an editing mode,
+.I insert
+or
+.I replace
+mode.  The last typed character can be removed by
+.BR ^h ,
+the last typed word by
+.BR ^w ,
+the last typed line by
+.BR ^u .
+Only text typed after switching to the current editing mode can be removed with the commands above.  Commands have to be used for other deletions.
+When in
+.I replace
+mode, text is overwritten.  Newlines are never overwritting though, so when the cursor is at the end of a line,
+.I replace
+mode is effectively the same as
+.I insert
+mode.  Erasing overwritten text reveals the original text again.
+
+.SS Plumbing
+Plumbing is supported through the non-visual
+.BR gP , gpp " and " gp<motion>
+commands, and the (identical) visual commands
+.BR gp " and " gP .
+Quick write and compile cycles are supported by the
+.B gb
+and
+.I ex'
+.B ":b [command]"
+commands.
+.I Vixen
+uses a helper program
+.I vixenplumb
+(not otherwise documented)
+to keep track of which files are being edited, and routes plumb requests accordingly or starts a new
+.IR vixen .
+
+.SH COMMANDS
+
+.SS Motion commands
+Motion commands set the insertion cursor.  While in visual select, they change the selection.  As modifier in commands, they indicate the range of text for the command to operate on.  Many motion commands can be prefixed by a repeat count (not starting with zero).  For example
+.B j
+moves the cursor down one line, while
+.B 3j
+moves the cursor down three lines.
+
+.PP
+The table below lists the motion commands and their cursor destination when a repeat has not been specified.  Commands prefixed with
+.I <n>
+interpret a repeat count, but all commands accept them.
+.TP
+.BI 0 " or " Home
+Column 0 of line.
+.TP
+.BI <n>h " or " <n>Left
+Character to the left, stopping at column 0.
+.TP
+.BI <n>l " or " <n>Right
+Character to the right, stopping at the newline.
+.TP
+.B <n>space
+Character to the right, not stopping at newlines.
+.TP
+.B <n>w
+Start of next word, stopping at interpunction.
+.TP
+.B <n>W
+Start of next word, not stopping at interpunction.
+.TP
+.B <n>e
+End of next word, stopping at interpunction.
+.TP
+.B <n>E
+End of next word, not stopping at interpunction.
+.TP
+.B <n>b
+To start of previous word, stopping at interpunction.
+.TP
+.B <n>G
+If
+.I <n>
+was not specified, moves to the end of the file.
+Otherwise, interprets
+.I <n>
+as line number and moves to first non-blank character on the line.
+.TP
+.BI $ " or " End
+End of current line (at newline if any).
+.TP
+.B ^
+First non-blank character on line.
+.TP
+.B <n>-
+First non-blank character on previous line.
+.TP
+.BI <n>+ " or " <n><newline>
+First non-blank character on next line.
+.TP
+.B <n>_
+First non-blank on current line or
+.I <n>-1
+lines relative from cursor.
+.TP
+.B <n>[fFtT]<char>
+Find next (for
+.B f
+and
+.BR t )
+or previous (for
+.B F
+and
+.BR T )
+.I char
+on current line.
+.B f
+and
+.B F
+place the cursor at the character,
+.B t
+on the character before it,
+.B T
+on the character after it.
+.TP
+.B <n>;
+Repeat last character find command (
+.BR [fFtT] )
+.TP
+.B <n>,
+Repeat last character find command, but in reverse direction (effectively executing the command with swapped case).
+.TP
+.BI <n>/ " or " <n>?
+Search forward or backward.  The
+.IR regex (6)
+pattern to search for must entered in the edit entry at the bottom of the window.
+.TP
+.BI <n>* " or " <n>#
+Search for the word under the cursor,
+.B #
+searches in reverse.  This sets the last search string used by commands
+.B n
+and
+.BR N .
+.TP
+.BI <n>n " or " <n>N
+Repeat last search,
+.B N
+reverse the search direction.
+.TP
+.B <n>%
+If 
+.I n
+was given it is interpreted as a percentage of the lines in the file, and the cursor is moved to that line, to the first non-blank character.
+Otherwise,
+jump to the counterpart of the nearest (forward) character in
+.IR (){}[] ,
+taking nesting into account.
+.TP
+.B <n>|
+Column
+.I n
+on current line, or column 0 by default.
+.TP
+.BI <n>( " or " <n>) " or " <n>{ " or " <n>}
+Next/previous sentence (
+.B )
+and
+.BR ( )
+or next/previous paragraph (
+.B }
+and
+.BR { ).
+In other vi implementations, the
+.B {
+and
+.B }
+commands also stop at troff paragraph commands,
+.I vixen
+does not.
+.TP
+.BI `<mark> " or " '<mark>
+To
+.IR mark .
+.TP
+.B 
+.TP
+.BI <n>H " or " M " or " <n>L
+Move relative to currently visible part of buffer.  If
+.I n
+was not specified, it defaults to 0.
+.B H
+jumps to the
+.RI first+ n th
+visible line.
+.B L
+jumps to the
+.RI last- n th
+visible line.
+.B M
+jumps to the middle, between the first and last.
+.TP
+.B <n>gg
+Like
+.BR G ,
+but jump to first line if 
+.I n
+was not specified.
+.TP
+.B <n>go
+To character
+.IR n th
+character of file, 1 by default (the first character in the file).
+.TP
+.BI ^e " and " ^y
+Scroll visible window up/down by one line.  This is not really a motion command because it only scrolls the window, however the insertion cursor is always kept in view and so may change as a side effect.  The same applies to
+.BR ^u ", " ^d ", " ^b " and " ^f .
+.TP
+.BI ^u " and " ^d
+Scroll visible window up/down by half a window.
+.TP
+.BI ^b " and " ^f
+Scroll visible window up/down by one window.
+
+.SS Visual commands
+Visual commands operate on the current text selection and can only be executed while in one of the visual modes.  Some commands operate on whole lines, even if lines are partially selected.  The cursor is usually left at the first character of the affected range after an execution.  The commands are very similar to the non-visual commands.  Visual commands:
+
+.PP
+.TP
+.B d
+Delete text.
+.TP
+.B y
+Yank text, i.e. copy text to the register.
+.TP
+.B J
+Join lines, i.e. replace newlines by a space.
+.TP
+.BI < " or " >
+Unindent/indent text, removing or inserting tabs.  Empty lines are kept empty.
+.TP
+.B r<char>
+Replace all selected characters by as many
+.IR char 's.
+.TP
+.B !
+Start
+.I ex
+command with visual registers and execute command preset as range, i.e.
+.IR :'<,'>! .
+.TP
+.B :
+Start
+.I ex
+command with visual registers as range, i.e.
+.IR :'<,'> .
+.TP
+.BI ~ " or " g~
+Swap case (of ascii characters only).
+.TP
+.B gJ
+Join, but remove newlines altogether, don't replace them by a space.
+.TP
+.BI gu " or " gU
+Change to lower/upper case (ascii characters only).
+.TP
+.BI q<reg> " or " q
+Start (with
+.IR reg )
+or stop (without)
+recording a macro to register
+.IR reg .
+.TP
+.B o
+Swap
+.I start
+and
+.I end
+of visual selection, changing the cursor.
+.TP
+.B "<reg>
+Use register
+.I reg
+for next command.
+.TP
+.BI c " or " s
+Change (substitute) selected text:  delete it and switch to
+.I insert
+mode.
+.TP
+.BI C " or " S " or " R
+Change (substitute, replace) all lines that are part of the selection:  Delete them and switch to
+.I insert
+mode.
+.TP
+.BI p " or " P
+Plumb text.
+
+.SS Commands
+Non-visual commands are very similar to the visual commands.  Instead of operating on visually selected text, they often operate on the line with the insertion cursor.  Many of the commands accept a motion command as modifier, the text range to operate on is then from the cursor to where the motion command moves (the motion command is interpreted relative to the cursor).
+Just like motion commands, most modification commands also accept a repeat count.  This allows for two repeat counts in a combined modification and motion command, e.g.
+.BR 3d4w .
+This means "delete 4 words and do it 3 times".  This is interpreted as "delete twelve words".
+Commands that delete text put it in register
+.BR " .
+The register can be overridden by prefixing the command with the
+.B "<reg>
+command, e.g.
+.BR "a3d4w .
+
+.TP
+.B i
+Switch to
+.I insert
+mode, at the cursor.
+.TP
+.B I
+switch to
+.I insert
+mode, at the first non-blank character on the line.
+.TP
+.B a
+Move to next position on the line and switch to
+.I insert
+mode.
+.TP
+.B A
+Move to end of line and switch to
+.I insert
+mode.
+.TP
+.B o
+Insert empty line after current, move to it and switch to
+.I insert
+mode.
+.TP
+.B O
+Like
+.BR o ,
+but inserts empty line before current line.
+.TP
+.B R
+Switch to
+.I replace
+mode.
+.TP
+.B <n>r<char>
+Replace
+.I n
+(1 by default)
+characters by occurrences of
+.IR char .
+.TP
+.B gi
+To
+.I insert
+mode at the end of the last text insertion.
+.TP
+.B gI
+To
+.I insert
+mode at the first column of the line.
+.TP
+.BI <n>cc " or " c<motion>
+Change
+.I n
+lines or by motion range.  I.e. delete the text and switch to
+.I insert
+mode.
+.TP
+.B <n>C
+Change from cursor to end of
+.RI current+ n -1th
+line.
+.TP
+.B <n>s
+Substitute
+.I n
+characters, i.e. delete them and switch to
+.I insert
+mode.
+.TP
+.B <n>S
+Substitute the entire current and following
+.IR n -1
+lines.
+.TP
+.B v
+Switch to
+.I visual
+mode, setting start and end of visual selection to current cursor.
+.TP
+.B V
+switch to
+.I "visual line"
+mode, setting start of visual selection to start of current line, and end of selection to end of current line (including newline).
+.TP
+.B <n>x
+Delete character at cursor, on same line.
+.TP
+.B <n>X
+Delete character before cursor, on same line.
+.TP
+.BI <n>dd " or "<n>d<motion>"
+Delete line or motion range.
+.TP
+.B <n>D
+Delete end of 
+.RI current+ n -1th
+line.
+.TP
+.BI <n>yy " or " <n>y<motion>
+Yank text (copy to register,
+.B "
+by default) line or motion range.
+.TP
+.B <n>Y
+Yank entire current line.
+.TP
+.B <n>p
+Paste from register to after cursor.  If the register (
+.B "
+by default) ends with a newline, the text is inserted on a new line after the current cursor.
+.TP
+.B <n>P
+Like
+.B p
+but if register ends with a newline place the text on a new line
+.I before
+the current cursor.
+.TP
+.BI [n]<< " or " [n]<[motion] " or " [n]>> " or " [n]>[motion] " or " 
+Unindent or indent line or motion range.
+.TP
+.B <n>J
+Join current and next
+.I n
+lines.
+.TP
+.B <n>gJ
+Join
+.I n
+lines, remove newlines altogether, don't replace them by a space.
+.TP
+.B m<mark>
+Set
+.I mark
+to current position.
+.TP
+.BI <n>!! " or " !<motion>
+Start
+.I ex
+command for
+.I n
+lines or motion range.
+.TP
+.B ZZ
+Quit, but first write the buffer if it has unwritten changes.
+.TP
+.B ~
+Change case of character under cursor, on same line.
+.TP
+.BI <n>g~~ " or " g~<motion>
+Change case of
+.I n
+lines, or up to motion range.
+.TP
+.BI <n>guu " or " <n>gUU " or g[uU]<motion>
+Change
+.I n
+lines to lower/upper case, or by motion range.
+.TP
+.B ^g
+Update status bar.
+.TP
+.B ^l
+Redraw screen.
+.TP
+.B u
+Undo last change.
+.TP
+.B ^r
+Redo last change.
+.TP
+.BI @<reg> " or " @@
+Execute register
+.I reg
+as macro, or execute same macro as last
+.B @
+command.
+.TP
+.BI : " or " Q
+Start
+.I ex
+command.
+.TP
+.B .
+Repeat last modification command.  E.g. if last command was
+.B d3w
+that same command is executed again, interpreted from the current cursor.
+.TP
+.BI q<reg> " or " q
+Start recording macro into register
+.IR reg ,
+or stop recording.
+.TP
+.B "<reg>
+Use register
+.I reg
+for the next command.
+.TP
+.B gP
+Plumb pattern under cursor.  Pattern is path optionally followed by a semicolon and an
+.I ex
+address, e.g. a line number.  The start of the pattern is looked for before the cursor too.
+.TP
+.B gpp " or " <n>gp<motion>
+Plumb line or motion range.
+.TP
+.B gb
+Equivalent to
+.I ex
+command
+.BR b :
+execute the command in register
+.I !
+and plumb the standard output and standard error as message
+.I kind
+`newtext'.
+
+.SH EX COMMANDS
+.I "Ex commands"
+can be entered after pressing
+.BR :
+(this shows an edit entry and places the focus in it),
+and are executed when return is hit, or aborted by escape.
+Either way, text entry is replaced by the status bar again.
+.I Vixen
+does not have an
+.I ex
+mode for consecutive entering of
+.I ex
+commands.
+
+.PP
+.I Ex
+commands operate on whole lines of text.  By default they operate on
+.IR dot ,
+the line of the cursor.  Most commands can be prefixed with an
+.I address
+expression.
+And many of those commands accept a
+.I range
+expression to instead operate an multiple range.
+A
+.I range
+is specified as two addresses, separated by a comma or semicolon.  The semicolon causes the second address to be interpreted relative to the first address, instead of to the start of the file.
+An address is one or more (optionally whitespace separated) components, evaluated after each other, starting with the current cursor:
+.TP
+.B .
+.IR Dot ,
+the current location.  Initially the cursor.
+.TP
+.B $
+Last line of the file.
+.TP
+.B 0-9+
+Number, absolute line number in file.
+.TP
+.BI +0-9* " or " -0-9*
+Moves
+.I number
+lines relative to
+.IR dot .
+If the number is absent, it defaults to 1.
+.TP
+.BI /pattern/ " or " ?pattern?
+Line that contains pattern.
+.TP
+.B '<mark>
+Line that contains
+.IR mark .
+
+.PP
+Supported commands:
+.TP
+.B [range]!command
+Execute command.  If no
+.I range
+is given, just execute
+.IR command .
+Otherwise, pass the text range to
+.I command
+and replace it with its output.
+.TP
+.B [range]s/pattern/repl/[g]
+For each line in
+.IR range ,
+match against
+.I pattern
+and replace the matching text with
+.IR repl .
+References to matching groups from the regular expression can be made in
+.I repl
+by a backslash followed by a digit.  Digit `0' is the whole match, groups are counted from 1.
+If
+.I g
+was specified, perform the replacement for every match on the line, instead of on only the first.
+See
+.IR regex (6)
+for valid values for
+.IR pattern .
+The separator can be any non-alphanumeric besides whitespace, double quote and pipe.
+.TP
+.B [range]r [!]arg
+If
+.I !
+is specified,
+.I arg
+is executed as command.
+Otherwise it is read as a file.
+The text reads is inserted either instead of
+.IR range ,
+or on a newline after the cursor when
+.I range
+was not specified.
+.TP
+.BI "[range]w[q!] [>>]filename" " or " q[!]"
+Write and/or quite.
+If
+.B w
+is specified, the current buffer is written to disk.  If a file name has previously been set for the file, the
+.I filename
+parameter may be left out.  If
+.I filename
+is specified and different from the current file name, and
+.I filename
+already exists, the write will fail unless
+.I !
+was also specified.
+If
+.I filename
+starts with
+.IR >> ,
+the text is appended to the file.
+If
+.I range
+is specified, only that range is written.
+Appending and writing a range never clears the
+.I modified
+status from the buffer.
+Then, if
+.B q
+is specified,
+.I vixen
+quits.  If the buffer still has unwritten modifications,
+.I vixen
+will not quit unless
+.B !
+was also specified.
+.TP
+.B x
+Same as the
+.B ZZ
+command:  quit, but first write the buffer if it has unwritten modifications.
+.TP
+.B "cd <path>"
+Change working directory to
+.IR path .
+.TP
+.B "f [filename]"
+If
+.I filename
+is absent, prints the current file name associated with the buffer.
+Otherwise sets the current filename for the buffer to
+.IR filename .
+.TP
+.B D<chars>
+Toggle debug settings for
+.IR chars ,
+which must be one or more chars of:  a-z, or - (disable all) or + (enable all).
+.TP
+.B "b [command]"
+.I Build
+by executing
+.IR command .
+The output (both standard output and standard error) are plumbed with
+.I kind
+`newtext', with only the current working directory set.  This the text to be sent to a
+.I vixen
+that gathers output for that directory, a mechanism similar to that in
+.IR acme (1).
+If
+.I command
+is absent, the command previously executed with
+.B b
+is executed again.  This command is available in register
+.IR !
+and is set to
+.I `mk'
+by default.
+Also see
+.RI non- ex
+command
+.BR gb .
+
+.SH Missing features
+.I Vixen
+lacks many features and commands that other vi implementations do have.  Below, some of the more popular commands and features are listed.
+
+.PP
+Commands
+.BR zz ,
+.BR zt ,
+and
+.BR zb ,
+for scrolling the window to place the cursor at the middle, top and bottom of the window.
+
+.PP
+Other implementations treat some motion commands differently based on the command they are executed with, e.g.
+.B cw
+is actually interpreted as ``delete to end of word'', i.e. more like
+.BR ce .
+.I Vixen
+always interprets motion commands in one single way.
+
+.PP
+Key mappings for commands, through ex'
+.B :map
+command is not supported,
+.B :ab
+for abbreviations in
+.I insert
+mode is not supported either.
+
+.PP
+.I Vixen
+does not have a full
+.I ex
+mode, where consecutive commands are interpreted as
+.I ex
+commands.  Commands to print multiple lines of text have not been implemented.
+
+.PP
+The full command names of
+.I ex
+commands are not supported.  E.g. only
+.B :q
+is supported, not
+.BR :quit .
+
+.PP
+Some featurse that likely won't be supported:
+.br
+\(bu Multiple editing windows within on instance of
+.IR vixen .
+.br
+\(bu Text folding (hiding parts of the text).
+.br
+\(bu Visual block mode, where any rectangular block of text (over multiple lines) can be selected.
+.br
+\(bu Spell checking, text completion.
+.br
+\(bu Editing non-utf-8 (e.g. binary) files.
+
+.SH SOURCE
+.B /appl/wm/vixen.b
+.br
+.B /appl/wm/vixen/buffers.b
+.br
+.B /appl/wm/vixen/change.b
+.br
+.B /appl/wm/vixen/cmd.b
+.br
+.B /appl/wm/vixen/ex.b
+.br
+.B /appl/wm/vixen/filter.b
+.br
+.B /appl/wm/vixen/interp.b
+.br
+.B /appl/wm/vixen/subs.b
+.br
+.SH SEE ALSO
+.IR acme (1),
+.IR regex (6).
+.SH BUGS
+Many.
+.PP
+.I Vixen
+will not implement all commands and features found in the various vi clones, or the original vi.  The behaviour of some commands may be different, that could be a bug.
+.I Vixen
+also has features that the vi clones and the original vi do not have, such as plumbing.
+.PP
+Entire file is kept in memory twice:  in vixen and in Tk.