nvim-ui progress
A year and a half passed since I set off to create a simple Neovim UI in C++ in my spare time. The project nvim-ui became an exciting journey with quite a bit of discovery, experience and notable conclusions to share.
SpyUI in nvim-gdb
First of all, the idea emerged when SpyUI was implemented to support maintenance of my Neovim plugin nvim-gdb. SpyUI would allow just mirroring and capturing the UI progression during automatic tests or actual use cases to simplify bug hunting. Not surprisingly, Neovim itself actively uses a UI client for automatic testing. The script was embarrassingly simple suggesting that a full-fledged UI shouldn’t be too difficult either.
+----------------------------------------+
| |
|~ |
|~ |
|~ |
|[No Name] 0,0-1 All|
|/src/test.cpp:17 |
|/tmp/nvim-gdb/test/src/test.cpp:17:190:b|
|eg:0x5555555551e6 |
|(gdb) |
|<port -- gdb -q a.out 21,1 Bot|
| |
|"/tmp/nvim-gdb/test/src/test.cpp" |
+----------------------------------------+
The essential summary of the SpyUI was the data model that Neovim maintains
to share the UI state. The grid is a matrix of text cells with their
highlighting ID associated: [[(text, hl_id)] * width] * height
. So the first idea is to
assemble the grid matrix based on the Neovim messages, and process it further
for the best appearance and performance. For instance, concatenate the
adjacent cells with the same hl_id
into words, and skip the invisible spaces
at all: [[(stride, word, hl_id)]] * height
.
nvim-ui in SDL2
So the first attempt was with SDL2. Surely, it’d allow achieving superb performance by with hardware acceleration! Pango and Cairo from the GNOME stack were used to render the text into bitmaps, and the bitmaps were transformed into textures. The prepared textures could be reused multiple times if that specific piece of text didn’t change. One more way to relieve the CPU usage was throttling of grid flushes. Really, there’s no need to do rendering more frequently than 25 FPS.
It worked decently so the version 0.0.1 was released just to get things started. Then soon the version 0.0.2 followed with a couple of bugs fixed, a bit of development infrastructure automatated.
But there was also a significant drawback discovered (issue #20). Let’s call it texture noise. And I couldn’t find any way to fix it. Apparently, it’s attributed to the GPU rendering.
Porting to GTK 4
After a bit of research, I decided to try using GTK 4 directly. That’s because Pango and Cairo are already part of GTK, first of all. Then GTK would also allow using ready widgets for configuration dialogs, for instance, for font selection. And a lot more. But what’s most important, the toolkit recommends using stock widgets for all the custom drawing whenever possible. That means that what was a “texture” in SDL2 could now easily be implemented as a GTK label! Moreover, all the styling could be conveniently done using CSS, leaving behind very low level coding.
This is the time when the application got the menu, and the ability to connect to
a remote Neovim instance. The performance was OK, except when there was a lot of
little labels to be created at once. For example, when getting a listing of
digraphs with :digraphs
, around 1,500 labels would be created and placed on
the grid on every PgUp/PgDn. This caused a noticeable delay of 0.3 seconds.
Enter Gir2cpp
That version wasn’t released immediately because I realized that GTK’s C API is a bit cumbersome and unpleasant. For example, one should know in beforehand that the class GtkEntry implements the interface Editable:
auto t = gtk_editable_get_text(GTK_EDITABLE(entry));
instead of the conventional
auto t = entry.get_text();
Bringing in gtkmm seemed an overkill for just a handful of widgets I was intending to use. But one idea seemed particularly appealing: cppgir uses GObject introspection to generate C++ wrappers. Unfortunately, I failed to build the code with it properly. It didn’t seem too complicated to create an custom ad-hoc generator of C++ wrappers for GObject-based libraries. So there’s another auxiliary side project that could potentially has it’s own application: gir2cpp.
It’s just a Python library that needs to be properly configured with the white and black list of desired symbols to be wrapped in C++. Unfortunately, I wasn’t able to find a way to seamlessly integrate it into the Meson build system. Perhaps because it actually isn’t known what exactly files are going to be generated given the required configuration. So it’s utilized as a preconfiguration step before the Meson build system is invoked: prepare-gtkpp.sh.
However quick and dirty the approach is, the code improved significantly:
Texture t{row, Gtk::Label::new_("").g_obj()};
t.label.set_markup(text.c_str());
t.label.set_sensitive(false);
t.label.set_can_focus(false);
t.label.set_focus_on_click(false);
t.label.get_style_context().add_provider(_css_provider.get(), GTK_STYLE_PROVIDER_PRIORITY_APPLICATION);
_grid.put(t.label, 0, y);
Dealing with the performance issues
Fine-grained labels/textures are perfect to minimize the redrawing work, but
display a couple of significant drawbacks. The most annoying one is
that there can be lots of labels to update when listing messages (see
:digraphs
). Another one is the horizontal alignment of separate textures,
especially thin vertical lines in the adjacent lines in
telescope.
So I tried to simplify the renderer to create only one big label for one grid
row. This makes the data model a bit more complex, but simplifies the code a
lot: [[(text, hl_id)] * cols] * rows
→ [[(word, hl_id)]] * rows
.
Specifically, the text layout is now done using Pango markup. This turned out to
be a great idea for both the UI appearance and responsiveness and code
maintainability.
Perspective
So here we are after 1.5 years of experimentation. There’s an ongoing project, a Neovim UI with the following features:
- Simplicity of the idea and C++ implementation
- Decent appearance and performance
- Solid foundation for implementing more features
- A good sandbox for further experiments.
To be continued.