from worst terminal to merely mediocre
Another month of poking around trying to make an almost useful terminal emulator.
tmux sends a sequence I was not previously familiar with to enable “application escape” mode. Escape keys are now translated to the sequence
\eO[ which lets tmux differentiate from single escape bytes and the beginning of a longer sequence. So this explains why vim in tmux has always felt really sluggish. Without the hint, it pauses to see if there are any more bytes coming. Well, I implemented this, was running tmux remotely, got disconnected, and then ran vim locally. And couldn’t escape! Sending
\eO[ to the local vim only resulted in a string of
[ being inserted, and remaining in insert mode. Hard to run reset when you can’t get back to a shell. Added a hotkey to reset some of the terminal properties.
Added support for OSC 8 hyperlinks which I saw was added to ripgrep. Tested it out, rg Printf, and immediately scrambled my terminal. Oof. Dug around a bit, tried to figure out what it was doing, but nothing made sense. Long story short, I eventually figured out ripgrep was finding an old trace file filled with terminal escape sequences. Basically, my entire source tree as output via vim, and thus one giant line. ripgrep printed that out, and kaboom. Not really a bug, but a cautionary tale. Automatic directory recursion can be surprising, and also terminal escape sequences don’t count as binary files. There doesn’t seem to be an option to vis the output, short of piping through vis, but that will escape rg’s own output as well. (And then I blew myself up again deliberately checking some raw files into the source repository as tests. The stove is still hot.)
Oh, yeah, hyperlinks. I saw ripgrep 14 added support for hyperlinks. This may be the feature that gets me to move off my grep scripts. If only I had a terminal that supports it. After figuring out the above nonbug, this was really easy to add, and works great. Click a line number, and instantly opens the file in vim right at the line.
Saw that tmux has a command mode, which is how some other terminals integrate with it, but it’s much easier to cheat. tmux already tells the terminal what it’s doing by doing it. I just needed a sneaky mode to switch the normal next tab and previous tab hotkeys to instead send tmux sequences like ^bn and ^bp. Then tmux detaches and it’s back to normal.
Ported to macos, which was not particularly difficult, although the way it does window scaling is unfortunate. There’s no way, at least via glfw, to get the real monitor size. You get the fake “looks like” display size, but this isn’t very helpful because the OpenGL framebuffer is still in pixels. And back on linux, adjusting by the scale is crazy because I do have accurate monitor sizes. Got everything working on all the systems I could test, via assorted hacks. I think these little white lies from the OS are helpful during transition periods, but now they’re getting in the way again.
Made a little script to create an app bundle, complete with super fancy icon. Pro tip: macos uses the timestamp of the Prog.app directory, not the Prog.icns file inside, to determine when to refresh the displayed icon.
In other arm news, I got vertigo running on an arm chromebook, but only by smashing the opengl code through the OpenGL ES fuglifier. I’m not doing anything even remotely challenging, but they insist on everything be spelled differently just because. So that’s gross, and I’m happy to leave those changes to rot on that computer.
Instead of drawing the whole screen and double buffering, what if we track damage and draw directly to the front buffer? This should be faster. But is it really? Made a few efforts at measuring, with mixed results.
My first attempt was to record pressing a key at 240 fps, and then count how many frames until the letter shows up. In the process, I discovered that my lamp flickers at 240hz, so every other frame was light or dark. This was unpleasant. However, I did learn that you can see this happening with an iPhone, because the preview screen before recording shows a strobing light effect. Probably better to record a sample, but it’s nice that there’s an indication as well.
Try again at 120 fps and the lighting has stabilized. But my key travel is too shallow; it’s hard to tell when the key is pressed. I basically have to wait for the letter to appear, then scrub backwards to look for a twitch on the key. Not entirely accurate, but the results seemed plausible. But one question remains. Do I stop the timer when the screen starts updating or when it finishes? It’s an LCD with a slow response time, and it takes three frames from when I see the next cell start to light up (new cursor position) to when the previous cell background is faded to black. Those three frames are a significant portion of the latency!
Third attempt was to introduce an artificial delay with time.Sleep until it was just noticeable. Then increase latency until it felt twice as slow. Entirely nonscientific, but now I have two equations to math. Solving for inherent latency looked promising, but then I reran the experiment a few times and got a negative latency. If only I could believe that. In the end, I settled on the fact that if I have to add a decent amount of delay before noticing anything is amiss, that counts as my latency headroom. I think I’m in a good spot. The ol’ ‘make it slow so you can make it fast’ trick.
Along the way, I noticed that by making the draw loop tighter, I had introduced a 3x slowdown in the all important throughput benchmark. Previously, the input loop was guaranteed at least one refresh cycle of processing time, but now it was constantly interrupted to synchronize with the window. Imagine that, vsync makes things faster. Added back a delay if we’ve done a full repaint.
I elected not to use screen recording with a key injector because I couldn’t be bothered to set it up, but also that’s not how I use a terminal. I can’t do anything about keyboard or screen refresh latency (or LCD response times, short of buying a different laptop), but it’s still there, so it seems we should include it. i.e., halving the latency of my program won’t do much if the problem is elsewhere.
A new challenger emerges! I personally am migrating from xterm, but had been using alacritty and kitty as comparisons, as they seemed more similar in terms of design and focus. But I recently discovered zutty, a high-end terminal for low-end systems.
I like that he took the approach of pushing even the cell layout to the GPU. I thought about this, decided to postpone, and then found it’s easier to make tiny adjustments like dynamic cell sizing by keeping the geometry on the CPU. But still interesting to see somebody do it. (It’s also OpenGL ES... eh.)
There’s some very good commentary on the design of terminal emulators, and comparisons to other projects, covering performance and compliance. Definitely worth a read.
I also found the zutty source a very helpful reference when determining how specific control sequences should be handled. Inspired by zutty’s commentary, I added support for left and right margins, then ironically found I can’t get xterm to work with my test. (Well, I got one xterm to work. Apparently depends on the build config.) So now zutty is my reference implementation for testing.
After reviewing the zutty results with vttest I thought I’d give it a try as well. A little more correctness can’t hurt, right?
Alas, vttest has a serious focus on edge conditions. As in literal edge conditions like move cursor to column 80, write A, backspace, write B. A normal program like vim that wants a B in column 80 will just move the cursor there and write B.
vttest, as perhaps appropriate for a vt100 tester, also assumes the terminal is exactly 80x24. Except when it switches to 132 column mode. Otherwise the screen borders, wrapping, scrolling, etc. will all be wrong. In 2023, I’d consider any other program that assumes terminal dimensions to be broken. And if something like top decided to flip into 132 column mode on its own, I’d never run it again. So there’s a fair bit of work getting yourself into position to test with vttest. (Thomas Dickey points out vttest does support configurable geometry. It’s even clearly documented in the man page, which I did not read.)
I did find a few bugs with vttest, which I was happy to fix, but I also spent a long time figuring out what the bug was. Mostly, all the bugs I had were unrelated to the functionality being tested, so I had to pore over traces trying to figure out what was supposed to happen to the screen before running the test. I need an “I’m too young to die” difficulty level.
And then there’s a lot of focus on weird stuff like
\e[4\b2C advancing the cursor two spaces. Yes, apparently vt100 supports backspace in the middle of a control sequence. Why? Is there some mode of emacs where it will output the beginning of a sequence to move the cursor, then change its mind, and backspace over the partially submitted command? When will this be useful?
I have a new found level of sympathy for all the terminal emulators that don’t live up to full vt100 compliance. That said, I think common decency would suggest you not claim support that doesn’t work.
Depending on how you’ve written your input processing loop, it’s not hard to handle
\e[4\rC to move the cursor to the fifth column, because oh yes, you can also put CR in the middle of a sequence and it works. I prefer to organize my functions slightly differently though, which makes it more annoying to implement the “elegance” of a 1970s no error checking state machine.
What are we trying to accomplish, again? The vt100 hardware had to do something with all possible inputs, but does that make every possible input a valid one? The vt100 demo scene may care about cycle accurate emulation, but personally, I just want a window to run vim inside.
It feels weird to argue for features over correctness, but in this case, what does correct mean? I don’t think vertigo is made more robust by processing these sequences. Certainly, the larger terminal software ecosystem is not enhanced by accepting them.
Don’t get me wrong, I think vttest is designed to do one thing, and it does that thing very well, it’s just not the thing that you might expect. Started building up my own ad hoc test suite of replay files. There’s a test suite included with zutty as well, but god damn if I can figure out how to run it. I feel that the end state of every terminal program is writing your own test suite because none of the existing ones overlap with the behaviors you actually care about.
There was a brief moment between installing vttest and running it for the first time where I contemplated the goal of full compliance. Lesson learned: check your aspirations.
It’s been a fun project, but I think I’m ready to move on to the next one. There’s still an infinitely long tail of changes that can be made, but I’ve crossed the hump where it’s personally useful.
I was predicting 4k lines eventually, but somehow got here a lot sooner than expected.
55 171 1035 draw.glsl
320 896 6410 font.go
166 419 2730 io.go
166 428 3668 main.go
679 1774 16134 opengl.go
126 315 2339 proc.go
1094 3316 23180 screen.go
649 1661 13051 term.go
799 2074 18860 window.go
4054 11054 87407 total
I have thus far resisted the temptation to add some form of graphics support, but then I discovered glkitty, which draws glxgears by rendering off screen and feeding the pixel data to the terminal. This is totally ridiculous. It should obviously be sending the vertex data and shader to the terminal, so that the terminal does the drawing. A true successor to the Tektronix.