What happens if you press 'x' in Helix in a Zellij pane running in Alacritty?

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.

Bypassing a trait to find the real implementation in Helix.

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.