Snippets

SyncTeX reverse search in Helix

I've recently been using the Helix text editor. One of the shortcomings of Helix is that it doesn't provide a RPC interface to enable SyncTeX reverse search.1

For a while I trudged along without this useful tool, but today, with the help of Claude, I managed to find a hacky solution that kind of works.

The idea is that even though Helix doesn't offer an RPC interface, the terminal emulator that Helix is running in might. You can then use this interface to pass input to the application running in the emulator, which in our case is Helix. Specifically, we will use this interface to tell Helix to open the file and line number corresponding to the PDF location we clicked.

Prerequisites

  1. Helix (duh).
  2. A terminal emulator with some kind of RPC interface. Popular examples include kitty and WezTerm. I'm using kitty, so the rest of this guide will be adapted to that.
  3. A PDF reader that supports SyncTeX. I'm using Skim because zathura doesn't seem to work well on MacOS. (But if you manage to make it work, let me know!)

Step 1: Enable remote/RPC access

The first step is to enable RPC access in your emulator. In kitty, this is done by editing ~/.config/kitty/kitty.conf, and adding the following two lines:

allow_remote_control socket-only
listen_on unix:/tmp/kitty

The first line tells kitty to listen for remote inputs only via the UNIX socket interface, while the second line specifies which socket to listen on.

Step 2: Generate inputs for your emulator

Now kitty is ready to listen for remote inputs. To generate these inputs, we will create a script (that I'll call hx-synctex, but you can call whatever) that will interpose between Skim and kitty, accepting the SyncTeX metadata from Skim and translating it to calls to the appropriate kitty window.

The script accepts two inputs: the full path to the file, and the line number within the file.

#!/bin/bash
FILE="$1"
LINE="$2"

The script starts by finding the specific socket used by kitty. This is necessary because each kitty process generates a fresh socket. On MacOS there is a single long-running kitty process from which subprocesses are spawned whenever you open a new window, but all these subprocesses share the same socket. Hence we can just grab the first matching socket file and use that.

SOCKET="unix:$(ls /tmp/kitty-* 2>/dev/null | head -1)"

At this point we can already send commands to kitty windows, but doing this naively would send commands to all running kitty windows, which is obviously undesirable. Instead, we want to find the specific window matching our LaTeX project. Thankfully, this is fairly easy for two reasons:

  1. kitty offers a way to filter which windows a command is sent to, and this filtering can be done by window title, and
  2. Helix changes the terminal window title according to the format hx <file_name> <abbreviated_enclosing_folder_path>. So for example opening ~/Research/arc/paper.tex results in a terminal window title of hx paper.tex ~/R/arc.

These two properties combined allow us to precisely specify the affected window:

# Construct abbreviated folder path
DIR=$(dirname "$FILE" | sed "s|$HOME|~|" | awk -F/ '{
    for (i=1; i<NF; i++) printf substr($i,1,1) "/";
    print $NF
}')

KITTY=/Applications/kitty.app/Contents/MacOS/kitty

# Switch focus to windows which match the pattern 
# `hx <anything> <abbreviated_enclosing_folder_path>`
"$KITTY" @ --to "$SOCKET" focus-window --match "title:^hx\s.*\s${DIR}" 

Note that the filtering above uses regex to ensure a correct match; figuring out the correct regex incantation took some trial and error, and I make no guarantees that it's bulletproof. Regardless, now that we can send commands to the correct window, we have to decide what to send. This is simple: we simply enter whatever we would have typed into helix to achieve the same goal. Namely, the input we provide

  1. escapes into normal mode (indicated by the control code sequence \x1b\x1b),
  2. switches to the file indicated by the SyncTeX metadata (:open $FILE\r, where \r is the control code for the return key), and
  3. navigates to the correct line within that file (:goto $LINE\r).
"$KITTY" @ --to "$SOCKET" send-text --match "title:^hx\s.*\s${DIR}" "$(printf '\x1b\x1b:open %s\r:goto %s\r' "$FILE" "$LINE")"

This completes the script. The full version is below.

#!/bin/bash
FILE="$1"
LINE="$2"

LOGFILE="$HOME/hx-synctex.log"
SOCKET="unix:$(ls /tmp/kitty-* 2>/dev/null | head -1)"

DIR=$(dirname "$FILE" | sed "s|$HOME|~|" | awk -F/ '{
    for (i=1; i<NF; i++) printf substr($i,1,1) "/";
    print $NF
}')

KITTY=/Applications/kitty.app/Contents/MacOS/kitty

"$KITTY" @ --to "$SOCKET" focus-window --match "title:^hx\s.*\s${DIR}" 

"$KITTY" @ --to "$SOCKET" send-text --match "title:^hx\s.*\s${DIR}" "$(printf '\x1b\x1b:open %s\r:goto %s\r' "$FILE" "$LINE")"

The last step is to have Skim invoke this script correctly. To do that, open Skim's Preferences menu, switch to the "Sync" tab, and there, under the "PDF-TeX Sync support" setting select "Custom" and fill in the blanks as follows:

This completes the setup.

  1. Unlike, say, neovim, which provides nvim-remote