pneo on the browser
pneo (πνέω) is a TUI client for INSPIRE, an open-access digital library in the field of High Energy Physics. It is a neat little utility that I wrote as a substitute of spire.app, a macOS-only GUI client with the same purpose.

Its features are mostly straightforward: you can download and open papers and they stay in a local cache, you can search by querying INSPIRE, navigate while you read the abstracts, etc. It is written in Rust using ratatui, so it is lean and fast.
One not-so-straightforward feature is offline mode. I'm strangely proud of this one, even if it is kind of limited. Offline mode has to be activated explicitly and only yields papers that you previously opened. To query the local database, we need to parse the INSPIRE query syntax, which is more complicated than it sounds. Their code is public, but it is a pretty gnarly grammar (in Python), and the final artifact they produce is an ElasticSearch query.
I ended up downscoping offline mode to a tiny subset of the INSPIRE query language (which could be easily extended). This gave me an opportunity to try tree-sitter and write my first grammar from scratch. A cool side-effect of this effort is that I can add syntax highlighting while in offline mode!

What about the browser?
I wanted to create a WebAssembly demo for pneo so I could showcase it instantly to anybody interested in trying it out. At its core, it needs very little from the underlying platform: mostly, just network requests to download preprints. Local caching can be shimmed to be in-memory for demo purposes.[1] It did require a sizable refactor to abstract the platform in a Runtime trait.
The most interesting/challenging part is, however, the terminal-related stuff. First of all, we need a suitable terminal emulator. xterm.js seems like the sensible choice, given that it is used everywhere. Once this is settled, we have to figure out to hook to this terminal from ratatui. ratatui assumes (a) a Backend to write to the terminal — which is the same as drawing to the terminal — and (b) some way to get events from the terminal, possibly through another crate. The default setup favors crossterm as the implementation, but it cannot be used as-is for several reasons, so I had to fork it and patch those issues away.
First of all, crossterm does not compile to WebAssembly, as low-level terminal shenanigans are platform-specific. Hence, they're guarded behind #[cfg(...)] flags for Unix and Windows. Luckily, fixing that is just a matter of adding a few placeholder implementations with #[cfg(target_arch = "wasm32")]. They all are unimplemented!() since, ultimately, there's no corresponding "system call" in WebAssembly. Once this is fixed, a whole lot stuff from crossterm just works. You don't need those shims when sending commands to the terminal to, say, move the cursor or change the text color.
The second issue is that the event source that crossterm provides is, again, coupled to platform specifics, Unix ones in particular. It assumes that std::env::stdin() exists and is a terminal, it manipulates the underlying file descriptor with termios system calls, it listens for SIGWINCH for terminal resize events, etc. None of this makes sense in a WebAssembly context. However, there's a good deal of internal logic that is reusable. Most of the incoming events are parsed from simple data coming through a file descriptor. Once we get a hold of some data source in our web implementation, we can reuse this parser easily to generate our events. To this end, I re-exposed an inner, Unix-only parse module as public.
With these two issues out of the way, the implementation is now more or less clear:
- On the JS side, we create a
Terminalinstance fromxterm.js, and pass it to the Rust side. - By hooking into
Terminal#write(), we can create aWriteimplementation on the Rust side. This writer can receivecrosstermcommands and can be used to build a properBackendfor rendering purposes. - By subscribing to
Terminal#onData(), we can create aStream<Item = Vec<u8>>. Once mapped through the parsing logic from the forkedcrossterm, it becomes a stream of terminal events, which we can feed into our main application loop.
And that's it, it works!. You can check it out at https://_rvidal.gitlab.io/pneo.
Limitations, wishlist
There are several features that I did not port, but ultimately I will probably leave it alone since it is just a demo.
Making offline mode work is tricky because of the query parsing logic. I would need to heavily refactor the tree-sitter logic into the Runtime abstraction, since I can't use the generated C parser and merge it with my Rust code when compiling to WebAssembly. [2] So I would need to use a pre-made Wasm-friendly tree-sitter implementation, and do the parsing on a separate Wasm module.