One of the little things that inspired me to learn some Rust was the realisation that suddenly all the tools I'm using every day are built with it. In particular, Alacritty, Zellij and Helix are the bedrock of my workflow and they're all written in Rust.
It felt like an interesting Rust learning exerise to try to understand the
journey of a single keystroke through that stack. So iI have Helix running
inside a Zellij pane in an Alacritty window and I press x on my keyboard, what
kind of adventure does that keypress go on?
flowchart LR Alacritty --> Zellij Zellij --> Helix
Alacritty
The handle_event() function is the topmost entrypoint. Here the keypress
traverses a pair of nested match blocks. The outermost match block filters
by winit::Event type, of which a keypress is a WindowEvent. The
innermost block filters by the variant of WindowEvent, which in this case is
KeyboardInput.
All that filtering leads to a call to key_input(). This is a wonderfully
readable function with nice little explanatory comments for each early return.
None of those guard clauses apply to our simple x keypress, which makes it all
the way to the write_to_pty() call at the very end.
That write_to_pty() call brings us right back to event.rs, where a
single-line function passes the value directly to a notify() call. I got a
little stuck here, because when you use gd to navigate to the definition of
the notify() function you end up in some kind of trait instead of the
implementation. Eventually I found my way past that by using gr on the trait
to list references to it and find the real implementation.
We land in a notify() implementation inside a struct that contains an
EventLoopSender, which is a pretty big clue as to what happens next. Our
keypress becomes a Msg::Input on the event loop as a result of the code in
notify().
That Msg::Input is read back out of the event loop in
drain_recv_channel(). It puts it into a write queue called write_list.
Later a goto_next() call moves it into a value called writing. As a
result of this, the subsequent call to pty_write() sends the keypress data
to pty.writer().write().
That final write() call is happening on a std::fs::File. We're at the
boundary between Alacritty and Zellij here, where data changes hands by writing
to virtual files in /dev. Alacritty writes the data to the master PTY file
descriptor and from there it's Zellij's problem.
Zellij
The second leg of the keypress's journey begins in Zellij's stdin_loop()
function. This reads the data written by Alacritty and turns it into an
InputInstruction::KeyEvent. A call to send_input_instructions.send() hands
the event off to the input handler.
Zellij uses a message passing crate called crossbeam-channel for this step.
That send_input_instructions struct is a crossbeam_channel::Sender. And
there's a crossbeam_channel::Receiver struct called
receive_input_instructions in handle_input() which receives the message
and passes the raw bytes into a call to handle_key().
Those raw bytes are repackaged once again as a ClientToServerMsg::Key, which
is a big clue about what happens next. Zellij has a client-server architecture,
and up to now we've been in the client part. Inside route_thread_main(), the
server receives the ClientToServerMsg::Key and passes it to route_action()
as an Action::Write.
From there it's converted to a ScreenInstruction::WriteCharacter and passed to
send_to_screen(). That's picked up by screen_thread_main() which sends it
onwards to tab.write_to_active_terminal().
Zellij then figures out the ID of the active pane and sends the keypress to it
by calling write_to_pane_id(). There, it becomes a
PtyWriteInstruction::Write and makes its third crossbeam sender/receiver hop
via send_to_pty_writer() into pty_writer_main().
That forwards it on to write_to_tty_stdin(), which figures out the right
file descriptor for the terminal ID and uses nix::unistd::write() to write
the data to it. We're writing to a file again, which means we're at the border
between two programs. It's the end of the Zellij leg of the journey, and the
keypress is about to enter Helix.
Helix
In Helix, a function called event_loop_until_idle() polls for events from
stdin and sends them to handle_terminal_events().
An into() call on the event takes us on a detour via a From trait's
from() function to convert from a termina::Event::KeyEvent to Helix's
own internal Event::Key struct. That Helix event then goes into a
handle_event()
function which uses event bubbling to route it to the right part of Helix.
In the case of our x keypress, with Helix in insert mode, the right part of
Helix is the editor. The editor's own
handle_event()
function routes the event to the insert_mode() function, which passes it on
to a function called insert_char().
The event then becomes a transaction which is applied to the document. The x
character from the keypress is now a part of the document but it's not yet
visible on the screen. The successful consumption of the event sets in motion a
render within Helix which will work its way back via Zellij to Alacritty.
I'm too exhausted from following the keypress down the chain to be arsed following it back up. This was a fun exercise though. I learned a lot about Rust, which was the point, but also about systems programming, which was unintentional.
All three of these are tools I expect to use daily for a long time to come too, so it feels really worthwhile to work towards understanding what makes them tick. And Rust makes that a lot more feasible for me. I like C a lot, and remember reading The C Programming Language very fondly, but I've always really struggled to read real-world C code. It's fun finally being able to understand stuff at this level.